/** * Projects list page: table or cards view with search, sort, pagination, actions. * Sort by last edited (default), name, or created; configurable page size; export toast; undo delete. */ import React, { useCallback, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, MoreHorizontal, Pencil, Trash2, Download, FolderOpen, Search, Plus, LayoutGrid, List, Network, } from 'lucide-react' import { usePlatform } from './platformContext' import { getProjectIcon } from '../../lib/iconMap' import { loadGraphFromStorage, removeGraphFromStorage, PROJECT_FILE_EXT, PROJECT_VERSION, } from './projectGraphStorage' import { toast } from 'sonner' import { NewProjectDialog } from './NewProjectDialog' import { ProjectsPageBackground } from './ProjectsPageBackground' import type { Project } from './types' const PAGE_SIZE_OPTIONS = [10, 25, 50] as const type SortKey = 'lastEdited' | 'name' | 'created' type SortDir = 'asc' | 'desc' function formatDate(ts: number) { return new Date(ts).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }) } function getRelativeTime(ts: number): string { const d = new Date(ts) const now = Date.now() const diff = now - d.getTime() const sec = Math.floor(diff / 1000) const min = Math.floor(sec / 60) const hour = Math.floor(min / 60) const day = Math.floor(hour / 24) if (day > 7) return formatDate(ts) if (day >= 1) return `${day} day${day === 1 ? '' : 's'} ago` if (hour >= 1) return `${hour} hour${hour === 1 ? '' : 's'} ago` if (min >= 1) return `${min} min ago` return 'Just now' } function getGraphCounts(projectId: string): { nodes: number; edges: number } { const stored = loadGraphFromStorage(projectId) if (!stored) return { nodes: 0, edges: 0 } return { nodes: Array.isArray(stored.nodes) ? stored.nodes.length : 0, edges: Array.isArray(stored.edges) ? stored.edges.length : 0, } } type NodeLike = { id: string; position?: { x: number; y: number } } type EdgeLike = { id?: string; source: string; target: string } /** Renders a minimal SVG preview of the graph from storage, or a placeholder. */ function GraphThumbnail({ projectId, className }: { projectId: string; className?: string }) { const stored = loadGraphFromStorage(projectId) const nodes = (stored?.nodes ?? []) as NodeLike[] const edges = (stored?.edges ?? []) as EdgeLike[] const withPos = nodes.filter((n) => n.position && typeof n.position.x === 'number' && typeof n.position.y === 'number') const dotGridPatternId = `dotgrid-${projectId.replace(/\W/g, '-')}` if (withPos.length === 0) { return (
No graph
) } const padding = 12 const xs = withPos.map((n) => n.position!.x) const ys = withPos.map((n) => n.position!.y) const minX = Math.min(...xs) const maxX = Math.max(...xs) const minY = Math.min(...ys) const maxY = Math.max(...ys) const w = Math.max(maxX - minX, 1) const h = Math.max(maxY - minY, 1) const scale = Math.min((200 - padding * 2) / w, (120 - padding * 2) / h, 8) const ox = padding - minX * scale const oy = padding - minY * scale const nodeById = new Map(withPos.map((n) => [n.id, n])) const validEdges = edges.filter((e) => nodeById.has(e.source) && nodeById.has(e.target)) return ( {validEdges.slice(0, 50).map((e, i) => { const a = nodeById.get(e.source)!.position! const b = nodeById.get(e.target)!.position! return ( ) })} {withPos.slice(0, 80).map((n) => ( ))} ) } export type ViewMode = 'table' | 'cards' export function ProjectsPage() { const { orderedProjects, deleteProject, renameProject, createProject, restoreProject } = usePlatform() const navigate = useNavigate() const [viewMode, setViewMode] = useState('cards') const [search, setSearch] = useState('') const [pageSize, setPageSize] = useState(10) const [page, setPage] = useState(0) const [sortKey, setSortKey] = useState('lastEdited') const [sortDir, setSortDir] = useState('desc') const [renameTarget, setRenameTarget] = useState(null) const [renameValue, setRenameValue] = useState('') const renameInputRef = React.useRef(null) const [deleteTarget, setDeleteTarget] = useState(null) const filtered = useMemo(() => { const q = search.trim().toLowerCase() if (!q) return orderedProjects return orderedProjects.filter((p) => p.name.toLowerCase().includes(q)) }, [orderedProjects, search]) const sorted = useMemo(() => { const arr = [...filtered] const mult = sortDir === 'asc' ? 1 : -1 arr.sort((a, b) => { if (sortKey === 'name') { return mult * a.name.localeCompare(b.name) } if (sortKey === 'created') { return mult * (a.createdAt - b.createdAt) } return mult * ((a.lastEditedAt ?? a.createdAt) - (b.lastEditedAt ?? b.createdAt)) }) return arr }, [filtered, sortKey, sortDir]) const totalPages = Math.max(1, pageSize === -1 ? 1 : Math.ceil(sorted.length / pageSize)) const start = pageSize === -1 ? 0 : page * pageSize const pageItems = pageSize === -1 ? sorted : sorted.slice(start, start + pageSize) const handleSort = (key: SortKey) => { if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')) else { setSortKey(key) setSortDir(key === 'name' ? 'asc' : 'desc') } setPage(0) } const handleOpen = useCallback( (projectId: string) => { navigate(`/projects/${projectId}`) }, [navigate] ) const handleRenameOpen = useCallback((project: Project) => { setRenameTarget(project) setRenameValue(project.name) }, []) React.useEffect(() => { if (renameTarget) { const t = setTimeout(() => renameInputRef.current?.focus(), 0) return () => clearTimeout(t) } }, [renameTarget]) const handleRenameSubmit = useCallback(() => { if (renameTarget && renameValue.trim()) { renameProject(renameTarget.id, renameValue.trim()) setRenameTarget(null) setRenameValue('') } }, [renameTarget, renameValue, renameProject]) const handleDeleteOpen = useCallback((project: Project) => { setDeleteTarget(project) }, []) const handleDeleteConfirm = useCallback(() => { if (!deleteTarget) return const project = deleteTarget const graphSnapshot = loadGraphFromStorage(project.id) const snapshot = graphSnapshot && (graphSnapshot.nodes.length > 0 || graphSnapshot.edges.length > 0) ? { nodes: graphSnapshot.nodes, edges: graphSnapshot.edges } : null removeGraphFromStorage(project.id) deleteProject(project.id) setDeleteTarget(null) navigate('/projects', { replace: true }) toast(`"${project.name}" deleted`, { action: { label: 'Undo', onClick: () => restoreProject(project, snapshot), }, duration: 8000, }) }, [deleteTarget, deleteProject, navigate, restoreProject]) const handleExport = useCallback( (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') const filename = `${project.name.replace(/[^\w.-]/g, '_')}${PROJECT_FILE_EXT}` a.href = url a.download = filename a.click() URL.revokeObjectURL(url) toast.success(`Exported as ${filename}`) }, [] ) const handleCreateProject = useCallback( (project: Project) => { createProject(project) navigate(`/projects/${project.id}`) }, [createProject, navigate] ) return (

Hi, Demiurge

{ setSearch(e.target.value) setPage(0) }} className="h-9 w-full pl-8" aria-label="Search projects by name" />
v && setViewMode(v as ViewMode)} aria-label="View mode" variant="outline" size="sm" className="gap-0 rounded-md p-0.5 [&>button]:rounded-none [&>button:first-child]:rounded-l-md [&>button:last-child]:rounded-r-md [&>button:not(:first-child)]:border-l-0" > Cards Table
{sorted.length === 0 ? (
p.name)} trigger={
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() ; (e.currentTarget as HTMLElement).click() } }} aria-label="Create new project" >
New project {search.trim() ? 'No projects match your search.' : 'Create a new project'}
} />
) : viewMode === 'table' ? ( <>
Size {pageItems.map((project) => { const Icon = getProjectIcon(project.iconId) const counts = getGraphCounts(project.id) const lastEdited = project.lastEditedAt ?? project.createdAt return ( handleOpen(project.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() handleOpen(project.id) } }} tabIndex={0} role="button" aria-label={`Open ${project.name}`} >
{project.name}
{formatDate(project.createdAt)} {counts.nodes}n, {counts.edges}e {getRelativeTime(lastEdited)} {formatDate(lastEdited)} e.stopPropagation()} > handleOpen(project.id)}> Open handleRenameOpen(project)}> Rename handleExport(project)}> Export handleDeleteOpen(project)} > Delete
) })}
{totalPages > 1 && (

Page {page + 1} of {totalPages} {pageSize !== -1 && ` · ${sorted.length} projects`}

)} ) : ( <>
p.name)} trigger={
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() ; (e.currentTarget as HTMLElement).click() } }} aria-label="Create new project" >
New project Create a new project
} /> {pageItems.map((project) => { const Icon = getProjectIcon(project.iconId) const lastEdited = project.lastEditedAt ?? project.createdAt return (
handleOpen(project.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() handleOpen(project.id) } }} role="button" tabIndex={0} aria-label={`Open ${project.name}`} >
e.stopPropagation()} > handleOpen(project.id)}> Open handleRenameOpen(project)}> Rename handleExport(project)}> Export handleDeleteOpen(project)} > Delete
{project.name}
{getRelativeTime(lastEdited)} {formatDate(lastEdited)}
) })}
{totalPages > 1 && (

Page {page + 1} of {totalPages} {pageSize !== -1 && ` · ${sorted.length} projects`}

)} )}
{/* Rename dialog */} !open && setRenameTarget(null)}> e.preventDefault()}> Rename project Enter a new name for this project. setRenameValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleRenameSubmit() if (e.key === 'Escape') setRenameTarget(null) }} placeholder="Project name" aria-label="Project name" /> {renameTarget && renameValue.trim() && sorted.some((p) => p.id !== renameTarget.id && p.name.toLowerCase() === renameValue.trim().toLowerCase()) && (

A project with this name already exists.

)}
{/* Delete confirmation */} !open && setDeleteTarget(null)}> Delete project {deleteTarget ? `Are you sure you want to delete "${deleteTarget.name}"? You can undo this from the notification.` : ''}
) }