/** * 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 (
No graph
) } 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 ( {validEdges.slice(0, 50).map((e, i) => { const a = nodeById.get(e.source)!.position! const b = nodeById.get(e.target)!.position! return ( ) })} {withPos.slice(0, 80).map((n) => ( ))} ) } 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 ( {trigger} onOpen(recollection.id)}> Open onRenameOpen(recollection)}> Rename onDuplicateOpen(recollection)}> Duplicate onExport(recollection)}> Export onDeleteOpen(recollection)}> Delete ) } export type ViewMode = 'table' | 'cards' export function RecollectionsPage() { const { orderedRecollections, deleteRecollection, renameRecollection, createRecollection, restoreRecollection } = usePlatform() const navigate = useNavigate() const [viewMode, setViewMode] = useState('cards') const [search, setSearch] = useState('') const [pageSize, setPageSize] = useState(10) const [page, setPage] = useState(0) const [sortKey, setSortKey] = useState('lastEdited') const [sortDir, setSortDir] = useState('desc') const [renameTarget, setRenameTarget] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) const [duplicateTarget, setDuplicateTarget] = useState(null) const [duplicateName, setDuplicateName] = useState('') const duplicateInputRef = React.useRef(null) const [selectedIds, setSelectedIds] = useState>(new Set()) const [bulkDeleteTargets, setBulkDeleteTargets] = useState(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 return sortDir === 'asc' ? : } return (

Hi, Demiurge

{ setSearch(e.target.value) setPage(0) }} className="h-9 w-full pl-8" aria-label="Search recollections by name" />
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" >
{sorted.length === 0 ? (
p.name)} trigger={
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() ; (e.currentTarget as HTMLElement).click() } }} aria-label="Create new recollection" >
New recollection {search.trim() ? 'No recollections match your search.' : 'Create a new recollection'}
} />
) : viewMode === 'table' ? ( <>
{(selectedIds.size > 0) && (
{selectedIds.size} selected
)} toggleSelectAll()} aria-label="Select all on page" /> Size {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 ( 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}`} > e.stopPropagation()}> toggleSelection(recollection.id)} aria-label={`Select ${recollection.name}`} />
{recollection.name}
{formatDate(recollection.createdAt)} {counts.nodes}n, {counts.edges}e {getRelativeTime(lastEdited)} {formatDate(lastEdited)} e.stopPropagation()} > } />
) })}
{totalPages > 1 && (

Page {page + 1} of {totalPages} {pageSize !== -1 && ` · ${sorted.length} recollections`}

)} ) : ( <>
p.name)} trigger={
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() ; (e.currentTarget as HTMLElement).click() } }} aria-label="Create new recollection" >
New recollection Create a new recollection
} /> {pageItems.map((recollection) => { const Icon = getRecollectionIcon(recollection.iconId) const lastEdited = recollection.lastEditedAt ?? recollection.createdAt return (
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}`} >
e.stopPropagation()} > e.stopPropagation()} > } />
{recollection.name}
{getRelativeTime(lastEdited)} {formatDate(lastEdited)}
) })}
{totalPages > 1 && (

Page {page + 1} of {totalPages} {pageSize !== -1 && ` · ${sorted.length} recollections`}

)} )}
!open && setRenameTarget(null)} recollectionId={renameTarget?.id ?? ''} initialName={renameTarget?.name ?? ''} recollections={sorted} onRename={(id, newName) => { renameRecollection(id, newName) setRenameTarget(null) }} /> {/* Duplicate dialog */} { if (!open) { setDuplicateTarget(null); setDuplicateName('') } }}> e.preventDefault()}> Duplicate recollection Enter a name for the duplicate recollection. 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()) && (

An recollection with this name already exists.

)}
{/* Delete confirmation */} !open && setDeleteTarget(null)}> Delete recollection {deleteTarget ? `Are you sure you want to delete "${deleteTarget.name}"? You can undo this from the notification.` : ''} {/* Bulk delete confirmation */} 0} onOpenChange={(open) => !open && setBulkDeleteTargets(null)}> Delete {bulkDeleteTargets?.length ?? 0} recollections Are you sure you want to delete these recollections? This cannot be undone.
) }