feat: enhance platform context with recent project tracking and access recording
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user