1005 lines
42 KiB
TypeScript
1005 lines
42 KiB
TypeScript
/**
|
|
* Recollections list page: table or cards view with search, sort, pagination, actions.
|
|
* Sort by last edited (default), name, or created; configurable page size; export toast; undo delete.
|
|
*/
|
|
|
|
import React, { useCallback, useMemo, useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ChevronsLeft,
|
|
ChevronsRight,
|
|
MoreHorizontal,
|
|
Pencil,
|
|
Trash2,
|
|
Download,
|
|
FolderOpen,
|
|
Search,
|
|
Plus,
|
|
LayoutGrid,
|
|
List,
|
|
Network,
|
|
ArrowUpDown,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
X,
|
|
Copy,
|
|
} from 'lucide-react'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
|
import { getRecollectionIcon } from '@/lib/iconMap'
|
|
import {
|
|
loadGraphFromStorage,
|
|
saveGraphToStorage,
|
|
removeGraphFromStorage,
|
|
RECOLLECTION_FILE_EXT,
|
|
RECOLLECTION_VERSION,
|
|
} from './recollectionGraphStorage'
|
|
import { getLogosContent, setLogosContent } from './recollectionStore'
|
|
import { toast } from 'sonner'
|
|
import { NewRecollectionDialog } from '@/app/kosmos/NewRecollectionDialog'
|
|
import { RecollectionsPageBackground } from './RecollectionsPageBackground'
|
|
import { RenameRecollectionDialog } from './RenameRecollectionDialog'
|
|
import type { Recollection } from '@/app/kosmos/types'
|
|
|
|
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, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
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(recollectionId: string): { nodes: number; edges: number } {
|
|
const stored = loadGraphFromStorage(recollectionId)
|
|
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,
|
|
}
|
|
}
|
|
|
|
type NodeLike = { id: string; position?: { x: number; y: number } }
|
|
type EdgeLike = { id?: string; source: string; target: string }
|
|
|
|
/** Shared grid style for empty thumbnail, non-empty thumbnail SVG, and New recollection placeholder. */
|
|
const THUMBNAIL_GRID = {
|
|
baseFill: 'hsl(var(--muted) / 0.4)',
|
|
dotFill: 'hsl(var(--muted-foreground) / 0.06)',
|
|
size: 8,
|
|
} as const
|
|
|
|
const thumbnailGridStyle: React.CSSProperties = {
|
|
backgroundColor: THUMBNAIL_GRID.baseFill,
|
|
backgroundImage: `radial-gradient(circle, ${THUMBNAIL_GRID.dotFill} 1px, transparent 1px)`,
|
|
backgroundSize: `${THUMBNAIL_GRID.size}px ${THUMBNAIL_GRID.size}px`,
|
|
}
|
|
|
|
/** Renders a minimal SVG preview of the graph from storage, or a placeholder. */
|
|
function GraphThumbnail({ recollectionId, className }: { recollectionId: string; className?: string }) {
|
|
const stored = loadGraphFromStorage(recollectionId)
|
|
const nodes = (stored?.nodes ?? []) as NodeLike[]
|
|
const edges = (stored?.edges ?? []) as EdgeLike[]
|
|
const withPos = nodes.filter((n) => n.position && typeof n.position.x === 'number' && typeof n.position.y === 'number')
|
|
|
|
const dotGridPatternId = `dotgrid-${recollectionId.replace(/\W/g, '-')}`
|
|
|
|
if (withPos.length === 0) {
|
|
return (
|
|
<div
|
|
className={`flex h-full w-full flex-col items-center justify-center gap-1 text-muted-foreground ${className ?? ''}`}
|
|
style={thumbnailGridStyle}
|
|
aria-hidden
|
|
>
|
|
<Network className="size-8" />
|
|
<span className="text-[10px]">No graph</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const padding = 12
|
|
const viewBoxW = 200
|
|
const viewBoxH = 120
|
|
const xs = withPos.map((n) => n.position!.x)
|
|
const ys = withPos.map((n) => n.position!.y)
|
|
const minX = Math.min(...xs)
|
|
const maxX = Math.max(...xs)
|
|
const minY = Math.min(...ys)
|
|
const maxY = Math.max(...ys)
|
|
const w = Math.max(maxX - minX, 1)
|
|
const h = Math.max(maxY - minY, 1)
|
|
const scale = Math.min((viewBoxW - padding * 2) / w, (viewBoxH - padding * 2) / h, 8)
|
|
const graphW = w * scale
|
|
const graphH = h * scale
|
|
const ox = (viewBoxW - graphW) / 2 - minX * scale
|
|
const oy = (viewBoxH - graphH) / 2 - minY * scale
|
|
|
|
const nodeById = new Map(withPos.map((n) => [n.id, n]))
|
|
const validEdges = edges.filter((e) => nodeById.has(e.source) && nodeById.has(e.target))
|
|
|
|
return (
|
|
<svg
|
|
className={`h-full w-full ${className ?? ''}`}
|
|
viewBox="0 0 200 120"
|
|
preserveAspectRatio="xMidYMid meet"
|
|
aria-hidden
|
|
>
|
|
<defs>
|
|
<pattern id={dotGridPatternId} width={THUMBNAIL_GRID.size} height={THUMBNAIL_GRID.size} patternUnits="userSpaceOnUse">
|
|
<circle cx={THUMBNAIL_GRID.size / 2} cy={THUMBNAIL_GRID.size / 2} r={1} fill={THUMBNAIL_GRID.dotFill} />
|
|
</pattern>
|
|
</defs>
|
|
<rect width={200} height={120} fill={THUMBNAIL_GRID.baseFill} />
|
|
<rect width={200} height={120} fill={`url(#${dotGridPatternId})`} />
|
|
{validEdges.slice(0, 50).map((e, i) => {
|
|
const a = nodeById.get(e.source)!.position!
|
|
const b = nodeById.get(e.target)!.position!
|
|
return (
|
|
<line
|
|
key={e.id ?? `e-${i}`}
|
|
x1={ox + a.x * scale}
|
|
y1={oy + a.y * scale}
|
|
x2={ox + b.x * scale}
|
|
y2={oy + b.y * scale}
|
|
stroke="hsl(var(--muted-foreground) / 0.4)"
|
|
strokeWidth={1.5}
|
|
/>
|
|
)
|
|
})}
|
|
{withPos.slice(0, 80).map((n) => (
|
|
<circle
|
|
key={n.id}
|
|
cx={ox + n.position!.x * scale}
|
|
cy={oy + n.position!.y * scale}
|
|
r={3}
|
|
fill="hsl(var(--primary))"
|
|
/>
|
|
))}
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
type RecollectionActionsMenuProps = {
|
|
recollection: Recollection
|
|
onOpen: (id: string) => void
|
|
onRenameOpen: (recollection: Recollection) => void
|
|
onDuplicateOpen: (recollection: Recollection) => void
|
|
onExport: (recollection: Recollection) => void
|
|
onDeleteOpen: (recollection: Recollection) => void
|
|
trigger: React.ReactNode
|
|
}
|
|
|
|
function RecollectionActionsMenu({
|
|
recollection,
|
|
onOpen,
|
|
onRenameOpen,
|
|
onDuplicateOpen,
|
|
onExport,
|
|
onDeleteOpen,
|
|
trigger,
|
|
}: RecollectionActionsMenuProps) {
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => onOpen(recollection.id)}>
|
|
<FolderOpen className="size-4" />
|
|
Open
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => onRenameOpen(recollection)}>
|
|
<Pencil className="size-4" />
|
|
Rename
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => onDuplicateOpen(recollection)}>
|
|
<Copy className="size-4" />
|
|
Duplicate
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => onExport(recollection)}>
|
|
<Download className="size-4" />
|
|
Export
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => onDeleteOpen(recollection)}>
|
|
<Trash2 className="size-4" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
export type ViewMode = 'table' | 'cards'
|
|
|
|
export function RecollectionsPage() {
|
|
const { orderedRecollections, deleteRecollection, renameRecollection, createRecollection, restoreRecollection } = usePlatform()
|
|
const navigate = useNavigate()
|
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
|
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<Recollection | null>(null)
|
|
const [deleteTarget, setDeleteTarget] = useState<Recollection | null>(null)
|
|
const [duplicateTarget, setDuplicateTarget] = useState<Recollection | null>(null)
|
|
const [duplicateName, setDuplicateName] = useState('')
|
|
const duplicateInputRef = React.useRef<HTMLInputElement>(null)
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
|
const [bulkDeleteTargets, setBulkDeleteTargets] = useState<Recollection[] | null>(null)
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = search.trim().toLowerCase()
|
|
if (!q) return orderedRecollections
|
|
return orderedRecollections.filter((p) => p.name.toLowerCase().includes(q))
|
|
}, [orderedRecollections, search])
|
|
|
|
const sorted = useMemo(() => {
|
|
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, 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 handleSort = (key: SortKey) => {
|
|
if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
|
else {
|
|
setSortKey(key)
|
|
setSortDir(key === 'name' ? 'asc' : 'desc')
|
|
}
|
|
setPage(0)
|
|
}
|
|
|
|
const handleOpen = useCallback(
|
|
(recollectionId: string) => {
|
|
navigate(`/recollections/${recollectionId}`)
|
|
},
|
|
[navigate]
|
|
)
|
|
|
|
const handleRenameOpen = useCallback((recollection: Recollection) => {
|
|
setRenameTarget(recollection)
|
|
}, [])
|
|
|
|
React.useEffect(() => {
|
|
if (duplicateTarget) {
|
|
const t = setTimeout(() => duplicateInputRef.current?.focus(), 0)
|
|
return () => clearTimeout(t)
|
|
}
|
|
}, [duplicateTarget])
|
|
|
|
const handleDeleteOpen = useCallback((recollection: Recollection) => {
|
|
setDeleteTarget(recollection)
|
|
}, [])
|
|
|
|
const handleDeleteConfirm = useCallback(() => {
|
|
if (!deleteTarget) return
|
|
const recollection = deleteTarget
|
|
const graphSnapshot = loadGraphFromStorage(recollection.id)
|
|
const snapshot =
|
|
graphSnapshot && (graphSnapshot.nodes.length > 0 || graphSnapshot.edges.length > 0)
|
|
? { nodes: graphSnapshot.nodes, edges: graphSnapshot.edges }
|
|
: null
|
|
removeGraphFromStorage(recollection.id)
|
|
deleteRecollection(recollection.id)
|
|
setDeleteTarget(null)
|
|
navigate('/recollections', { replace: true })
|
|
toast(`"${recollection.name}" deleted`, {
|
|
action: {
|
|
label: 'Undo',
|
|
onClick: () => restoreRecollection(recollection, snapshot),
|
|
},
|
|
duration: 8000,
|
|
})
|
|
}, [deleteTarget, deleteRecollection, navigate, restoreRecollection])
|
|
|
|
const handleExport = useCallback(
|
|
(recollection: Recollection) => {
|
|
const stored = loadGraphFromStorage(recollection.id)
|
|
const state = stored
|
|
? { version: RECOLLECTION_VERSION, nodes: stored.nodes, edges: stored.edges }
|
|
: { version: RECOLLECTION_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 = `${recollection.name.replace(/[^\w.-]/g, '_')}${RECOLLECTION_FILE_EXT}`
|
|
a.href = url
|
|
a.download = filename
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
toast.success(`Exported as ${filename}`)
|
|
},
|
|
[]
|
|
)
|
|
|
|
const handleCreateRecollection = useCallback(
|
|
(recollection: Recollection) => {
|
|
createRecollection(recollection)
|
|
navigate(`/recollections/${recollection.id}`)
|
|
},
|
|
[createRecollection, navigate]
|
|
)
|
|
|
|
const handleDuplicateOpen = useCallback((recollection: Recollection) => {
|
|
setDuplicateTarget(recollection)
|
|
setDuplicateName(`${recollection.name} (copy)`)
|
|
}, [])
|
|
|
|
const handleDuplicateConfirm = useCallback(() => {
|
|
if (!duplicateTarget || !duplicateName.trim()) return
|
|
const name = duplicateName.trim()
|
|
const newId = `recollection_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
|
|
const now = Date.now()
|
|
const newRecollection: Recollection = {
|
|
id: newId,
|
|
name,
|
|
iconId: duplicateTarget.iconId,
|
|
createdAt: now,
|
|
lastEditedAt: now,
|
|
}
|
|
createRecollection(newRecollection)
|
|
const graph = loadGraphFromStorage(duplicateTarget.id)
|
|
if (graph && (graph.nodes.length > 0 || graph.edges.length > 0)) {
|
|
saveGraphToStorage(newId, { version: RECOLLECTION_VERSION, nodes: graph.nodes, edges: graph.edges })
|
|
}
|
|
const logosContent = getLogosContent(duplicateTarget.id)
|
|
if (logosContent && logosContent.length > 0) {
|
|
setLogosContent(newId, logosContent)
|
|
}
|
|
toast.success('Recollection duplicated')
|
|
setDuplicateTarget(null)
|
|
setDuplicateName('')
|
|
}, [duplicateTarget, duplicateName, createRecollection])
|
|
|
|
const allOnPageSelected = pageItems.length > 0 && pageItems.every((p) => selectedIds.has(p.id))
|
|
const someOnPageSelected = pageItems.some((p) => selectedIds.has(p.id))
|
|
|
|
const toggleSelection = useCallback((id: string) => {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) next.delete(id)
|
|
else next.add(id)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
const toggleSelectAll = useCallback(() => {
|
|
if (allOnPageSelected) {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev)
|
|
pageItems.forEach((p) => next.delete(p.id))
|
|
return next
|
|
})
|
|
} else {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev)
|
|
pageItems.forEach((p) => next.add(p.id))
|
|
return next
|
|
})
|
|
}
|
|
}, [allOnPageSelected, pageItems])
|
|
|
|
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
|
|
|
|
const handleBulkDeleteOpen = useCallback(() => {
|
|
const toDelete = sorted.filter((p) => selectedIds.has(p.id))
|
|
if (toDelete.length > 0) setBulkDeleteTargets(toDelete)
|
|
}, [sorted, selectedIds])
|
|
|
|
const handleBulkDeleteConfirm = useCallback(() => {
|
|
if (!bulkDeleteTargets || bulkDeleteTargets.length === 0) return
|
|
const count = bulkDeleteTargets.length
|
|
bulkDeleteTargets.forEach((recollection) => {
|
|
removeGraphFromStorage(recollection.id)
|
|
deleteRecollection(recollection.id)
|
|
})
|
|
setBulkDeleteTargets(null)
|
|
setSelectedIds(new Set())
|
|
navigate('/recollections', { replace: true })
|
|
toast(`${count} recollection${count === 1 ? '' : 's'} deleted`)
|
|
}, [bulkDeleteTargets, deleteRecollection, navigate])
|
|
|
|
const handleBulkExport = useCallback(() => {
|
|
const toExport = sorted.filter((p) => selectedIds.has(p.id))
|
|
toExport.forEach((recollection) => {
|
|
const stored = loadGraphFromStorage(recollection.id)
|
|
const state = stored
|
|
? { version: RECOLLECTION_VERSION, nodes: stored.nodes, edges: stored.edges }
|
|
: { version: RECOLLECTION_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 = `${recollection.name.replace(/[^\w.-]/g, '_')}${RECOLLECTION_FILE_EXT}`
|
|
a.href = url
|
|
a.download = filename
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
})
|
|
toast.success(`Exported ${toExport.length} recollection${toExport.length === 1 ? '' : 's'}`)
|
|
}, [sorted, selectedIds])
|
|
|
|
const SortIcon = ({ columnKey }: { columnKey: SortKey }) => {
|
|
if (sortKey !== columnKey) return <ArrowUpDown className="size-3.5 opacity-50" />
|
|
return sortDir === 'asc' ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />
|
|
}
|
|
|
|
return (
|
|
<div className="relative flex flex-1 flex-col min-h-0">
|
|
<RecollectionsPageBackground className="absolute inset-0 pointer-events-none" />
|
|
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 overflow-auto">
|
|
<TooltipProvider>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<h1 className="p-3 scroll-m-20 text-4xl font-extrabold tracking-tight text-balance font-serif">
|
|
Hi, Demiurge
|
|
</h1>
|
|
</div>
|
|
<div className="flex flex-col gap-4 rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="relative w-64 shrink-0">
|
|
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
|
<Input
|
|
placeholder="Search recollections"
|
|
value={search}
|
|
onChange={(e) => {
|
|
setSearch(e.target.value)
|
|
setPage(0)
|
|
}}
|
|
className="h-9 w-full pl-8"
|
|
aria-label="Search recollections by name"
|
|
/>
|
|
</div>
|
|
<div className="ml-auto flex h-9 items-center gap-2">
|
|
<Select
|
|
value={pageSize === -1 ? 'all' : String(pageSize)}
|
|
onValueChange={(v) => {
|
|
setPageSize(v === 'all' ? -1 : Number(v))
|
|
setPage(0)
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-9 w-[120px]">
|
|
<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>
|
|
<ToggleGroup
|
|
type="single"
|
|
value={viewMode}
|
|
onValueChange={(v) => v && setViewMode(v as ViewMode)}
|
|
aria-label="View mode"
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-0 rounded-md p-0.5 [&>button]:rounded-none [&>button:first-child]:rounded-l-md [&>button:last-child]:rounded-r-md [&>button:not(:first-child)]:border-l-0"
|
|
>
|
|
<ToggleGroupItem value="cards" aria-label="Cards view" className="px-2.5">
|
|
<LayoutGrid className="size-4" />
|
|
</ToggleGroupItem>
|
|
<ToggleGroupItem value="table" aria-label="Table view" className="px-2.5">
|
|
<List className="size-4" />
|
|
</ToggleGroupItem>
|
|
</ToggleGroup>
|
|
</div>
|
|
</div>
|
|
|
|
{sorted.length === 0 ? (
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
<NewRecollectionDialog
|
|
onCreate={handleCreateRecollection}
|
|
existingNames={orderedRecollections.map((p) => p.name)}
|
|
trigger={
|
|
<div
|
|
className="flex cursor-pointer flex-col overflow-hidden rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 shadow-sm transition-colors hover:border-muted-foreground/50 hover:bg-muted/30"
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
; (e.currentTarget as HTMLElement).click()
|
|
}
|
|
}}
|
|
aria-label="Create new recollection"
|
|
>
|
|
<div className="flex aspect-video w-full shrink-0 items-center justify-center" style={thumbnailGridStyle}>
|
|
<Plus className="size-12 text-muted-foreground" />
|
|
</div>
|
|
<div className="flex flex-1 flex-col gap-1 p-3">
|
|
<span className="font-medium text-muted-foreground">New recollection</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{search.trim() ? 'No recollections match your search.' : 'Create a new recollection'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
) : viewMode === 'table' ? (
|
|
<>
|
|
<div className="overflow-auto rounded-md border">
|
|
{(selectedIds.size > 0) && (
|
|
<div className="flex flex-wrap items-center gap-2 border-b bg-muted/60 px-3 py-2">
|
|
<span className="text-sm font-medium tabular-nums">
|
|
{selectedIds.size} selected
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
<Button variant="outline" size="sm" onClick={handleBulkExport} className="h-8 gap-1.5 px-2.5">
|
|
<Download className="size-3.5" />
|
|
Export
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="h-8 gap-1.5 px-2.5 text-destructive hover:text-destructive" onClick={handleBulkDeleteOpen}>
|
|
<Trash2 className="size-3.5" />
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
<Button variant="ghost" size="sm" onClick={clearSelection} className="ml-auto h-8 gap-1.5 px-2.5">
|
|
<X className="size-3.5" />
|
|
Clear
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<Table className="table-fixed">
|
|
<TableHeader className="sticky top-0 z-10 bg-card">
|
|
<TableRow className="hover:bg-transparent border-b h-8">
|
|
<TableHead className="w-10 px-2 py-1.5 text-xs [&:has([role=checkbox])]:pr-0">
|
|
<Checkbox
|
|
checked={pageItems.length === 0 ? false : allOnPageSelected ? true : someOnPageSelected ? 'indeterminate' : false}
|
|
onCheckedChange={() => toggleSelectAll()}
|
|
aria-label="Select all on page"
|
|
/>
|
|
</TableHead>
|
|
<TableHead className="w-[35%] min-w-[100px] h-8 px-2 py-1.5 text-xs">
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-1 font-medium hover:underline"
|
|
onClick={() => handleSort('name')}
|
|
aria-sort={sortKey === 'name' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
|
>
|
|
Name
|
|
<SortIcon columnKey="name" />
|
|
</button>
|
|
</TableHead>
|
|
<TableHead className="hidden sm:table-cell w-[120px] min-w-[100px] h-8 px-2 py-1.5 text-xs">
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-1 font-medium hover:underline"
|
|
onClick={() => handleSort('created')}
|
|
aria-sort={sortKey === 'created' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
|
>
|
|
Created
|
|
<SortIcon columnKey="created" />
|
|
</button>
|
|
</TableHead>
|
|
<TableHead className="hidden md:table-cell w-[100px] min-w-[80px] h-8 px-2 py-1.5 text-xs">Size</TableHead>
|
|
<TableHead className="w-[115px] min-w-[90px] h-8 px-2 py-1.5 text-xs">
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-1 font-medium hover:underline"
|
|
onClick={() => handleSort('lastEdited')}
|
|
aria-sort={sortKey === 'lastEdited' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
|
>
|
|
Last edited
|
|
<SortIcon columnKey="lastEdited" />
|
|
</button>
|
|
</TableHead>
|
|
<TableHead className="sticky right-0 z-10 w-12 min-w-12 bg-card h-8 px-1 py-1.5 text-xs font-medium" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody className="text-xs">
|
|
{pageItems.map((recollection) => {
|
|
const Icon = getRecollectionIcon(recollection.iconId)
|
|
const counts = getGraphCounts(recollection.id)
|
|
const lastEdited = recollection.lastEditedAt ?? recollection.createdAt
|
|
const isSelected = selectedIds.has(recollection.id)
|
|
return (
|
|
<TableRow
|
|
key={recollection.id}
|
|
className={`group cursor-pointer hover:bg-muted/50 h-8 ${isSelected ? 'bg-muted/70' : ''}`}
|
|
onClick={() => handleOpen(recollection.id)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
handleOpen(recollection.id)
|
|
}
|
|
}}
|
|
tabIndex={0}
|
|
role="button"
|
|
aria-label={`Open ${recollection.name}`}
|
|
>
|
|
<TableCell className="px-2 py-1.5 w-10 [&:has([role=checkbox])]:pr-0" onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox
|
|
checked={isSelected}
|
|
onCheckedChange={() => toggleSelection(recollection.id)}
|
|
aria-label={`Select ${recollection.name}`}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="px-2 py-1.5 min-w-0">
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="font-medium truncate">{recollection.name}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="hidden sm:table-cell text-muted-foreground px-2 py-1.5 w-[120px] min-w-[100px]">
|
|
<span className="truncate block" title={formatDate(recollection.createdAt)}>
|
|
{formatDate(recollection.createdAt)}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="hidden md:table-cell text-muted-foreground px-2 py-1.5 w-[100px] min-w-[80px]" title={`${counts.nodes} nodes, ${counts.edges} edges`}>
|
|
{counts.nodes}n, {counts.edges}e
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground px-2 py-1.5 w-[115px] min-w-[90px]">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="truncate block">{getRelativeTime(lastEdited)}</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{formatDate(lastEdited)}</TooltipContent>
|
|
</Tooltip>
|
|
</TableCell>
|
|
<TableCell
|
|
className={`sticky right-0 w-12 min-w-12 px-1 py-1.5 ${isSelected ? 'bg-muted/70' : 'bg-card group-hover:bg-muted/50'}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<RecollectionActionsMenu
|
|
recollection={recollection}
|
|
onOpen={handleOpen}
|
|
onRenameOpen={handleRenameOpen}
|
|
onDuplicateOpen={handleDuplicateOpen}
|
|
onExport={handleExport}
|
|
onDeleteOpen={handleDeleteOpen}
|
|
trigger={
|
|
<Button variant="ghost" size="icon" className="size-7" aria-label={`Actions for ${recollection.name}`}>
|
|
<MoreHorizontal className="size-4" />
|
|
</Button>
|
|
}
|
|
/>
|
|
</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} recollections`}
|
|
</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 className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
<NewRecollectionDialog
|
|
onCreate={handleCreateRecollection}
|
|
existingNames={orderedRecollections.map((p) => p.name)}
|
|
trigger={
|
|
<div
|
|
className="flex cursor-pointer flex-col overflow-hidden rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 shadow-sm transition-colors hover:border-muted-foreground/50 hover:bg-muted/30"
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
; (e.currentTarget as HTMLElement).click()
|
|
}
|
|
}}
|
|
aria-label="Create new recollection"
|
|
>
|
|
<div className="flex aspect-video w-full shrink-0 items-center justify-center" style={thumbnailGridStyle}>
|
|
<Plus className="size-12 text-muted-foreground" />
|
|
</div>
|
|
<div className="flex flex-1 flex-col gap-1 p-3">
|
|
<span className="font-medium text-muted-foreground">New recollection</span>
|
|
<span className="text-xs text-muted-foreground">Create a new recollection</span>
|
|
</div>
|
|
</div>
|
|
}
|
|
/>
|
|
{pageItems.map((recollection) => {
|
|
const Icon = getRecollectionIcon(recollection.iconId)
|
|
const lastEdited = recollection.lastEditedAt ?? recollection.createdAt
|
|
return (
|
|
<div
|
|
key={recollection.id}
|
|
className="group flex cursor-pointer flex-col overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow hover:shadow-md"
|
|
onClick={() => handleOpen(recollection.id)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
handleOpen(recollection.id)
|
|
}
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`Open ${recollection.name}`}
|
|
>
|
|
<div className="relative aspect-video w-full shrink-0 overflow-hidden bg-muted">
|
|
<GraphThumbnail recollectionId={recollection.id} className="h-full w-full object-cover" />
|
|
<div
|
|
className="absolute right-1.5 top-1.5 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<RecollectionActionsMenu
|
|
recollection={recollection}
|
|
onOpen={handleOpen}
|
|
onRenameOpen={handleRenameOpen}
|
|
onDuplicateOpen={handleDuplicateOpen}
|
|
onExport={handleExport}
|
|
onDeleteOpen={handleDeleteOpen}
|
|
trigger={
|
|
<Button
|
|
variant="secondary"
|
|
size="icon"
|
|
className="size-7 rounded-full shadow-sm"
|
|
aria-label={`Actions for ${recollection.name}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<MoreHorizontal className="size-4" />
|
|
</Button>
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-1 flex-col gap-1 p-3">
|
|
<div className="flex items-center gap-2">
|
|
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
|
<span className="truncate font-medium font-serif">{recollection.name}</span>
|
|
</div>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="text-xs text-muted-foreground">{getRelativeTime(lastEdited)}</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{formatDate(lastEdited)}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</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} recollections`}
|
|
</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>
|
|
|
|
<RenameRecollectionDialog
|
|
open={!!renameTarget}
|
|
onOpenChange={(open) => !open && setRenameTarget(null)}
|
|
recollectionId={renameTarget?.id ?? ''}
|
|
initialName={renameTarget?.name ?? ''}
|
|
recollections={sorted}
|
|
onRename={(id, newName) => {
|
|
renameRecollection(id, newName)
|
|
setRenameTarget(null)
|
|
}}
|
|
/>
|
|
|
|
{/* Duplicate dialog */}
|
|
<Dialog open={!!duplicateTarget} onOpenChange={(open) => { if (!open) { setDuplicateTarget(null); setDuplicateName('') } }}>
|
|
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
|
<DialogHeader>
|
|
<DialogTitle>Duplicate recollection</DialogTitle>
|
|
<DialogDescription>Enter a name for the duplicate recollection.</DialogDescription>
|
|
</DialogHeader>
|
|
<Input
|
|
ref={duplicateInputRef}
|
|
value={duplicateName}
|
|
onChange={(e) => setDuplicateName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleDuplicateConfirm()
|
|
if (e.key === 'Escape') setDuplicateTarget(null)
|
|
}}
|
|
placeholder="Recollection name"
|
|
aria-label="Duplicate recollection name"
|
|
/>
|
|
{duplicateTarget && duplicateName.trim() && orderedRecollections.some((p) => p.name.toLowerCase() === duplicateName.trim().toLowerCase()) && (
|
|
<p className="text-xs text-amber-600 dark:text-amber-500">An recollection with this name already exists.</p>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => { setDuplicateTarget(null); setDuplicateName('') }}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleDuplicateConfirm} disabled={!duplicateName.trim()}>
|
|
Duplicate
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete confirmation */}
|
|
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete recollection</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>
|
|
|
|
{/* Bulk delete confirmation */}
|
|
<Dialog open={bulkDeleteTargets !== null && bulkDeleteTargets.length > 0} onOpenChange={(open) => !open && setBulkDeleteTargets(null)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete {bulkDeleteTargets?.length ?? 0} recollections</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to delete these recollections? This cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setBulkDeleteTargets(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleBulkDeleteConfirm}>
|
|
Delete all
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</TooltipProvider>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|