freat: renaming

This commit is contained in:
2026-03-15 14:11:11 +01:00
parent 2326bbe035
commit 04c039bdde
18 changed files with 455 additions and 462 deletions

View File

@@ -57,7 +57,7 @@ This doc summarizes recent improvements and suggested next steps for readability
- **StoredGraphState** shape for save/load (version + nodes + edges). - **StoredGraphState** shape for save/load (version + nodes + edges).
- **FlowContext**: Module doc splits value into (1) Graph state, (2) Connection path state, (3) UI state. Same flat props, clearer sections. - **FlowContext**: Module doc splits value into (1) Graph state, (2) Connection path state, (3) UI state. Same flat props, clearer sections.
- **useGraphStateWithHistory**: JSDoc explains history (past/future), setNodes vs setNodesSilent, setStateImmediate. - **useGraphStateWithHistory**: JSDoc explains history (past/future), setNodes vs setNodesSilent, setStateImmediate.
- **projectGraphStorage**: Uses **StoredGraphState** from state.ts; re-exports type. Doc references state flow. - **recollectionGraphStorage**: Uses **StoredGraphState** from state.ts; re-exports type. Doc references state flow.
- **useRenderingNodeState**: Returns **displayStatus** (for NodeStatusIndicator/empty/error UI) and **lifecycle** (updating/error/paused for useSyncConnectionStatus). Hook calls useSyncConnectionStatus(id, state.lifecycle). Type includes RenderingNodeLifecycle. - **useRenderingNodeState**: Returns **displayStatus** (for NodeStatusIndicator/empty/error UI) and **lifecycle** (updating/error/paused for useSyncConnectionStatus). Hook calls useSyncConnectionStatus(id, state.lifecycle). Type includes RenderingNodeLifecycle.
- **RenderingNode**: Uses **state.displayStatus** for NodeStatusIndicator instead of computing status locally. - **RenderingNode**: Uses **state.displayStatus** for NodeStatusIndicator instead of computing status locally.
- **nodeLifecycle** and **connectionStatus**: Docs reference state.ts for overall state flow. - **nodeLifecycle** and **connectionStatus**: Docs reference state.ts for overall state flow.

View File

