diff --git a/frontend/src/app/canvas/CanvasMenubar.tsx b/frontend/src/app/canvas/CanvasMenubar.tsx index 12cc300..3d8ef4e 100644 --- a/frontend/src/app/canvas/CanvasMenubar.tsx +++ b/frontend/src/app/canvas/CanvasMenubar.tsx @@ -2,8 +2,8 @@ * Menubar for the canvas page: Project (Import/Export), Edit (Undo/Redo), View (Fit View, Theme). */ -import React, { useEffect } from 'react' -import { Link } from 'react-router-dom' +import React, { useEffect, useMemo } from 'react' +import { Link, useParams } from 'react-router-dom' import { Menubar, MenubarContent, @@ -18,6 +18,7 @@ import { } from '@/components/ui/menubar' import { Kbd, KbdGroup } from '@/components/ui/kbd' import { useTheme } from '@/lib/themeContext' +import { usePlatform } from '@/app/platform/platformContext' import { ArrowLeft, Download, FolderOpen, Moon, Redo2, Sun, Undo2 } from 'lucide-react' export type CanvasMenubarProps = { @@ -48,6 +49,12 @@ export function CanvasMenubar({ onFitView, }: CanvasMenubarProps) { const { theme, setTheme } = useTheme() + const { projectId } = useParams<{ projectId: string }>() + const { projects } = usePlatform() + const projectName = useMemo( + () => (projectId ? projects.find((p) => p.id === projectId)?.name ?? null : null), + [projectId, projects] + ) useEffect(() => { const onKeyDown = (ev: KeyboardEvent) => { @@ -72,87 +79,94 @@ export function CanvasMenubar({ }, [undo, redo, canUndo, canRedo]) return ( - - - - - - Project - - - - Import… - - - - Export… - - - - - Edit - - - - Undo - - - ⌘ + Z - - - - - - Redo - - - ⌘ + ⇧ + Z - - - - - - - View - - {onFitView && ( - - Fit View +
+ + + + + + Project + + + + Import… + + + + Export… + + + + + Edit + + + + Undo - ⌘0 + ⌘ + Z - )} - {onFitView && } - - Theme - - setTheme('light')} - className="gap-2" - > - - Light - - setTheme('dark')} - className="gap-2" - > - - Dark - - - - - - + + + Redo + + + ⌘ + ⇧ + Z + + + + + + + View + + {onFitView && ( + + Fit View + + + ⌘0 + + + + )} + {onFitView && } + + Theme + + setTheme('light')} + className="gap-2" + > + + Light + + setTheme('dark')} + className="gap-2" + > + + Dark + + + + + + + {projectName && ( + + {projectName} + + )} +
) } diff --git a/frontend/src/app/platform/AppSidebar.tsx b/frontend/src/app/platform/AppSidebar.tsx index e894f4a..c5411b4 100644 --- a/frontend/src/app/platform/AppSidebar.tsx +++ b/frontend/src/app/platform/AppSidebar.tsx @@ -1,9 +1,8 @@ /** - * Platform sidebar: projects list (ordered, draggable), create, rename, delete. - * "Projects" link to list; empty state when no projects. + * Platform sidebar: All projects, Recently used, New project. */ -import React, { useCallback, useRef, useState } from 'react' +import React, { useCallback, useMemo } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { Sidebar, @@ -13,29 +12,12 @@ import { SidebarGroupLabel, SidebarHeader, SidebarMenu, - SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, SidebarRail, SidebarTrigger, } from '@/components/ui/sidebar' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -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 { Plus, ListTodo } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useSidebar } from '@/components/ui/sidebar' import { usePlatform } from './platformContext' @@ -44,22 +26,17 @@ import { NewProjectDialog } from './NewProjectDialog' import type { Project } from './types' export function AppSidebar() { - const { isMobile, state, setOpen } = useSidebar() + const { state, setOpen } = useSidebar() const isCollapsed = state === 'collapsed' - const { orderedProjects, createProject, deleteProject, renameProject, reorderProjects, projectOrder } = usePlatform() + const { orderedProjects, createProject, recentProjectIds } = usePlatform() + const recentProjects = useMemo(() => { + const byId = new Map(orderedProjects.map((p) => [p.id, p])) + return recentProjectIds.map((id) => byId.get(id)).filter((p): p is Project => p != null) + }, [orderedProjects, recentProjectIds]) const navigate = useNavigate() const { projectId: selectedProjectId } = useParams<{ projectId: string }>() - const dragRef = useRef(false) - const [renameTarget, setRenameTarget] = useState(null) - const [renameValue, setRenameValue] = useState('') - const handleSelectProject = useCallback( - (id: string) => { - if (dragRef.current) return - navigate(`/projects/${id}`) - }, - [navigate] - ) + const handleSelectProject = useCallback((id: string) => navigate(`/projects/${id}`), [navigate]) const handleCreateProject = useCallback( (project: Parameters[0]) => { @@ -69,58 +46,6 @@ export function AppSidebar() { [createProject, navigate] ) - 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 ( <> @@ -202,7 +127,6 @@ export function AppSidebar() { onClick={isCollapsed ? (e) => { if ((e.target as HTMLElement).closest('button') || (e.target as HTMLElement).closest('a')) return; setOpen(true) } : undefined} > - Projects @@ -212,126 +136,51 @@ export function AppSidebar() { - {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" + + + {recentProjects.length > 0 && ( + + Recently used + + {recentProjects.map((project) => { + const Icon = getProjectIcon(project.iconId) + const isActive = selectedProjectId === project.id + return ( + + handleSelectProject(project.id)} > - handleSelectProject(project.id)} - > - - {project.name} - - - - - - Actions for {project.name} - - - - - - - Open - - - handleRenameOpen(project)}> - - Rename - - handleDeleteProject(project.id)} - > - - Delete project - - - - - ) - })} - - p.name)} - trigger={ - - - New project - - } - /> - - - )} + + {project.name} + + + ) + })} + + + )} + + + + p.name)} + trigger={ + + + {orderedProjects.length === 0 ? 'Create your first project' : '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/PlatformPage.tsx b/frontend/src/app/platform/PlatformPage.tsx index 60bc31e..eec5356 100644 --- a/frontend/src/app/platform/PlatformPage.tsx +++ b/frontend/src/app/platform/PlatformPage.tsx @@ -10,7 +10,7 @@ import { AppSidebar } from './AppSidebar' function PlatformLayoutInner() { const { projectId } = useParams<{ projectId: string }>() - const { projects } = usePlatform() + const { recordProjectAccess } = usePlatform() const [sidebarOpen, setSidebarOpen] = useState(true) useEffect(() => { @@ -18,6 +18,10 @@ function PlatformLayoutInner() { else setSidebarOpen(true) }, [projectId]) + useEffect(() => { + if (projectId) recordProjectAccess(projectId) + }, [projectId, recordProjectAccess]) + return ( diff --git a/frontend/src/app/platform/platformContext.tsx b/frontend/src/app/platform/platformContext.tsx index 83ade06..df64d25 100644 --- a/frontend/src/app/platform/platformContext.tsx +++ b/frontend/src/app/platform/platformContext.tsx @@ -10,6 +10,8 @@ import { PROJECT_VERSION } from './projectGraphStorage' const STORAGE_KEY = 'zui_platform_projects' const ORDER_STORAGE_KEY = 'zui_platform_project_order' +const RECENT_STORAGE_KEY = 'zui_platform_recent_project_ids' +const RECENT_MAX = 5 function loadOrder(): string[] { try { @@ -26,6 +28,21 @@ function saveOrder(ids: string[]) { localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids)) } +function loadRecentIds(): string[] { + try { + const raw = localStorage.getItem(RECENT_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').slice(0, RECENT_MAX) : [] + } catch { + return [] + } +} + +function saveRecentIds(ids: string[]) { + localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(ids)) +} + function loadProjects(): Project[] { try { const raw = localStorage.getItem(STORAGE_KEY) @@ -80,6 +97,9 @@ export type PlatformContextValue = { projectOrder: string[] /** Projects in display order (sidebar order, then lastEditedAt) */ orderedProjects: Project[] + /** Last RECENT_MAX accessed project IDs (most recent first) */ + recentProjectIds: string[] + recordProjectAccess: (id: string) => void persist: (next: Project[]) => void createProject: (project: Project) => void deleteProject: (id: string) => void @@ -94,6 +114,7 @@ const PlatformContext = createContext(null) export function PlatformProvider({ children }: { children: React.ReactNode }) { const [projects, setProjects] = useState(loadProjects) const [projectOrder, setProjectOrder] = useState(loadOrder) + const [recentProjectIds, setRecentProjectIds] = useState(loadRecentIds) const persist = useCallback((next: Project[]) => { setProjects(next) @@ -122,6 +143,11 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) { saveOrder(nextOrder) return nextOrder }) + setRecentProjectIds((prev) => { + const next = prev.filter((oid) => oid !== id) + saveRecentIds(next) + return next + }) }, [projects, persist] ) @@ -148,6 +174,14 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) { saveOrder(orderedIds) }, []) + const recordProjectAccess = useCallback((id: string) => { + setRecentProjectIds((prev) => { + const next = [id, ...prev.filter((x) => x !== id)].slice(0, RECENT_MAX) + saveRecentIds(next) + return next + }) + }, []) + const restoreProject = useCallback( (project: Project, graphSnapshot: GraphSnapshot | null) => { persist([...projects, project]) @@ -177,6 +211,8 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) { projects, projectOrder, orderedProjects, + recentProjectIds, + recordProjectAccess, persist, createProject, deleteProject, @@ -189,6 +225,8 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) { projects, projectOrder, orderedProjects, + recentProjectIds, + recordProjectAccess, persist, createProject, deleteProject,