diff --git a/frontend/src/app/platform/ProjectsPage.tsx b/frontend/src/app/platform/ProjectsPage.tsx index 986f688..62c7cdd 100644 --- a/frontend/src/app/platform/ProjectsPage.tsx +++ b/frontend/src/app/platform/ProjectsPage.tsx @@ -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 ( + + {trigger} + + onOpen(project.id)}> + + Open + + onRenameOpen(project)}> + + Rename + + onDuplicateOpen(project)}> + + Duplicate + + onExport(project)}> + + Export + + + onDeleteOpen(project)}> + + Delete + + + + ) +} + export type ViewMode = 'table' | 'cards' export function ProjectsPage() { @@ -210,6 +262,9 @@ export function ProjectsPage() { const [renameValue, setRenameValue] = useState('') const renameInputRef = React.useRef(null) const [deleteTarget, setDeleteTarget] = useState(null) + const [duplicateTarget, setDuplicateTarget] = useState(null) + const [duplicateName, setDuplicateName] = useState('') + const duplicateInputRef = React.useRef(null) const [selectedIds, setSelectedIds] = useState>(new Set()) const [bulkDeleteTargets, setBulkDeleteTargets] = useState(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() { - - Actions - + @@ -617,67 +704,22 @@ export function ProjectsPage() { e.stopPropagation()} > - - - - handleOpen(project.id)} - > - - - - Open - - - - handleRenameOpen(project)} - > - - - - Rename - - - - handleExport(project)} - > - - - - Export - - - - handleDeleteOpen(project)} - > - - - - Delete - - + + + + } + /> ) @@ -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()} > - - + - - - handleOpen(project.id)}> - - Open - - handleRenameOpen(project)}> - - Rename - - handleExport(project)}> - - Export - - handleDeleteOpen(project)} - > - - Delete - - - + } + /> @@ -920,6 +947,38 @@ export function ProjectsPage() { + {/* Duplicate dialog */} + { if (!open) { setDuplicateTarget(null); setDuplicateName('') } }}> + e.preventDefault()}> + + Duplicate project + Enter a name for the duplicate project. + + 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()) && ( + A project with this name already exists. + )} + + { setDuplicateTarget(null); setDuplicateName('') }}> + Cancel + + + Duplicate + + + + + {/* Delete confirmation */} !open && setDeleteTarget(null)}>
A project with this name already exists.