Files
zui/frontend/src/app/platform/ProjectsPage.tsx
2026-03-10 21:43:27 +01:00

807 lines
34 KiB
TypeScript

/**
* 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 (
<div
className={`flex h-full w-full flex-col items-center justify-center gap-1 text-muted-foreground ${className ?? ''}`}
style={{
backgroundImage: 'radial-gradient(circle, currentColor 1px, transparent 1px)',
backgroundSize: '8px 8px',
backgroundColor: 'hsl(var(--muted) / 0.5)',
}}
aria-hidden
>
<Network className="size-8" />
<span className="text-[10px]">No graph</span>
</div>
)
}
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 (
<svg
className={`h-full w-full ${className ?? ''}`}
viewBox="0 0 200 120"
preserveAspectRatio="xMidYMid meet"
aria-hidden
>
<defs>
<pattern id={dotGridPatternId} width={8} height={8} patternUnits="userSpaceOnUse">
<circle cx={1} cy={1} r={0.6} fill="hsl(var(--muted-foreground) / 0.2)" />
</pattern>
</defs>
<rect width={200} height={120} fill="hsl(var(--muted) / 0.4)" />
<rect width={200} height={120} fill={`url(#${dotGridPatternId})`} />
{validEdges.slice(0, 50).map((e, i) => {
const a = nodeById.get(e.source)!.position!
const b = nodeById.get(e.target)!.position!
return (
<line
key={e.id ?? `e-${i}`}
x1={ox + a.x * scale}
y1={oy + a.y * scale}
x2={ox + b.x * scale}
y2={oy + b.y * scale}
stroke="hsl(var(--muted-foreground) / 0.4)"
strokeWidth={1.5}
/>
)
})}
{withPos.slice(0, 80).map((n) => (
<circle
key={n.id}
cx={ox + n.position!.x * scale}
cy={oy + n.position!.y * scale}
r={3}
fill="hsl(var(--primary))"
/>
))}
</svg>
)
}
export type ViewMode = 'table' | 'cards'
export function ProjectsPage() {
const { orderedProjects, deleteProject, renameProject, createProject, restoreProject } = usePlatform()
const navigate = useNavigate()
const [viewMode, setViewMode] = useState<ViewMode>('cards')
const [search, setSearch] = useState('')
const [pageSize, setPageSize] = useState<number>(10)
const [page, setPage] = useState(0)
const [sortKey, setSortKey] = useState<SortKey>('lastEdited')
const [sortDir, setSortDir] = useState<SortDir>('desc')
const [renameTarget, setRenameTarget] = useState<Project | null>(null)
const [renameValue, setRenameValue] = useState('')
const renameInputRef = React.useRef<HTMLInputElement>(null)
const [deleteTarget, setDeleteTarget] = useState<Project | null>(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 (
<div className="relative flex flex-1 flex-col min-h-0">
<ProjectsPageBackground className="absolute inset-0 pointer-events-none" />
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 overflow-auto">
<TooltipProvider>
<div className="flex flex-wrap items-center gap-3">
<h1 className="p-3 scroll-m-20 text-4xl font-extrabold tracking-tight text-balance">
Hi, Demiurge
</h1>
</div>
<div className="flex flex-col gap-4 rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
<div className="grid w-full grid-cols-1 gap-3 sm:grid-cols-3 sm:items-center">
<div className="relative w-64 shrink-0 sm:justify-self-start">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
placeholder="Search projects"
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(0)
}}
className="h-9 w-full pl-8"
aria-label="Search projects by name"
/>
</div>
<div className="flex justify-self-start sm:justify-self-center">
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(v) => 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"
>
<ToggleGroupItem value="cards" aria-label="Cards view" className="gap-1.5 px-2.5">
<LayoutGrid className="size-3.5" />
Cards
</ToggleGroupItem>
<ToggleGroupItem value="table" aria-label="Table view" className="gap-1.5 px-2.5">
<List className="size-3.5" />
Table
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="flex h-9 items-center gap-2 sm:justify-self-end">
<Select
value={pageSize === -1 ? 'all' : String(pageSize)}
onValueChange={(v) => {
setPageSize(v === 'all' ? -1 : Number(v))
setPage(0)
}}
>
<SelectTrigger className="h-9 w-[120px]">
<SelectValue placeholder="Page size" />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n} per page
</SelectItem>
))}
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{sorted.length === 0 ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<NewProjectDialog
onCreate={handleCreateProject}
existingNames={orderedProjects.map((p) => p.name)}
trigger={
<div
className="flex cursor-pointer flex-col overflow-hidden rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 shadow-sm transition-colors hover:border-muted-foreground/50 hover:bg-muted/30"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
; (e.currentTarget as HTMLElement).click()
}
}}
aria-label="Create new project"
>
<div className="flex aspect-video w-full shrink-0 items-center justify-center bg-muted/30">
<Plus className="size-12 text-muted-foreground" />
</div>
<div className="flex flex-1 flex-col gap-1 p-3">
<span className="font-medium text-muted-foreground">New project</span>
<span className="text-xs text-muted-foreground">
{search.trim() ? 'No projects match your search.' : 'Create a new project'}
</span>
</div>
</div>
}
/>
</div>
) : viewMode === 'table' ? (
<>
<div className="overflow-auto">
<Table className="table-fixed">
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow className="hover:bg-transparent border-b h-8">
<TableHead className="w-[35%] min-w-[100px] h-8 px-2 py-1.5 text-xs">
<button
type="button"
className="flex items-center font-medium hover:underline"
onClick={() => handleSort('name')}
aria-sort={sortKey === 'name' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
>
Name
{sortKey === 'name' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
</button>
</TableHead>
<TableHead className="hidden sm:table-cell w-[120px] min-w-[100px] h-8 px-2 py-1.5 text-xs">
<button
type="button"
className="font-medium hover:underline"
onClick={() => handleSort('created')}
aria-sort={sortKey === 'created' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
>
Created
{sortKey === 'created' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
</button>
</TableHead>
<TableHead className="hidden md:table-cell w-[100px] min-w-[80px] h-8 px-2 py-1.5 text-xs">Size</TableHead>
<TableHead className="w-[115px] min-w-[90px] h-8 px-2 py-1.5 text-xs">
<button
type="button"
className="font-medium hover:underline"
onClick={() => handleSort('lastEdited')}
aria-sort={sortKey === 'lastEdited' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
>
Last edited
{sortKey === 'lastEdited' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
</button>
</TableHead>
<TableHead className="sticky right-0 z-10 w-[52px] min-w-[52px] bg-card h-8 px-1 py-1.5 text-xs" />
</TableRow>
</TableHeader>
<TableBody className="text-xs">
{pageItems.map((project) => {
const Icon = getProjectIcon(project.iconId)
const counts = getGraphCounts(project.id)
const lastEdited = project.lastEditedAt ?? project.createdAt
return (
<TableRow
key={project.id}
className="cursor-pointer hover:bg-muted/50 h-8"
onClick={() => 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}`}
>
<TableCell className="px-2 py-1.5 min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="font-medium truncate">{project.name}</span>
</div>
</TableCell>
<TableCell className="hidden sm:table-cell text-muted-foreground px-2 py-1.5 w-[120px] min-w-[100px]">
<span className="truncate block" title={formatDate(project.createdAt)}>
{formatDate(project.createdAt)}
</span>
</TableCell>
<TableCell className="hidden md:table-cell text-muted-foreground px-2 py-1.5 w-[100px] min-w-[80px]" title={`${counts.nodes} nodes, ${counts.edges} edges`}>
{counts.nodes}n, {counts.edges}e
</TableCell>
<TableCell className="text-muted-foreground px-2 py-1.5 w-[115px] min-w-[90px]">
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate block">{getRelativeTime(lastEdited)}</span>
</TooltipTrigger>
<TooltipContent>{formatDate(lastEdited)}</TooltipContent>
</Tooltip>
</TableCell>
<TableCell
className="sticky right-0 bg-card w-[52px] min-w-[52px] px-1 py-1.5"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={`Actions for ${project.name}`}
>
<MoreHorizontal className="size-4" />
</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>
</div>
{totalPages > 1 && (
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
<p className="text-sm text-muted-foreground">
Page {page + 1} of {totalPages}
{pageSize !== -1 && ` · ${sorted.length} projects`}
</p>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setPage(0)}
disabled={page === 0}
aria-label="First page"
>
<ChevronsLeft className="size-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
aria-label="Previous page"
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
aria-label="Next page"
>
<ChevronRight className="size-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(totalPages - 1)}
disabled={page >= totalPages - 1}
aria-label="Last page"
>
<ChevronsRight className="size-4" />
</Button>
</div>
</div>
)}
</>
) : (
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<NewProjectDialog
onCreate={handleCreateProject}
existingNames={orderedProjects.map((p) => p.name)}
trigger={
<div
className="flex cursor-pointer flex-col overflow-hidden rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 shadow-sm transition-colors hover:border-muted-foreground/50 hover:bg-muted/30"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
; (e.currentTarget as HTMLElement).click()
}
}}
aria-label="Create new project"
>
<div className="flex aspect-video w-full shrink-0 items-center justify-center bg-muted/30">
<Plus className="size-12 text-muted-foreground" />
</div>
<div className="flex flex-1 flex-col gap-1 p-3">
<span className="font-medium text-muted-foreground">New project</span>
<span className="text-xs text-muted-foreground">Create a new project</span>
</div>
</div>
}
/>
{pageItems.map((project) => {
const Icon = getProjectIcon(project.iconId)
const lastEdited = project.lastEditedAt ?? project.createdAt
return (
<div
key={project.id}
className="group flex cursor-pointer flex-col overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow hover:shadow-md"
onClick={() => 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}`}
>
<div className="relative aspect-video w-full shrink-0 overflow-hidden bg-muted">
<GraphThumbnail projectId={project.id} className="h-full w-full object-cover" />
<div
className="absolute right-1.5 top-1.5 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
size="icon"
className="size-7 rounded-full shadow-sm"
aria-label={`Actions for ${project.name}`}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-4" />
</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>
</div>
</div>
<div className="flex flex-1 flex-col gap-1 p-3">
<div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{project.name}</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-muted-foreground">{getRelativeTime(lastEdited)}</span>
</TooltipTrigger>
<TooltipContent>{formatDate(lastEdited)}</TooltipContent>
</Tooltip>
</div>
</div>
)
})}
</div>
{totalPages > 1 && (
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
<p className="text-sm text-muted-foreground">
Page {page + 1} of {totalPages}
{pageSize !== -1 && ` · ${sorted.length} projects`}
</p>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setPage(0)}
disabled={page === 0}
aria-label="First page"
>
<ChevronsLeft className="size-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
aria-label="Previous page"
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
aria-label="Next page"
>
<ChevronRight className="size-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(totalPages - 1)}
disabled={page >= totalPages - 1}
aria-label="Last page"
>
<ChevronsRight className="size-4" />
</Button>
</div>
</div>
)}
</>
)}
</div>
{/* Rename dialog */}
<Dialog open={!!renameTarget} onOpenChange={(open) => !open && setRenameTarget(null)}>
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Rename project</DialogTitle>
<DialogDescription>Enter a new name for this project.</DialogDescription>
</DialogHeader>
<Input
ref={renameInputRef}
value={renameValue}
onChange={(e) => 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()) && (
<p className="text-xs text-amber-600 dark:text-amber-500">A project with this name already exists.</p>
)}
<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}"? You can undo this from the notification.`
: ''}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TooltipProvider>
</div>
</div>
)
}