/**
* 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 './state/recollectionGraphStorage'
import { getLogosContent, setLogosContent } from './state/recollectionStore'
import { toast } from 'sonner'
import { NewRecollectionDialog } from '@/app/kosmos/NewRecollectionDialog'
import { RecollectionsPageBackground } from './layout/RecollectionsPageBackground'
import { RenameRecollectionDialog } from './layout/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 (
)
}
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
{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 */}
{/* Delete confirmation */}
{/* Bulk delete confirmation */}
)
}