feat: menu refactoring
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user