feat: menu refactoring
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
@@ -51,12 +52,14 @@ import {
|
|||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
X,
|
X,
|
||||||
|
Copy,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { usePlatform } from './platformContext'
|
import { usePlatform } from './platformContext'
|
||||||
import { getProjectIcon } from '../../lib/iconMap'
|
import { getProjectIcon } from '../../lib/iconMap'
|
||||||
import {
|
import {
|
||||||
loadGraphFromStorage,
|
loadGraphFromStorage,
|
||||||
|
saveGraphToStorage,
|
||||||
removeGraphFromStorage,
|
removeGraphFromStorage,
|
||||||
PROJECT_FILE_EXT,
|
PROJECT_FILE_EXT,
|
||||||
PROJECT_VERSION,
|
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 type ViewMode = 'table' | 'cards'
|
||||||
|
|
||||||
export function ProjectsPage() {
|
export function ProjectsPage() {
|
||||||
@@ -210,6 +262,9 @@ export function ProjectsPage() {
|
|||||||
const [renameValue, setRenameValue] = useState('')
|
const [renameValue, setRenameValue] = useState('')
|
||||||
const renameInputRef = React.useRef<HTMLInputElement>(null)
|
const renameInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(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 [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [bulkDeleteTargets, setBulkDeleteTargets] = useState<Project[] | null>(null)
|
const [bulkDeleteTargets, setBulkDeleteTargets] = useState<Project[] | null>(null)
|
||||||
|
|
||||||
@@ -266,6 +321,13 @@ export function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
}, [renameTarget])
|
}, [renameTarget])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (duplicateTarget) {
|
||||||
|
const t = setTimeout(() => duplicateInputRef.current?.focus(), 0)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}
|
||||||
|
}, [duplicateTarget])
|
||||||
|
|
||||||
const handleRenameSubmit = useCallback(() => {
|
const handleRenameSubmit = useCallback(() => {
|
||||||
if (renameTarget && renameValue.trim()) {
|
if (renameTarget && renameValue.trim()) {
|
||||||
renameProject(renameTarget.id, renameValue.trim())
|
renameProject(renameTarget.id, renameValue.trim())
|
||||||
@@ -326,6 +388,33 @@ export function ProjectsPage() {
|
|||||||
[createProject, navigate]
|
[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 allOnPageSelected = pageItems.length > 0 && pageItems.every((p) => selectedIds.has(p.id))
|
||||||
const someOnPageSelected = pageItems.some((p) => selectedIds.has(p.id))
|
const someOnPageSelected = pageItems.some((p) => selectedIds.has(p.id))
|
||||||
|
|
||||||
@@ -561,9 +650,7 @@ export function ProjectsPage() {
|
|||||||
<SortIcon columnKey="lastEdited" />
|
<SortIcon columnKey="lastEdited" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</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">
|
<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" />
|
||||||
Actions
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="text-xs">
|
<TableBody className="text-xs">
|
||||||
@@ -617,67 +704,22 @@ export function ProjectsPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-end gap-0.5">
|
<ProjectActionsMenu
|
||||||
<Tooltip>
|
project={project}
|
||||||
<TooltipTrigger asChild>
|
onOpen={handleOpen}
|
||||||
<Button
|
onRenameOpen={handleRenameOpen}
|
||||||
variant="ghost"
|
onDuplicateOpen={handleDuplicateOpen}
|
||||||
size="icon"
|
onExport={handleExport}
|
||||||
className="size-7 text-muted-foreground hover:text-foreground"
|
onDeleteOpen={handleDeleteOpen}
|
||||||
aria-label={`Open ${project.name}`}
|
trigger={
|
||||||
onClick={() => handleOpen(project.id)}
|
<Button variant="ghost" size="icon" className="size-7" aria-label={`Actions for ${project.name}`}>
|
||||||
>
|
<MoreHorizontal className="size-4" />
|
||||||
<FolderOpen className="size-3.5" />
|
</Button>
|
||||||
</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>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DropdownMenu>
|
<ProjectActionsMenu
|
||||||
<DropdownMenuTrigger asChild>
|
project={project}
|
||||||
|
onOpen={handleOpen}
|
||||||
|
onRenameOpen={handleRenameOpen}
|
||||||
|
onDuplicateOpen={handleDuplicateOpen}
|
||||||
|
onExport={handleExport}
|
||||||
|
onDeleteOpen={handleDeleteOpen}
|
||||||
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -797,29 +845,8 @@ export function ProjectsPage() {
|
|||||||
>
|
>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||||
@@ -920,6 +947,38 @@ export function ProjectsPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
{/* Delete confirmation */}
|
||||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user