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.
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Platform sidebar (sidebar-07 style): projects list, create, delete, collapse to icons.
|
* Platform sidebar: projects list (ordered, draggable), create, rename, delete.
|
||||||
* Uses platform context and router for navigation.
|
* "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 { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -25,31 +25,99 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} 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 { useSidebar } from '@/components/ui/sidebar'
|
||||||
import { usePlatform } from './platformContext'
|
import { usePlatform } from './platformContext'
|
||||||
import { getProjectIcon } from './icon-map'
|
import { getProjectIcon } from './icon-map'
|
||||||
import { NewProjectDialog } from './NewProjectDialog'
|
import { NewProjectDialog } from './NewProjectDialog'
|
||||||
|
import type { Project } from './types'
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
const { projects, createProject, deleteProject } = usePlatform()
|
const { orderedProjects, createProject, deleteProject, renameProject, reorderProjects, projectOrder } = usePlatform()
|
||||||
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 = (id: string) => {
|
const handleSelectProject = useCallback(
|
||||||
navigate(`/projects/${id}`)
|
(id: string) => {
|
||||||
}
|
if (dragRef.current) return
|
||||||
|
navigate(`/projects/${id}`)
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
)
|
||||||
|
|
||||||
const handleCreateProject = (project: Parameters<typeof createProject>[0]) => {
|
const handleCreateProject = useCallback(
|
||||||
createProject(project)
|
(project: Parameters<typeof createProject>[0]) => {
|
||||||
navigate(`/projects/${project.id}`)
|
createProject(project)
|
||||||
}
|
navigate(`/projects/${project.id}`)
|
||||||
|
},
|
||||||
|
[createProject, navigate]
|
||||||
|
)
|
||||||
|
|
||||||
const handleDeleteProject = (id: string) => {
|
const handleDeleteProject = useCallback(
|
||||||
deleteProject(id)
|
(id: string) => {
|
||||||
if (selectedProjectId === id) navigate('/projects', { replace: true })
|
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 (
|
||||||
<>
|
<>
|
||||||
@@ -86,60 +154,134 @@ export function AppSidebar() {
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Projects</SidebarGroupLabel>
|
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Projects</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{projects.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)}
|
|
||||||
>
|
|
||||||
<Icon className="size-4" />
|
|
||||||
<span>{project.name}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<SidebarMenuAction showOnHover>
|
|
||||||
<MoreHorizontal />
|
|
||||||
<span className="sr-only">More</span>
|
|
||||||
</SidebarMenuAction>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
className="w-48 rounded-lg"
|
|
||||||
side={isMobile ? 'bottom' : 'right'}
|
|
||||||
align={isMobile ? 'end' : 'start'}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onClick={() => handleDeleteProject(project.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
Delete project
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<NewProjectDialog
|
<SidebarMenuButton asChild tooltip="All projects" isActive={!selectedProjectId}>
|
||||||
onCreate={handleCreateProject}
|
<Link to="/projects">
|
||||||
trigger={
|
<ListTodo className="size-4" />
|
||||||
<SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer">
|
<span>All projects</span>
|
||||||
<Plus className="size-4" />
|
</Link>
|
||||||
<span>New project</span>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuButton>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SidebarMenuItem>
|
</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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter />
|
<SidebarFooter />
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,16 +22,20 @@ import { getProjectIcon } from './icon-map'
|
|||||||
type NewProjectDialogProps = {
|
type NewProjectDialogProps = {
|
||||||
onCreate: (project: Project) => void
|
onCreate: (project: Project) => void
|
||||||
trigger?: React.ReactNode
|
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 [open, setOpen] = useState(false)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [iconId, setIconId] = useState<ProjectIconId>('cat')
|
const [iconId, setIconId] = useState<ProjectIconId>('cat')
|
||||||
|
|
||||||
|
const trimmed = name.trim()
|
||||||
|
const isDuplicate = trimmed.length > 0 && existingNames.some((n) => n.toLowerCase() === trimmed.toLowerCase())
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const trimmed = name.trim()
|
|
||||||
if (!trimmed) return
|
if (!trimmed) return
|
||||||
const project: Project = {
|
const project: Project = {
|
||||||
id: `proj_${Date.now()}`,
|
id: `proj_${Date.now()}`,
|
||||||
@@ -73,6 +77,9 @@ export function NewProjectDialog({ onCreate, trigger }: NewProjectDialogProps) {
|
|||||||
placeholder="My project"
|
placeholder="My project"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
{isDuplicate && (
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-500">A project with this name already exists.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label className="text-sm font-medium">Icon</label>
|
<label className="text-sm font-medium">Icon</label>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Projects list page: table of all projects with metadata, sorted by last edited.
|
* Projects list page: table with search, sort, pagination, actions.
|
||||||
* Actions: open, rename, export, delete. Pagination.
|
* 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 { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -29,7 +29,21 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
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 { usePlatform } from './platformContext'
|
||||||
import { getProjectIcon } from './icon-map'
|
import { getProjectIcon } from './icon-map'
|
||||||
import {
|
import {
|
||||||
@@ -38,9 +52,13 @@ import {
|
|||||||
PROJECT_FILE_EXT,
|
PROJECT_FILE_EXT,
|
||||||
PROJECT_VERSION,
|
PROJECT_VERSION,
|
||||||
} from './projectGraphStorage'
|
} from './projectGraphStorage'
|
||||||
|
import { useToast } from '@/components/ui/toast'
|
||||||
|
import { NewProjectDialog } from './NewProjectDialog'
|
||||||
import type { Project } from './types'
|
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) {
|
function formatDate(ts: number) {
|
||||||
return new Date(ts).toLocaleString(undefined, {
|
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() {
|
export function ProjectsPage() {
|
||||||
const { projects, deleteProject, renameProject } = usePlatform()
|
const { orderedProjects, deleteProject, renameProject, createProject, restoreProject } = usePlatform()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { addToast } = useToast()
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [pageSize, setPageSize] = useState<number>(10)
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>('lastEdited')
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
||||||
const [renameTarget, setRenameTarget] = useState<Project | null>(null)
|
const [renameTarget, setRenameTarget] = useState<Project | null>(null)
|
||||||
const [renameValue, setRenameValue] = useState('')
|
const [renameValue, setRenameValue] = useState('')
|
||||||
|
const renameInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<Project | null>(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(() => {
|
const sorted = useMemo(() => {
|
||||||
return [...projects].sort((a, b) => (b.lastEditedAt ?? b.createdAt) - (a.lastEditedAt ?? a.createdAt))
|
const arr = [...filtered]
|
||||||
}, [projects])
|
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 totalPages = Math.max(1, pageSize === -1 ? 1 : Math.ceil(sorted.length / pageSize))
|
||||||
const start = page * PAGE_SIZE
|
const start = pageSize === -1 ? 0 : page * pageSize
|
||||||
const pageItems = sorted.slice(start, start + PAGE_SIZE)
|
const pageItems = pageSize === -1 ? sorted : sorted.slice(start, start + pageSize)
|
||||||
|
|
||||||
const handleOpen = (projectId: string) => {
|
const handleSort = (key: SortKey) => {
|
||||||
navigate(`/projects/${projectId}`)
|
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)
|
setRenameTarget(project)
|
||||||
setRenameValue(project.name)
|
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()) {
|
if (renameTarget && renameValue.trim()) {
|
||||||
renameProject(renameTarget.id, renameValue.trim())
|
renameProject(renameTarget.id, renameValue.trim())
|
||||||
setRenameTarget(null)
|
setRenameTarget(null)
|
||||||
setRenameValue('')
|
setRenameValue('')
|
||||||
}
|
}
|
||||||
}
|
}, [renameTarget, renameValue, renameProject])
|
||||||
|
|
||||||
const handleDeleteOpen = (project: Project) => {
|
const handleDeleteOpen = useCallback((project: Project) => {
|
||||||
setDeleteTarget(project)
|
setDeleteTarget(project)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleDeleteConfirm = () => {
|
const handleDeleteConfirm = useCallback(() => {
|
||||||
if (deleteTarget) {
|
if (!deleteTarget) return
|
||||||
removeGraphFromStorage(deleteTarget.id)
|
const project = deleteTarget
|
||||||
deleteProject(deleteTarget.id)
|
const graphSnapshot = loadGraphFromStorage(project.id)
|
||||||
setDeleteTarget(null)
|
const snapshot =
|
||||||
navigate('/projects', { replace: true })
|
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 handleExport = useCallback(
|
||||||
const stored = loadGraphFromStorage(project.id)
|
(project: Project) => {
|
||||||
const state = stored
|
const stored = loadGraphFromStorage(project.id)
|
||||||
? { version: PROJECT_VERSION, nodes: stored.nodes, edges: stored.edges }
|
const state = stored
|
||||||
: { version: PROJECT_VERSION, nodes: [], edges: [] }
|
? { version: PROJECT_VERSION, nodes: stored.nodes, edges: stored.edges }
|
||||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
: { version: PROJECT_VERSION, nodes: [], edges: [] }
|
||||||
const url = URL.createObjectURL(blob)
|
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||||
const a = document.createElement('a')
|
const url = URL.createObjectURL(blob)
|
||||||
a.href = url
|
const a = document.createElement('a')
|
||||||
a.download = `${project.name.replace(/[^\w.-]/g, '_')}${PROJECT_FILE_EXT}`
|
const filename = `${project.name.replace(/[^\w.-]/g, '_')}${PROJECT_FILE_EXT}`
|
||||||
a.click()
|
a.href = url
|
||||||
URL.revokeObjectURL(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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4">
|
<div className="flex flex-1 flex-col gap-4 p-4">
|
||||||
<div className="flex flex-col gap-4 rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
|
<TooltipProvider>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
|
||||||
<h2 className="text-lg font-semibold">Projects</h2>
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
</div>
|
<h2 className="text-lg font-semibold">Projects</h2>
|
||||||
{sorted.length === 0 ? (
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="text-sm text-muted-foreground">No projects yet. Create one from the sidebar.</p>
|
<div className="relative flex-1 sm:w-48">
|
||||||
) : (
|
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<>
|
<Input
|
||||||
<Table>
|
placeholder="Search projects"
|
||||||
<TableHeader>
|
value={search}
|
||||||
<TableRow>
|
onChange={(e) => {
|
||||||
<TableHead>Name</TableHead>
|
setSearch(e.target.value)
|
||||||
<TableHead className="hidden sm:table-cell">Created</TableHead>
|
setPage(0)
|
||||||
<TableHead>Last edited</TableHead>
|
}}
|
||||||
<TableHead className="w-[70px]" />
|
className="pl-8"
|
||||||
</TableRow>
|
aria-label="Search projects by name"
|
||||||
</TableHeader>
|
/>
|
||||||
<TableBody>
|
|
||||||
{pageItems.map((project) => {
|
|
||||||
const Icon = getProjectIcon(project.iconId)
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={project.id}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => handleOpen(project.id)}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="font-medium">{project.name}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
|
||||||
{formatDate(project.createdAt)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{formatDate(project.lastEditedAt ?? project.createdAt)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="size-8">
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
<span className="sr-only">Actions</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => handleOpen(project.id)}>
|
|
||||||
<FolderOpen className="size-4" />
|
|
||||||
Open
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleRenameOpen(project)}>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
Rename
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleExport(project)}>
|
|
||||||
<Download className="size-4" />
|
|
||||||
Export
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onClick={() => handleDeleteOpen(project)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between border-t pt-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Page {page + 1} of {totalPages}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
||||||
disabled={page === 0}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
|
||||||
disabled={page >= totalPages - 1}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Select
|
||||||
|
value={pageSize === -1 ? 'all' : String(pageSize)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setPageSize(v === 'all' ? -1 : Number(v))
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[100px]">
|
||||||
|
<SelectValue placeholder="Page size" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((n) => (
|
||||||
|
<SelectItem key={n} value={String(n)}>
|
||||||
|
{n} per page
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="all">All</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed bg-muted/20 py-12">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{search.trim() ? 'No projects match your search.' : 'No projects yet. Create one to get started.'}
|
||||||
|
</p>
|
||||||
|
{!search.trim() && (
|
||||||
|
<NewProjectDialog
|
||||||
|
onCreate={handleCreateProject}
|
||||||
|
existingNames={orderedProjects.map((p) => p.name)}
|
||||||
|
trigger={
|
||||||
|
<Button>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New project
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-card">
|
||||||
|
<TableRow className="hover:bg-transparent border-b">
|
||||||
|
<TableHead>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center font-medium hover:underline"
|
||||||
|
onClick={() => handleSort('name')}
|
||||||
|
aria-sort={sortKey === 'name' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
{sortKey === 'name' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell w-[100px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
onClick={() => handleSort('created')}
|
||||||
|
aria-sort={sortKey === 'created' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
||||||
|
>
|
||||||
|
Created
|
||||||
|
{sortKey === 'created' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell w-[90px]">Size</TableHead>
|
||||||
|
<TableHead className="w-[140px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
onClick={() => handleSort('lastEdited')}
|
||||||
|
aria-sort={sortKey === 'lastEdited' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
||||||
|
>
|
||||||
|
Last edited
|
||||||
|
{sortKey === 'lastEdited' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="sticky right-0 z-10 w-[70px] bg-card" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{pageItems.map((project) => {
|
||||||
|
const Icon = getProjectIcon(project.iconId)
|
||||||
|
const counts = getGraphCounts(project.id)
|
||||||
|
const lastEdited = project.lastEditedAt ?? project.createdAt
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={project.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => 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}`}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{project.name}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell text-muted-foreground">
|
||||||
|
{formatDate(project.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell text-muted-foreground">
|
||||||
|
{counts.nodes} nodes, {counts.edges} edges
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>{getRelativeTime(lastEdited)}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{formatDate(lastEdited)}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className="sticky right-0 bg-card"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
aria-label={`Actions for ${project.name}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleOpen(project.id)}>
|
||||||
|
<FolderOpen className="size-4" />
|
||||||
|
Open
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleRenameOpen(project)}>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleExport(project)}>
|
||||||
|
<Download className="size-4" />
|
||||||
|
Export
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => handleDeleteOpen(project)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Page {page + 1} of {totalPages}
|
||||||
|
{pageSize !== -1 && ` · ${sorted.length} projects`}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(0)}
|
||||||
|
disabled={page === 0}
|
||||||
|
aria-label="First page"
|
||||||
|
>
|
||||||
|
<ChevronsLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(totalPages - 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
aria-label="Last page"
|
||||||
|
>
|
||||||
|
<ChevronsRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rename dialog */}
|
||||||
|
<Dialog open={!!renameTarget} onOpenChange={(open) => !open && setRenameTarget(null)}>
|
||||||
|
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rename project</DialogTitle>
|
||||||
|
<DialogDescription>Enter a new name for this project.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
ref={renameInputRef}
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => 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()) && (
|
||||||
|
<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)}>
|
||||||
</div>
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleRenameSubmit} disabled={!renameValue.trim()}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Rename dialog */}
|
{/* Delete confirmation */}
|
||||||
<Dialog open={!!renameTarget} onOpenChange={(open) => !open && setRenameTarget(null)}>
|
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Rename project</DialogTitle>
|
<DialogTitle>Delete project</DialogTitle>
|
||||||
<DialogDescription>Enter a new name for this project.</DialogDescription>
|
<DialogDescription>
|
||||||
</DialogHeader>
|
{deleteTarget
|
||||||
<Input
|
? `Are you sure you want to delete "${deleteTarget.name}"? You can undo this from the notification.`
|
||||||
value={renameValue}
|
: ''}
|
||||||
onChange={(e) => setRenameValue(e.target.value)}
|
</DialogDescription>
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleRenameSubmit()}
|
</DialogHeader>
|
||||||
placeholder="Project name"
|
<DialogFooter>
|
||||||
/>
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||||
<DialogFooter>
|
Cancel
|
||||||
<Button variant="outline" onClick={() => setRenameTarget(null)}>
|
</Button>
|
||||||
Cancel
|
<Button variant="destructive" onClick={handleDeleteConfirm}>
|
||||||
</Button>
|
Delete
|
||||||
<Button onClick={handleRenameSubmit} disabled={!renameValue.trim()}>
|
</Button>
|
||||||
Save
|
</DialogFooter>
|
||||||
</Button>
|
</DialogContent>
|
||||||
</DialogFooter>
|
</Dialog>
|
||||||
</DialogContent>
|
</TooltipProvider>
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
|
||||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete project</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{deleteTarget
|
|
||||||
? `Are you sure you want to delete "${deleteTarget.name}"? This cannot be undone.`
|
|
||||||
: ''}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" onClick={handleDeleteConfirm}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
* 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 type { Project } from './types'
|
||||||
|
import { saveGraphToStorage } from './projectGraphStorage'
|
||||||
|
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'
|
||||||
|
|
||||||
|
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[] {
|
function loadProjects(): Project[] {
|
||||||
try {
|
try {
|
||||||
@@ -37,19 +55,45 @@ function saveProjects(projects: Project[]) {
|
|||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects))
|
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<string>()
|
||||||
|
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 = {
|
export type PlatformContextValue = {
|
||||||
projects: Project[]
|
projects: Project[]
|
||||||
|
projectOrder: string[]
|
||||||
|
/** Projects in display order (sidebar order, then lastEditedAt) */
|
||||||
|
orderedProjects: Project[]
|
||||||
persist: (next: Project[]) => void
|
persist: (next: Project[]) => void
|
||||||
createProject: (project: Project) => void
|
createProject: (project: Project) => void
|
||||||
deleteProject: (id: string) => void
|
deleteProject: (id: string) => void
|
||||||
renameProject: (id: string, name: string) => void
|
renameProject: (id: string, name: string) => void
|
||||||
updateLastEdited: (id: string) => void
|
updateLastEdited: (id: string) => void
|
||||||
|
reorderProjects: (orderedIds: string[]) => void
|
||||||
|
restoreProject: (project: Project, graphSnapshot: GraphSnapshot | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlatformContext = createContext<PlatformContextValue | null>(null)
|
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 persist = useCallback((next: Project[]) => {
|
const persist = useCallback((next: Project[]) => {
|
||||||
setProjects(next)
|
setProjects(next)
|
||||||
@@ -60,6 +104,11 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
|
|||||||
(project: Project) => {
|
(project: Project) => {
|
||||||
const withEdited = { ...project, lastEditedAt: project.createdAt }
|
const withEdited = { ...project, lastEditedAt: project.createdAt }
|
||||||
persist([...projects, withEdited])
|
persist([...projects, withEdited])
|
||||||
|
setProjectOrder((prev) => {
|
||||||
|
const next = prev.includes(project.id) ? prev : [project.id, ...prev]
|
||||||
|
saveOrder(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[projects, persist]
|
[projects, persist]
|
||||||
)
|
)
|
||||||
@@ -68,6 +117,11 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
|
|||||||
(id: string) => {
|
(id: string) => {
|
||||||
const next = projects.filter((p) => p.id !== id)
|
const next = projects.filter((p) => p.id !== id)
|
||||||
persist(next)
|
persist(next)
|
||||||
|
setProjectOrder((prev) => {
|
||||||
|
const nextOrder = prev.filter((oid) => oid !== id)
|
||||||
|
saveOrder(nextOrder)
|
||||||
|
return nextOrder
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[projects, persist]
|
[projects, persist]
|
||||||
)
|
)
|
||||||
@@ -89,14 +143,61 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
|
|||||||
[projects, persist]
|
[projects, persist]
|
||||||
)
|
)
|
||||||
|
|
||||||
const value: PlatformContextValue = {
|
const reorderProjects = useCallback((orderedIds: string[]) => {
|
||||||
projects,
|
setProjectOrder(orderedIds)
|
||||||
persist,
|
saveOrder(orderedIds)
|
||||||
createProject,
|
}, [])
|
||||||
deleteProject,
|
|
||||||
renameProject,
|
const restoreProject = useCallback(
|
||||||
updateLastEdited,
|
(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 <PlatformContext.Provider value={value}>{children}</PlatformContext.Provider>
|
return <PlatformContext.Provider value={value}>{children}</PlatformContext.Provider>
|
||||||
}
|
}
|
||||||
|
|||||||
111
frontend/src/components/ui/toast.tsx
Normal file
111
frontend/src/components/ui/toast.tsx
Normal file
@@ -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<ToastContextValue | null>(null)
|
||||||
|
|
||||||
|
const TOAST_DURATION = 5000
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||||
|
const timersRef = React.useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||||||
|
{children}
|
||||||
|
<ToastViewport toasts={toasts} removeToast={removeToast} />
|
||||||
|
</ToastContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastViewport({
|
||||||
|
toasts,
|
||||||
|
removeToast,
|
||||||
|
}: {
|
||||||
|
toasts: ToastItem[]
|
||||||
|
removeToast: (id: string) => void
|
||||||
|
}) {
|
||||||
|
if (toasts.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-4 right-4 z-[100] flex max-w-[420px] flex-col gap-2"
|
||||||
|
role="region"
|
||||||
|
aria-label="Notifications"
|
||||||
|
>
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between gap-3 rounded-lg border bg-background px-4 py-3 text-sm shadow-lg',
|
||||||
|
'animate-in slide-in-from-bottom-2 fade-in-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-1">{t.message}</span>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
{t.undo && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||||
|
onClick={() => {
|
||||||
|
t.undo?.()
|
||||||
|
removeToast(t.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => removeToast(t.id)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const ctx = useContext(ToastContext)
|
||||||
|
if (!ctx) throw new Error('useToast must be used within ToastProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { ThemeProvider } from './lib/themeContext'
|
import { ThemeProvider } from './lib/themeContext'
|
||||||
|
import { ToastProvider } from './components/ui/toast'
|
||||||
import { registerBuiltinNodes } from './lib/registerBuiltinNodes'
|
import { registerBuiltinNodes } from './lib/registerBuiltinNodes'
|
||||||
import { PlatformPage } from './app/platform/PlatformPage'
|
import { PlatformPage } from './app/platform/PlatformPage'
|
||||||
import { ProjectsPage } from './app/platform/ProjectsPage'
|
import { ProjectsPage } from './app/platform/ProjectsPage'
|
||||||
@@ -14,15 +15,17 @@ registerBuiltinNodes()
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<BrowserRouter>
|
<ToastProvider>
|
||||||
<Routes>
|
<BrowserRouter>
|
||||||
<Route path="/" element={<PlatformPage />}>
|
<Routes>
|
||||||
<Route index element={<Navigate to="/projects" replace />} />
|
<Route path="/" element={<PlatformPage />}>
|
||||||
<Route path="projects" element={<ProjectsPage />} />
|
<Route index element={<Navigate to="/projects" replace />} />
|
||||||
<Route path="projects/:projectId" element={<CanvasRoute />} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
</Route>
|
<Route path="projects/:projectId" element={<CanvasRoute />} />
|
||||||
</Routes>
|
</Route>
|
||||||
</BrowserRouter>
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ToastProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user