From 57dfcb6458567253a39fc860a168f37e59ce13eb Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 9 Mar 2026 22:36:14 +0100 Subject: [PATCH] feat: enhance project management with duplicate name checks and toast notifications - Added duplicate name check in NewProjectDialog to warn users of existing project names. - Integrated toast notifications for actions like project deletion and export in ProjectsPage. - Implemented project ordering and restoration functionality in platform context. - Updated ProjectsPage to include search, sorting, and pagination features. - Created a new Toast component for displaying notifications with optional undo actions. - Refactored main application entry to include ToastProvider for global toast management. --- frontend/src/app/platform/AppSidebar.tsx | 266 ++++++-- .../src/app/platform/NewProjectDialog.tsx | 11 +- frontend/src/app/platform/ProjectsPage.tsx | 620 ++++++++++++------ frontend/src/app/platform/platformContext.tsx | 121 +++- frontend/src/components/ui/toast.tsx | 111 ++++ frontend/src/main.tsx | 21 +- 6 files changed, 880 insertions(+), 270 deletions(-) create mode 100644 frontend/src/components/ui/toast.tsx diff --git a/frontend/src/app/platform/AppSidebar.tsx b/frontend/src/app/platform/AppSidebar.tsx index 4485bed..511afc7 100644 --- a/frontend/src/app/platform/AppSidebar.tsx +++ b/frontend/src/app/platform/AppSidebar.tsx @@ -1,9 +1,9 @@ /** - * Platform sidebar (sidebar-07 style): projects list, create, delete, collapse to icons. - * Uses platform context and router for navigation. + * Platform sidebar: projects list (ordered, draggable), create, rename, delete. + * "Projects" link to list; empty state when no projects. */ -import React from 'react' +import React, { useCallback, useRef, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { Sidebar, @@ -25,31 +25,99 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { MoreHorizontal, Plus, Trash2 } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { MoreHorizontal, Plus, Trash2, Pencil, FolderOpen, ListTodo } from 'lucide-react' import { useSidebar } from '@/components/ui/sidebar' import { usePlatform } from './platformContext' import { getProjectIcon } from './icon-map' import { NewProjectDialog } from './NewProjectDialog' +import type { Project } from './types' export function AppSidebar() { const { isMobile } = useSidebar() - const { projects, createProject, deleteProject } = usePlatform() + const { orderedProjects, createProject, deleteProject, renameProject, reorderProjects, projectOrder } = usePlatform() const navigate = useNavigate() const { projectId: selectedProjectId } = useParams<{ projectId: string }>() + const dragRef = useRef(false) + const [renameTarget, setRenameTarget] = useState(null) + const [renameValue, setRenameValue] = useState('') - const handleSelectProject = (id: string) => { - navigate(`/projects/${id}`) - } + const handleSelectProject = useCallback( + (id: string) => { + if (dragRef.current) return + navigate(`/projects/${id}`) + }, + [navigate] + ) - const handleCreateProject = (project: Parameters[0]) => { - createProject(project) - navigate(`/projects/${project.id}`) - } + const handleCreateProject = useCallback( + (project: Parameters[0]) => { + createProject(project) + navigate(`/projects/${project.id}`) + }, + [createProject, navigate] + ) - const handleDeleteProject = (id: string) => { - deleteProject(id) - if (selectedProjectId === id) navigate('/projects', { replace: true }) - } + const handleDeleteProject = useCallback( + (id: string) => { + deleteProject(id) + if (selectedProjectId === id) navigate('/projects', { replace: true }) + }, + [deleteProject, selectedProjectId, navigate] + ) + + const handleRenameOpen = useCallback((project: Project) => { + setRenameTarget(project) + setRenameValue(project.name) + }, []) + + const handleRenameSubmit = useCallback(() => { + if (renameTarget && renameValue.trim()) { + renameProject(renameTarget.id, renameValue.trim()) + setRenameTarget(null) + setRenameValue('') + } + }, [renameTarget, renameValue, renameProject]) + + const handleDragStart = useCallback((e: React.DragEvent, projectId: string) => { + dragRef.current = true + e.dataTransfer.setData('text/plain', projectId) + e.dataTransfer.effectAllowed = 'move' + }, []) + + const handleDragEnd = useCallback(() => { + dragRef.current = false + }, []) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent, dropTargetId: string) => { + e.preventDefault() + const draggedId = e.dataTransfer.getData('text/plain') + if (!draggedId || draggedId === dropTargetId) return + const dropIdx = projectOrder.indexOf(dropTargetId) + if (dropIdx === -1) return + const next = projectOrder.filter((id) => id !== draggedId) + const insertIdx = next.indexOf(dropTargetId) + next.splice(insertIdx >= 0 ? insertIdx : next.length, 0, draggedId) + reorderProjects(next) + dragRef.current = false + }, + [projectOrder, reorderProjects] + ) return ( <> @@ -86,60 +154,134 @@ export function AppSidebar() { Projects - {projects.map((project) => { - const Icon = getProjectIcon(project.iconId) - const isActive = selectedProjectId === project.id - return ( - - handleSelectProject(project.id)} - > - - {project.name} - - - - - - More - - - - handleDeleteProject(project.id)} - > - - Delete project - - - - - ) - })} - - - New project - - } - /> + + + + All projects + + + {orderedProjects.length === 0 ? ( + + p.name)} + trigger={ + + + Create your first project + + } + /> + + ) : ( + <> + {orderedProjects.map((project) => { + const Icon = getProjectIcon(project.iconId) + const isActive = selectedProjectId === project.id + return ( + handleDragStart(e, project.id)} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, project.id)} + className="cursor-grab active:cursor-grabbing" + > + handleSelectProject(project.id)} + > + + {project.name} + + + + + + Actions for {project.name} + + + + + + + Open + + + handleRenameOpen(project)}> + + Rename + + handleDeleteProject(project.id)} + > + + Delete project + + + + + ) + })} + + p.name)} + trigger={ + + + New project + + } + /> + + + )} + + !open && setRenameTarget(null)}> + + + Rename project + Enter a new name for this project. + + setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRenameSubmit() + if (e.key === 'Escape') setRenameTarget(null) + }} + placeholder="Project name" + /> + {renameTarget && renameValue.trim() && orderedProjects.some((p) => p.id !== renameTarget.id && p.name.toLowerCase() === renameValue.trim().toLowerCase()) && ( +

A project with this name already exists.

+ )} + + + + +
+
) } diff --git a/frontend/src/app/platform/NewProjectDialog.tsx b/frontend/src/app/platform/NewProjectDialog.tsx index 4d5587b..64f810e 100644 --- a/frontend/src/app/platform/NewProjectDialog.tsx +++ b/frontend/src/app/platform/NewProjectDialog.tsx @@ -22,16 +22,20 @@ import { getProjectIcon } from './icon-map' type NewProjectDialogProps = { onCreate: (project: Project) => void trigger?: React.ReactNode + /** Other project names to check for duplicates (case-insensitive warning only) */ + existingNames?: string[] } -export function NewProjectDialog({ onCreate, trigger }: NewProjectDialogProps) { +export function NewProjectDialog({ onCreate, trigger, existingNames = [] }: NewProjectDialogProps) { const [open, setOpen] = useState(false) const [name, setName] = useState('') const [iconId, setIconId] = useState('cat') + const trimmed = name.trim() + const isDuplicate = trimmed.length > 0 && existingNames.some((n) => n.toLowerCase() === trimmed.toLowerCase()) + const handleSubmit = (e: React.FormEvent) => { e.preventDefault() - const trimmed = name.trim() if (!trimmed) return const project: Project = { id: `proj_${Date.now()}`, @@ -73,6 +77,9 @@ export function NewProjectDialog({ onCreate, trigger }: NewProjectDialogProps) { placeholder="My project" autoFocus /> + {isDuplicate && ( +

A project with this name already exists.

+ )}
diff --git a/frontend/src/app/platform/ProjectsPage.tsx b/frontend/src/app/platform/ProjectsPage.tsx index 5cd8c32..98606cd 100644 --- a/frontend/src/app/platform/ProjectsPage.tsx +++ b/frontend/src/app/platform/ProjectsPage.tsx @@ -1,9 +1,9 @@ /** - * Projects list page: table of all projects with metadata, sorted by last edited. - * Actions: open, rename, export, delete. Pagination. + * Projects list page: table with search, sort, pagination, actions. + * Sort by last edited (default), name, or created; configurable page size; export toast; undo delete. */ -import React, { useMemo, useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { Table, @@ -29,7 +29,21 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' -import { ChevronLeft, ChevronRight, MoreHorizontal, Pencil, Trash2, Download, FolderOpen } from 'lucide-react' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + MoreHorizontal, + Pencil, + Trash2, + Download, + FolderOpen, + Search, + Plus, +} from 'lucide-react' import { usePlatform } from './platformContext' import { getProjectIcon } from './icon-map' import { @@ -38,9 +52,13 @@ import { PROJECT_FILE_EXT, PROJECT_VERSION, } from './projectGraphStorage' +import { useToast } from '@/components/ui/toast' +import { NewProjectDialog } from './NewProjectDialog' import type { Project } from './types' -const PAGE_SIZE = 10 +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, { @@ -52,218 +70,446 @@ function formatDate(ts: number) { }) } +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, + } +} + export function ProjectsPage() { - const { projects, deleteProject, renameProject } = usePlatform() + const { orderedProjects, deleteProject, renameProject, createProject, restoreProject } = usePlatform() const navigate = useNavigate() + const { addToast } = useToast() + const [search, setSearch] = useState('') + const [pageSize, setPageSize] = useState(10) const [page, setPage] = useState(0) + const [sortKey, setSortKey] = useState('lastEdited') + const [sortDir, setSortDir] = useState('desc') const [renameTarget, setRenameTarget] = useState(null) const [renameValue, setRenameValue] = useState('') + const renameInputRef = React.useRef(null) const [deleteTarget, setDeleteTarget] = useState(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(() => { - return [...projects].sort((a, b) => (b.lastEditedAt ?? b.createdAt) - (a.lastEditedAt ?? a.createdAt)) - }, [projects]) + 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, Math.ceil(sorted.length / PAGE_SIZE)) - const start = page * PAGE_SIZE - const pageItems = sorted.slice(start, start + PAGE_SIZE) + 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 handleOpen = (projectId: string) => { - navigate(`/projects/${projectId}`) + const handleSort = (key: SortKey) => { + if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')) + else { + setSortKey(key) + setSortDir(key === 'name' ? 'asc' : 'desc') + } + setPage(0) } - const handleRenameOpen = (project: Project) => { + const handleOpen = useCallback( + (projectId: string) => { + navigate(`/projects/${projectId}`) + }, + [navigate] + ) + + const handleRenameOpen = useCallback((project: Project) => { setRenameTarget(project) setRenameValue(project.name) - } + }, []) - const handleRenameSubmit = () => { + 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 = (project: Project) => { + const handleDeleteOpen = useCallback((project: Project) => { setDeleteTarget(project) - } + }, []) - const handleDeleteConfirm = () => { - if (deleteTarget) { - removeGraphFromStorage(deleteTarget.id) - deleteProject(deleteTarget.id) - setDeleteTarget(null) - navigate('/projects', { replace: true }) - } - } + 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 }) + addToast(`"${project.name}" deleted`, { + undo: () => restoreProject(project, snapshot), + duration: 8000, + }) + }, [deleteTarget, deleteProject, navigate, addToast, restoreProject]) - const handleExport = (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') - a.href = url - a.download = `${project.name.replace(/[^\w.-]/g, '_')}${PROJECT_FILE_EXT}` - a.click() - URL.revokeObjectURL(url) - } + 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) + addToast(`Exported as ${filename}`) + }, + [addToast] + ) + + const handleCreateProject = useCallback( + (project: Project) => { + createProject(project) + navigate(`/projects/${project.id}`) + }, + [createProject, navigate] + ) return (
-
-
-

Projects

-
- {sorted.length === 0 ? ( -

No projects yet. Create one from the sidebar.

- ) : ( - <> - - - - Name - Created - Last edited - - - - - {pageItems.map((project) => { - const Icon = getProjectIcon(project.iconId) - return ( - handleOpen(project.id)} - > - -
- - {project.name} -
-
- - {formatDate(project.createdAt)} - - - {formatDate(project.lastEditedAt ?? project.createdAt)} - - e.stopPropagation()}> - - - - - - handleOpen(project.id)}> - - Open - - handleRenameOpen(project)}> - - Rename - - handleExport(project)}> - - Export - - handleDeleteOpen(project)} - > - - Delete - - - - -
- ) - })} -
-
- {totalPages > 1 && ( -
-

- Page {page + 1} of {totalPages} -

-
- - -
+ +
+
+

Projects

+
+
+ + { + setSearch(e.target.value) + setPage(0) + }} + className="pl-8" + aria-label="Search projects by name" + />
+ +
+
+ + {sorted.length === 0 ? ( +
+

+ {search.trim() ? 'No projects match your search.' : 'No projects yet. Create one to get started.'} +

+ {!search.trim() && ( + p.name)} + trigger={ + + } + /> + )} +
+ ) : ( + <> +
+ + + + + + + + + + Size + + + + + + + + {pageItems.map((project) => { + const Icon = getProjectIcon(project.iconId) + const counts = getGraphCounts(project.id) + const lastEdited = project.lastEditedAt ?? project.createdAt + return ( + 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}`} + > + +
+ + {project.name} +
+
+ + {formatDate(project.createdAt)} + + + {counts.nodes} nodes, {counts.edges} edges + + + + + {getRelativeTime(lastEdited)} + + {formatDate(lastEdited)} + + + e.stopPropagation()} + > + + + + + + handleOpen(project.id)}> + + Open + + handleRenameOpen(project)}> + + Rename + + handleExport(project)}> + + Export + + handleDeleteOpen(project)} + > + + Delete + + + + +
+ ) + })} +
+
+
+ + {totalPages > 1 && ( +
+

+ Page {page + 1} of {totalPages} + {pageSize !== -1 && ` · ${sorted.length} projects`} +

+
+ + + + +
+
+ )} + + )} +
+ + {/* Rename dialog */} + !open && setRenameTarget(null)}> + e.preventDefault()}> + + Rename project + Enter a new name for this project. + + 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()) && ( +

A project with this name already exists.

)} - - )} -
+ + + + + + - {/* Rename dialog */} - !open && setRenameTarget(null)}> - - - Rename project - Enter a new name for this project. - - setRenameValue(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleRenameSubmit()} - placeholder="Project name" - /> - - - - - - - - {/* Delete confirmation */} - !open && setDeleteTarget(null)}> - - - Delete project - - {deleteTarget - ? `Are you sure you want to delete "${deleteTarget.name}"? This cannot be undone.` - : ''} - - - - - - - - + {/* Delete confirmation */} + !open && setDeleteTarget(null)}> + + + Delete project + + {deleteTarget + ? `Are you sure you want to delete "${deleteTarget.name}"? You can undo this from the notification.` + : ''} + + + + + + + + +
) } diff --git a/frontend/src/app/platform/platformContext.tsx b/frontend/src/app/platform/platformContext.tsx index bbffd4f..83ade06 100644 --- a/frontend/src/app/platform/platformContext.tsx +++ b/frontend/src/app/platform/platformContext.tsx @@ -1,12 +1,30 @@ /** - * Platform context: projects list and handlers for create/delete/rename/updateLastEdited. + * Platform context: projects list and handlers for create/delete/rename/updateLastEdited/reorder/restore. * Used by AppSidebar, ProjectsTablePage, and canvas route. */ -import React, { createContext, useCallback, useContext, useState } from 'react' +import React, { createContext, useCallback, useContext, useMemo, useState } from 'react' import type { Project } from './types' +import { saveGraphToStorage } from './projectGraphStorage' +import { PROJECT_VERSION } from './projectGraphStorage' const STORAGE_KEY = 'zui_platform_projects' +const ORDER_STORAGE_KEY = 'zui_platform_project_order' + +function loadOrder(): string[] { + try { + const raw = localStorage.getItem(ORDER_STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) as unknown + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [] + } catch { + return [] + } +} + +function saveOrder(ids: string[]) { + localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids)) +} function loadProjects(): Project[] { try { @@ -37,19 +55,45 @@ function saveProjects(projects: Project[]) { localStorage.setItem(STORAGE_KEY, JSON.stringify(projects)) } +export type GraphSnapshot = { nodes: unknown[]; edges: unknown[] } + +/** Projects sorted by projectOrder, then by lastEditedAt desc for any not in order */ +export function sortProjectsByOrder(projects: Project[], order: string[]): Project[] { + const byId = new Map(projects.map((p) => [p.id, p])) + const ordered: Project[] = [] + const seen = new Set() + for (const id of order) { + const p = byId.get(id) + if (p) { + ordered.push(p) + seen.add(id) + } + } + const rest = projects + .filter((p) => !seen.has(p.id)) + .sort((a, b) => (b.lastEditedAt ?? b.createdAt) - (a.lastEditedAt ?? a.createdAt)) + return [...ordered, ...rest] +} + export type PlatformContextValue = { projects: Project[] + projectOrder: string[] + /** Projects in display order (sidebar order, then lastEditedAt) */ + orderedProjects: Project[] persist: (next: Project[]) => void createProject: (project: Project) => void deleteProject: (id: string) => void renameProject: (id: string, name: string) => void updateLastEdited: (id: string) => void + reorderProjects: (orderedIds: string[]) => void + restoreProject: (project: Project, graphSnapshot: GraphSnapshot | null) => void } const PlatformContext = createContext(null) export function PlatformProvider({ children }: { children: React.ReactNode }) { const [projects, setProjects] = useState(loadProjects) + const [projectOrder, setProjectOrder] = useState(loadOrder) const persist = useCallback((next: Project[]) => { setProjects(next) @@ -60,6 +104,11 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) { (project: Project) => { const withEdited = { ...project, lastEditedAt: project.createdAt } persist([...projects, withEdited]) + setProjectOrder((prev) => { + const next = prev.includes(project.id) ? prev : [project.id, ...prev] + saveOrder(next) + return next + }) }, [projects, persist] ) @@ -68,6 +117,11 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) { (id: string) => { const next = projects.filter((p) => p.id !== id) persist(next) + setProjectOrder((prev) => { + const nextOrder = prev.filter((oid) => oid !== id) + saveOrder(nextOrder) + return nextOrder + }) }, [projects, persist] ) @@ -89,14 +143,61 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) { [projects, persist] ) - const value: PlatformContextValue = { - projects, - persist, - createProject, - deleteProject, - renameProject, - updateLastEdited, - } + const reorderProjects = useCallback((orderedIds: string[]) => { + setProjectOrder(orderedIds) + saveOrder(orderedIds) + }, []) + + const restoreProject = useCallback( + (project: Project, graphSnapshot: GraphSnapshot | null) => { + persist([...projects, project]) + setProjectOrder((prev) => { + const next = prev.includes(project.id) ? prev : [project.id, ...prev] + saveOrder(next) + return next + }) + if (graphSnapshot) { + saveGraphToStorage(project.id, { + version: PROJECT_VERSION, + nodes: graphSnapshot.nodes, + edges: graphSnapshot.edges, + }) + } + }, + [projects, persist] + ) + + const orderedProjects = useMemo( + () => sortProjectsByOrder(projects, projectOrder), + [projects, projectOrder] + ) + + const value: PlatformContextValue = useMemo( + () => ({ + projects, + projectOrder, + orderedProjects, + persist, + createProject, + deleteProject, + renameProject, + updateLastEdited, + reorderProjects, + restoreProject, + }), + [ + projects, + projectOrder, + orderedProjects, + persist, + createProject, + deleteProject, + renameProject, + updateLastEdited, + reorderProjects, + restoreProject, + ] + ) return {children} } diff --git a/frontend/src/components/ui/toast.tsx b/frontend/src/components/ui/toast.tsx new file mode 100644 index 0000000..c463407 --- /dev/null +++ b/frontend/src/components/ui/toast.tsx @@ -0,0 +1,111 @@ +/** + * Simple toast: message + optional Undo action. Auto-dismisses after a delay. + */ + +import React, { createContext, useCallback, useContext, useState } from 'react' +import { cn } from '@/lib/utils' + +export type ToastItem = { + id: string + message: string + undo?: () => void +} + +type ToastContextValue = { + toasts: ToastItem[] + addToast: (message: string, options?: { undo?: () => void; duration?: number }) => void + removeToast: (id: string) => void +} + +const ToastContext = createContext(null) + +const TOAST_DURATION = 5000 + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]) + const timersRef = React.useRef>>({}) + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + const t = timersRef.current[id] + if (t) { + clearTimeout(t) + delete timersRef.current[id] + } + }, []) + + const addToast = useCallback( + (message: string, options?: { undo?: () => void; duration?: number }) => { + const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2)}` + const duration = options?.duration ?? TOAST_DURATION + setToasts((prev) => [...prev, { id, message, undo: options?.undo }]) + const timer = setTimeout(() => removeToast(id), duration) + timersRef.current[id] = timer + }, + [removeToast] + ) + + return ( + + {children} + + + ) +} + +function ToastViewport({ + toasts, + removeToast, +}: { + toasts: ToastItem[] + removeToast: (id: string) => void +}) { + if (toasts.length === 0) return null + return ( +
+ {toasts.map((t) => ( +
+ {t.message} +
+ {t.undo && ( + + )} + +
+
+ ))} +
+ ) +} + +export function useToast() { + const ctx = useContext(ToastContext) + if (!ctx) throw new Error('useToast must be used within ToastProvider') + return ctx +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0c3e4f9..227d3ed 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import React from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { ThemeProvider } from './lib/themeContext' +import { ToastProvider } from './components/ui/toast' import { registerBuiltinNodes } from './lib/registerBuiltinNodes' import { PlatformPage } from './app/platform/PlatformPage' import { ProjectsPage } from './app/platform/ProjectsPage' @@ -14,15 +15,17 @@ registerBuiltinNodes() createRoot(document.getElementById('root')!).render( - - - }> - } /> - } /> - } /> - - - + + + + }> + } /> + } /> + } /> + + + + )