feat: implement project management features with routing, including project creation, deletion, renaming, and graph persistence

This commit is contained in:
2026-03-09 22:10:32 +01:00
parent c73aaca45a
commit e441d36cf4
12 changed files with 740 additions and 142 deletions

View 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>
)
}