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 @@
|
||||
/**
|
||||
* Projects list page: table of all projects with metadata, sorted by last edited.
|
||||
* Actions: open, rename, export, delete. Pagination.
|
||||
* Projects list page: table with search, sort, pagination, actions.
|
||||
* 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 {
|
||||
Table,
|
||||
@@ -29,7 +29,21 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
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 { getProjectIcon } from './icon-map'
|
||||
import {
|
||||
@@ -38,9 +52,13 @@ import {
|
||||
PROJECT_FILE_EXT,
|
||||
PROJECT_VERSION,
|
||||
} from './projectGraphStorage'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { NewProjectDialog } from './NewProjectDialog'
|
||||
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) {
|
||||
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() {
|
||||
const { projects, deleteProject, renameProject } = usePlatform()
|
||||
const { orderedProjects, deleteProject, renameProject, createProject, restoreProject } = usePlatform()
|
||||
const navigate = useNavigate()
|
||||
const { addToast } = useToast()
|
||||
const [search, setSearch] = useState('')
|
||||
const [pageSize, setPageSize] = useState<number>(10)
|
||||
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 [renameValue, setRenameValue] = useState('')
|
||||
const renameInputRef = React.useRef<HTMLInputElement>(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(() => {
|
||||
return [...projects].sort((a, b) => (b.lastEditedAt ?? b.createdAt) - (a.lastEditedAt ?? a.createdAt))
|
||||
}, [projects])
|
||||
const arr = [...filtered]
|
||||
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 start = page * PAGE_SIZE
|
||||
const pageItems = sorted.slice(start, start + PAGE_SIZE)
|
||||
const totalPages = Math.max(1, pageSize === -1 ? 1 : Math.ceil(sorted.length / pageSize))
|
||||
const start = pageSize === -1 ? 0 : page * pageSize
|
||||
const pageItems = pageSize === -1 ? sorted : sorted.slice(start, start + pageSize)
|
||||
|
||||
const handleOpen = (projectId: string) => {
|
||||
navigate(`/projects/${projectId}`)
|
||||
const handleSort = (key: SortKey) => {
|
||||
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)
|
||||
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()) {
|
||||
renameProject(renameTarget.id, renameValue.trim())
|
||||
setRenameTarget(null)
|
||||
setRenameValue('')
|
||||
}
|
||||
}
|
||||
}, [renameTarget, renameValue, renameProject])
|
||||
|
||||
const handleDeleteOpen = (project: Project) => {
|
||||
const handleDeleteOpen = useCallback((project: Project) => {
|
||||
setDeleteTarget(project)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (deleteTarget) {
|
||||
removeGraphFromStorage(deleteTarget.id)
|
||||
deleteProject(deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
navigate('/projects', { replace: true })
|
||||
}
|
||||
}
|
||||
const handleDeleteConfirm = useCallback(() => {
|
||||
if (!deleteTarget) return
|
||||
const project = deleteTarget
|
||||
const graphSnapshot = loadGraphFromStorage(project.id)
|
||||
const snapshot =
|
||||
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 stored = loadGraphFromStorage(project.id)
|
||||
const state = stored
|
||||
? { version: PROJECT_VERSION, nodes: stored.nodes, edges: stored.edges }
|
||||
: { version: PROJECT_VERSION, nodes: [], edges: [] }
|
||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${project.name.replace(/[^\w.-]/g, '_')}${PROJECT_FILE_EXT}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
const handleExport = useCallback(
|
||||
(project: Project) => {
|
||||
const stored = loadGraphFromStorage(project.id)
|
||||
const state = stored
|
||||
? { version: PROJECT_VERSION, nodes: stored.nodes, edges: stored.edges }
|
||||
: { version: PROJECT_VERSION, nodes: [], edges: [] }
|
||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
const filename = `${project.name.replace(/[^\w.-]/g, '_')}${PROJECT_FILE_EXT}`
|
||||
a.href = 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 (
|
||||
<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">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Projects</h2>
|
||||
</div>
|
||||
{sorted.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No projects yet. Create one from the sidebar.</p>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Created</TableHead>
|
||||
<TableHead>Last edited</TableHead>
|
||||
<TableHead className="w-[70px]" />
|
||||
</TableRow>
|
||||
</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>
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col gap-4 rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold">Projects</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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
|
||||
placeholder="Search projects"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
className="pl-8"
|
||||
aria-label="Search projects by name"
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRenameSubmit} disabled={!renameValue.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Rename dialog */}
|
||||
<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) => e.key === 'Enter' && handleRenameSubmit()}
|
||||
placeholder="Project name"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRenameSubmit} disabled={!renameValue.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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>
|
||||
{/* 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}"? You can undo this from the notification.`
|
||||
: ''}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteConfirm}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user