feat: enhance platform context with recent project tracking and access recording

This commit is contained in:
2026-03-10 00:16:37 +01:00
parent bb58727e04
commit 80653b7fce
4 changed files with 184 additions and 279 deletions

View File

@@ -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 (
<Menubar className="shrink-0 rounded-none border-x-0 border-t-0 border-b-0">
<Link
to="/projects"
aria-label="Back to projects"
className="flex shrink-0 items-center rounded-sm px-2 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
>
<ArrowLeft className="size-4" />
</Link>
<MenubarMenu>
<MenubarTrigger className="font-medium">Project</MenubarTrigger>
<MenubarContent>
<MenubarItem onClick={onImport} className="gap-2">
<FolderOpen className="h-4 w-4" />
Import
</MenubarItem>
<MenubarItem onClick={onExport} className="gap-2">
<Download className="h-4 w-4" />
Export
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="font-medium">Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem onClick={undo} disabled={!canUndo} className="gap-2">
<Undo2 className="h-4 w-4" />
Undo
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd> + Z</Kbd>
</KbdGroup>
</span>
</MenubarItem>
<MenubarItem onClick={redo} disabled={!canRedo} className="gap-2">
<Redo2 className="h-4 w-4" />
Redo
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd> + + Z</Kbd>
</KbdGroup>
</span>
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="font-medium">View</MenubarTrigger>
<MenubarContent>
{onFitView && (
<MenubarItem onClick={onFitView} className="gap-2">
Fit View
<div className="relative flex h-9 w-full shrink-0 items-center border-b border-border/40 bg-background">
<Menubar className="flex-1 shrink-0 rounded-none border-0 border-b-0 bg-transparent p-0 shadow-none">
<Link
to="/projects"
aria-label="Back to projects"
className="flex shrink-0 items-center rounded-sm px-2 py-1 ml-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
>
<ArrowLeft className="size-4" />
</Link>
<MenubarMenu>
<MenubarTrigger className="font-medium">Project</MenubarTrigger>
<MenubarContent>
<MenubarItem onClick={onImport} className="gap-2">
<FolderOpen className="h-4 w-4" />
Import
</MenubarItem>
<MenubarItem onClick={onExport} className="gap-2">
<Download className="h-4 w-4" />
Export
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="font-medium">Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem onClick={undo} disabled={!canUndo} className="gap-2">
<Undo2 className="h-4 w-4" />
Undo
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>0</Kbd>
<Kbd> + Z</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
{onFitView && <MenubarSeparator />}
<MenubarSub>
<MenubarSubTrigger>Theme</MenubarSubTrigger>
<MenubarSubContent>
<MenubarCheckboxItem
checked={theme === 'light'}
onCheckedChange={() => setTheme('light')}
className="gap-2"
>
<Sun className="h-4 w-4" />
Light
</MenubarCheckboxItem>
<MenubarCheckboxItem
checked={theme === 'dark'}
onCheckedChange={() => setTheme('dark')}
className="gap-2"
>
<Moon className="h-4 w-4" />
Dark
</MenubarCheckboxItem>
</MenubarSubContent>
</MenubarSub>
</MenubarContent>
</MenubarMenu>
</Menubar>
<MenubarItem onClick={redo} disabled={!canRedo} className="gap-2">
<Redo2 className="h-4 w-4" />
Redo
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd> + + Z</Kbd>
</KbdGroup>
</span>
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="font-medium">View</MenubarTrigger>
<MenubarContent>
{onFitView && (
<MenubarItem onClick={onFitView} className="gap-2">
Fit View
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>0</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
{onFitView && <MenubarSeparator />}
<MenubarSub>
<MenubarSubTrigger>Theme</MenubarSubTrigger>
<MenubarSubContent>
<MenubarCheckboxItem
checked={theme === 'light'}
onCheckedChange={() => setTheme('light')}
className="gap-2"
>
<Sun className="h-4 w-4" />
Light
</MenubarCheckboxItem>
<MenubarCheckboxItem
checked={theme === 'dark'}
onCheckedChange={() => setTheme('dark')}
className="gap-2"
>
<Moon className="h-4 w-4" />
Dark
</MenubarCheckboxItem>
</MenubarSubContent>
</MenubarSub>
</MenubarContent>
</MenubarMenu>
</Menubar>
{projectName && (
<span className="pointer-events-none absolute left-1/2 -translate-x-1/2 truncate max-w-[40%] text-sm font-medium text-foreground">
{projectName}
</span>
)}
</div>
)
}

View File

@@ -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<Project | null>(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<typeof createProject>[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 (
<>
<Sidebar collapsible="icon" className="relative">
@@ -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}
>
<SidebarGroup>
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Projects</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="All projects" isActive={!selectedProjectId}>
@@ -212,126 +136,51 @@ export function AppSidebar() {
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{orderedProjects.length === 0 ? (
<SidebarMenuItem>
<NewProjectDialog
onCreate={handleCreateProject}
existingNames={orderedProjects.map((p) => p.name)}
trigger={
<SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer">
<Plus className="size-4" />
<span>Create your first project</span>
</SidebarMenuButton>
}
/>
</SidebarMenuItem>
) : (
<>
{orderedProjects.map((project) => {
const Icon = getProjectIcon(project.iconId)
const isActive = selectedProjectId === project.id
return (
<SidebarMenuItem
key={project.id}
data-project-id={project.id}
draggable
onDragStart={(e) => handleDragStart(e, project.id)}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, project.id)}
className="cursor-grab active:cursor-grabbing"
</SidebarMenu>
</SidebarGroup>
{recentProjects.length > 0 && (
<SidebarGroup>
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Recently used</SidebarGroupLabel>
<SidebarMenu>
{recentProjects.map((project) => {
const Icon = getProjectIcon(project.iconId)
const isActive = selectedProjectId === project.id
return (
<SidebarMenuItem key={project.id}>
<SidebarMenuButton
tooltip={project.name}
isActive={isActive}
onClick={() => handleSelectProject(project.id)}
>
<SidebarMenuButton
tooltip={project.name}
isActive={isActive}
onClick={() => handleSelectProject(project.id)}
>
<Icon className="size-4" />
<span>{project.name}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreHorizontal />
<span className="sr-only">Actions for {project.name}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align={isMobile ? 'end' : 'start'}
>
<DropdownMenuItem asChild>
<Link to={`/projects/${project.id}`}>
<FolderOpen className="size-4" />
Open
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRenameOpen(project)}>
<Pencil className="size-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => handleDeleteProject(project.id)}
>
<Trash2 className="size-4" />
Delete project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
)
})}
<SidebarMenuItem>
<NewProjectDialog
onCreate={handleCreateProject}
existingNames={orderedProjects.map((p) => p.name)}
trigger={
<SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer">
<Plus className="size-4" />
<span>New project</span>
</SidebarMenuButton>
}
/>
</SidebarMenuItem>
</>
)}
<Icon className="size-4" />
<span>{project.name}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroup>
)}
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<NewProjectDialog
onCreate={handleCreateProject}
existingNames={orderedProjects.map((p) => p.name)}
trigger={
<SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer">
<Plus className="size-4" />
<span>{orderedProjects.length === 0 ? 'Create your first project' : 'New project'}</span>
</SidebarMenuButton>
}
/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="relative z-10" />
<SidebarRail className="relative z-10" />
</Sidebar>
<Dialog open={!!renameTarget} onOpenChange={(open) => !open && setRenameTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename project</DialogTitle>
<DialogDescription>Enter a new name for this project.</DialogDescription>
</DialogHeader>
<Input
value={renameValue}
onChange={(e) => 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()) && (
<p className="text-xs text-amber-600 dark:text-amber-500">A project with this name already exists.</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setRenameTarget(null)}>
Cancel
</Button>
<Button onClick={handleRenameSubmit} disabled={!renameValue.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -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 (
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
<AppSidebar />

View File

@@ -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<PlatformContextValue | null>(null)
export function PlatformProvider({ children }: { children: React.ReactNode }) {
const [projects, setProjects] = useState<Project[]>(loadProjects)
const [projectOrder, setProjectOrder] = useState<string[]>(loadOrder)
const [recentProjectIds, setRecentProjectIds] = useState<string[]>(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,