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). * Menubar for the canvas page: Project (Import/Export), Edit (Undo/Redo), View (Fit View, Theme).
*/ */
import React, { useEffect } from 'react' import React, { useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { import {
Menubar, Menubar,
MenubarContent, MenubarContent,
@@ -18,6 +18,7 @@ import {
} from '@/components/ui/menubar' } from '@/components/ui/menubar'
import { Kbd, KbdGroup } from '@/components/ui/kbd' import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { useTheme } from '@/lib/themeContext' import { useTheme } from '@/lib/themeContext'
import { usePlatform } from '@/app/platform/platformContext'
import { ArrowLeft, Download, FolderOpen, Moon, Redo2, Sun, Undo2 } from 'lucide-react' import { ArrowLeft, Download, FolderOpen, Moon, Redo2, Sun, Undo2 } from 'lucide-react'
export type CanvasMenubarProps = { export type CanvasMenubarProps = {
@@ -48,6 +49,12 @@ export function CanvasMenubar({
onFitView, onFitView,
}: CanvasMenubarProps) { }: CanvasMenubarProps) {
const { theme, setTheme } = useTheme() 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(() => { useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => { const onKeyDown = (ev: KeyboardEvent) => {
@@ -72,87 +79,94 @@ export function CanvasMenubar({
}, [undo, redo, canUndo, canRedo]) }, [undo, redo, canUndo, canRedo])
return ( return (
<Menubar className="shrink-0 rounded-none border-x-0 border-t-0 border-b-0"> <div className="relative flex h-9 w-full shrink-0 items-center border-b border-border/40 bg-background">
<Link <Menubar className="flex-1 shrink-0 rounded-none border-0 border-b-0 bg-transparent p-0 shadow-none">
to="/projects" <Link
aria-label="Back to projects" 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" 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> <ArrowLeft className="size-4" />
<MenubarMenu> </Link>
<MenubarTrigger className="font-medium">Project</MenubarTrigger> <MenubarMenu>
<MenubarContent> <MenubarTrigger className="font-medium">Project</MenubarTrigger>
<MenubarItem onClick={onImport} className="gap-2"> <MenubarContent>
<FolderOpen className="h-4 w-4" /> <MenubarItem onClick={onImport} className="gap-2">
Import <FolderOpen className="h-4 w-4" />
</MenubarItem> Import
<MenubarItem onClick={onExport} className="gap-2"> </MenubarItem>
<Download className="h-4 w-4" /> <MenubarItem onClick={onExport} className="gap-2">
Export <Download className="h-4 w-4" />
</MenubarItem> Export
</MenubarContent> </MenubarItem>
</MenubarMenu> </MenubarContent>
<MenubarMenu> </MenubarMenu>
<MenubarTrigger className="font-medium">Edit</MenubarTrigger> <MenubarMenu>
<MenubarContent> <MenubarTrigger className="font-medium">Edit</MenubarTrigger>
<MenubarItem onClick={undo} disabled={!canUndo} className="gap-2"> <MenubarContent>
<Undo2 className="h-4 w-4" /> <MenubarItem onClick={undo} disabled={!canUndo} className="gap-2">
Undo <Undo2 className="h-4 w-4" />
<span className="ml-auto pl-4"> Undo
<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
<span className="ml-auto pl-4"> <span className="ml-auto pl-4">
<KbdGroup> <KbdGroup>
<Kbd>0</Kbd> <Kbd> + Z</Kbd>
</KbdGroup> </KbdGroup>
</span> </span>
</MenubarItem> </MenubarItem>
)} <MenubarItem onClick={redo} disabled={!canRedo} className="gap-2">
{onFitView && <MenubarSeparator />} <Redo2 className="h-4 w-4" />
<MenubarSub> Redo
<MenubarSubTrigger>Theme</MenubarSubTrigger> <span className="ml-auto pl-4">
<MenubarSubContent> <KbdGroup>
<MenubarCheckboxItem <Kbd> + + Z</Kbd>
checked={theme === 'light'} </KbdGroup>
onCheckedChange={() => setTheme('light')} </span>
className="gap-2" </MenubarItem>
> </MenubarContent>
<Sun className="h-4 w-4" /> </MenubarMenu>
Light <MenubarMenu>
</MenubarCheckboxItem> <MenubarTrigger className="font-medium">View</MenubarTrigger>
<MenubarCheckboxItem <MenubarContent>
checked={theme === 'dark'} {onFitView && (
onCheckedChange={() => setTheme('dark')} <MenubarItem onClick={onFitView} className="gap-2">
className="gap-2" Fit View
> <span className="ml-auto pl-4">
<Moon className="h-4 w-4" /> <KbdGroup>
Dark <Kbd>0</Kbd>
</MenubarCheckboxItem> </KbdGroup>
</MenubarSubContent> </span>
</MenubarSub> </MenubarItem>
</MenubarContent> )}
</MenubarMenu> {onFitView && <MenubarSeparator />}
</Menubar> <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. * Platform sidebar: All projects, Recently used, New project.
* "Projects" link to list; empty state when no projects.
*/ */
import React, { useCallback, useRef, useState } from 'react' import React, { useCallback, useMemo } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { import {
Sidebar, Sidebar,
@@ -13,29 +12,12 @@ import {
SidebarGroupLabel, SidebarGroupLabel,
SidebarHeader, SidebarHeader,
SidebarMenu, SidebarMenu,
SidebarMenuAction,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarRail, SidebarRail,
SidebarTrigger, SidebarTrigger,
} from '@/components/ui/sidebar' } from '@/components/ui/sidebar'
import { import { Plus, ListTodo } from 'lucide-react'
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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSidebar } from '@/components/ui/sidebar' import { useSidebar } from '@/components/ui/sidebar'
import { usePlatform } from './platformContext' import { usePlatform } from './platformContext'
@@ -44,22 +26,17 @@ import { NewProjectDialog } from './NewProjectDialog'
import type { Project } from './types' import type { Project } from './types'
export function AppSidebar() { export function AppSidebar() {
const { isMobile, state, setOpen } = useSidebar() const { state, setOpen } = useSidebar()
const isCollapsed = state === 'collapsed' 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 navigate = useNavigate()
const { projectId: selectedProjectId } = useParams<{ projectId: string }>() const { projectId: selectedProjectId } = useParams<{ projectId: string }>()
const dragRef = useRef(false)
const [renameTarget, setRenameTarget] = useState<Project | null>(null)
const [renameValue, setRenameValue] = useState('')
const handleSelectProject = useCallback( const handleSelectProject = useCallback((id: string) => navigate(`/projects/${id}`), [navigate])
(id: string) => {
if (dragRef.current) return
navigate(`/projects/${id}`)
},
[navigate]
)
const handleCreateProject = useCallback( const handleCreateProject = useCallback(
(project: Parameters<typeof createProject>[0]) => { (project: Parameters<typeof createProject>[0]) => {
@@ -69,58 +46,6 @@ export function AppSidebar() {
[createProject, navigate] [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 ( return (
<> <>
<Sidebar collapsible="icon" className="relative"> <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} onClick={isCollapsed ? (e) => { if ((e.target as HTMLElement).closest('button') || (e.target as HTMLElement).closest('a')) return; setOpen(true) } : undefined}
> >
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Projects</SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild tooltip="All projects" isActive={!selectedProjectId}> <SidebarMenuButton asChild tooltip="All projects" isActive={!selectedProjectId}>
@@ -212,126 +136,51 @@ export function AppSidebar() {
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
{orderedProjects.length === 0 ? ( </SidebarMenu>
<SidebarMenuItem> </SidebarGroup>
<NewProjectDialog {recentProjects.length > 0 && (
onCreate={handleCreateProject} <SidebarGroup>
existingNames={orderedProjects.map((p) => p.name)} <SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Recently used</SidebarGroupLabel>
trigger={ <SidebarMenu>
<SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer"> {recentProjects.map((project) => {
<Plus className="size-4" /> const Icon = getProjectIcon(project.iconId)
<span>Create your first project</span> const isActive = selectedProjectId === project.id
</SidebarMenuButton> return (
} <SidebarMenuItem key={project.id}>
/> <SidebarMenuButton
</SidebarMenuItem> tooltip={project.name}
) : ( isActive={isActive}
<> onClick={() => handleSelectProject(project.id)}
{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"
> >
<SidebarMenuButton <Icon className="size-4" />
tooltip={project.name} <span>{project.name}</span>
isActive={isActive} </SidebarMenuButton>
onClick={() => handleSelectProject(project.id)} </SidebarMenuItem>
> )
<Icon className="size-4" /> })}
<span>{project.name}</span> </SidebarMenu>
</SidebarMenuButton> </SidebarGroup>
<DropdownMenu> )}
<DropdownMenuTrigger asChild> <SidebarGroup>
<SidebarMenuAction showOnHover> <SidebarMenu>
<MoreHorizontal /> <SidebarMenuItem>
<span className="sr-only">Actions for {project.name}</span> <NewProjectDialog
</SidebarMenuAction> onCreate={handleCreateProject}
</DropdownMenuTrigger> existingNames={orderedProjects.map((p) => p.name)}
<DropdownMenuContent trigger={
className="w-48 rounded-lg" <SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer">
side={isMobile ? 'bottom' : 'right'} <Plus className="size-4" />
align={isMobile ? 'end' : 'start'} <span>{orderedProjects.length === 0 ? 'Create your first project' : 'New project'}</span>
> </SidebarMenuButton>
<DropdownMenuItem asChild> }
<Link to={`/projects/${project.id}`}> />
<FolderOpen className="size-4" /> </SidebarMenuItem>
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>
</>
)}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter className="relative z-10" /> <SidebarFooter className="relative z-10" />
<SidebarRail className="relative z-10" /> <SidebarRail className="relative z-10" />
</Sidebar> </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() { function PlatformLayoutInner() {
const { projectId } = useParams<{ projectId: string }>() const { projectId } = useParams<{ projectId: string }>()
const { projects } = usePlatform() const { recordProjectAccess } = usePlatform()
const [sidebarOpen, setSidebarOpen] = useState(true) const [sidebarOpen, setSidebarOpen] = useState(true)
useEffect(() => { useEffect(() => {
@@ -18,6 +18,10 @@ function PlatformLayoutInner() {
else setSidebarOpen(true) else setSidebarOpen(true)
}, [projectId]) }, [projectId])
useEffect(() => {
if (projectId) recordProjectAccess(projectId)
}, [projectId, recordProjectAccess])
return ( return (
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}> <SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
<AppSidebar /> <AppSidebar />

View File

@@ -10,6 +10,8 @@ import { PROJECT_VERSION } from './projectGraphStorage'
const STORAGE_KEY = 'zui_platform_projects' const STORAGE_KEY = 'zui_platform_projects'
const ORDER_STORAGE_KEY = 'zui_platform_project_order' const ORDER_STORAGE_KEY = 'zui_platform_project_order'
const RECENT_STORAGE_KEY = 'zui_platform_recent_project_ids'
const RECENT_MAX = 5
function loadOrder(): string[] { function loadOrder(): string[] {
try { try {
@@ -26,6 +28,21 @@ function saveOrder(ids: string[]) {
localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids)) 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[] { function loadProjects(): Project[] {
try { try {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
@@ -80,6 +97,9 @@ export type PlatformContextValue = {
projectOrder: string[] projectOrder: string[]
/** Projects in display order (sidebar order, then lastEditedAt) */ /** Projects in display order (sidebar order, then lastEditedAt) */
orderedProjects: Project[] orderedProjects: Project[]
/** Last RECENT_MAX accessed project IDs (most recent first) */
recentProjectIds: string[]
recordProjectAccess: (id: string) => void
persist: (next: Project[]) => void persist: (next: Project[]) => void
createProject: (project: Project) => void createProject: (project: Project) => void
deleteProject: (id: string) => void deleteProject: (id: string) => void
@@ -94,6 +114,7 @@ const PlatformContext = createContext<PlatformContextValue | null>(null)
export function PlatformProvider({ children }: { children: React.ReactNode }) { export function PlatformProvider({ children }: { children: React.ReactNode }) {
const [projects, setProjects] = useState<Project[]>(loadProjects) const [projects, setProjects] = useState<Project[]>(loadProjects)
const [projectOrder, setProjectOrder] = useState<string[]>(loadOrder) const [projectOrder, setProjectOrder] = useState<string[]>(loadOrder)
const [recentProjectIds, setRecentProjectIds] = useState<string[]>(loadRecentIds)
const persist = useCallback((next: Project[]) => { const persist = useCallback((next: Project[]) => {
setProjects(next) setProjects(next)
@@ -122,6 +143,11 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
saveOrder(nextOrder) saveOrder(nextOrder)
return nextOrder return nextOrder
}) })
setRecentProjectIds((prev) => {
const next = prev.filter((oid) => oid !== id)
saveRecentIds(next)
return next
})
}, },
[projects, persist] [projects, persist]
) )
@@ -148,6 +174,14 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
saveOrder(orderedIds) 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( const restoreProject = useCallback(
(project: Project, graphSnapshot: GraphSnapshot | null) => { (project: Project, graphSnapshot: GraphSnapshot | null) => {
persist([...projects, project]) persist([...projects, project])
@@ -177,6 +211,8 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
projects, projects,
projectOrder, projectOrder,
orderedProjects, orderedProjects,
recentProjectIds,
recordProjectAccess,
persist, persist,
createProject, createProject,
deleteProject, deleteProject,
@@ -189,6 +225,8 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
projects, projects,
projectOrder, projectOrder,
orderedProjects, orderedProjects,
recentProjectIds,
recordProjectAccess,
persist, persist,
createProject, createProject,
deleteProject, deleteProject,