feat: implement project management features with routing, including project creation, deletion, renaming, and graph persistence
This commit is contained in:
269
frontend/src/app/platform/ProjectsPage.tsx
Normal file
269
frontend/src/app/platform/ProjectsPage.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Projects list page: table of all projects with metadata, sorted by last edited.
|
||||
* Actions: open, rename, export, delete. Pagination.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Pencil, Trash2, Download, FolderOpen } from 'lucide-react'
|
||||
import { usePlatform } from './platformContext'
|
||||
import { getProjectIcon } from './icon-map'
|
||||
import {
|
||||
loadGraphFromStorage,
|
||||
removeGraphFromStorage,
|
||||
PROJECT_FILE_EXT,
|
||||
PROJECT_VERSION,
|
||||
} from './projectGraphStorage'
|
||||
import type { Project } from './types'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
function formatDate(ts: number) {
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function ProjectsPage() {
|
||||
const { projects, deleteProject, renameProject } = usePlatform()
|
||||
const navigate = useNavigate()
|
||||
const [page, setPage] = useState(0)
|
||||
const [renameTarget, setRenameTarget] = useState<Project | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...projects].sort((a, b) => (b.lastEditedAt ?? b.createdAt) - (a.lastEditedAt ?? a.createdAt))
|
||||
}, [projects])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE))
|
||||
const start = page * PAGE_SIZE
|
||||
const pageItems = sorted.slice(start, start + PAGE_SIZE)
|
||||
|
||||
const handleOpen = (projectId: string) => {
|
||||
navigate(`/projects/${projectId}`)
|
||||
}
|
||||
|
||||
const handleRenameOpen = (project: Project) => {
|
||||
setRenameTarget(project)
|
||||
setRenameValue(project.name)
|
||||
}
|
||||
|
||||
const handleRenameSubmit = () => {
|
||||
if (renameTarget && renameValue.trim()) {
|
||||
renameProject(renameTarget.id, renameValue.trim())
|
||||
setRenameTarget(null)
|
||||
setRenameValue('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteOpen = (project: Project) => {
|
||||
setDeleteTarget(project)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (deleteTarget) {
|
||||
removeGraphFromStorage(deleteTarget.id)
|
||||
deleteProject(deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
navigate('/projects', { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = (project: Project) => {
|
||||
const stored = loadGraphFromStorage(project.id)
|
||||
const state = stored
|
||||
? { version: PROJECT_VERSION, nodes: stored.nodes, edges: stored.edges }
|
||||
: { version: PROJECT_VERSION, nodes: [], edges: [] }
|
||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${project.name.replace(/[^\w.-]/g, '_')}${PROJECT_FILE_EXT}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4">
|
||||
<div className="flex flex-col gap-4 rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Projects</h2>
|
||||
</div>
|
||||
{sorted.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No projects yet. Create one from the sidebar.</p>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Created</TableHead>
|
||||
<TableHead>Last edited</TableHead>
|
||||
<TableHead className="w-[70px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageItems.map((project) => {
|
||||
const Icon = getProjectIcon(project.iconId)
|
||||
return (
|
||||
<TableRow
|
||||
key={project.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleOpen(project.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">{project.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
||||
{formatDate(project.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(project.lastEditedAt ?? project.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">Actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleOpen(project.id)}>
|
||||
<FolderOpen className="size-4" />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleRenameOpen(project)}>
|
||||
<Pencil className="size-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleExport(project)}>
|
||||
<Download className="size-4" />
|
||||
Export
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => handleDeleteOpen(project)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {page + 1} of {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rename dialog */}
|
||||
<Dialog open={!!renameTarget} onOpenChange={(open) => !open && setRenameTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename project</DialogTitle>
|
||||
<DialogDescription>Enter a new name for this project.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRenameSubmit()}
|
||||
placeholder="Project name"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRenameSubmit} disabled={!renameValue.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete project</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deleteTarget
|
||||
? `Are you sure you want to delete "${deleteTarget.name}"? This cannot be undone.`
|
||||
: ''}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteConfirm}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user