@@ -1,5 +1,5 @@
/** /**
* Menubar for the canvas page: Project (Import/Export), Edit (Undo/Redo, Duplicate/Copy/Paste, Rename), View (Fit View, Minimap). * Menubar for the canvas page: Recollection (Import/Export), Edit (Undo/Redo, Duplicate/Copy/Paste, Rename), View (Fit View, Minimap).
*/ */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -24,7 +24,7 @@ export type CanvasMenubarProps = {
onExport: () => void onExport: () => void
onSave?: () => void onSave?: () => void
canSave?: boolean canSave?: boolean
/** Shown next to project title: unsaved | saving | saved */ /** Shown next to recollection title: unsaved | saving | saved */
saveStatus?: SaveStatus saveStatus?: SaveStatus
undo: () => void undo: () => void
redo: () => void redo: () => void
@@ -64,14 +64,14 @@ export function CanvasMenubar({
canCopy = false, canCopy = false,
onFitView, onFitView,
}: CanvasMenubarProps) { }: CanvasMenubarProps) {
const { projectId } = useParams<{ projectId: string }>() const { recollectionId } = useParams<{ recollectionId: string }>()
const { projects, renameProject } = usePlatform() const { recollections, renameRecollection } = usePlatform()
const projectName = useMemo( const recollectionName = useMemo(
() => (projectId ? projects.find((p) => p.id === projectId)?.name ?? null : null), () => (recollectionId ? recollections.find((p) => p.id === recollectionId)?.name ?? null : null),
[projectId, projects] [recollectionId, recollections]
) )
const [isRenamingProject, setIsRenamingProject] = useState(false) const [isRenamingRecollection, setIsRenamingRecollection] = useState(false)
const [renameValue, setRenameValue] = useState('') const [renameValue, setRenameValue] = useState('')
const [showSavedBriefly, setShowSavedBriefly] = useState(false) const [showSavedBriefly, setShowSavedBriefly] = useState(false)
const renameInputRef = useRef<HTMLInputElement>(null) const renameInputRef = useRef<HTMLInputElement>(null)
@@ -107,27 +107,27 @@ export function CanvasMenubar({
}, []) }, [])
useEffect(() => { useEffect(() => {
if (isRenamingProject) { if (isRenamingRecollection) {
setRenameValue(projectName ?? '') setRenameValue(recollectionName ?? '')
ignoreNextBlurRef.current = true ignoreNextBlurRef.current = true
// Delay focus so the Project dropdown can close first and not steal focus back (which would trigger blur) // Delay focus so the Recollection dropdown can close first and not steal focus back (which would trigger blur)
const t = setTimeout(() => { const t = setTimeout(() => {
renameInputRef.current?.focus() renameInputRef.current?.focus()
renameInputRef.current?.select() renameInputRef.current?.select()
}, 100) }, 100)
return () => clearTimeout(t) return () => clearTimeout(t)
} }
}, [isRenamingProject, projectName]) }, [isRenamingRecollection, recollectionName])
const applyRename = useCallback(() => { const applyRename = useCallback(() => {
if (!projectId || !renameProject) return if (!recollectionId || !renameRecollection) return
const trimmed = renameValue.trim() const trimmed = renameValue.trim()
if (trimmed) renameProject(projectId, trimmed) if (trimmed) renameRecollection(recollectionId, trimmed)
setIsRenamingProject(false) setIsRenamingRecollection(false)
}, [projectId, renameProject, renameValue]) }, [recollectionId, renameRecollection, renameValue])
const cancelRename = useCallback(() => { const cancelRename = useCallback(() => {
setIsRenamingProject(false) setIsRenamingRecollection(false)
}, []) }, [])
const handleRenameBlur = useCallback(() => { const handleRenameBlur = useCallback(() => {
@@ -172,16 +172,16 @@ export function CanvasMenubar({
<div className="relative flex h-9 w-full shrink-0 items-center border-b border-border/40 bg-background"> <div className="relative flex h-9 w-full shrink-0 items-center border-b border-border/40 bg-background">
<Menubar className="flex-1 shrink-0 rounded-none border-0 border-b-0 bg-transparent p-0 shadow-none"> <Menubar className="flex-1 shrink-0 rounded-none border-0 border-b-0 bg-transparent p-0 shadow-none">
<Link <Link
to="/projects" to="/recollections"
aria-label="Back to projects" aria-label="Back to recollections"
className="flex shrink-0 items-center rounded-sm px-2 py-1 ml-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground" className="flex shrink-0 items-center rounded-sm px-2 py-1 ml-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
> >
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
</Link> </Link>
<MenubarMenu> <MenubarMenu>
<MenubarTrigger className="font-normal text-muted-foreground">Project</MenubarTrigger> <MenubarTrigger className="font-normal text-muted-foreground">Recollection</MenubarTrigger>
<MenubarContent> <MenubarContent>
{projectId && ( {recollectionId && (
<> <>
{onSave != null && ( {onSave != null && (
<> <>
@@ -198,7 +198,7 @@ export function CanvasMenubar({
</> </>
)} )}
<MenubarItem <MenubarItem
onClick={() => setIsRenamingProject(true)} onClick={() => setIsRenamingRecollection(true)}
className="gap-2" className="gap-2"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
@@ -290,9 +290,9 @@ export function CanvasMenubar({
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
</Menubar> </Menubar>
{projectId && ( {recollectionId && (
<div className="absolute left-1/2 -translate-x-1/2 flex items-center justify-center gap-2 max-w-[50%] min-w-[120px]"> <div className="absolute left-1/2 -translate-x-1/2 flex items-center justify-center gap-2 max-w-[50%] min-w-[120px]">
{isRenamingProject ? ( {isRenamingRecollection ? (
<Input <Input
ref={renameInputRef} ref={renameInputRef}
type="text" type="text"
@@ -309,12 +309,12 @@ export function CanvasMenubar({
}} }}
onBlur={handleRenameBlur} onBlur={handleRenameBlur}
className="h-7 text-sm font-medium text-center font-serif" className="h-7 text-sm font-medium text-center font-serif"
aria-label="Project name" aria-label="Recollection name"
/> />
) : ( ) : (
<> <>
<span className="pointer-events-none truncate text-sm font-medium text-foreground font-serif"> <span className="pointer-events-none truncate text-sm font-medium text-foreground font-serif">
{projectName ?? 'Untitled'} {recollectionName ?? 'Untitled'}
</span> </span>
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<Tooltip> <Tooltip>

View File

@@ -1,6 +1,6 @@
/** /**
* Canvas page: the graph editor (React Flow) with nodes, edges, context menu, import/export. * Canvas page: the graph editor (React Flow) with nodes, edges, context menu, import/export.
* Rendered inside the platform when a project is selected. * Rendered inside the platform when a recollection is selected.
*/ */
import React, { useCallback, useEffect, useMemo, useRef } from 'react' import React, { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -66,7 +66,7 @@ import {
} from '@/lib/graph/nodeRegistry' } from '@/lib/graph/nodeRegistry'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PROJECT_FILE_EXT, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage' import { RECOLLECTION_FILE_EXT, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
const SNAP_GRID: [number, number] = [15, 15] const SNAP_GRID: [number, number] = [15, 15]
const DUPLICATE_OFFSET = { x: 30, y: 30 } const DUPLICATE_OFFSET = { x: 30, y: 30 }
@@ -149,11 +149,11 @@ function FullscreenNodeContent({ node }: { node: AppNode }) {
} }
export type CanvasPageProps = { export type CanvasPageProps = {
/** Optional project id for future per-project graph loading */ /** Optional recollection id for per-recollection graph loading */
projectId?: string recollectionId?: string
} }
export function CanvasPage({ projectId }: CanvasPageProps) { export function CanvasPage({ recollectionId }: CanvasPageProps) {
const { theme } = useTheme() const { theme } = useTheme()
const { showMinimap } = usePlatform() const { showMinimap } = usePlatform()
const { const {
@@ -172,7 +172,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
setStateImmediate, setStateImmediate,
save, save,
saveStatus, saveStatus,
} = useCanvasGraph(projectId) } = useCanvasGraph(recollectionId)
const importInputRef = useRef<HTMLInputElement | null>(null) const importInputRef = useRef<HTMLInputElement | null>(null)
const [rfInstance, setRfInstance] = React.useState<unknown>(null) const [rfInstance, setRfInstance] = React.useState<unknown>(null)
@@ -323,19 +323,19 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd]) const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd])
const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd]) const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd])
const handleExportProject = useCallback(() => { const handleExportRecollection = useCallback(() => {
const state = { version: PROJECT_VERSION, nodes, edges } const state = { version: RECOLLECTION_VERSION, nodes, edges }
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' }) const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `project${PROJECT_FILE_EXT}` a.download = `recollection${RECOLLECTION_FILE_EXT}`
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
toast.success('Project exported') toast.success('Recollection exported')
}, [nodes, edges]) }, [nodes, edges])
const handleImportProject = useCallback(() => importInputRef.current?.click(), []) const handleImportRecollection = useCallback(() => importInputRef.current?.click(), [])
const handleLoadExample = useCallback(() => { const handleLoadExample = useCallback(() => {
const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph() const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
@@ -360,10 +360,10 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
const nodes = state.nodes as AppNode[] const nodes = state.nodes as AppNode[]
const edges = backfillEdgeTargetTypes(nodes, state.edges as AppEdge[]) const edges = backfillEdgeTargetTypes(nodes, state.edges as AppEdge[])
setStateImmediate({ nodes, edges }) setStateImmediate({ nodes, edges })
if (state.version != null && state.version > PROJECT_VERSION) { if (state.version != null && state.version > RECOLLECTION_VERSION) {
toast.error('Project was created with a newer app version') toast.error('Recollection was created with a newer app version')
} else { } else {
toast.success('Project loaded') toast.success('Recollection loaded')
} }
} catch { } catch {
toast.error('Invalid file: not valid JSON') toast.error('Invalid file: not valid JSON')
@@ -534,8 +534,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
const clientX = click?.clientX ?? window.innerWidth / 2 const clientX = click?.clientX ?? window.innerWidth / 2
const clientY = click?.clientY ?? window.innerHeight / 2 const clientY = click?.clientY ?? window.innerHeight / 2
try { try {
const inst = rfInstance as { screenToFlowPosition?: (p: { x: number; y: number }) => { x: number; y: number }; project?: (p: { x: number; y: number }) => { x: number; y: number } } type ScreenToFlow = (p: { x: number; y: number }) => { x: number; y: number }
const screenToFlow = inst.screenToFlowPosition ?? inst.project const inst = rfInstance as { screenToFlowPosition?: ScreenToFlow; [k: string]: unknown }
const screenToFlow = inst.screenToFlowPosition ?? (inst['project'] as ScreenToFlow | undefined)
const p = screenToFlow?.call(rfInstance, { x: clientX, y: clientY }) const p = screenToFlow?.call(rfInstance, { x: clientX, y: clientY })
return p ? snapToGrid(p.x, p.y) : null return p ? snapToGrid(p.x, p.y) : null
} catch { } catch {
@@ -646,17 +647,17 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
aria-hidden aria-hidden
/> />
<CanvasMenubar <CanvasMenubar
onImport={handleImportProject} onImport={handleImportRecollection}
onExport={handleExportProject} onExport={handleExportRecollection}
onSave={ onSave={
projectId recollectionId
? () => { ? () => {
save() save()
toast.success('Saved') toast.success('Saved')
} }
: undefined : undefined
} }
canSave={Boolean(projectId)} canSave={Boolean(recollectionId)}
saveStatus={saveStatus} saveStatus={saveStatus}
undo={undo} undo={undo}
redo={redo} redo={redo}
@@ -694,11 +695,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
<EmptyTitle>Start adding a new node!</EmptyTitle> <EmptyTitle>Start adding a new node!</EmptyTitle>
<EmptyDescription> <EmptyDescription>
Rightclick to add nodes. <br /> Rightclick to add nodes. <br />
Import a project or paste a node. Import a recollection or paste a node.
</EmptyDescription> </EmptyDescription>
</EmptyHeader> </EmptyHeader>
<EmptyContent className="flex-row flex-wrap justify-center gap-2"> <EmptyContent className="flex-row flex-wrap justify-center gap-2">
<Button onClick={handleImportProject} variant="outline" size="sm"> <Button onClick={handleImportRecollection} variant="outline" size="sm">
<FolderOpen className="size-4" /> <FolderOpen className="size-4" />
Import Import
</Button> </Button>

View File

@@ -1,5 +1,5 @@
/** /**
* Route wrapper for the canvas: resolves projectId from URL and updates lastEditedAt on open. * Route wrapper for the canvas: resolves recollectionId from URL and updates lastEditedAt on open.
*/ */
import React, { useEffect, useRef } from 'react' import React, { useEffect, useRef } from 'react'
@@ -8,29 +8,29 @@ import { CanvasPage } from './CanvasPage'
import { usePlatform } from '@/app/kosmos/KosmosContext' import { usePlatform } from '@/app/kosmos/KosmosContext'
export function CanvasRoute() { export function CanvasRoute() {
const { projectId } = useParams<{ projectId: string }>() const { recollectionId } = useParams<{ recollectionId: string }>()
const { projects, updateLastEdited } = usePlatform() const { recollections, updateLastEdited } = usePlatform()
const updateLastEditedRef = useRef(updateLastEdited) const updateLastEditedRef = useRef(updateLastEdited)
updateLastEditedRef.current = updateLastEdited updateLastEditedRef.current = updateLastEdited
const project = projects.find((p) => p.id === projectId) const recollection = recollections.find((p) => p.id === recollectionId)
useEffect(() => { useEffect(() => {
if (projectId) updateLastEditedRef.current(projectId) if (recollectionId) updateLastEditedRef.current(recollectionId)
}, [projectId]) }, [recollectionId])
if (!projectId) return null if (!recollectionId) return null
if (!project) { if (!recollection) {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8"> <div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
<p className="text-sm text-muted-foreground">Project not found.</p> <p className="text-sm text-muted-foreground">Recollection not found.</p>
</div> </div>
) )
} }
return ( return (
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
<CanvasPage key={projectId} projectId={projectId} /> <CanvasPage key={recollectionId} recollectionId={recollectionId} />
</div> </div>
) )
} }

View File

@@ -5,7 +5,7 @@
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils' import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
import { loadGraphFromStorage } from '@/app/pleroma/projectGraphStorage' import { loadGraphFromStorage } from '@/app/recollections/recollectionGraphStorage'
/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */ /** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */
export function backfillEdgeTargetTypes( export function backfillEdgeTargetTypes(
@@ -70,9 +70,9 @@ export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
return { nodes, edges } return { nodes, edges }
} }
export function getInitialGraph(projectId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } { export function getInitialGraph(recollectionId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
if (projectId) { if (recollectionId) {
const stored = loadGraphFromStorage(projectId) const stored = loadGraphFromStorage(recollectionId)
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) { if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
const nodes = stored.nodes as AppNode[] const nodes = stored.nodes as AppNode[]
const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[]) const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[])

View File

@@ -1,25 +1,25 @@
/** /**
* Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory * Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory
* with initial graph from project storage (or example). Save is explicit via save(). * with initial graph from recollection storage (or example). Save is explicit via save().
*/ */
import { useCallback, useMemo, useRef, useState } from 'react' import { useCallback, useMemo, useRef, useState } from 'react'
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory' import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils' import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
import { saveGraphToStorage, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage' import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
export type SaveStatus = 'saved' | 'unsaved' | 'saving' export type SaveStatus = 'saved' | 'unsaved' | 'saving'
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> & { export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> & {
/** Persist current nodes/edges to storage. No-op when projectId is missing. */ /** Persist current nodes/edges to storage. No-op when recollectionId is missing. */
save: () => void save: () => void
/** For menubar: show "Unsaved changes" | "Saving…" | "All changes saved". */ /** For menubar: show "Unsaved changes" | "Saving…" | "All changes saved". */
saveStatus: SaveStatus saveStatus: SaveStatus
} }
export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphResult { export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGraphResult {
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId]) const initialGraph = useMemo(() => getInitialGraph(recollectionId), [recollectionId])
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges) const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
const { nodes, edges } = result const { nodes, edges } = result
@@ -41,14 +41,14 @@ export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphRes
const saveStatus: SaveStatus = isSaving ? 'saving' : isDirty ? 'unsaved' : 'saved' const saveStatus: SaveStatus = isSaving ? 'saving' : isDirty ? 'unsaved' : 'saved'
const save = useCallback(() => { const save = useCallback(() => {
if (!projectId) return if (!recollectionId) return
const snapshot = JSON.stringify({ const snapshot = JSON.stringify({
nodes: nodesRef.current, nodes: nodesRef.current,
edges: edgesRef.current, edges: edgesRef.current,
}) })
setIsSaving(true) setIsSaving(true)
saveGraphToStorage(projectId, { saveGraphToStorage(recollectionId, {
version: PROJECT_VERSION, version: RECOLLECTION_VERSION,
nodes: nodesRef.current, nodes: nodesRef.current,
edges: edgesRef.current, edges: edgesRef.current,
}) })
@@ -57,7 +57,7 @@ export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphRes
setLastSavedSerialized(snapshot) setLastSavedSerialized(snapshot)
setIsSaving(false) setIsSaving(false)
}, SAVING_DISPLAY_MS) }, SAVING_DISPLAY_MS)
}, [projectId]) }, [recollectionId])
return { ...result, save, saveStatus } return { ...result, save, saveStatus }
} }

View File

@@ -1,20 +0,0 @@
/**
* Keroma page. Rendered at /keroma.
*/
import React from 'react'
export function KeromaPage() {
return (
<div className="relative flex flex-1 flex-col min-h-0 p-4">
<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">
Keroma
</h1>
</div>
<div className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
<p className="text-muted-foreground">Welcome to Keroma.</p>
</div>
</div>
)
}

View File

@@ -1,16 +1,19 @@
/** /**
* Platform context: projects list and handlers for create/delete/rename/updateLastEdited/reorder/restore. * Platform context: recollections list and handlers for create/delete/rename/updateLastEdited/reorder/restore.
* Used by AppSidebar, ProjectsTablePage, and canvas route. * Used by AppSidebar, RecollectionsPage, and canvas route.
*/ */
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react' import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
import type { Project } from './types' import type { Recollection } from './types'
import { saveGraphToStorage } from '@/app/pleroma/projectGraphStorage' import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
import { PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
const STORAGE_KEY = 'zui_platform_projects' const STORAGE_KEY = 'zui_platform_recollections'
const ORDER_STORAGE_KEY = 'zui_platform_project_order' const ORDER_STORAGE_KEY = 'zui_platform_recollection_order'
const RECENT_STORAGE_KEY = 'zui_platform_recent_project_ids' const RECENT_STORAGE_KEY = 'zui_platform_recent_recollection_ids'
const LEGACY_STORAGE_KEY = 'zui_platform_emanations'
const LEGACY_ORDER_STORAGE_KEY = 'zui_platform_emanation_order'
const LEGACY_RECENT_STORAGE_KEY = 'zui_platform_recent_emanation_ids'
const CANVAS_MINIMAP_KEY = 'zui_canvas_show_minimap' const CANVAS_MINIMAP_KEY = 'zui_canvas_show_minimap'
const AI_CONNECTION_KEY = 'zui_ai_connection' const AI_CONNECTION_KEY = 'zui_ai_connection'
const RECENT_MAX = 5 const RECENT_MAX = 5
@@ -72,8 +75,15 @@ function saveShowMinimap(value: boolean) {
function loadOrder(): string[] { function loadOrder(): string[] {
try { try {
const raw = localStorage.getItem(ORDER_STORAGE_KEY) let raw = localStorage.getItem(ORDER_STORAGE_KEY)
if (!raw) return [] if (!raw) {
const legacy = localStorage.getItem(LEGACY_ORDER_STORAGE_KEY)
if (legacy) {
localStorage.setItem(ORDER_STORAGE_KEY, legacy)
localStorage.removeItem(LEGACY_ORDER_STORAGE_KEY)
raw = legacy
} else return []
}
const parsed = JSON.parse(raw) as unknown const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [] return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
} catch { } catch {
@@ -87,8 +97,15 @@ function saveOrder(ids: string[]) {
function loadRecentIds(): string[] { function loadRecentIds(): string[] {
try { try {
const raw = localStorage.getItem(RECENT_STORAGE_KEY) let raw = localStorage.getItem(RECENT_STORAGE_KEY)
if (!raw) return [] if (!raw) {
const legacy = localStorage.getItem(LEGACY_RECENT_STORAGE_KEY)
if (legacy) {
localStorage.setItem(RECENT_STORAGE_KEY, legacy)
localStorage.removeItem(LEGACY_RECENT_STORAGE_KEY)
raw = legacy
} else return []
}
const parsed = JSON.parse(raw) as unknown const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string').slice(0, RECENT_MAX) : [] return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string').slice(0, RECENT_MAX) : []
} catch { } catch {
@@ -100,41 +117,48 @@ function saveRecentIds(ids: string[]) {
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(ids)) localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(ids))
} }
function loadProjects(): Project[] { function loadRecollections(): Recollection[] {
try { try {
const raw = localStorage.getItem(STORAGE_KEY) let raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return [] if (!raw) {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY)
if (legacy) {
localStorage.setItem(STORAGE_KEY, legacy)
localStorage.removeItem(LEGACY_STORAGE_KEY)
raw = legacy
} else return []
}
const parsed = JSON.parse(raw) as unknown const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return [] if (!Array.isArray(parsed)) return []
return parsed return parsed
.filter( .filter(
(p): p is Project => (p): p is Recollection =>
p && p &&
typeof p === 'object' && typeof p === 'object' &&
typeof (p as Project).id === 'string' && typeof (p as Recollection).id === 'string' &&
typeof (p as Project).name === 'string' && typeof (p as Recollection).name === 'string' &&
typeof (p as Project).iconId === 'string' && typeof (p as Recollection).iconId === 'string' &&
typeof (p as Project).createdAt === 'number' typeof (p as Recollection).createdAt === 'number'
) )
.map((p) => ({ .map((p) => ({
...p, ...p,
lastEditedAt: typeof (p as Project).lastEditedAt === 'number' ? (p as Project).lastEditedAt : p.createdAt, lastEditedAt: typeof (p as Recollection).lastEditedAt === 'number' ? (p as Recollection).lastEditedAt : p.createdAt,
})) }))
} catch { } catch {
return [] return []
} }
} }
function saveProjects(projects: Project[]) { function saveRecollections(recollections: Recollection[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects)) localStorage.setItem(STORAGE_KEY, JSON.stringify(recollections))
} }
export type GraphSnapshot = { nodes: unknown[]; edges: unknown[] } export type GraphSnapshot = { nodes: unknown[]; edges: unknown[] }
/** Projects sorted by projectOrder, then by lastEditedAt desc for any not in order */ /** Recollections sorted by recollectionOrder, then by lastEditedAt desc for any not in order */
export function sortProjectsByOrder(projects: Project[], order: string[]): Project[] { export function sortRecollectionsByOrder(recollections: Recollection[], order: string[]): Recollection[] {
const byId = new Map(projects.map((p) => [p.id, p])) const byId = new Map(recollections.map((p) => [p.id, p]))
const ordered: Project[] = [] const ordered: Recollection[] = []
const seen = new Set<string>() const seen = new Set<string>()
for (const id of order) { for (const id of order) {
const p = byId.get(id) const p = byId.get(id)
@@ -143,27 +167,27 @@ export function sortProjectsByOrder(projects: Project[], order: string[]): Proje
seen.add(id) seen.add(id)
} }
} }
const rest = projects const rest = recollections
.filter((p) => !seen.has(p.id)) .filter((p) => !seen.has(p.id))
.sort((a, b) => (b.lastEditedAt ?? b.createdAt) - (a.lastEditedAt ?? a.createdAt)) .sort((a, b) => (b.lastEditedAt ?? b.createdAt) - (a.lastEditedAt ?? a.createdAt))
return [...ordered, ...rest] return [...ordered, ...rest]
} }
export type KosmosContextValue = { export type KosmosContextValue = {
projects: Project[] recollections: Recollection[]
projectOrder: string[] recollectionOrder: string[]
/** Projects in display order (sidebar order, then lastEditedAt) */ /** Recollections in display order (sidebar order, then lastEditedAt) */
orderedProjects: Project[] orderedRecollections: Recollection[]
/** Last RECENT_MAX accessed project IDs (most recent first) */ /** Last RECENT_MAX accessed recollection IDs (most recent first) */
recentProjectIds: string[] recentRecollectionIds: string[]
recordProjectAccess: (id: string) => void recordRecollectionAccess: (id: string) => void
persist: (next: Project[]) => void persist: (next: Recollection[]) => void
createProject: (project: Project) => void createRecollection: (recollection: Recollection) => void
deleteProject: (id: string) => void deleteRecollection: (id: string) => void
renameProject: (id: string, name: string) => void renameRecollection: (id: string, name: string) => void
updateLastEdited: (id: string) => void updateLastEdited: (id: string) => void
reorderProjects: (orderedIds: string[]) => void reorderRecollections: (orderedIds: string[]) => void
restoreProject: (project: Project, graphSnapshot: GraphSnapshot | null) => void restoreRecollection: (recollection: Recollection, graphSnapshot: GraphSnapshot | null) => void
/** Canvas: show React Flow minimap (persisted) */ /** Canvas: show React Flow minimap (persisted) */
showMinimap: boolean showMinimap: boolean
setShowMinimap: (value: boolean) => void setShowMinimap: (value: boolean) => void
@@ -175,9 +199,9 @@ export type KosmosContextValue = {
const KosmosContext = createContext<KosmosContextValue | null>(null) const KosmosContext = createContext<KosmosContextValue | null>(null)
export function KosmosProvider({ children }: { children: React.ReactNode }) { export function KosmosProvider({ children }: { children: React.ReactNode }) {
const [projects, setProjects] = useState<Project[]>(loadProjects) const [recollections, setRecollections] = useState<Recollection[]>(loadRecollections)
const [projectOrder, setProjectOrder] = useState<string[]>(loadOrder) const [recollectionOrder, setRecollectionOrder] = useState<string[]>(loadOrder)
const [recentProjectIds, setRecentProjectIds] = useState<string[]>(loadRecentIds) const [recentRecollectionIds, setRecentRecollectionIds] = useState<string[]>(loadRecentIds)
const [showMinimap, setShowMinimapState] = useState<boolean>(loadShowMinimap) const [showMinimap, setShowMinimapState] = useState<boolean>(loadShowMinimap)
const [aiConnection, setAiConnectionState] = useState<AiConnection>(loadAiConnection) const [aiConnection, setAiConnectionState] = useState<AiConnection>(loadAiConnection)
@@ -191,128 +215,128 @@ export function KosmosProvider({ children }: { children: React.ReactNode }) {
saveAiConnection(value) saveAiConnection(value)
}, []) }, [])
const persist = useCallback((next: Project[]) => { const persist = useCallback((next: Recollection[]) => {
setProjects(next) setRecollections(next)
saveProjects(next) saveRecollections(next)
}, []) }, [])
const createProject = useCallback( const createRecollection = useCallback(
(project: Project) => { (recollection: Recollection) => {
const withEdited = { ...project, lastEditedAt: project.createdAt } const withEdited = { ...recollection, lastEditedAt: recollection.createdAt }
persist([...projects, withEdited]) persist([...recollections, withEdited])
setProjectOrder((prev) => { setRecollectionOrder((prev) => {
const next = prev.includes(project.id) ? prev : [project.id, ...prev] const next = prev.includes(recollection.id) ? prev : [recollection.id, ...prev]
saveOrder(next) saveOrder(next)
return next return next
}) })
}, },
[projects, persist] [recollections, persist]
) )
const deleteProject = useCallback( const deleteRecollection = useCallback(
(id: string) => { (id: string) => {
const next = projects.filter((p) => p.id !== id) const next = recollections.filter((p) => p.id !== id)
persist(next) persist(next)
setProjectOrder((prev) => { setRecollectionOrder((prev) => {
const nextOrder = prev.filter((oid) => oid !== id) const nextOrder = prev.filter((oid) => oid !== id)
saveOrder(nextOrder) saveOrder(nextOrder)
return nextOrder return nextOrder
}) })
setRecentProjectIds((prev) => { setRecentRecollectionIds((prev) => {
const next = prev.filter((oid) => oid !== id) const next = prev.filter((oid) => oid !== id)
saveRecentIds(next) saveRecentIds(next)
return next return next
}) })
}, },
[projects, persist] [recollections, persist]
) )
const renameProject = useCallback( const renameRecollection = useCallback(
(id: string, name: string) => { (id: string, name: string) => {
const next = projects.map((p) => (p.id === id ? { ...p, name } : p)) const next = recollections.map((p) => (p.id === id ? { ...p, name } : p))
persist(next) persist(next)
}, },
[projects, persist] [recollections, persist]
) )
const updateLastEdited = useCallback( const updateLastEdited = useCallback(
(id: string) => { (id: string) => {
const now = Date.now() const now = Date.now()
const next = projects.map((p) => (p.id === id ? { ...p, lastEditedAt: now } : p)) const next = recollections.map((p) => (p.id === id ? { ...p, lastEditedAt: now } : p))
persist(next) persist(next)
}, },
[projects, persist] [recollections, persist]
) )
const reorderProjects = useCallback((orderedIds: string[]) => { const reorderRecollections = useCallback((orderedIds: string[]) => {
setProjectOrder(orderedIds) setRecollectionOrder(orderedIds)
saveOrder(orderedIds) saveOrder(orderedIds)
}, []) }, [])
const recordProjectAccess = useCallback((id: string) => { const recordRecollectionAccess = useCallback((id: string) => {
setRecentProjectIds((prev) => { setRecentRecollectionIds((prev) => {
const next = [id, ...prev.filter((x) => x !== id)].slice(0, RECENT_MAX) const next = [id, ...prev.filter((x) => x !== id)].slice(0, RECENT_MAX)
saveRecentIds(next) saveRecentIds(next)
return next return next
}) })
}, []) }, [])
const restoreProject = useCallback( const restoreRecollection = useCallback(
(project: Project, graphSnapshot: GraphSnapshot | null) => { (recollection: Recollection, graphSnapshot: GraphSnapshot | null) => {
persist([...projects, project]) persist([...recollections, recollection])
setProjectOrder((prev) => { setRecollectionOrder((prev) => {
const next = prev.includes(project.id) ? prev : [project.id, ...prev] const next = prev.includes(recollection.id) ? prev : [recollection.id, ...prev]
saveOrder(next) saveOrder(next)
return next return next
}) })
if (graphSnapshot) { if (graphSnapshot) {
saveGraphToStorage(project.id, { saveGraphToStorage(recollection.id, {
version: PROJECT_VERSION, version: RECOLLECTION_VERSION,
nodes: graphSnapshot.nodes, nodes: graphSnapshot.nodes,
edges: graphSnapshot.edges, edges: graphSnapshot.edges,
}) })
} }
}, },
[projects, persist] [recollections, persist]
) )
const orderedProjects = useMemo( const orderedRecollections = useMemo(
() => sortProjectsByOrder(projects, projectOrder), () => sortRecollectionsByOrder(recollections, recollectionOrder),
[projects, projectOrder] [recollections, recollectionOrder]
) )
const value: KosmosContextValue = useMemo( const value: KosmosContextValue = useMemo(
() => ({ () => ({
projects, recollections,
projectOrder, recollectionOrder,
orderedProjects, orderedRecollections,
recentProjectIds, recentRecollectionIds,
recordProjectAccess, recordRecollectionAccess,
persist, persist,
createProject, createRecollection,
deleteProject, deleteRecollection,
renameProject, renameRecollection,
updateLastEdited, updateLastEdited,
reorderProjects, reorderRecollections,
restoreProject, restoreRecollection,
showMinimap, showMinimap,
setShowMinimap, setShowMinimap,
aiConnection, aiConnection,
setAiConnection, setAiConnection,
}), }),
[ [
projects, recollections,
projectOrder, recollectionOrder,
orderedProjects, orderedRecollections,
recentProjectIds, recentRecollectionIds,
recordProjectAccess, recordRecollectionAccess,
persist, persist,
createProject, createRecollection,
deleteProject, deleteRecollection,
renameProject, renameRecollection,
updateLastEdited, updateLastEdited,
reorderProjects, reorderRecollections,
restoreProject, restoreRecollection,
showMinimap, showMinimap,
setShowMinimap, setShowMinimap,
aiConnection, aiConnection,

View File

@@ -1,5 +1,5 @@
/** /**
* Platform: main layout with sidebar and header. Renders child routes (projects list or canvas) via Outlet. * Platform: main layout with sidebar and header. Renders child routes (recollections list or canvas) via Outlet.
*/ */
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
@@ -9,13 +9,13 @@ import { KosmosProvider, usePlatform } from './KosmosContext'
import { KosmosSidebar } from './KosmosSidebar' import { KosmosSidebar } from './KosmosSidebar'
function KosmosLayoutInner() { function KosmosLayoutInner() {
const { projectId } = useParams<{ projectId: string }>() const { recollectionId } = useParams<{ recollectionId: string }>()
const { recordProjectAccess } = usePlatform() const { recordRecollectionAccess } = usePlatform()
const [sidebarOpen, setSidebarOpen] = useState(true) const [sidebarOpen, setSidebarOpen] = useState(true)
useEffect(() => { useEffect(() => {
if (projectId) recordProjectAccess(projectId) if (recollectionId) recordRecollectionAccess(recollectionId)
}, [projectId, recordProjectAccess]) }, [recollectionId, recordRecollectionAccess])
return ( return (
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}> <SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>

View File

@@ -1,5 +1,5 @@
/** /**
* Platform sidebar: All projects, Recently used, New project. * Platform sidebar: Recollections, Recently used, New recollection.
*/ */
import React, { useCallback, useMemo } from 'react' import React, { useCallback, useMemo } from 'react'
@@ -21,32 +21,30 @@ import { Plus, Settings, Triangle } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSidebar } from '@/components/ui/sidebar' import { useSidebar } from '@/components/ui/sidebar'
import { usePlatform } from './KosmosContext' import { usePlatform } from './KosmosContext'
import { getProjectIcon } from '@/lib/iconMap' import { getRecollectionIcon } from '@/lib/iconMap'
import { NewProjectDialog } from './NewProjectDialog' import { NewRecollectionDialog } from './NewRecollectionDialog'
import { SettingsDialog } from './SettingsDialog' import { SettingsDialog } from './SettingsDialog'
import type { Project } from './types' import type { Recollection } from './types'
export function KosmosSidebar() { export function KosmosSidebar() {
const { state, setOpen } = useSidebar() const { state, setOpen } = useSidebar()
const isCollapsed = state === 'collapsed' const isCollapsed = state === 'collapsed'
const { orderedProjects, createProject, recentProjectIds } = usePlatform() const { orderedRecollections, createRecollection, recentRecollectionIds } = usePlatform()
const recentProjects = useMemo(() => { const recentRecollections = useMemo(() => {
const byId = new Map(orderedProjects.map((p) => [p.id, p])) const byId = new Map(orderedRecollections.map((p) => [p.id, p]))
return recentProjectIds.map((id) => byId.get(id)).filter((p): p is Project => p != null) return recentRecollectionIds.map((id) => byId.get(id)).filter((p): p is Recollection => p != null)
}, [orderedProjects, recentProjectIds]) }, [orderedRecollections, recentRecollectionIds])
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { projectId: selectedProjectId } = useParams<{ projectId: string }>() const { recollectionId: selectedRecollectionId } = useParams<{ recollectionId: string }>()
const isKeroma = location.pathname === '/keroma' const handleSelectRecollection = useCallback((id: string) => navigate(`/recollections/${id}`), [navigate])
const handleSelectProject = useCallback((id: string) => navigate(`/projects/${id}`), [navigate]) const handleCreateRecollection = useCallback(
(recollection: Parameters<typeof createRecollection>[0]) => {
const handleCreateProject = useCallback( createRecollection(recollection)
(project: Parameters<typeof createProject>[0]) => { navigate(`/recollections/${recollection.id}`)
createProject(project)
navigate(`/projects/${project.id}`)
}, },
[createProject, navigate] [createRecollection, navigate]
) )
return ( return (
@@ -99,7 +97,7 @@ export function KosmosSidebar() {
</Tooltip> </Tooltip>
) : ( ) : (
<SidebarMenuButton asChild size="lg" tooltip="Zoë" className="font-semibold font-serif"> <SidebarMenuButton asChild size="lg" tooltip="Zoë" className="font-semibold font-serif">
<Link to="/projects"> <Link to="/recollections">
<span className="flex size-8 min-w-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground dark:bg-white dark:text-black"> <span className="flex size-8 min-w-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground dark:bg-white dark:text-black">
<span <span
className="size-6 shrink-0 rounded-[2px] opacity-90" className="size-6 shrink-0 rounded-[2px] opacity-90"
@@ -132,39 +130,31 @@ export function KosmosSidebar() {
<SidebarGroup> <SidebarGroup>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild tooltip="All projects" isActive={location.pathname === '/projects' && !selectedProjectId}> <SidebarMenuButton asChild tooltip="Recollections" isActive={location.pathname === '/recollections' && !selectedRecollectionId}>
<Link to="/projects"> <Link to="/recollections">
<Triangle className="size-4" /> <Triangle className="size-4" />
<span>Pleroma</span> <span>Recollections</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Keroma" isActive={isKeroma}>
<Link to="/keroma">
<Triangle className="size-4 rotate-180" />
<span>Keroma</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
{recentProjects.length > 0 && ( {recentRecollections.length > 0 && (
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Recently used</SidebarGroupLabel> <SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Recently used</SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
{recentProjects.map((project) => { {recentRecollections.map((recollection) => {
const Icon = getProjectIcon(project.iconId) const Icon = getRecollectionIcon(recollection.iconId)
const isActive = selectedProjectId === project.id const isActive = selectedRecollectionId === recollection.id
return ( return (
<SidebarMenuItem key={project.id}> <SidebarMenuItem key={recollection.id}>
<SidebarMenuButton <SidebarMenuButton
tooltip={project.name} tooltip={recollection.name}
isActive={isActive} isActive={isActive}
onClick={() => handleSelectProject(project.id)} onClick={() => handleSelectRecollection(recollection.id)}
> >
<Icon className="size-4" /> <Icon className="size-4" />
<span>{project.name}</span> <span>{recollection.name}</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
) )
@@ -175,13 +165,13 @@ export function KosmosSidebar() {
<SidebarGroup> <SidebarGroup>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<NewProjectDialog <NewRecollectionDialog
onCreate={handleCreateProject} onCreate={handleCreateRecollection}
existingNames={orderedProjects.map((p) => p.name)} existingNames={orderedRecollections.map((p) => p.name)}
trigger={ trigger={
<SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer"> <SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer">
<Plus className="size-4" /> <Plus className="size-4" />
<span>{orderedProjects.length === 0 ? 'Create your first project' : 'New project'}</span> <span>{orderedRecollections.length === 0 ? 'Create your first recollection' : 'New recollection'}</span>
</SidebarMenuButton> </SidebarMenuButton>
} }
/> />

View File

@@ -1,5 +1,5 @@
/** /**
* Dialog to create a new project: name + icon. * Dialog to create a new recollection: name + icon.
*/ */
import React, { useState } from 'react' import React, { useState } from 'react'
@@ -16,20 +16,20 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import { PROJECT_ICON_IDS, type Project, type ProjectIconId } from './types' import { RECOLLECTION_ICON_IDS, type Recollection, type RecollectionIconId } from './types'
import { getProjectIcon } from '@/lib/iconMap' import { getRecollectionIcon } from '@/lib/iconMap'
type NewProjectDialogProps = { type NewRecollectionDialogProps = {
onCreate: (project: Project) => void onCreate: (recollection: Recollection) => void
trigger?: React.ReactNode trigger?: React.ReactNode
/** Other project names to check for duplicates (case-insensitive warning only) */ /** Other recollection names to check for duplicates (case-insensitive warning only) */
existingNames?: string[] existingNames?: string[]
} }
export function NewProjectDialog({ onCreate, trigger, existingNames = [] }: NewProjectDialogProps) { export function NewRecollectionDialog({ onCreate, trigger, existingNames = [] }: NewRecollectionDialogProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [name, setName] = useState('') const [name, setName] = useState('')
const [iconId, setIconId] = useState<ProjectIconId>('cat') const [iconId, setIconId] = useState<RecollectionIconId>('cat')
const trimmed = name.trim() const trimmed = name.trim()
const isDuplicate = trimmed.length > 0 && existingNames.some((n) => n.toLowerCase() === trimmed.toLowerCase()) const isDuplicate = trimmed.length > 0 && existingNames.some((n) => n.toLowerCase() === trimmed.toLowerCase())
@@ -37,13 +37,13 @@ export function NewProjectDialog({ onCreate, trigger, existingNames = [] }: NewP
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!trimmed) return if (!trimmed) return
const project: Project = { const recollection: Recollection = {
id: `proj_${Date.now()}`, id: `recollection_${Date.now()}`,
name: trimmed, name: trimmed,
iconId, iconId,
createdAt: Date.now(), createdAt: Date.now(),
} }
onCreate(project) onCreate(recollection)
setName('') setName('')
setIconId('cat') setIconId('cat')
setOpen(false) setOpen(false)
@@ -55,41 +55,41 @@ export function NewProjectDialog({ onCreate, trigger, existingNames = [] }: NewP
{trigger ?? ( {trigger ?? (
<Button variant="outline" size="sm" className="w-full justify-start gap-2"> <Button variant="outline" size="sm" className="w-full justify-start gap-2">
<Plus className="size-4" /> <Plus className="size-4" />
New project New recollection
</Button> </Button>
)} )}
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<DialogHeader> <DialogHeader>
<DialogTitle>New project</DialogTitle> <DialogTitle>New recollection</DialogTitle>
<DialogDescription>Create a project to start editing a graph canvas.</DialogDescription> <DialogDescription>Create a recollection to start editing a graph canvas.</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<label htmlFor="project-name" className="text-sm font-medium"> <label htmlFor="recollection-name" className="text-sm font-medium">
Name Name
</label> </label>
<Input <Input
id="project-name" id="recollection-name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="My project" placeholder="My recollection"
autoFocus autoFocus
/> />
{isDuplicate && ( {isDuplicate && (
<p className="text-xs text-amber-600 dark:text-amber-500">A project with this name already exists.</p> <p className="text-xs text-amber-600 dark:text-amber-500">A recollection with this name already exists.</p>
)} )}
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<label className="text-sm font-medium">Icon</label> <label className="text-sm font-medium">Icon</label>
<Select value={iconId} onValueChange={(v) => setIconId(v as ProjectIconId)}> <Select value={iconId} onValueChange={(v) => setIconId(v as RecollectionIconId)}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{PROJECT_ICON_IDS.map((id) => { {RECOLLECTION_ICON_IDS.map((id) => {
const Icon = getProjectIcon(id) const Icon = getRecollectionIcon(id)
return ( return (
<SelectItem key={id} value={id}> <SelectItem key={id} value={id}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">

View File

@@ -1,21 +1,21 @@
/** /**
* Platform types: projects and sidebar state. * Platform types: recollections and sidebar state.
*/ */
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
export type Project = { export type Recollection = {
id: string id: string
name: string name: string
/** Icon identifier: key of PROJECT_ICONS map */ /** Icon identifier: key of RECOLLECTION_ICONS map */
iconId: string iconId: string
createdAt: number createdAt: number
/** Last time the project was opened/edited; used for sorting. Defaults to createdAt if missing. */ /** Last time the recollection was opened/edited; used for sorting. Defaults to createdAt if missing. */
lastEditedAt?: number lastEditedAt?: number
} }
/** Project icon ids: Lucide "Animals" category only */ /** Recollection icon ids: Lucide "Animals" category only */
export const PROJECT_ICON_IDS = [ export const RECOLLECTION_ICON_IDS = [
'bird', 'bird',
'bug', 'bug',
'cat', 'cat',
@@ -30,4 +30,4 @@ export const PROJECT_ICON_IDS = [
'egg' 'egg'
] as const ] as const
export type ProjectIconId = (typeof PROJECT_ICON_IDS)[number] export type RecollectionIconId = (typeof RECOLLECTION_ICON_IDS)[number]

View File

@@ -1,37 +0,0 @@
/**
* Per-project graph persistence (localStorage).
* Saves and loads StoredGraphState (version + nodes + edges). Used by useCanvasGraph and export.
*/
import type { StoredGraphState } from '@/lib/graph/state'
export type { StoredGraphState }
export const PROJECT_FILE_EXT = '.zui.json'
export const PROJECT_VERSION = 1
const GRAPH_KEY_PREFIX = 'zui_graph_'
export function getGraphStorageKey(projectId: string): string {
return `${GRAPH_KEY_PREFIX}${projectId}`
}
export function loadGraphFromStorage(projectId: string): StoredGraphState | null {
try {
const raw = localStorage.getItem(getGraphStorageKey(projectId))
if (!raw) return null
const data = JSON.parse(raw) as unknown
if (!data || typeof data !== 'object' || !Array.isArray((data as StoredGraphState).nodes) || !Array.isArray((data as StoredGraphState).edges))
return null
return data as StoredGraphState
} catch {
return null
}
}
export function saveGraphToStorage(projectId: string, state: StoredGraphState): void {
localStorage.setItem(getGraphStorageKey(projectId), JSON.stringify(state))
}
export function removeGraphFromStorage(projectId: string): void {
localStorage.removeItem(getGraphStorageKey(projectId))
}

View File

@@ -1,5 +1,5 @@
/** /**
* Projects list page: table or cards view with search, sort, pagination, actions. * 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. * Sort by last edited (default), name, or created; configurable page size; export toast; undo delete.
*/ */
@@ -56,18 +56,18 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { usePlatform } from '@/app/kosmos/KosmosContext' import { usePlatform } from '@/app/kosmos/KosmosContext'
import { getProjectIcon } from '@/lib/iconMap' import { getRecollectionIcon } from '@/lib/iconMap'
import { import {
loadGraphFromStorage, loadGraphFromStorage,
saveGraphToStorage, saveGraphToStorage,
removeGraphFromStorage, removeGraphFromStorage,
PROJECT_FILE_EXT, RECOLLECTION_FILE_EXT,
PROJECT_VERSION, RECOLLECTION_VERSION,
} from './projectGraphStorage' } from './recollectionGraphStorage'
import { toast } from 'sonner' import { toast } from 'sonner'
import { NewProjectDialog } from '@/app/kosmos/NewProjectDialog' import { NewRecollectionDialog } from '@/app/kosmos/NewRecollectionDialog'
import { ProjectsPageBackground } from './ProjectsPageBackground' import { RecollectionsPageBackground } from './RecollectionsPageBackground'
import type { Project } from '@/app/kosmos/types' import type { Recollection } from '@/app/kosmos/types'
const PAGE_SIZE_OPTIONS = [10, 25, 50] as const const PAGE_SIZE_OPTIONS = [10, 25, 50] as const
type SortKey = 'lastEdited' | 'name' | 'created' type SortKey = 'lastEdited' | 'name' | 'created'
@@ -98,8 +98,8 @@ function getRelativeTime(ts: number): string {
return 'Just now' return 'Just now'
} }
function getGraphCounts(projectId: string): { nodes: number; edges: number } { function getGraphCounts(recollectionId: string): { nodes: number; edges: number } {
const stored = loadGraphFromStorage(projectId) const stored = loadGraphFromStorage(recollectionId)
if (!stored) return { nodes: 0, edges: 0 } if (!stored) return { nodes: 0, edges: 0 }
return { return {
nodes: Array.isArray(stored.nodes) ? stored.nodes.length : 0, nodes: Array.isArray(stored.nodes) ? stored.nodes.length : 0,
@@ -110,7 +110,7 @@ function getGraphCounts(projectId: string): { nodes: number; edges: number } {
type NodeLike = { id: string; position?: { x: number; y: number } } type NodeLike = { id: string; position?: { x: number; y: number } }
type EdgeLike = { id?: string; source: string; target: string } type EdgeLike = { id?: string; source: string; target: string }
/** Shared grid style for empty thumbnail, non-empty thumbnail SVG, and New project placeholder. */ /** Shared grid style for empty thumbnail, non-empty thumbnail SVG, and New recollection placeholder. */
const THUMBNAIL_GRID = { const THUMBNAIL_GRID = {
baseFill: 'hsl(var(--muted) / 0.4)', baseFill: 'hsl(var(--muted) / 0.4)',
dotFill: 'hsl(var(--muted-foreground) / 0.06)', dotFill: 'hsl(var(--muted-foreground) / 0.06)',
@@ -124,13 +124,13 @@ const thumbnailGridStyle: React.CSSProperties = {
} }
/** Renders a minimal SVG preview of the graph from storage, or a placeholder. */ /** Renders a minimal SVG preview of the graph from storage, or a placeholder. */
function GraphThumbnail({ projectId, className }: { projectId: string; className?: string }) { function GraphThumbnail({ recollectionId, className }: { recollectionId: string; className?: string }) {
const stored = loadGraphFromStorage(projectId) const stored = loadGraphFromStorage(recollectionId)
const nodes = (stored?.nodes ?? []) as NodeLike[] const nodes = (stored?.nodes ?? []) as NodeLike[]
const edges = (stored?.edges ?? []) as EdgeLike[] const edges = (stored?.edges ?? []) as EdgeLike[]
const withPos = nodes.filter((n) => n.position && typeof n.position.x === 'number' && typeof n.position.y === 'number') const withPos = nodes.filter((n) => n.position && typeof n.position.x === 'number' && typeof n.position.y === 'number')
const dotGridPatternId = `dotgrid-${projectId.replace(/\W/g, '-')}` const dotGridPatternId = `dotgrid-${recollectionId.replace(/\W/g, '-')}`
if (withPos.length === 0) { if (withPos.length === 0) {
return ( return (
@@ -207,47 +207,47 @@ function GraphThumbnail({ projectId, className }: { projectId: string; className
) )
} }
type ProjectActionsMenuProps = { type RecollectionActionsMenuProps = {
project: Project recollection: Recollection
onOpen: (id: string) => void onOpen: (id: string) => void
onRenameOpen: (project: Project) => void onRenameOpen: (recollection: Recollection) => void
onDuplicateOpen: (project: Project) => void onDuplicateOpen: (recollection: Recollection) => void
onExport: (project: Project) => void onExport: (recollection: Recollection) => void
onDeleteOpen: (project: Project) => void onDeleteOpen: (recollection: Recollection) => void
trigger: React.ReactNode trigger: React.ReactNode
} }
function ProjectActionsMenu({ function RecollectionActionsMenu({
project, recollection,
onOpen, onOpen,
onRenameOpen, onRenameOpen,
onDuplicateOpen, onDuplicateOpen,
onExport, onExport,
onDeleteOpen, onDeleteOpen,
trigger, trigger,
}: ProjectActionsMenuProps) { }: RecollectionActionsMenuProps) {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onOpen(project.id)}> <DropdownMenuItem onClick={() => onOpen(recollection.id)}>
<FolderOpen className="size-4" /> <FolderOpen className="size-4" />
Open Open
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => onRenameOpen(project)}> <DropdownMenuItem onClick={() => onRenameOpen(recollection)}>
<Pencil className="size-4" /> <Pencil className="size-4" />
Rename Rename
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => onDuplicateOpen(project)}> <DropdownMenuItem onClick={() => onDuplicateOpen(recollection)}>
<Copy className="size-4" /> <Copy className="size-4" />
Duplicate Duplicate
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => onExport(project)}> <DropdownMenuItem onClick={() => onExport(recollection)}>
<Download className="size-4" /> <Download className="size-4" />
Export Export
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => onDeleteOpen(project)}> <DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => onDeleteOpen(recollection)}>
<Trash2 className="size-4" /> <Trash2 className="size-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
@@ -258,8 +258,8 @@ function ProjectActionsMenu({
export type ViewMode = 'table' | 'cards' export type ViewMode = 'table' | 'cards'
export function ProjectsPage() { export function RecollectionsPage() {
const { orderedProjects, deleteProject, renameProject, createProject, restoreProject } = usePlatform() const { orderedRecollections, deleteRecollection, renameRecollection, createRecollection, restoreRecollection } = usePlatform()
const navigate = useNavigate() const navigate = useNavigate()
const [viewMode, setViewMode] = useState<ViewMode>('cards') const [viewMode, setViewMode] = useState<ViewMode>('cards')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@@ -267,21 +267,21 @@ export function ProjectsPage() {
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const [sortKey, setSortKey] = useState<SortKey>('lastEdited') const [sortKey, setSortKey] = useState<SortKey>('lastEdited')
const [sortDir, setSortDir] = useState<SortDir>('desc') const [sortDir, setSortDir] = useState<SortDir>('desc')
const [renameTarget, setRenameTarget] = useState<Project | null>(null) const [renameTarget, setRenameTarget] = useState<Recollection | null>(null)
const [renameValue, setRenameValue] = useState('') const [renameValue, setRenameValue] = useState('')
const renameInputRef = React.useRef<HTMLInputElement>(null) const renameInputRef = React.useRef<HTMLInputElement>(null)
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null) const [deleteTarget, setDeleteTarget] = useState<Recollection | null>(null)
const [duplicateTarget, setDuplicateTarget] = useState<Project | null>(null) const [duplicateTarget, setDuplicateTarget] = useState<Recollection | null>(null)
const [duplicateName, setDuplicateName] = useState('') const [duplicateName, setDuplicateName] = useState('')
const duplicateInputRef = React.useRef<HTMLInputElement>(null) const duplicateInputRef = React.useRef<HTMLInputElement>(null)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [bulkDeleteTargets, setBulkDeleteTargets] = useState<Project[] | null>(null) const [bulkDeleteTargets, setBulkDeleteTargets] = useState<Recollection[] | null>(null)
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.trim().toLowerCase() const q = search.trim().toLowerCase()
if (!q) return orderedProjects if (!q) return orderedRecollections
return orderedProjects.filter((p) => p.name.toLowerCase().includes(q)) return orderedRecollections.filter((p) => p.name.toLowerCase().includes(q))
}, [orderedProjects, search]) }, [orderedRecollections, search])
const sorted = useMemo(() => { const sorted = useMemo(() => {
const arr = [...filtered] const arr = [...filtered]
@@ -312,15 +312,15 @@ export function ProjectsPage() {
} }
const handleOpen = useCallback( const handleOpen = useCallback(
(projectId: string) => { (recollectionId: string) => {
navigate(`/projects/${projectId}`) navigate(`/recollections/${recollectionId}`)
}, },
[navigate] [navigate]
) )
const handleRenameOpen = useCallback((project: Project) => { const handleRenameOpen = useCallback((recollection: Recollection) => {
setRenameTarget(project) setRenameTarget(recollection)
setRenameValue(project.name) setRenameValue(recollection.name)
}, []) }, [])
React.useEffect(() => { React.useEffect(() => {
@@ -339,47 +339,47 @@ export function ProjectsPage() {
const handleRenameSubmit = useCallback(() => { const handleRenameSubmit = useCallback(() => {
if (renameTarget && renameValue.trim()) { if (renameTarget && renameValue.trim()) {
renameProject(renameTarget.id, renameValue.trim()) renameRecollection(renameTarget.id, renameValue.trim())
setRenameTarget(null) setRenameTarget(null)
setRenameValue('') setRenameValue('')
} }
}, [renameTarget, renameValue, renameProject]) }, [renameTarget, renameValue, renameRecollection])
const handleDeleteOpen = useCallback((project: Project) => { const handleDeleteOpen = useCallback((recollection: Recollection) => {
setDeleteTarget(project) setDeleteTarget(recollection)
}, []) }, [])
const handleDeleteConfirm = useCallback(() => { const handleDeleteConfirm = useCallback(() => {
if (!deleteTarget) return if (!deleteTarget) return
const project = deleteTarget const recollection = deleteTarget
const graphSnapshot = loadGraphFromStorage(project.id) const graphSnapshot = loadGraphFromStorage(recollection.id)
const snapshot = const snapshot =
graphSnapshot && (graphSnapshot.nodes.length > 0 || graphSnapshot.edges.length > 0) graphSnapshot && (graphSnapshot.nodes.length > 0 || graphSnapshot.edges.length > 0)
? { nodes: graphSnapshot.nodes, edges: graphSnapshot.edges } ? { nodes: graphSnapshot.nodes, edges: graphSnapshot.edges }
: null : null
removeGraphFromStorage(project.id) removeGraphFromStorage(recollection.id)
deleteProject(project.id) deleteRecollection(recollection.id)
setDeleteTarget(null) setDeleteTarget(null)
navigate('/projects', { replace: true }) navigate('/recollections', { replace: true })
toast(`"${project.name}" deleted`, { toast(`"${recollection.name}" deleted`, {
action: { action: {
label: 'Undo', label: 'Undo',
onClick: () => restoreProject(project, snapshot), onClick: () => restoreRecollection(recollection, snapshot),
}, },
duration: 8000, duration: 8000,
}) })
}, [deleteTarget, deleteProject, navigate, restoreProject]) }, [deleteTarget, deleteRecollection, navigate, restoreRecollection])
const handleExport = useCallback( const handleExport = useCallback(
(project: Project) => { (recollection: Recollection) => {
const stored = loadGraphFromStorage(project.id) const stored = loadGraphFromStorage(recollection.id)
const state = stored const state = stored
? { version: PROJECT_VERSION, nodes: stored.nodes, edges: stored.edges } ? { version: RECOLLECTION_VERSION, nodes: stored.nodes, edges: stored.edges }
: { version: PROJECT_VERSION, nodes: [], edges: [] } : { version: RECOLLECTION_VERSION, nodes: [], edges: [] }
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' }) const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
const filename = `${project.name.replace(/[^\w.-]/g, '_')}${PROJECT_FILE_EXT}` const filename = `${recollection.name.replace(/[^\w.-]/g, '_')}${RECOLLECTION_FILE_EXT}`
a.href = url a.href = url
a.download = filename a.download = filename
a.click() a.click()
@@ -389,40 +389,40 @@ export function ProjectsPage() {
[] []
) )
const handleCreateProject = useCallback( const handleCreateRecollection = useCallback(
(project: Project) => { (recollection: Recollection) => {
createProject(project) createRecollection(recollection)
navigate(`/projects/${project.id}`) navigate(`/recollections/${recollection.id}`)
}, },
[createProject, navigate] [createRecollection, navigate]
) )
const handleDuplicateOpen = useCallback((project: Project) => { const handleDuplicateOpen = useCallback((recollection: Recollection) => {
setDuplicateTarget(project) setDuplicateTarget(recollection)
setDuplicateName(`${project.name} (copy)`) setDuplicateName(`${recollection.name} (copy)`)
}, []) }, [])
const handleDuplicateConfirm = useCallback(() => { const handleDuplicateConfirm = useCallback(() => {
if (!duplicateTarget || !duplicateName.trim()) return if (!duplicateTarget || !duplicateName.trim()) return
const name = duplicateName.trim() const name = duplicateName.trim()
const newId = `proj_${Date.now()}_${Math.random().toString(36).slice(2, 6)}` const newId = `recollection_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
const now = Date.now() const now = Date.now()
const newProject: Project = { const newRecollection: Recollection = {
id: newId, id: newId,
name, name,
iconId: duplicateTarget.iconId, iconId: duplicateTarget.iconId,
createdAt: now, createdAt: now,
lastEditedAt: now, lastEditedAt: now,
} }
createProject(newProject) createRecollection(newRecollection)
const graph = loadGraphFromStorage(duplicateTarget.id) const graph = loadGraphFromStorage(duplicateTarget.id)
if (graph && (graph.nodes.length > 0 || graph.edges.length > 0)) { if (graph && (graph.nodes.length > 0 || graph.edges.length > 0)) {
saveGraphToStorage(newId, { version: PROJECT_VERSION, nodes: graph.nodes, edges: graph.edges }) saveGraphToStorage(newId, { version: RECOLLECTION_VERSION, nodes: graph.nodes, edges: graph.edges })
} }
toast.success('Project duplicated') toast.success('Recollection duplicated')
setDuplicateTarget(null) setDuplicateTarget(null)
setDuplicateName('') setDuplicateName('')
}, [duplicateTarget, duplicateName, createProject]) }, [duplicateTarget, duplicateName, createRecollection])
const allOnPageSelected = pageItems.length > 0 && pageItems.every((p) => selectedIds.has(p.id)) const allOnPageSelected = pageItems.length > 0 && pageItems.every((p) => selectedIds.has(p.id))
const someOnPageSelected = pageItems.some((p) => selectedIds.has(p.id)) const someOnPageSelected = pageItems.some((p) => selectedIds.has(p.id))
@@ -462,33 +462,33 @@ export function ProjectsPage() {
const handleBulkDeleteConfirm = useCallback(() => { const handleBulkDeleteConfirm = useCallback(() => {
if (!bulkDeleteTargets || bulkDeleteTargets.length === 0) return if (!bulkDeleteTargets || bulkDeleteTargets.length === 0) return
const count = bulkDeleteTargets.length const count = bulkDeleteTargets.length
bulkDeleteTargets.forEach((project) => { bulkDeleteTargets.forEach((recollection) => {
removeGraphFromStorage(project.id) removeGraphFromStorage(recollection.id)
deleteProject(project.id) deleteRecollection(recollection.id)
}) })
setBulkDeleteTargets(null) setBulkDeleteTargets(null)
setSelectedIds(new Set()) setSelectedIds(new Set())
navigate('/projects', { replace: true }) navigate('/recollections', { replace: true })
toast(`${count} project${count === 1 ? '' : 's'} deleted`) toast(`${count} recollection${count === 1 ? '' : 's'} deleted`)
}, [bulkDeleteTargets, deleteProject, navigate]) }, [bulkDeleteTargets, deleteRecollection, navigate])
const handleBulkExport = useCallback(() => { const handleBulkExport = useCallback(() => {
const toExport = sorted.filter((p) => selectedIds.has(p.id)) const toExport = sorted.filter((p) => selectedIds.has(p.id))
toExport.forEach((project) => { toExport.forEach((recollection) => {
const stored = loadGraphFromStorage(project.id) const stored = loadGraphFromStorage(recollection.id)
const state = stored const state = stored
? { version: PROJECT_VERSION, nodes: stored.nodes, edges: stored.edges } ? { version: RECOLLECTION_VERSION, nodes: stored.nodes, edges: stored.edges }
: { version: PROJECT_VERSION, nodes: [], edges: [] } : { version: RECOLLECTION_VERSION, nodes: [], edges: [] }
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' }) const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
const filename = `${project.name.replace(/[^\w.-]/g, '_')}${PROJECT_FILE_EXT}` const filename = `${recollection.name.replace(/[^\w.-]/g, '_')}${RECOLLECTION_FILE_EXT}`
a.href = url a.href = url
a.download = filename a.download = filename
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
}) })
toast.success(`Exported ${toExport.length} project${toExport.length === 1 ? '' : 's'}`) toast.success(`Exported ${toExport.length} recollection${toExport.length === 1 ? '' : 's'}`)
}, [sorted, selectedIds]) }, [sorted, selectedIds])
const SortIcon = ({ columnKey }: { columnKey: SortKey }) => { const SortIcon = ({ columnKey }: { columnKey: SortKey }) => {
@@ -498,7 +498,7 @@ export function ProjectsPage() {
return ( return (
<div className="relative flex flex-1 flex-col min-h-0"> <div className="relative flex flex-1 flex-col min-h-0">
<ProjectsPageBackground className="absolute inset-0 pointer-events-none" /> <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"> <div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 overflow-auto">
<TooltipProvider> <TooltipProvider>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@@ -511,14 +511,14 @@ export function ProjectsPage() {
<div className="relative w-64 shrink-0"> <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" /> <Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input <Input
placeholder="Search projects" placeholder="Search recollections"
value={search} value={search}
onChange={(e) => { onChange={(e) => {
setSearch(e.target.value) setSearch(e.target.value)
setPage(0) setPage(0)
}} }}
className="h-9 w-full pl-8" className="h-9 w-full pl-8"
aria-label="Search projects by name" aria-label="Search recollections by name"
/> />
</div> </div>
<div className="ml-auto flex h-9 items-center gap-2"> <div className="ml-auto flex h-9 items-center gap-2">
@@ -562,9 +562,9 @@ export function ProjectsPage() {
{sorted.length === 0 ? ( {sorted.length === 0 ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<NewProjectDialog <NewRecollectionDialog
onCreate={handleCreateProject} onCreate={handleCreateRecollection}
existingNames={orderedProjects.map((p) => p.name)} existingNames={orderedRecollections.map((p) => p.name)}
trigger={ trigger={
<div <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" 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"
@@ -576,15 +576,15 @@ export function ProjectsPage() {
; (e.currentTarget as HTMLElement).click() ; (e.currentTarget as HTMLElement).click()
} }
}} }}
aria-label="Create new project" aria-label="Create new recollection"
> >
<div className="flex aspect-video w-full shrink-0 items-center justify-center" style={thumbnailGridStyle}> <div className="flex aspect-video w-full shrink-0 items-center justify-center" style={thumbnailGridStyle}>
<Plus className="size-12 text-muted-foreground" /> <Plus className="size-12 text-muted-foreground" />
</div> </div>
<div className="flex flex-1 flex-col gap-1 p-3"> <div className="flex flex-1 flex-col gap-1 p-3">
<span className="font-medium text-muted-foreground">New project</span> <span className="font-medium text-muted-foreground">New recollection</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{search.trim() ? 'No projects match your search.' : 'Create a new project'} {search.trim() ? 'No recollections match your search.' : 'Create a new recollection'}
</span> </span>
</div> </div>
</div> </div>
@@ -663,42 +663,42 @@ export function ProjectsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody className="text-xs"> <TableBody className="text-xs">
{pageItems.map((project) => { {pageItems.map((recollection) => {
const Icon = getProjectIcon(project.iconId) const Icon = getRecollectionIcon(recollection.iconId)
const counts = getGraphCounts(project.id) const counts = getGraphCounts(recollection.id)
const lastEdited = project.lastEditedAt ?? project.createdAt const lastEdited = recollection.lastEditedAt ?? recollection.createdAt
const isSelected = selectedIds.has(project.id) const isSelected = selectedIds.has(recollection.id)
return ( return (
<TableRow <TableRow
key={project.id} key={recollection.id}
className={`group cursor-pointer hover:bg-muted/50 h-8 ${isSelected ? 'bg-muted/70' : ''}`} className={`group cursor-pointer hover:bg-muted/50 h-8 ${isSelected ? 'bg-muted/70' : ''}`}
onClick={() => handleOpen(project.id)} onClick={() => handleOpen(recollection.id)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
handleOpen(project.id) handleOpen(recollection.id)
} }
}} }}
tabIndex={0} tabIndex={0}
role="button" role="button"
aria-label={`Open ${project.name}`} aria-label={`Open ${recollection.name}`}
> >
<TableCell className="px-2 py-1.5 w-10 [&:has([role=checkbox])]:pr-0" onClick={(e) => e.stopPropagation()}> <TableCell className="px-2 py-1.5 w-10 [&:has([role=checkbox])]:pr-0" onClick={(e) => e.stopPropagation()}>
<Checkbox <Checkbox
checked={isSelected} checked={isSelected}
onCheckedChange={() => toggleSelection(project.id)} onCheckedChange={() => toggleSelection(recollection.id)}
aria-label={`Select ${project.name}`} aria-label={`Select ${recollection.name}`}
/> />
</TableCell> </TableCell>
<TableCell className="px-2 py-1.5 min-w-0"> <TableCell className="px-2 py-1.5 min-w-0">
<div className="flex items-center gap-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" /> <Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="font-medium truncate">{project.name}</span> <span className="font-medium truncate">{recollection.name}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell className="hidden sm:table-cell text-muted-foreground px-2 py-1.5 w-[120px] min-w-[100px]"> <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(project.createdAt)}> <span className="truncate block" title={formatDate(recollection.createdAt)}>
{formatDate(project.createdAt)} {formatDate(recollection.createdAt)}
</span> </span>
</TableCell> </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`}> <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`}>
@@ -716,15 +716,15 @@ export function ProjectsPage() {
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'}`} 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()} onClick={(e) => e.stopPropagation()}
> >
<ProjectActionsMenu <RecollectionActionsMenu
project={project} recollection={recollection}
onOpen={handleOpen} onOpen={handleOpen}
onRenameOpen={handleRenameOpen} onRenameOpen={handleRenameOpen}
onDuplicateOpen={handleDuplicateOpen} onDuplicateOpen={handleDuplicateOpen}
onExport={handleExport} onExport={handleExport}
onDeleteOpen={handleDeleteOpen} onDeleteOpen={handleDeleteOpen}
trigger={ trigger={
<Button variant="ghost" size="icon" className="size-7" aria-label={`Actions for ${project.name}`}> <Button variant="ghost" size="icon" className="size-7" aria-label={`Actions for ${recollection.name}`}>
<MoreHorizontal className="size-4" /> <MoreHorizontal className="size-4" />
</Button> </Button>
} }
@@ -741,7 +741,7 @@ export function ProjectsPage() {
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4"> <div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Page {page + 1} of {totalPages} Page {page + 1} of {totalPages}
{pageSize !== -1 && ` · ${sorted.length} projects`} {pageSize !== -1 && ` · ${sorted.length} recollections`}
</p> </p>
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button
@@ -787,9 +787,9 @@ export function ProjectsPage() {
) : ( ) : (
<> <>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<NewProjectDialog <NewRecollectionDialog
onCreate={handleCreateProject} onCreate={handleCreateRecollection}
existingNames={orderedProjects.map((p) => p.name)} existingNames={orderedRecollections.map((p) => p.name)}
trigger={ trigger={
<div <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" 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"
@@ -801,44 +801,44 @@ export function ProjectsPage() {
; (e.currentTarget as HTMLElement).click() ; (e.currentTarget as HTMLElement).click()
} }
}} }}
aria-label="Create new project" aria-label="Create new recollection"
> >
<div className="flex aspect-video w-full shrink-0 items-center justify-center" style={thumbnailGridStyle}> <div className="flex aspect-video w-full shrink-0 items-center justify-center" style={thumbnailGridStyle}>
<Plus className="size-12 text-muted-foreground" /> <Plus className="size-12 text-muted-foreground" />
</div> </div>
<div className="flex flex-1 flex-col gap-1 p-3"> <div className="flex flex-1 flex-col gap-1 p-3">
<span className="font-medium text-muted-foreground">New project</span> <span className="font-medium text-muted-foreground">New recollection</span>
<span className="text-xs text-muted-foreground">Create a new project</span> <span className="text-xs text-muted-foreground">Create a new recollection</span>
</div> </div>
</div> </div>
} }
/> />
{pageItems.map((project) => { {pageItems.map((recollection) => {
const Icon = getProjectIcon(project.iconId) const Icon = getRecollectionIcon(recollection.iconId)
const lastEdited = project.lastEditedAt ?? project.createdAt const lastEdited = recollection.lastEditedAt ?? recollection.createdAt
return ( return (
<div <div
key={project.id} 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" 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(project.id)} onClick={() => handleOpen(recollection.id)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
handleOpen(project.id) handleOpen(recollection.id)
} }
}} }}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label={`Open ${project.name}`} aria-label={`Open ${recollection.name}`}
> >
<div className="relative aspect-video w-full shrink-0 overflow-hidden bg-muted"> <div className="relative aspect-video w-full shrink-0 overflow-hidden bg-muted">
<GraphThumbnail projectId={project.id} className="h-full w-full object-cover" /> <GraphThumbnail recollectionId={recollection.id} className="h-full w-full object-cover" />
<div <div
className="absolute right-1.5 top-1.5 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100" 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()} onClick={(e) => e.stopPropagation()}
> >
<ProjectActionsMenu <RecollectionActionsMenu
project={project} recollection={recollection}
onOpen={handleOpen} onOpen={handleOpen}
onRenameOpen={handleRenameOpen} onRenameOpen={handleRenameOpen}
onDuplicateOpen={handleDuplicateOpen} onDuplicateOpen={handleDuplicateOpen}
@@ -849,7 +849,7 @@ export function ProjectsPage() {
variant="secondary" variant="secondary"
size="icon" size="icon"
className="size-7 rounded-full shadow-sm" className="size-7 rounded-full shadow-sm"
aria-label={`Actions for ${project.name}`} aria-label={`Actions for ${recollection.name}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<MoreHorizontal className="size-4" /> <MoreHorizontal className="size-4" />
@@ -861,7 +861,7 @@ export function ProjectsPage() {
<div className="flex flex-1 flex-col gap-1 p-3"> <div className="flex flex-1 flex-col gap-1 p-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-muted-foreground" /> <Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium font-serif">{project.name}</span> <span className="truncate font-medium font-serif">{recollection.name}</span>
</div> </div>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -878,7 +878,7 @@ export function ProjectsPage() {
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4"> <div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Page {page + 1} of {totalPages} Page {page + 1} of {totalPages}
{pageSize !== -1 && ` · ${sorted.length} projects`} {pageSize !== -1 && ` · ${sorted.length} recollections`}
</p> </p>
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button
@@ -928,8 +928,8 @@ export function ProjectsPage() {
<Dialog open={!!renameTarget} onOpenChange={(open) => !open && setRenameTarget(null)}> <Dialog open={!!renameTarget} onOpenChange={(open) => !open && setRenameTarget(null)}>
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}> <DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle>Rename project</DialogTitle> <DialogTitle>Rename recollection</DialogTitle>
<DialogDescription>Enter a new name for this project.</DialogDescription> <DialogDescription>Enter a new name for this recollection.</DialogDescription>
</DialogHeader> </DialogHeader>
<Input <Input
ref={renameInputRef} ref={renameInputRef}
@@ -939,11 +939,11 @@ export function ProjectsPage() {
if (e.key === 'Enter') handleRenameSubmit() if (e.key === 'Enter') handleRenameSubmit()
if (e.key === 'Escape') setRenameTarget(null) if (e.key === 'Escape') setRenameTarget(null)
}} }}
placeholder="Project name" placeholder="Recollection name"
aria-label="Project name" aria-label="Recollection name"
/> />
{renameTarget && renameValue.trim() && sorted.some((p) => p.id !== renameTarget.id && p.name.toLowerCase() === renameValue.trim().toLowerCase()) && ( {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> <p className="text-xs text-amber-600 dark:text-amber-500">An recollection with this name already exists.</p>
)} )}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setRenameTarget(null)}> <Button variant="outline" onClick={() => setRenameTarget(null)}>
@@ -960,8 +960,8 @@ export function ProjectsPage() {
<Dialog open={!!duplicateTarget} onOpenChange={(open) => { if (!open) { setDuplicateTarget(null); setDuplicateName('') } }}> <Dialog open={!!duplicateTarget} onOpenChange={(open) => { if (!open) { setDuplicateTarget(null); setDuplicateName('') } }}>
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}> <DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle>Duplicate project</DialogTitle> <DialogTitle>Duplicate recollection</DialogTitle>
<DialogDescription>Enter a name for the duplicate project.</DialogDescription> <DialogDescription>Enter a name for the duplicate recollection.</DialogDescription>
</DialogHeader> </DialogHeader>
<Input <Input
ref={duplicateInputRef} ref={duplicateInputRef}
@@ -971,11 +971,11 @@ export function ProjectsPage() {
if (e.key === 'Enter') handleDuplicateConfirm() if (e.key === 'Enter') handleDuplicateConfirm()
if (e.key === 'Escape') setDuplicateTarget(null) if (e.key === 'Escape') setDuplicateTarget(null)
}} }}
placeholder="Project name" placeholder="Recollection name"
aria-label="Duplicate project name" aria-label="Duplicate recollection name"
/> />
{duplicateTarget && duplicateName.trim() && orderedProjects.some((p) => p.name.toLowerCase() === duplicateName.trim().toLowerCase()) && ( {duplicateTarget && duplicateName.trim() && orderedRecollections.some((p) => p.name.toLowerCase() === duplicateName.trim().toLowerCase()) && (
<p className="text-xs text-amber-600 dark:text-amber-500">A project with this name already exists.</p> <p className="text-xs text-amber-600 dark:text-amber-500">An recollection with this name already exists.</p>
)} )}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => { setDuplicateTarget(null); setDuplicateName('') }}> <Button variant="outline" onClick={() => { setDuplicateTarget(null); setDuplicateName('') }}>
@@ -992,7 +992,7 @@ export function ProjectsPage() {
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}> <Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete project</DialogTitle> <DialogTitle>Delete recollection</DialogTitle>
<DialogDescription> <DialogDescription>
{deleteTarget {deleteTarget
? `Are you sure you want to delete "${deleteTarget.name}"? You can undo this from the notification.` ? `Are you sure you want to delete "${deleteTarget.name}"? You can undo this from the notification.`
@@ -1014,9 +1014,9 @@ export function ProjectsPage() {
<Dialog open={bulkDeleteTargets !== null && bulkDeleteTargets.length > 0} onOpenChange={(open) => !open && setBulkDeleteTargets(null)}> <Dialog open={bulkDeleteTargets !== null && bulkDeleteTargets.length > 0} onOpenChange={(open) => !open && setBulkDeleteTargets(null)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete {bulkDeleteTargets?.length ?? 0} projects</DialogTitle> <DialogTitle>Delete {bulkDeleteTargets?.length ?? 0} recollections</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to delete these projects? This cannot be undone. Are you sure you want to delete these recollections? This cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>

View File

@@ -1,5 +1,5 @@
/** /**
* Subtle animated dot grid background for ProjectsPage. * Subtle animated dot grid background for RecollectionsPage.
* Matches canvas grid (20px gap), with gentle wave movement. * Matches canvas grid (20px gap), with gentle wave movement.
*/ */
@@ -17,7 +17,7 @@ function getDotColor(): string {
return isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)' return isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)'
} }
export function ProjectsPageBackground({ className }: { className?: string }) { export function RecollectionsPageBackground({ className }: { className?: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const timeRef = useRef(0) const timeRef = useRef(0)
const rafRef = useRef<number>(0) const rafRef = useRef<number>(0)

View File

@@ -0,0 +1,37 @@
/**
* Per-recollection graph persistence (localStorage).
* Saves and loads StoredGraphState (version + nodes + edges). Used by useCanvasGraph and export.
*/
import type { StoredGraphState } from '@/lib/graph/state'
export type { StoredGraphState }
export const RECOLLECTION_FILE_EXT = '.zui.json'
export const RECOLLECTION_VERSION = 1
const GRAPH_KEY_PREFIX = 'zui_graph_'
export function getGraphStorageKey(recollectionId: string): string {
return `${GRAPH_KEY_PREFIX}${recollectionId}`
}
export function loadGraphFromStorage(recollectionId: string): StoredGraphState | null {
try {
const raw = localStorage.getItem(getGraphStorageKey(recollectionId))
if (!raw) return null
const data = JSON.parse(raw) as unknown
if (!data || typeof data !== 'object' || !Array.isArray((data as StoredGraphState).nodes) || !Array.isArray((data as StoredGraphState).edges))
return null
return data as StoredGraphState
} catch {
return null
}
}
export function saveGraphToStorage(recollectionId: string, state: StoredGraphState): void {
localStorage.setItem(getGraphStorageKey(recollectionId), JSON.stringify(state))
}
export function removeGraphFromStorage(recollectionId: string): void {
localStorage.removeItem(getGraphStorageKey(recollectionId))
}

View File

@@ -1,6 +1,6 @@
/** /**
* Map project icon ids to Lucide icons for the sidebar. * Map recollection icon ids to Lucide icons for the sidebar.
* Only Lucide "Animals" category icons are allowed for projects. * Only Lucide "Animals" category icons are allowed for recollections.
*/ */
import { import {
@@ -18,9 +18,9 @@ import {
Turtle, Turtle,
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import type { ProjectIconId } from '@/app/kosmos/types' import type { RecollectionIconId } from '@/app/kosmos/types'
export const PROJECT_ICON_MAP: Record<ProjectIconId, LucideIcon> = { export const RECOLLECTION_ICON_MAP: Record<RecollectionIconId, LucideIcon> = {
bird: Bird, bird: Bird,
bug: Bug, bug: Bug,
cat: Cat, cat: Cat,
@@ -35,6 +35,6 @@ export const PROJECT_ICON_MAP: Record<ProjectIconId, LucideIcon> = {
egg: Egg egg: Egg
} }
export function getProjectIcon(iconId: string): LucideIcon { export function getRecollectionIcon(iconId: string): LucideIcon {
return PROJECT_ICON_MAP[iconId as ProjectIconId] ?? Cat return RECOLLECTION_ICON_MAP[iconId as RecollectionIconId] ?? Cat
} }

View File

@@ -6,8 +6,7 @@ import { ThemeProvider } from './lib/themeContext'
import { registerBuiltinNodes } from './lib/graph/registerBuiltinNodes' import { registerBuiltinNodes } from './lib/graph/registerBuiltinNodes'
import { registerBuiltinConfigTypes } from './lib/graph/configTypes' import { registerBuiltinConfigTypes } from './lib/graph/configTypes'
import { KosmosPage } from './app/kosmos/KosmosPage' import { KosmosPage } from './app/kosmos/KosmosPage'
import { ProjectsPage } from './app/pleroma/PleromaPage' import { RecollectionsPage } from './app/recollections/RecollectionsPage'
import { KeromaPage } from './app/keroma/KeromaPage'
import { CanvasRoute } from './app/canvas/CanvasRoute' import { CanvasRoute } from './app/canvas/CanvasRoute'
import './lib/prismSetup' import './lib/prismSetup'
import 'prismjs/themes/prism.css' import 'prismjs/themes/prism.css'
@@ -23,10 +22,9 @@ createRoot(document.getElementById('root')!).render(
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<KosmosPage />}> <Route path="/" element={<KosmosPage />}>
<Route index element={<Navigate to="/projects" replace />} /> <Route index element={<Navigate to="/recollections" replace />} />
<Route path="projects" element={<ProjectsPage />} /> <Route path="recollections" element={<RecollectionsPage />} />
<Route path="projects/:projectId" element={<CanvasRoute />} /> <Route path="recollections/:recollectionId" element={<CanvasRoute />} />
<Route path="keroma" element={<KeromaPage />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>