feat: menu refactoring

This commit is contained in:
2026-03-11 17:16:13 +01:00
parent b92088f583
commit 6471cee05f

View File

@@ -18,6 +18,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
@@ -51,12 +52,14 @@ import {
ArrowUp,
ArrowDown,
X,
Copy,
} from 'lucide-react'
import { Checkbox } from '@/components/ui/checkbox'
import { usePlatform } from './platformContext'
import { getProjectIcon } from '../../lib/iconMap'
import {
loadGraphFromStorage,
saveGraphToStorage,
removeGraphFromStorage,
PROJECT_FILE_EXT,
PROJECT_VERSION,
@@ -195,6 +198,55 @@ function GraphThumbnail({ projectId, className }: { projectId: string; className
)
}
type ProjectActionsMenuProps = {
project: Project
onOpen: (id: string) => void
onRenameOpen: (project: Project) => void
onDuplicateOpen: (project: Project) => void
onExport: (project: Project) => void
onDeleteOpen: (project: Project) => void
trigger: React.ReactNode
}
function ProjectActionsMenu({
project,
onOpen,
onRenameOpen,
onDuplicateOpen,
onExport,
onDeleteOpen,
trigger,
}: ProjectActionsMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onOpen(project.id)}>
<FolderOpen className="size-4" />
Open
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onRenameOpen(project)}>
<Pencil className="size-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDuplicateOpen(project)}>
<Copy className="size-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onExport(project)}>
<Download className="size-4" />
Export
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => onDeleteOpen(project)}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export type ViewMode = 'table' | 'cards'
export function ProjectsPage() {
@@ -210,6 +262,9 @@ export function ProjectsPage() {
const [renameValue, setRenameValue] = useState('')
const renameInputRef = React.useRef<HTMLInputElement>(null)
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
const [duplicateTarget, setDuplicateTarget] = useState<Project | null>(null)
const [duplicateName, setDuplicateName] = useState('')
const duplicateInputRef = React.useRef<HTMLInputElement>(null)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [bulkDeleteTargets, setBulkDeleteTargets] = useState<Project[] | null>(null)
@@ -266,6 +321,13 @@ export function ProjectsPage() {
}
}, [renameTarget])
React.useEffect(() => {
if (duplicateTarget) {
const t = setTimeout(() => duplicateInputRef.current?.focus(), 0)
return () => clearTimeout(t)
}
}, [duplicateTarget])
const handleRenameSubmit = useCallback(() => {
if (renameTarget && renameValue.trim()) {
renameProject(renameTarget.id, renameValue.trim())
@@ -326,6 +388,33 @@ export function ProjectsPage() {
[createProject, navigate]
)
const handleDuplicateOpen = useCallback((project: Project) => {
setDuplicateTarget(project)
setDuplicateName(`${project.name} (copy)`)
}, [])
const handleDuplicateConfirm = useCallback(() => {
if (!duplicateTarget || !duplicateName.trim()) return
const name = duplicateName.trim()
const newId = `proj_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
const now = Date.now()
const newProject: Project = {
id: newId,
name,
iconId: duplicateTarget.iconId,
createdAt: now,
lastEditedAt: now,
}
createProject(newProject)
const graph = loadGraphFromStorage(duplicateTarget.id)
if (graph && (graph.nodes.length > 0 || graph.edges.length > 0)) {
saveGraphToStorage(newId, { version: PROJECT_VERSION, nodes: graph.nodes, edges: graph.edges })
}
toast.success('Project duplicated')
setDuplicateTarget(null)
setDuplicateName('')
}, [duplicateTarget, duplicateName, createProject])
const allOnPageSelected = pageItems.length > 0 && pageItems.every((p) => selectedIds.has(p.id))
const someOnPageSelected = pageItems.some((p) => selectedIds.has(p.id))
@@ -561,9 +650,7 @@ export function ProjectsPage() {
<SortIcon columnKey="lastEdited" />
</button>
</TableHead>
<TableHead className="sticky right-0 z-10 w-[140px] min-w-[140px] bg-card h-8 px-2 py-1.5 text-xs font-medium">
Actions
</TableHead>
<TableHead className="sticky right-0 z-10 w-12 min-w-12 bg-card h-8 px-1 py-1.5 text-xs font-medium" />
</TableRow>
</TableHeader>
<TableBody className="text-xs">
@@ -617,67 +704,22 @@ export function ProjectsPage() {
</Tooltip>
</TableCell>
<TableCell
className={`sticky right-0 w-[140px] min-w-[140px] px-2 py-1.5 ${isSelected ? 'bg-muted/70' : 'bg-card group-hover:bg-muted/50'}`}
className={`sticky right-0 w-12 min-w-12 px-1 py-1.5 ${isSelected ? 'bg-muted/70' : 'bg-card group-hover:bg-muted/50'}`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-end gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground"
aria-label={`Open ${project.name}`}
onClick={() => handleOpen(project.id)}
>
<FolderOpen className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Open</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground"
aria-label={`Rename ${project.name}`}
onClick={() => handleRenameOpen(project)}
>
<Pencil className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Rename</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground"
aria-label={`Export ${project.name}`}
onClick={() => handleExport(project)}
>
<Download className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Export</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
aria-label={`Delete ${project.name}`}
onClick={() => handleDeleteOpen(project)}
>
<Trash2 className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
<ProjectActionsMenu
project={project}
onOpen={handleOpen}
onRenameOpen={handleRenameOpen}
onDuplicateOpen={handleDuplicateOpen}
onExport={handleExport}
onDeleteOpen={handleDeleteOpen}
trigger={
<Button variant="ghost" size="icon" className="size-7" aria-label={`Actions for ${project.name}`}>
<MoreHorizontal className="size-4" />
</Button>
}
/>
</TableCell>
</TableRow>
)
@@ -786,8 +828,14 @@ export function ProjectsPage() {
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>
<ProjectActionsMenu
project={project}
onOpen={handleOpen}
onRenameOpen={handleRenameOpen}
onDuplicateOpen={handleDuplicateOpen}
onExport={handleExport}
onDeleteOpen={handleDeleteOpen}
trigger={
<Button
variant="secondary"
size="icon"
@@ -797,29 +845,8 @@ export function ProjectsPage() {
>
<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">
@@ -920,6 +947,38 @@ export function ProjectsPage() {
</DialogContent>
</Dialog>
{/* Duplicate dialog */}
<Dialog open={!!duplicateTarget} onOpenChange={(open) => { if (!open) { setDuplicateTarget(null); setDuplicateName('') } }}>
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Duplicate project</DialogTitle>
<DialogDescription>Enter a name for the duplicate project.</DialogDescription>
</DialogHeader>
<Input
ref={duplicateInputRef}
value={duplicateName}
onChange={(e) => setDuplicateName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleDuplicateConfirm()
if (e.key === 'Escape') setDuplicateTarget(null)
}}
placeholder="Project name"
aria-label="Duplicate project name"
/>
{duplicateTarget && duplicateName.trim() && orderedProjects.some((p) => p.name.toLowerCase() === duplicateName.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={() => { setDuplicateTarget(null); setDuplicateName('') }}>
Cancel
</Button>
<Button onClick={handleDuplicateConfirm} disabled={!duplicateName.trim()}>
Duplicate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>