Compare commits

...

14 Commits

Author SHA1 Message Date
7c7d470f15 fix: font size 2026-03-16 00:21:43 +01:00
c2db0b33d6 feat: improvements 2026-03-16 00:15:36 +01:00
f67c491deb feat: flux to logos 2026-03-15 23:41:10 +01:00
45c387c9e6 feat: improvements 2026-03-15 22:08:31 +01:00
4d673ab556 fix: scroll events 2026-03-15 21:46:08 +01:00
290217791f feat: add logos editor 2026-03-15 21:12:05 +01:00
929359b4bc feat: refactor menus 2026-03-15 21:11:57 +01:00
a8bd579043 feat: flip card transition 2026-03-15 20:19:00 +01:00
202462bad7 feat: custom icons 2026-03-15 15:13:41 +01:00
449d2cb382 feat: logos and flux 2026-03-15 14:43:31 +01:00
04c039bdde freat: renaming 2026-03-15 14:11:11 +01:00
2326bbe035 feat: render image in markdown 2026-03-14 22:58:57 +01:00
ce58673d18 fix: performance 2026-03-14 22:39:38 +01:00
bcb9c5946d fix: several smaller improvements 2026-03-14 22:13:38 +01:00
50 changed files with 5049 additions and 1131 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).
- **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.
- **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.
- **RenderingNode**: Uses **state.displayStatus** for NodeStatusIndicator instead of computing status locally.
- **nodeLifecycle** and **connectionStatus**: Docs reference state.ts for overall state flow.

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,9 @@
"test:run": "vitest run"
},
"dependencies": {
"@blocknote/core": "^0.47.1",
"@blocknote/react": "^0.47.1",
"@blocknote/shadcn": "^0.47.1",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",

View File

@@ -1,353 +0,0 @@
/**
* Menubar for the canvas page: Project (Import/Export), Edit (Undo/Redo, Duplicate/Copy/Paste, Rename), View (Fit View, Minimap).
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarTrigger
} from '@/components/ui/menubar'
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { ArrowLeft, CheckCircle2, CircleDot, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Loader2, Pencil, Redo2, Save, Undo2 } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import type { SaveStatus } from '@/app/canvas/useCanvasGraph'
export type CanvasMenubarProps = {
onImport: () => void
onExport: () => void
onSave?: () => void
canSave?: boolean
/** Shown next to project title: unsaved | saving | saved */
saveStatus?: SaveStatus
undo: () => void
redo: () => void
canUndo: boolean
canRedo: boolean
onDuplicate?: () => void
onCopy?: () => void
onPaste?: () => void
canDuplicate?: boolean
canCopy?: boolean
onFitView?: () => void
}
const UNDO_KEYS = { key: 'z', shiftKey: false }
const REDO_KEYS = { key: 'z', shiftKey: true }
const SAVE_KEYS = { key: 's', shiftKey: false }
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
const mod = ev.ctrlKey || ev.metaKey
return ev.key.toLowerCase() === want.key && !!mod && !!ev.shiftKey === want.shiftKey
}
export function CanvasMenubar({
onImport,
onExport,
onSave,
canSave = true,
saveStatus = 'saved',
undo,
redo,
canUndo,
canRedo,
onDuplicate,
onCopy,
onPaste,
canDuplicate = false,
canCopy = false,
onFitView,
}: CanvasMenubarProps) {
const { projectId } = useParams<{ projectId: string }>()
const { projects, renameProject } = usePlatform()
const projectName = useMemo(
() => (projectId ? projects.find((p) => p.id === projectId)?.name ?? null : null),
[projectId, projects]
)
const [isRenamingProject, setIsRenamingProject] = useState(false)
const [renameValue, setRenameValue] = useState('')
const [showSavedBriefly, setShowSavedBriefly] = useState(false)
const renameInputRef = useRef<HTMLInputElement>(null)
const ignoreNextBlurRef = useRef(false)
const savedBrieflyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const prevSaveStatusRef = useRef<SaveStatus>(saveStatus)
useEffect(() => {
if (prevSaveStatusRef.current === 'saving' && saveStatus === 'saved') {
setShowSavedBriefly(true)
if (savedBrieflyTimerRef.current) clearTimeout(savedBrieflyTimerRef.current)
savedBrieflyTimerRef.current = setTimeout(() => {
savedBrieflyTimerRef.current = null
setShowSavedBriefly(false)
}, 2500)
}
prevSaveStatusRef.current = saveStatus
}, [saveStatus])
useEffect(() => {
return () => {
if (savedBrieflyTimerRef.current) {
clearTimeout(savedBrieflyTimerRef.current)
savedBrieflyTimerRef.current = null
}
}
}, [])
useEffect(() => {
if (isRenamingProject) {
setRenameValue(projectName ?? '')
ignoreNextBlurRef.current = true
// Delay focus so the Project dropdown can close first and not steal focus back (which would trigger blur)
const t = setTimeout(() => {
renameInputRef.current?.focus()
renameInputRef.current?.select()
}, 100)
return () => clearTimeout(t)
}
}, [isRenamingProject, projectName])
const applyRename = useCallback(() => {
if (!projectId || !renameProject) return
const trimmed = renameValue.trim()
if (trimmed) renameProject(projectId, trimmed)
setIsRenamingProject(false)
}, [projectId, renameProject, renameValue])
const cancelRename = useCallback(() => {
setIsRenamingProject(false)
}, [])
const handleRenameBlur = useCallback(() => {
if (ignoreNextBlurRef.current) {
ignoreNextBlurRef.current = false
return
}
applyRename()
}, [applyRename])
useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => {
if (matchKey(ev, UNDO_KEYS)) {
if (canUndo) {
ev.preventDefault()
ev.stopPropagation()
undo()
}
return
}
if (matchKey(ev, REDO_KEYS)) {
if (canRedo) {
ev.preventDefault()
ev.stopPropagation()
redo()
}
return
}
if (matchKey(ev, SAVE_KEYS)) {
if (onSave && canSave) {
ev.preventDefault()
ev.stopPropagation()
onSave()
}
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [undo, redo, canUndo, canRedo, onSave, canSave])
return (
<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">
<Link
to="/projects"
aria-label="Back to projects"
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" />
</Link>
<MenubarMenu>
<MenubarTrigger className="font-normal text-muted-foreground">Project</MenubarTrigger>
<MenubarContent>
{projectId && (
<>
{onSave != null && (
<>
<MenubarItem onClick={onSave} disabled={!canSave} className="gap-2">
<Save className="h-4 w-4" />
Save
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>S</Kbd>
</KbdGroup>
</span>
</MenubarItem>
<MenubarSeparator />
</>
)}
<MenubarItem
onClick={() => setIsRenamingProject(true)}
className="gap-2"
>
<Pencil className="h-4 w-4" />
Rename
</MenubarItem>
<MenubarSeparator />
</>
)}
<MenubarItem onClick={onImport} className="gap-2">
<FolderOpen className="h-4 w-4" />
Import
</MenubarItem>
<MenubarItem onClick={onExport} className="gap-2">
<Download className="h-4 w-4" />
Export
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="font-normal text-muted-foreground">Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem onClick={undo} disabled={!canUndo} className="gap-2">
<Undo2 className="h-4 w-4" />
Undo
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd> + Z</Kbd>
</KbdGroup>
</span>
</MenubarItem>
<MenubarItem onClick={redo} disabled={!canRedo} className="gap-2">
<Redo2 className="h-4 w-4" />
Redo
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd> + + Z</Kbd>
</KbdGroup>
</span>
</MenubarItem>
{(onDuplicate != null || onCopy != null || onPaste != null) && <MenubarSeparator />}
{onDuplicate != null && (
<MenubarItem onClick={onDuplicate} disabled={!canDuplicate} className="gap-2">
<CopyPlus className="h-4 w-4" />
Duplicate
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>D</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
{onCopy != null && (
<MenubarItem onClick={onCopy} disabled={!canCopy} className="gap-2">
<Copy className="h-4 w-4" />
Copy
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>C</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
{onPaste != null && (
<MenubarItem onClick={onPaste} className="gap-2">
<ClipboardPaste className="h-4 w-4" />
Paste
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>V</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="font-normal text-muted-foreground">View</MenubarTrigger>
<MenubarContent>
{onFitView && (
<MenubarItem onClick={onFitView} className="gap-2">
Fit View
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>0</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
</MenubarContent>
</MenubarMenu>
</Menubar>
{projectId && (
<div className="absolute left-1/2 -translate-x-1/2 flex items-center justify-center gap-2 max-w-[50%] min-w-[120px]">
{isRenamingProject ? (
<Input
ref={renameInputRef}
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
applyRename()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelRename()
}
}}
onBlur={handleRenameBlur}
className="h-7 text-sm font-medium text-center font-serif"
aria-label="Project name"
/>
) : (
<>
<span className="pointer-events-none truncate text-sm font-medium text-foreground font-serif">
{projectName ?? 'Untitled'}
</span>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span
className="shrink-0 flex items-center text-muted-foreground cursor-default"
aria-live="polite"
aria-label={
saveStatus === 'saving'
? 'Saving'
: saveStatus === 'unsaved'
? 'Unsaved changes'
: 'All changes saved'
}
>
{saveStatus === 'saving' && (
<Loader2 className="size-3.5 animate-spin" aria-hidden />
)}
{saveStatus === 'unsaved' && (
<CircleDot className="size-3.5" aria-hidden />
)}
{(saveStatus === 'saved' || showSavedBriefly) && (
<CheckCircle2 className="size-3.5 text-muted-foreground/70" aria-hidden />
)}
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{saveStatus === 'saving'
? 'Saving…'
: saveStatus === 'unsaved'
? 'Unsaved changes'
: 'All changes saved'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
/**
* 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'
@@ -36,7 +36,8 @@ import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore'
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
import { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
import { useRecollectionActions } from '@/app/recollections/RecollectionActionsContext'
import type { StoredGraphState } from '@/app/recollections/recollectionStore'
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts'
@@ -66,7 +67,7 @@ import {
} from '@/lib/graph/nodeRegistry'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import { toast } from 'sonner'
import { PROJECT_FILE_EXT, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
import { RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
const SNAP_GRID: [number, number] = [15, 15]
const DUPLICATE_OFFSET = { x: 30, y: 30 }
@@ -149,11 +150,11 @@ function FullscreenNodeContent({ node }: { node: AppNode }) {
}
export type CanvasPageProps = {
/** Optional project id for future per-project graph loading */
projectId?: string
/** Optional recollection id for per-recollection graph loading */
recollectionId?: string
}
export function CanvasPage({ projectId }: CanvasPageProps) {
export function CanvasPage({ recollectionId }: CanvasPageProps) {
const { theme } = useTheme()
const { showMinimap } = usePlatform()
const {
@@ -172,9 +173,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
setStateImmediate,
save,
saveStatus,
} = useCanvasGraph(projectId)
} = useCanvasGraph(recollectionId)
const importInputRef = useRef<HTMLInputElement | null>(null)
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
@@ -188,8 +188,21 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
const [isSelecting, setIsSelecting] = React.useState(false)
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)
const graphApplyTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const GRAPH_APPLY_DEBOUNCE_MS = 300
useEffect(() => {
dispatchCanvasCommand({ type: 'graph/apply', payload: { nodes, edges } })
if (graphApplyTimeoutRef.current) clearTimeout(graphApplyTimeoutRef.current)
graphApplyTimeoutRef.current = setTimeout(() => {
graphApplyTimeoutRef.current = null
dispatchCanvasCommand({ type: 'graph/apply', payload: { nodes, edges } })
}, GRAPH_APPLY_DEBOUNCE_MS)
return () => {
if (graphApplyTimeoutRef.current) {
clearTimeout(graphApplyTimeoutRef.current)
graphApplyTimeoutRef.current = null
}
}
}, [nodes, edges])
const connectionPath = useCanvasConnectionPathFromStore()
@@ -218,7 +231,22 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null
const toApply = pendingChangesRef.current.splice(0, pendingChangesRef.current.length)
if (toApply.length > 0) setNodesSilent((nds) => applyNodeChanges(toApply, nds))
if (toApply.length === 0) return
setNodesSilent((nds) => {
// Drop dimension-only changes that don't change the node (e.g. React Flow re-reporting on visibility).
// Avoids graph/apply and store churn when nodes become visible with onlyRenderVisibleElements.
const filtered = toApply.filter((c) => {
const ch = c as NodeChange<Node> & { type?: string; dimensions?: { width?: number; height?: number } }
if (ch.type !== 'dimensions' || ch.dimensions == null) return true
const node = nds.find((n) => n.id === (ch as { id?: string }).id)
if (!node) return true
const nw = (node as Node & { width?: number }).width
const nh = (node as Node & { height?: number }).height
return nw !== ch.dimensions.width || nh !== ch.dimensions.height
})
if (filtered.length === 0) return nds
return applyNodeChanges(filtered, nds)
})
})
}
},
@@ -295,57 +323,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd])
const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd])
const handleExportProject = useCallback(() => {
const state = { version: PROJECT_VERSION, nodes, edges }
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `project${PROJECT_FILE_EXT}`
a.click()
URL.revokeObjectURL(url)
toast.success('Project exported')
}, [nodes, edges])
const handleImportProject = useCallback(() => importInputRef.current?.click(), [])
const handleLoadExample = useCallback(() => {
const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
setStateImmediate({ nodes: exampleNodes, edges: exampleEdges })
toast.success('Example loaded')
}, [setStateImmediate])
const onImportFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
const reader = new FileReader()
reader.onload = () => {
try {
const text = reader.result as string
const state = JSON.parse(text) as { version?: number; nodes?: unknown[]; edges?: unknown[] }
if (!state || !Array.isArray(state.nodes) || !Array.isArray(state.edges)) {
toast.error('Invalid file: expected nodes and edges arrays')
return
}
const nodes = state.nodes as AppNode[]
const edges = backfillEdgeTargetTypes(nodes, state.edges as AppEdge[])
setStateImmediate({ nodes, edges })
if (state.version != null && state.version > PROJECT_VERSION) {
toast.error('Project was created with a newer app version')
} else {
toast.success('Project loaded')
}
} catch {
toast.error('Invalid file: not valid JSON')
}
}
reader.readAsText(file)
},
[setStateImmediate]
)
const selectedNodes = useMemo(
() => nodes.filter((n) => (n as Node & { selected?: boolean }).selected),
[nodes]
@@ -399,6 +382,57 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
flowActionsRef.current?.pasteAtViewportCenter?.()
}, [])
const { setFluxSlot, onImport } = useRecollectionActions()
const onRefreshFromStore = useCallback(
(graph: StoredGraphState) => {
const nodes = graph.nodes as AppNode[]
const edges = backfillEdgeTargetTypes(nodes, graph.edges as AppEdge[])
setStateImmediate({ nodes, edges })
},
[setStateImmediate]
)
useEffect(() => {
const slot = {
saveStatus,
onSave: recollectionId
? () => {
save()
toast.success('Saved')
}
: () => {},
canSave: Boolean(recollectionId),
undo,
redo,
canUndo,
canRedo,
onRefreshFromStore,
onDuplicate: handleDuplicate,
onCopy: handleCopy,
onPaste: handlePaste,
canDuplicate: selectedNodes.length > 0,
canCopy: selectedNodes.length === 1,
onFitView: () => flowActionsRef.current?.fitView?.(),
}
setFluxSlot(slot)
return () => setFluxSlot(null)
}, [
setFluxSlot,
saveStatus,
recollectionId,
save,
undo,
redo,
canUndo,
canRedo,
onRefreshFromStore,
handleDuplicate,
handleCopy,
handlePaste,
selectedNodes.length,
])
const graphContextValue = useMemo(
() => ({ setNodes, setEdges, graphRef, edges }),
[setNodes, setEdges, edges]
@@ -506,8 +540,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
const clientX = click?.clientX ?? window.innerWidth / 2
const clientY = click?.clientY ?? window.innerHeight / 2
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 } }
const screenToFlow = inst.screenToFlowPosition ?? inst.project
type ScreenToFlow = (p: { x: number; y: number }) => { x: number; y: number }
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 })
return p ? snapToGrid(p.x, p.y) : null
} catch {
@@ -609,38 +644,6 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
<div role="status" aria-live="polite" aria-atomic className="sr-only">
{ariaAnnouncement}
</div>
<input
ref={importInputRef}
type="file"
accept=".json,.zui.json,application/json"
className="hidden"
onChange={onImportFileChange}
aria-hidden
/>
<CanvasMenubar
onImport={handleImportProject}
onExport={handleExportProject}
onSave={
projectId
? () => {
save()
toast.success('Saved')
}
: undefined
}
canSave={Boolean(projectId)}
saveStatus={saveStatus}
undo={undo}
redo={redo}
canUndo={canUndo}
canRedo={canRedo}
onDuplicate={handleDuplicate}
onCopy={handleCopy}
onPaste={handlePaste}
canDuplicate={selectedNodes.length > 0}
canCopy={selectedNodes.length === 1}
onFitView={() => flowActionsRef.current?.fitView?.()}
/>
<div className="flex-1 min-h-0 relative flex flex-col">
<div className="flex-1 min-h-0 flex flex-col">
<GraphContext.Provider value={graphContextValue}>
@@ -666,11 +669,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
<EmptyTitle>Start adding a new node!</EmptyTitle>
<EmptyDescription>
Rightclick to add nodes. <br />
Import a project or paste a node.
Import a recollection or paste a node.
</EmptyDescription>
</EmptyHeader>
<EmptyContent className="flex-row flex-wrap justify-center gap-2">
<Button onClick={handleImportProject} variant="outline" size="sm">
<Button onClick={onImport} variant="outline" size="sm">
<FolderOpen className="size-4" />
Import
</Button>

View File

@@ -1,36 +0,0 @@
/**
* Route wrapper for the canvas: resolves projectId from URL and updates lastEditedAt on open.
*/
import React, { useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { CanvasPage } from './CanvasPage'
import { usePlatform } from '@/app/kosmos/KosmosContext'
export function CanvasRoute() {
const { projectId } = useParams<{ projectId: string }>()
const { projects, updateLastEdited } = usePlatform()
const updateLastEditedRef = useRef(updateLastEdited)
updateLastEditedRef.current = updateLastEdited
const project = projects.find((p) => p.id === projectId)
useEffect(() => {
if (projectId) updateLastEditedRef.current(projectId)
}, [projectId])
if (!projectId) return null
if (!project) {
return (
<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>
</div>
)
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<CanvasPage key={projectId} projectId={projectId} />
</div>
)
}

View File

@@ -5,7 +5,7 @@
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
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). */
export function backfillEdgeTargetTypes(
@@ -70,9 +70,9 @@ export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
return { nodes, edges }
}
export function getInitialGraph(projectId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
if (projectId) {
const stored = loadGraphFromStorage(projectId)
export function getInitialGraph(recollectionId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
if (recollectionId) {
const stored = loadGraphFromStorage(recollectionId)
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
const nodes = stored.nodes as AppNode[]
const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[])

View File

@@ -1,89 +1,63 @@
/**
* Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory
* with initial graph from project storage (or example). Save is explicit via save();
* autosave runs on an interval (~10s) and saves when the graph has changed.
* with initial graph from recollection storage (or example). Save is explicit via save().
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
import { useCanvasStore } from '@/app/canvas/canvasStore'
import { saveGraphToStorage, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
export type SaveStatus = 'saved' | 'unsaved' | 'saving'
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
/** For menubar: show "Unsaved changes" | "Saving…" | "All changes saved". */
saveStatus: SaveStatus
}
const AUTOSAVE_INTERVAL_MS = 10_000
export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphResult {
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGraphResult {
const initialGraph = useMemo(() => getInitialGraph(recollectionId), [recollectionId])
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
const { nodes, edges } = result
const storeNodes = useCanvasStore((s) => s.graph.nodes)
const storeEdges = useCanvasStore((s) => s.graph.edges)
const nodesRef = useRef(nodes)
const edgesRef = useRef(edges)
nodesRef.current = nodes
edgesRef.current = edges
const [isSaving, setIsSaving] = useState(false)
const lastSavedSnapshotRef = useRef(
const [lastSavedSerialized, setLastSavedSerialized] = useState(() =>
JSON.stringify({ nodes: initialGraph.nodes, edges: initialGraph.edges })
)
const serializedFromStore = useMemo(
() => JSON.stringify({ nodes: storeNodes, edges: storeEdges }),
[storeNodes, storeEdges]
const currentSerialized = useMemo(
() => JSON.stringify({ nodes, edges }),
[nodes, edges]
)
const isDirty = serializedFromStore !== lastSavedSnapshotRef.current
const isDirty = currentSerialized !== lastSavedSerialized
const saveStatus: SaveStatus = isSaving ? 'saving' : isDirty ? 'unsaved' : 'saved'
const performSave = useCallback(() => {
if (!projectId) return
const current = JSON.stringify({
nodes: nodesRef.current,
edges: edgesRef.current,
})
if (current === lastSavedSnapshotRef.current) return
setIsSaving(true)
saveGraphToStorage(projectId, {
version: PROJECT_VERSION,
nodes: nodesRef.current,
edges: edgesRef.current,
})
lastSavedSnapshotRef.current = current
setIsSaving(false)
}, [projectId])
const save = useCallback(() => {
if (!projectId) return
if (!recollectionId) return
const snapshot = JSON.stringify({
nodes: nodesRef.current,
edges: edgesRef.current,
})
setIsSaving(true)
saveGraphToStorage(projectId, {
version: PROJECT_VERSION,
saveGraphToStorage(recollectionId, {
version: RECOLLECTION_VERSION,
nodes: nodesRef.current,
edges: edgesRef.current,
})
lastSavedSnapshotRef.current = JSON.stringify({
nodes: nodesRef.current,
edges: edgesRef.current,
})
setIsSaving(false)
}, [projectId])
useEffect(() => {
if (!projectId) return
const id = setInterval(performSave, AUTOSAVE_INTERVAL_MS)
return () => clearInterval(id)
}, [projectId, performSave])
const SAVING_DISPLAY_MS = 360
setTimeout(() => {
setLastSavedSerialized(snapshot)
setIsSaving(false)
}, SAVING_DISPLAY_MS)
}, [recollectionId])
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.
* Used by AppSidebar, ProjectsTablePage, and canvas route.
* Platform context: recollections list and handlers for create/delete/rename/updateLastEdited/reorder/restore.
* Used by AppSidebar, RecollectionsPage, and canvas route.
*/
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
import type { Project } from './types'
import { saveGraphToStorage } from '@/app/pleroma/projectGraphStorage'
import { PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
import type { Recollection } from './types'
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
const STORAGE_KEY = 'zui_platform_projects'
const ORDER_STORAGE_KEY = 'zui_platform_project_order'
const RECENT_STORAGE_KEY = 'zui_platform_recent_project_ids'
const STORAGE_KEY = 'zui_platform_recollections'
const ORDER_STORAGE_KEY = 'zui_platform_recollection_order'
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 AI_CONNECTION_KEY = 'zui_ai_connection'
const RECENT_MAX = 5
@@ -72,8 +75,15 @@ function saveShowMinimap(value: boolean) {
function loadOrder(): string[] {
try {
const raw = localStorage.getItem(ORDER_STORAGE_KEY)
if (!raw) return []
let raw = localStorage.getItem(ORDER_STORAGE_KEY)
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
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
} catch {
@@ -87,8 +97,15 @@ function saveOrder(ids: string[]) {
function loadRecentIds(): string[] {
try {
const raw = localStorage.getItem(RECENT_STORAGE_KEY)
if (!raw) return []
let raw = localStorage.getItem(RECENT_STORAGE_KEY)
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
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string').slice(0, RECENT_MAX) : []
} catch {
@@ -100,41 +117,48 @@ function saveRecentIds(ids: string[]) {
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(ids))
}
function loadProjects(): Project[] {
function loadRecollections(): Recollection[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
let raw = localStorage.getItem(STORAGE_KEY)
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
if (!Array.isArray(parsed)) return []
return parsed
.filter(
(p): p is Project =>
(p): p is Recollection =>
p &&
typeof p === 'object' &&
typeof (p as Project).id === 'string' &&
typeof (p as Project).name === 'string' &&
typeof (p as Project).iconId === 'string' &&
typeof (p as Project).createdAt === 'number'
typeof (p as Recollection).id === 'string' &&
typeof (p as Recollection).name === 'string' &&
typeof (p as Recollection).iconId === 'string' &&
typeof (p as Recollection).createdAt === 'number'
)
.map((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 {
return []
}
}
function saveProjects(projects: Project[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects))
function saveRecollections(recollections: Recollection[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(recollections))
}
export type GraphSnapshot = { nodes: unknown[]; edges: unknown[] }
/** Projects sorted by projectOrder, then by lastEditedAt desc for any not in order */
export function sortProjectsByOrder(projects: Project[], order: string[]): Project[] {
const byId = new Map(projects.map((p) => [p.id, p]))
const ordered: Project[] = []
/** Recollections sorted by recollectionOrder, then by lastEditedAt desc for any not in order */
export function sortRecollectionsByOrder(recollections: Recollection[], order: string[]): Recollection[] {
const byId = new Map(recollections.map((p) => [p.id, p]))
const ordered: Recollection[] = []
const seen = new Set<string>()
for (const id of order) {
const p = byId.get(id)
@@ -143,27 +167,27 @@ export function sortProjectsByOrder(projects: Project[], order: string[]): Proje
seen.add(id)
}
}
const rest = projects
const rest = recollections
.filter((p) => !seen.has(p.id))
.sort((a, b) => (b.lastEditedAt ?? b.createdAt) - (a.lastEditedAt ?? a.createdAt))
return [...ordered, ...rest]
}
export type KosmosContextValue = {
projects: Project[]
projectOrder: string[]
/** Projects in display order (sidebar order, then lastEditedAt) */
orderedProjects: Project[]
/** Last RECENT_MAX accessed project IDs (most recent first) */
recentProjectIds: string[]
recordProjectAccess: (id: string) => void
persist: (next: Project[]) => void
createProject: (project: Project) => void
deleteProject: (id: string) => void
renameProject: (id: string, name: string) => void
recollections: Recollection[]
recollectionOrder: string[]
/** Recollections in display order (sidebar order, then lastEditedAt) */
orderedRecollections: Recollection[]
/** Last RECENT_MAX accessed recollection IDs (most recent first) */
recentRecollectionIds: string[]
recordRecollectionAccess: (id: string) => void
persist: (next: Recollection[]) => void
createRecollection: (recollection: Recollection) => void
deleteRecollection: (id: string) => void
renameRecollection: (id: string, name: string) => void
updateLastEdited: (id: string) => void
reorderProjects: (orderedIds: string[]) => void
restoreProject: (project: Project, graphSnapshot: GraphSnapshot | null) => void
reorderRecollections: (orderedIds: string[]) => void
restoreRecollection: (recollection: Recollection, graphSnapshot: GraphSnapshot | null) => void
/** Canvas: show React Flow minimap (persisted) */
showMinimap: boolean
setShowMinimap: (value: boolean) => void
@@ -175,9 +199,9 @@ export type KosmosContextValue = {
const KosmosContext = createContext<KosmosContextValue | null>(null)
export function KosmosProvider({ children }: { children: React.ReactNode }) {
const [projects, setProjects] = useState<Project[]>(loadProjects)
const [projectOrder, setProjectOrder] = useState<string[]>(loadOrder)
const [recentProjectIds, setRecentProjectIds] = useState<string[]>(loadRecentIds)
const [recollections, setRecollections] = useState<Recollection[]>(loadRecollections)
const [recollectionOrder, setRecollectionOrder] = useState<string[]>(loadOrder)
const [recentRecollectionIds, setRecentRecollectionIds] = useState<string[]>(loadRecentIds)
const [showMinimap, setShowMinimapState] = useState<boolean>(loadShowMinimap)
const [aiConnection, setAiConnectionState] = useState<AiConnection>(loadAiConnection)
@@ -191,128 +215,128 @@ export function KosmosProvider({ children }: { children: React.ReactNode }) {
saveAiConnection(value)
}, [])
const persist = useCallback((next: Project[]) => {
setProjects(next)
saveProjects(next)
const persist = useCallback((next: Recollection[]) => {
setRecollections(next)
saveRecollections(next)
}, [])
const createProject = useCallback(
(project: Project) => {
const withEdited = { ...project, lastEditedAt: project.createdAt }
persist([...projects, withEdited])
setProjectOrder((prev) => {
const next = prev.includes(project.id) ? prev : [project.id, ...prev]
const createRecollection = useCallback(
(recollection: Recollection) => {
const withEdited = { ...recollection, lastEditedAt: recollection.createdAt }
persist([...recollections, withEdited])
setRecollectionOrder((prev) => {
const next = prev.includes(recollection.id) ? prev : [recollection.id, ...prev]
saveOrder(next)
return next
})
},
[projects, persist]
[recollections, persist]
)
const deleteProject = useCallback(
const deleteRecollection = useCallback(
(id: string) => {
const next = projects.filter((p) => p.id !== id)
const next = recollections.filter((p) => p.id !== id)
persist(next)
setProjectOrder((prev) => {
setRecollectionOrder((prev) => {
const nextOrder = prev.filter((oid) => oid !== id)
saveOrder(nextOrder)
return nextOrder
})
setRecentProjectIds((prev) => {
setRecentRecollectionIds((prev) => {
const next = prev.filter((oid) => oid !== id)
saveRecentIds(next)
return next
})
},
[projects, persist]
[recollections, persist]
)
const renameProject = useCallback(
const renameRecollection = useCallback(
(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)
},
[projects, persist]
[recollections, persist]
)
const updateLastEdited = useCallback(
(id: string) => {
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)
},
[projects, persist]
[recollections, persist]
)
const reorderProjects = useCallback((orderedIds: string[]) => {
setProjectOrder(orderedIds)
const reorderRecollections = useCallback((orderedIds: string[]) => {
setRecollectionOrder(orderedIds)
saveOrder(orderedIds)
}, [])
const recordProjectAccess = useCallback((id: string) => {
setRecentProjectIds((prev) => {
const recordRecollectionAccess = useCallback((id: string) => {
setRecentRecollectionIds((prev) => {
const next = [id, ...prev.filter((x) => x !== id)].slice(0, RECENT_MAX)
saveRecentIds(next)
return next
})
}, [])
const restoreProject = useCallback(
(project: Project, graphSnapshot: GraphSnapshot | null) => {
persist([...projects, project])
setProjectOrder((prev) => {
const next = prev.includes(project.id) ? prev : [project.id, ...prev]
const restoreRecollection = useCallback(
(recollection: Recollection, graphSnapshot: GraphSnapshot | null) => {
persist([...recollections, recollection])
setRecollectionOrder((prev) => {
const next = prev.includes(recollection.id) ? prev : [recollection.id, ...prev]
saveOrder(next)
return next
})
if (graphSnapshot) {
saveGraphToStorage(project.id, {
version: PROJECT_VERSION,
saveGraphToStorage(recollection.id, {
version: RECOLLECTION_VERSION,
nodes: graphSnapshot.nodes,
edges: graphSnapshot.edges,
})
}
},
[projects, persist]
[recollections, persist]
)
const orderedProjects = useMemo(
() => sortProjectsByOrder(projects, projectOrder),
[projects, projectOrder]
const orderedRecollections = useMemo(
() => sortRecollectionsByOrder(recollections, recollectionOrder),
[recollections, recollectionOrder]
)
const value: KosmosContextValue = useMemo(
() => ({
projects,
projectOrder,
orderedProjects,
recentProjectIds,
recordProjectAccess,
recollections,
recollectionOrder,
orderedRecollections,
recentRecollectionIds,
recordRecollectionAccess,
persist,
createProject,
deleteProject,
renameProject,
createRecollection,
deleteRecollection,
renameRecollection,
updateLastEdited,
reorderProjects,
restoreProject,
reorderRecollections,
restoreRecollection,
showMinimap,
setShowMinimap,
aiConnection,
setAiConnection,
}),
[
projects,
projectOrder,
orderedProjects,
recentProjectIds,
recordProjectAccess,
recollections,
recollectionOrder,
orderedRecollections,
recentRecollectionIds,
recordRecollectionAccess,
persist,
createProject,
deleteProject,
renameProject,
createRecollection,
deleteRecollection,
renameRecollection,
updateLastEdited,
reorderProjects,
restoreProject,
reorderRecollections,
restoreRecollection,
showMinimap,
setShowMinimap,
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'
@@ -9,13 +9,13 @@ import { KosmosProvider, usePlatform } from './KosmosContext'
import { KosmosSidebar } from './KosmosSidebar'
function KosmosLayoutInner() {
const { projectId } = useParams<{ projectId: string }>()
const { recordProjectAccess } = usePlatform()
const { recollectionId } = useParams<{ recollectionId: string }>()
const { recordRecollectionAccess } = usePlatform()
const [sidebarOpen, setSidebarOpen] = useState(true)
useEffect(() => {
if (projectId) recordProjectAccess(projectId)
}, [projectId, recordProjectAccess])
if (recollectionId) recordRecollectionAccess(recollectionId)
}, [recollectionId, recordRecollectionAccess])
return (
<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'
@@ -17,36 +17,35 @@ import {
SidebarRail,
SidebarTrigger,
} from '@/components/ui/sidebar'
import { Plus, Settings, Triangle } from 'lucide-react'
import { Plus, Settings } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSidebar } from '@/components/ui/sidebar'
import { usePlatform } from './KosmosContext'
import { getProjectIcon } from '@/lib/iconMap'
import { NewProjectDialog } from './NewProjectDialog'
import { getRecollectionIcon } from '@/lib/iconMap'
import { RecollectionsIcon } from '@/lib/icons'
import { NewRecollectionDialog } from './NewRecollectionDialog'
import { SettingsDialog } from './SettingsDialog'
import type { Project } from './types'
import type { Recollection } from './types'
export function KosmosSidebar() {
const { state, setOpen } = useSidebar()
const isCollapsed = state === 'collapsed'
const { orderedProjects, createProject, recentProjectIds } = usePlatform()
const recentProjects = useMemo(() => {
const byId = new Map(orderedProjects.map((p) => [p.id, p]))
return recentProjectIds.map((id) => byId.get(id)).filter((p): p is Project => p != null)
}, [orderedProjects, recentProjectIds])
const { orderedRecollections, createRecollection, recentRecollectionIds } = usePlatform()
const recentRecollections = useMemo(() => {
const byId = new Map(orderedRecollections.map((p) => [p.id, p]))
return recentRecollectionIds.map((id) => byId.get(id)).filter((p): p is Recollection => p != null)
}, [orderedRecollections, recentRecollectionIds])
const navigate = useNavigate()
const location = useLocation()
const { projectId: selectedProjectId } = useParams<{ projectId: string }>()
const isKeroma = location.pathname === '/keroma'
const { recollectionId: selectedRecollectionId } = useParams<{ recollectionId: string }>()
const handleSelectRecollection = useCallback((id: string) => navigate(`/recollections/${id}`), [navigate])
const handleSelectProject = useCallback((id: string) => navigate(`/projects/${id}`), [navigate])
const handleCreateProject = useCallback(
(project: Parameters<typeof createProject>[0]) => {
createProject(project)
navigate(`/projects/${project.id}`)
const handleCreateRecollection = useCallback(
(recollection: Parameters<typeof createRecollection>[0]) => {
createRecollection(recollection)
navigate(`/recollections/${recollection.id}`)
},
[createProject, navigate]
[createRecollection, navigate]
)
return (
@@ -99,7 +98,7 @@ export function KosmosSidebar() {
</Tooltip>
) : (
<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="size-6 shrink-0 rounded-[2px] opacity-90"
@@ -132,39 +131,31 @@ export function KosmosSidebar() {
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="All projects" isActive={location.pathname === '/projects' && !selectedProjectId}>
<Link to="/projects">
<Triangle className="size-4" />
<span>Pleroma</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Keroma" isActive={isKeroma}>
<Link to="/keroma">
<Triangle className="size-4 rotate-180" />
<span>Keroma</span>
<SidebarMenuButton asChild tooltip="Recollections" isActive={location.pathname === '/recollections' && !selectedRecollectionId}>
<Link to="/recollections">
<RecollectionsIcon className="size-4 shrink-0" />
<span>Recollections</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
{recentProjects.length > 0 && (
{recentRecollections.length > 0 && (
<SidebarGroup>
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Recently used</SidebarGroupLabel>
<SidebarMenu>
{recentProjects.map((project) => {
const Icon = getProjectIcon(project.iconId)
const isActive = selectedProjectId === project.id
{recentRecollections.map((recollection) => {
const Icon = getRecollectionIcon(recollection.iconId)
const isActive = selectedRecollectionId === recollection.id
return (
<SidebarMenuItem key={project.id}>
<SidebarMenuItem key={recollection.id}>
<SidebarMenuButton
tooltip={project.name}
tooltip={recollection.name}
isActive={isActive}
onClick={() => handleSelectProject(project.id)}
onClick={() => handleSelectRecollection(recollection.id)}
>
<Icon className="size-4" />
<span>{project.name}</span>
<span>{recollection.name}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
@@ -175,13 +166,13 @@ export function KosmosSidebar() {
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<NewProjectDialog
onCreate={handleCreateProject}
existingNames={orderedProjects.map((p) => p.name)}
<NewRecollectionDialog
onCreate={handleCreateRecollection}
existingNames={orderedRecollections.map((p) => p.name)}
trigger={
<SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer">
<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>
}
/>

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'
@@ -16,20 +16,20 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Plus } from 'lucide-react'
import { PROJECT_ICON_IDS, type Project, type ProjectIconId } from './types'
import { getProjectIcon } from '@/lib/iconMap'
import { RECOLLECTION_ICON_IDS, type Recollection, type RecollectionIconId } from './types'
import { getRecollectionIcon } from '@/lib/iconMap'
type NewProjectDialogProps = {
onCreate: (project: Project) => void
type NewRecollectionDialogProps = {
onCreate: (recollection: Recollection) => void
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[]
}
export function NewProjectDialog({ onCreate, trigger, existingNames = [] }: NewProjectDialogProps) {
export function NewRecollectionDialog({ onCreate, trigger, existingNames = [] }: NewRecollectionDialogProps) {
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [iconId, setIconId] = useState<ProjectIconId>('cat')
const [iconId, setIconId] = useState<RecollectionIconId>('cat')
const trimmed = name.trim()
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) => {
e.preventDefault()
if (!trimmed) return
const project: Project = {
id: `proj_${Date.now()}`,
const recollection: Recollection = {
id: `recollection_${Date.now()}`,
name: trimmed,
iconId,
createdAt: Date.now(),
}
onCreate(project)
onCreate(recollection)
setName('')
setIconId('cat')
setOpen(false)
@@ -55,41 +55,41 @@ export function NewProjectDialog({ onCreate, trigger, existingNames = [] }: NewP
{trigger ?? (
<Button variant="outline" size="sm" className="w-full justify-start gap-2">
<Plus className="size-4" />
New project
New recollection
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>New project</DialogTitle>
<DialogDescription>Create a project to start editing a graph canvas.</DialogDescription>
<DialogTitle>New recollection</DialogTitle>
<DialogDescription>Create a recollection to start editing a graph canvas.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<label htmlFor="project-name" className="text-sm font-medium">
<label htmlFor="recollection-name" className="text-sm font-medium">
Name
</label>
<Input
id="project-name"
id="recollection-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My project"
placeholder="My recollection"
autoFocus
/>
{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 className="grid gap-2">
<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>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROJECT_ICON_IDS.map((id) => {
const Icon = getProjectIcon(id)
{RECOLLECTION_ICON_IDS.map((id) => {
const Icon = getRecollectionIcon(id)
return (
<SelectItem key={id} value={id}>
<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'
export type Project = {
export type Recollection = {
id: string
name: string
/** Icon identifier: key of PROJECT_ICONS map */
/** Icon identifier: key of RECOLLECTION_ICONS map */
iconId: string
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
}
/** Project icon ids: Lucide "Animals" category only */
export const PROJECT_ICON_IDS = [
/** Recollection icon ids: Lucide "Animals" category only */
export const RECOLLECTION_ICON_IDS = [
'bird',
'bug',
'cat',
@@ -30,4 +30,4 @@ export const PROJECT_ICON_IDS = [
'egg'
] 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

@@ -0,0 +1,27 @@
/**
* Recollection view switcher: Logos or Flux based on pathname.
* Single container, conditional render — only the active view is mounted (no CSS flip).
*/
import React from 'react'
import { useLocation, useParams } from 'react-router-dom'
import { LogosPage } from './logos/LogosPage'
import { FluxRoute } from './flux/FluxRoute'
export function FlippingCardView() {
const { pathname } = useLocation()
const { recollectionId } = useParams<{ recollectionId: string }>()
const isFlux = pathname.endsWith('/flux')
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{isFlux ? (
recollectionId ? (
<FluxRoute />
) : null
) : (
<LogosPage />
)}
</div>
)
}

View File

@@ -0,0 +1,243 @@
/**
* Context for shared recollection actions: two slots (flux and logos) and layout-level import/export.
* Consumers use pathname to pick the active slot for title (save status, Save) and Edit/View menus (undo, redo, etc.).
*/
import React, { createContext, useCallback, useContext, useRef, useState } from 'react'
import { useLocation, useParams } from 'react-router-dom'
import type { SaveStatus } from '@/app/canvas/useCanvasGraph'
import {
getGraph,
setGraph,
getLogosContent,
setLogosContent,
upsertRenderOutputEntry,
RECOLLECTION_FILE_EXT,
RECOLLECTION_VERSION,
type StoredGraphState,
type StoredLogosContent,
type RenderOutputCacheEntry,
} from './recollectionStore'
export type { RenderOutputCacheEntry }
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import { backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
import { toast } from 'sonner'
export type { SaveStatus }
/** Parsed recollection file format (import/export). */
export type RecollectionFilePayload = {
version?: number
graph?: { nodes: unknown[]; edges: unknown[] }
logos?: StoredLogosContent
}
export type FluxSlot = {
saveStatus: SaveStatus
onSave: () => void
canSave: boolean
undo: () => void
redo: () => void
canUndo: boolean
canRedo: boolean
onRefreshFromStore: (graph: StoredGraphState) => void
onDuplicate?: () => void
onCopy?: () => void
onPaste?: () => void
canDuplicate?: boolean
canCopy?: boolean
onFitView?: () => void
}
export type LogosSlot = {
saveStatus: SaveStatus
onSave: () => void
canSave: boolean
undo: () => void
redo: () => void
canUndo: boolean
canRedo: boolean
onRefreshFromStore: () => void
}
export type RecollectionActionsContextValue = {
flux: FluxSlot | null
logos: LogosSlot | null
setFluxSlot: (slot: FluxSlot | null) => void
setLogosSlot: (slot: LogosSlot | null) => void
/** Whether the Flux view is active (pathname ends with /flux). */
isFluxActive: boolean
/** Whether the Logos view is active. */
isLogosActive: boolean
/** Active slot (flux or logos by pathname). */
activeSlot: FluxSlot | LogosSlot | null
onImport: () => void
onExport: () => void
/** Upsert a rendering node's output into the cache so Logos "Insert from Flux" block can show it. */
upsertRenderOutputToLogos: (entry: RenderOutputCacheEntry) => void
}
const RecollectionActionsContext = createContext<RecollectionActionsContextValue | null>(null)
function validateAndWritePayload(
recollectionId: string,
payload: RecollectionFilePayload,
backfillEdges: (nodes: AppNode[], edges: AppEdge[]) => AppEdge[]
): { graph?: StoredGraphState; logos?: StoredLogosContent } {
const result: { graph?: StoredGraphState; logos?: StoredLogosContent } = {}
if (payload.graph && Array.isArray(payload.graph.nodes) && Array.isArray(payload.graph.edges)) {
const nodes = payload.graph.nodes as AppNode[]
const edges = backfillEdges(nodes, payload.graph.edges as AppEdge[])
const graphState: StoredGraphState = {
version: payload.version ?? RECOLLECTION_VERSION,
nodes,
edges,
}
setGraph(recollectionId, graphState)
result.graph = graphState
}
if (payload.logos != null && Array.isArray(payload.logos)) {
if (payload.logos.every((item) => item != null && typeof item === 'object')) {
setLogosContent(recollectionId, payload.logos as StoredLogosContent)
result.logos = payload.logos as StoredLogosContent
}
}
return result
}
export function RecollectionActionsProvider({ children }: { children: React.ReactNode }) {
const { recollectionId } = useParams<{ recollectionId: string }>()
const { pathname } = useLocation()
const [flux, setFluxSlotState] = useState<FluxSlot | null>(null)
const [logos, setLogosSlotState] = useState<LogosSlot | null>(null)
const importInputRef = useRef<HTMLInputElement>(null)
const fluxRef = useRef<FluxSlot | null>(null)
const logosRef = useRef<LogosSlot | null>(null)
const pathnameRef = useRef(pathname)
fluxRef.current = flux
logosRef.current = logos
pathnameRef.current = pathname
const isFluxActive = pathname.endsWith('/flux')
const isLogosActive = pathname.endsWith('/logos') || /\/recollections\/[^/]+\/?$/.test(pathname)
const activeSlot = isFluxActive ? flux : isLogosActive ? logos : null
const setFluxSlot = useCallback((slot: FluxSlot | null) => {
setFluxSlotState(() => slot)
}, [])
const setLogosSlot = useCallback((slot: LogosSlot | null) => {
setLogosSlotState(() => slot)
}, [])
const onExport = useCallback(() => {
if (!recollectionId) return
const graph = getGraph(recollectionId)
const logosContent = getLogosContent(recollectionId)
const payload: RecollectionFilePayload = {
version: RECOLLECTION_VERSION,
...(graph && { graph: { nodes: graph.nodes, edges: graph.edges } }),
...(logosContent && { logos: logosContent }),
}
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `recollection${RECOLLECTION_FILE_EXT}`
a.click()
URL.revokeObjectURL(url)
toast.success('Recollection exported')
}, [recollectionId])
const onImport = useCallback(() => {
importInputRef.current?.click()
}, [])
const upsertRenderOutputToLogos = useCallback(
(entry: RenderOutputCacheEntry) => {
if (recollectionId) upsertRenderOutputEntry(recollectionId, entry)
},
[recollectionId]
)
const onImportFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!file || !recollectionId) return
const reader = new FileReader()
reader.onload = () => {
try {
const text = reader.result as string
const payload = JSON.parse(text) as RecollectionFilePayload
if (!payload || typeof payload !== 'object') {
toast.error('Invalid file: not valid JSON')
return
}
const written = validateAndWritePayload(recollectionId, payload, (nodes, edges) =>
backfillEdgeTargetTypes(nodes, edges)
)
const currentPath = pathnameRef.current
const fluxActive = currentPath.endsWith('/flux')
const logosActive = currentPath.endsWith('/logos') || /\/recollections\/[^/]+\/?$/.test(currentPath)
if (written.graph && fluxActive && fluxRef.current?.onRefreshFromStore) {
fluxRef.current.onRefreshFromStore(written.graph)
}
if ((written.graph != null || written.logos != null) && logosActive && logosRef.current?.onRefreshFromStore) {
logosRef.current.onRefreshFromStore()
}
if (payload.version != null && payload.version > RECOLLECTION_VERSION) {
toast.error('Recollection was created with a newer app version')
} else {
toast.success('Recollection loaded')
}
} catch {
toast.error('Invalid file: not valid JSON')
}
}
reader.readAsText(file)
},
[recollectionId]
)
const value: RecollectionActionsContextValue = React.useMemo(
() => ({
flux,
logos,
setFluxSlot,
setLogosSlot,
isFluxActive,
isLogosActive,
activeSlot,
onImport,
onExport,
upsertRenderOutputToLogos,
}),
[flux, logos, setFluxSlot, setLogosSlot, isFluxActive, isLogosActive, activeSlot, onImport, onExport, upsertRenderOutputToLogos]
)
return (
<RecollectionActionsContext.Provider value={value}>
{children}
<input
ref={importInputRef}
type="file"
accept=".json,application/json"
className="hidden"
aria-hidden
onChange={onImportFileChange}
/>
</RecollectionActionsContext.Provider>
)
}
export function useRecollectionActions(): RecollectionActionsContextValue {
const ctx = useContext(RecollectionActionsContext)
if (!ctx) throw new Error('useRecollectionActions must be used within RecollectionActionsProvider')
return ctx
}
/** Returns the context value or null when outside RecollectionActionsProvider. Use when the consumer may render outside recollection layout. */
export function useOptionalRecollectionActions(): RecollectionActionsContextValue | null {
return useContext(RecollectionActionsContext)
}

View File

@@ -0,0 +1,145 @@
/**
* Shared Edit and View menus for recollections. Always rendered in the menubar;
* uses the active slot (flux or logos by pathname) for undo, redo, and Flux-only actions.
*/
import React, { useEffect, useMemo } from 'react'
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarTrigger,
} from '@/components/ui/menubar'
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { useRecollectionActions } from './RecollectionActionsContext'
import { ClipboardPaste, Copy, CopyPlus, Redo2, Undo2 } from 'lucide-react'
const UNDO_KEYS = { key: 'z', shiftKey: false }
const REDO_KEYS = { key: 'z', shiftKey: true }
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
const mod = ev.ctrlKey || ev.metaKey
return ev.key.toLowerCase() === want.key && !!mod && !!ev.shiftKey === want.shiftKey
}
export function RecollectionEditViewMenus() {
const { activeSlot, flux, isFluxActive } = useRecollectionActions()
const fluxSlot = isFluxActive ? flux : null
useEffect(() => {
if (!activeSlot) return
const onKeyDown = (ev: KeyboardEvent) => {
if (matchKey(ev, UNDO_KEYS) && activeSlot.canUndo) {
ev.preventDefault()
ev.stopPropagation()
activeSlot.undo()
} else if (matchKey(ev, REDO_KEYS) && activeSlot.canRedo) {
ev.preventDefault()
ev.stopPropagation()
activeSlot.redo()
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [activeSlot])
const menus = useMemo(() => {
if (!activeSlot) return null
const hasFluxOnly =
fluxSlot &&
(fluxSlot.onDuplicate != null || fluxSlot.onCopy != null || fluxSlot.onPaste != null)
return (
<Menubar className="shrink-0 rounded-none border-0 bg-transparent p-0 shadow-none">
<MenubarMenu>
<MenubarTrigger className="font-normal text-muted-foreground">Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem onClick={activeSlot.undo} disabled={!activeSlot.canUndo} className="gap-2">
<Undo2 className="h-4 w-4" />
Undo
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd> + Z</Kbd>
</KbdGroup>
</span>
</MenubarItem>
<MenubarItem onClick={activeSlot.redo} disabled={!activeSlot.canRedo} className="gap-2">
<Redo2 className="h-4 w-4" />
Redo
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd> + + Z</Kbd>
</KbdGroup>
</span>
</MenubarItem>
{hasFluxOnly && (
<>
<MenubarSeparator />
{fluxSlot!.onDuplicate != null && (
<MenubarItem
onClick={fluxSlot!.onDuplicate}
disabled={!fluxSlot!.canDuplicate}
className="gap-2"
>
<CopyPlus className="h-4 w-4" />
Duplicate
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>D</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
{fluxSlot!.onCopy != null && (
<MenubarItem
onClick={fluxSlot!.onCopy}
disabled={!fluxSlot!.canCopy}
className="gap-2"
>
<Copy className="h-4 w-4" />
Copy
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>C</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
{fluxSlot!.onPaste != null && (
<MenubarItem onClick={fluxSlot!.onPaste} className="gap-2">
<ClipboardPaste className="h-4 w-4" />
Paste
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>V</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
</>
)}
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="font-normal text-muted-foreground">View</MenubarTrigger>
<MenubarContent>
{fluxSlot?.onFitView && (
<MenubarItem onClick={fluxSlot.onFitView} className="gap-2">
Fit View
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>0</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
</MenubarContent>
</MenubarMenu>
</Menubar>
)
}, [activeSlot, fluxSlot])
return menus
}

View File

@@ -0,0 +1,51 @@
/**
* Layout for a single recollection: shared menubar and flipping card (Logos front / Flux back).
*/
import React, { useEffect } from 'react'
import { Link, useParams } from 'react-router-dom'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { RecollectionMenubarProvider } from './RecollectionMenubarContext'
import { RecollectionActionsProvider } from './RecollectionActionsContext'
import { RecollectionTitleContent } from './RecollectionTitleContent'
import { RecollectionMenubar } from './RecollectionMenubar'
import { RecollectionViewSwitcher } from './RecollectionViewSwitcher'
import { FlippingCardView } from './FlippingCardView'
export function RecollectionLayout() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const { recollections, recordRecollectionAccess } = usePlatform()
useEffect(() => {
if (recollectionId) recordRecollectionAccess(recollectionId)
}, [recollectionId, recordRecollectionAccess])
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
if (!recollectionId) return null
if (!recollection) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
<p className="text-sm text-muted-foreground">Recollection not found.</p>
<Link to="/recollections" className="text-sm text-primary hover:underline">
Back to recollections
</Link>
</div>
)
}
return (
<RecollectionMenubarProvider>
<RecollectionActionsProvider>
<RecollectionTitleContent />
<div className="relative flex min-h-0 flex-1 flex-col">
<RecollectionMenubar />
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<FlippingCardView />
</div>
<RecollectionViewSwitcher />
</div>
</RecollectionActionsProvider>
</RecollectionMenubarProvider>
)
}

View File

@@ -0,0 +1,39 @@
/**
* Recollection menubar: Back | title + save status | registered menus (middle).
*/
import React from 'react'
import { Link, useParams } from 'react-router-dom'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { ArrowLeft } from 'lucide-react'
import { useRecollectionMenubar } from './RecollectionMenubarContext'
import { RecollectionEditViewMenus } from './RecollectionEditViewMenus'
export function RecollectionMenubar() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const { recollections } = usePlatform()
const { titleContent } = useRecollectionMenubar()
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
const defaultTitle = recollection ? (
<span className="truncate text-sm font-medium text-muted-foreground">{recollection.name}</span>
) : null
return (
<div className="flex h-9 w-full shrink-0 items-center gap-2 overflow-visible border-b border-border/40 bg-background px-2">
<Link
to="/recollections"
aria-label="Back to recollections"
className="flex shrink-0 items-center rounded-sm px-2 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
>
<ArrowLeft className="size-4" />
</Link>
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
{titleContent ?? defaultTitle}
</div>
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-visible">
<RecollectionEditViewMenus />
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
/**
* Context for the recollection menubar:
* - Title (next to back): optional title + status, e.g. recollection name and save state.
* - Middle: shared Edit/View menus (RecollectionEditViewMenus), no longer registrable.
*/
import React, { createContext, useCallback, useContext, useState } from 'react'
export type RecollectionMenubarContextValue = {
/** Content next to the back button (e.g. title + save status, with optional dropdown). Null = use default recollection name. */
titleContent: React.ReactNode
setTitleContent: (content: React.ReactNode) => void
}
const RecollectionMenubarContext = createContext<RecollectionMenubarContextValue | null>(null)
export function RecollectionMenubarProvider({ children }: { children: React.ReactNode }) {
const [titleContent, setTitleContentState] = useState<React.ReactNode>(null)
const setTitleContent = useCallback((content: React.ReactNode) => {
setTitleContentState(() => content)
}, [])
const value: RecollectionMenubarContextValue = React.useMemo(
() => ({ titleContent, setTitleContent }),
[titleContent, setTitleContent]
)
return (
<RecollectionMenubarContext.Provider value={value}>
{children}
</RecollectionMenubarContext.Provider>
)
}
export function useRecollectionMenubar(): RecollectionMenubarContextValue {
const ctx = useContext(RecollectionMenubarContext)
if (!ctx) throw new Error('useRecollectionMenubar must be used within RecollectionMenubarProvider')
return ctx
}

View File

@@ -0,0 +1,187 @@
/**
* Shared title + save status block (same for Logos and Flux). Gets save status and Save from
* the active slot (RecollectionActionsContext); Import/Export from layout-level handlers.
*/
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { RenameRecollectionDialog } from './RenameRecollectionDialog'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { useRecollectionMenubar } from './RecollectionMenubarContext'
import { useRecollectionActions } from './RecollectionActionsContext'
import { CheckCircle2, ChevronDown, CircleDot, Download, FolderOpen, Loader2, Pencil, Save } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
const SAVE_KEYS = { key: 's', shiftKey: false }
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
const mod = ev.ctrlKey || ev.metaKey
return ev.key.toLowerCase() === want.key && !!mod && !!ev.shiftKey === want.shiftKey
}
/** Registers the shared title + save dropdown as titleContent. Renders nothing. */
export function RecollectionTitleContent() {
const { setTitleContent } = useRecollectionMenubar()
const { activeSlot, onImport, onExport } = useRecollectionActions()
const { recollectionId } = useParams<{ recollectionId: string }>()
const { recollections, renameRecollection } = usePlatform()
const recollectionName = useMemo(
() => (recollectionId ? recollections.find((p) => p.id === recollectionId)?.name ?? null : null),
[recollectionId, recollections]
)
const [renameModalOpen, setRenameModalOpen] = useState(false)
const [showSavedBriefly, setShowSavedBriefly] = useState(false)
const savedBrieflyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const prevSaveStatusRef = useRef<string>('saved')
const saveStatus = activeSlot?.saveStatus ?? 'saved'
const onSave = activeSlot?.onSave
const canSave = activeSlot?.canSave ?? false
useEffect(() => {
if (saveStatus === 'unsaved') {
setShowSavedBriefly(false)
if (savedBrieflyTimerRef.current) {
clearTimeout(savedBrieflyTimerRef.current)
savedBrieflyTimerRef.current = null
}
} else if (prevSaveStatusRef.current === 'saving' && saveStatus === 'saved') {
setShowSavedBriefly(true)
if (savedBrieflyTimerRef.current) clearTimeout(savedBrieflyTimerRef.current)
savedBrieflyTimerRef.current = setTimeout(() => {
savedBrieflyTimerRef.current = null
setShowSavedBriefly(false)
}, 2500)
}
prevSaveStatusRef.current = saveStatus
}, [saveStatus])
useEffect(() => {
return () => {
if (savedBrieflyTimerRef.current) clearTimeout(savedBrieflyTimerRef.current)
}
}, [])
useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => {
if (matchKey(ev, SAVE_KEYS) && onSave && canSave) {
ev.preventDefault()
ev.stopPropagation()
onSave()
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [onSave, canSave])
const trigger = useMemo(
() => (
<span className="flex items-center gap-1.5 truncate">
<span className="truncate text-sm font-medium font-serif max-w-[180px]">
{recollectionName ?? 'Untitled'}
</span>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span
className="shrink-0 flex items-center text-muted-foreground cursor-default"
aria-live="polite"
aria-label={
saveStatus === 'saving' ? 'Saving' : saveStatus === 'unsaved' ? 'Unsaved changes' : 'All changes saved'
}
>
{saveStatus === 'saving' && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
{saveStatus === 'unsaved' && <CircleDot className="size-3.5" aria-hidden />}
{saveStatus !== 'unsaved' && (saveStatus === 'saved' || showSavedBriefly) && (
<CheckCircle2 className="size-3.5 text-muted-foreground/70" aria-hidden />
)}
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{saveStatus === 'saving' ? 'Saving…' : saveStatus === 'unsaved' ? 'Unsaved changes' : 'All changes saved'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
),
[recollectionName, saveStatus, showSavedBriefly]
)
const dropdownContent = useMemo(() => (
<>
{recollectionId && (
<>
{onSave != null && (
<>
<DropdownMenuItem
onClick={onSave}
disabled={!canSave}
className="gap-2"
>
<Save className="h-4 w-4" />
Save
<span className="ml-auto pl-4"><KbdGroup><Kbd>S</Kbd></KbdGroup></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={() => setRenameModalOpen(true)} className="gap-2">
<Pencil className="h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={onImport} className="gap-2">
<FolderOpen className="h-4 w-4" />
Import
</DropdownMenuItem>
<DropdownMenuItem onClick={onExport} className="gap-2">
<Download className="h-4 w-4" />
Export
</DropdownMenuItem>
</>
), [recollectionId, onSave, canSave, onImport, onExport])
const titleNode = useMemo(() => (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent">
{trigger}
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent align="start" className="z-[200] min-w-[12rem]">
{dropdownContent}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
), [trigger, dropdownContent])
useLayoutEffect(() => {
setTitleContent(titleNode)
return () => setTitleContent(null)
}, [titleNode, setTitleContent])
return (
<RenameRecollectionDialog
open={renameModalOpen}
onOpenChange={setRenameModalOpen}
recollectionId={recollectionId ?? ''}
initialName={recollectionName ?? ''}
recollections={recollections}
onRename={(id, newName) => {
renameRecollection(id, newName)
setRenameModalOpen(false)
}}
/>
)
}

View File

@@ -0,0 +1,88 @@
/**
* Elevator-style view switcher: circular button moves between top (Logos) and bottom (Flux).
* Click toggles; shortcut and tooltip on hover.
*/
import React, { useCallback, useEffect } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { FluxIcon, LogosIcon } from '@/lib/icons'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Kbd } from '@/components/ui/kbd'
const VIEW_TOGGLE_KEYS = { key: 'v', shiftKey: true }
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
const mod = ev.ctrlKey || ev.metaKey
return ev.key.toLowerCase() === want.key && !!mod && ev.shiftKey === want.shiftKey
}
function shortcutLabel() {
if (typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform)) {
return '⌘⇧V'
}
return 'Ctrl+⇧+V'
}
export function RecollectionViewSwitcher() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const { pathname } = useLocation()
const navigate = useNavigate()
const base = recollectionId ? `/recollections/${recollectionId}` : ''
const isFlux = pathname.endsWith('/flux')
const toggle = useCallback(() => {
if (!base) return
navigate(isFlux ? `${base}/logos` : `${base}/flux`)
}, [base, isFlux, navigate])
useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => {
if (matchKey(ev, VIEW_TOGGLE_KEYS)) {
ev.preventDefault()
toggle()
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [toggle])
if (!base) return null
const tooltipText = isFlux
? `Switch to Logos`
: `Switch to Flux`
return (
<div className="absolute left-1/2 top-[18px] z-10 flex -translate-x-1/2 -translate-y-1/2 items-center gap-2">
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggle}
aria-label={isFlux ? 'Switch to Logos' : 'Switch to Flux'}
className="flex items-center gap-2 rounded-full border border-border/60 bg-background shadow-md transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="relative h-14 w-8 rounded-full bg-muted/50 p-0.5">
<span
className="absolute left-1/2 flex h-6 w-6 -translate-x-1/2 items-center justify-center rounded-full border bg-background shadow-sm transition-[top] duration-200 ease-out"
style={{ top: isFlux ? 28 : 4 }}
>
{isFlux ? (
<FluxIcon className="size-3.5 text-muted-foreground" />
) : (
<LogosIcon className="size-3.5 text-muted-foreground" />
)}
</span>
</div>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{tooltipText}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Kbd className="text-[10px]">{shortcutLabel()}</Kbd>
</div>
)
}

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

View File

@@ -0,0 +1,94 @@
/**
* Shared modal for renaming a recollection. Used by RecollectionsPage and RecollectionTitleContent.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
export type RenameRecollectionDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
recollectionId: string
initialName: string
recollections: { id: string; name: string }[]
onRename: (id: string, newName: string) => void
}
export function RenameRecollectionDialog({
open,
onOpenChange,
recollectionId,
initialName,
recollections,
onRename,
}: RenameRecollectionDialogProps) {
const [value, setValue] = useState(initialName)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setValue(initialName)
const t = setTimeout(() => inputRef.current?.focus(), 0)
return () => clearTimeout(t)
}
}, [open, initialName])
const handleClose = useCallback(() => onOpenChange(false), [onOpenChange])
const handleSubmit = useCallback(() => {
const trimmed = value.trim()
if (!trimmed) return
onRename(recollectionId, trimmed)
onOpenChange(false)
}, [recollectionId, value, onRename, onOpenChange])
const isDuplicateName =
!!value.trim() &&
recollections.some(
(p) => p.id !== recollectionId && p.name.toLowerCase() === value.trim().toLowerCase()
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Rename recollection</DialogTitle>
<DialogDescription>Enter a new name for this recollection.</DialogDescription>
</DialogHeader>
<Input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmit()
if (e.key === 'Escape') handleClose()
}}
placeholder="Recollection name"
aria-label="Recollection name"
/>
{isDuplicateName && (
<p className="text-xs text-amber-600 dark:text-amber-500">
A recollection with this name already exists.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!value.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,27 @@
/**
* Flux route: renders the graph canvas (CanvasPage) for the current recollection.
*/
import React, { useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { CanvasPage } from '@/app/canvas/CanvasPage'
import { usePlatform } from '@/app/kosmos/KosmosContext'
export function FluxRoute() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const { updateLastEdited } = usePlatform()
const updateLastEditedRef = useRef(updateLastEdited)
updateLastEditedRef.current = updateLastEdited
useEffect(() => {
if (recollectionId) updateLastEditedRef.current(recollectionId)
}, [recollectionId])
if (!recollectionId) return null
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<CanvasPage key={recollectionId} recollectionId={recollectionId} />
</div>
)
}

View File

@@ -0,0 +1,192 @@
/**
* Logos page: BlockNote editor for the recollection. Content persisted in recollection store.
* Layout and styling aligned with Flux (same flex/overflow, bg-background, theme).
*/
import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef } from 'react'
import { useParams } from 'react-router-dom'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { FluxIcon } from '@/lib/icons'
import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuController } from '@blocknote/react'
import { BlockNoteView } from '@blocknote/shadcn'
import { useTheme } from '@/lib/themeContext'
import { useRecollectionActions } from '../RecollectionActionsContext'
import { getLogosContent, setLogosContent, type StoredLogosContent } from '../recollectionStore'
import { logosSchema } from './logosSchema'
import { toast } from 'sonner'
/** Wraps BlockNoteView so refs go to a div, not the function component (avoids ref warning). */
const BlockNoteViewWrapper = forwardRef<HTMLDivElement, React.ComponentProps<typeof BlockNoteView>>(
function BlockNoteViewWrapper(props, ref) {
const { className, ref: _ref, ...rest } = props as React.ComponentProps<typeof BlockNoteView> & { ref?: unknown }
return (
<div ref={ref} className={`logos-blocknote ${className ?? ''}`} style={{ minHeight: '100%', width: '100%' }}>
<BlockNoteView {...rest} />
</div>
)
}
)
const SAVE_DEBOUNCE_MS = 400
/** Known React ref warning from BlockNote/Radix internals; we can't fix it in our code. Suppress once at load so it's active before first BlockNote render. */
function isBlockNoteRefWarning(args: unknown[]): boolean {
const s = args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ')
return (
s.includes('Function components cannot be given refs') &&
s.includes('ForwardRef') &&
s.includes('blocknote')
)
}
let blockNoteRefWarningPatched = false
function patchBlockNoteRefWarning() {
if (blockNoteRefWarningPatched) return
blockNoteRefWarningPatched = true
const orig = console.error
console.error = (...args: unknown[]) => {
if (isBlockNoteRefWarning(args)) return
orig.apply(console, args)
}
}
export function LogosPage() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const { recollections } = usePlatform()
const { theme } = useTheme()
const { setLogosSlot } = useRecollectionActions()
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
const title = recollection?.name ?? 'Untitled'
const [reloadKey, setReloadKey] = useState(0)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved')
const initialContent = useMemo(
() => (recollectionId ? getLogosContent(recollectionId) ?? undefined : undefined),
[recollectionId, reloadKey]
)
const editor = useCreateBlockNote(
{ schema: logosSchema, initialContent },
[recollectionId, reloadKey]
)
const persistContent = useCallback(() => {
if (!recollectionId || !editor) return
setSaveStatus('saving')
try {
const doc = editor.document
const serialized = JSON.parse(JSON.stringify(doc)) as StoredLogosContent
setLogosContent(recollectionId, serialized)
setSaveStatus('saved')
} catch {
setSaveStatus('unsaved')
}
}, [recollectionId, editor])
const onSave = useCallback(() => {
persistContent()
toast.success('Saved')
}, [persistContent])
useEffect(() => {
if (!editor || !recollectionId) return
const handleChange = () => {
setSaveStatus('unsaved')
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = setTimeout(() => {
saveTimeoutRef.current = null
persistContent()
}, SAVE_DEBOUNCE_MS)
}
editor.onChange(handleChange)
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = null
}
}
}, [editor, recollectionId, persistContent])
useEffect(() => {
if (!editor || !recollectionId) return
const slot = {
saveStatus,
onSave,
canSave: true,
undo: () => (editor as { undo?: () => void }).undo?.(),
redo: () => (editor as { redo?: () => void }).redo?.(),
canUndo: true,
canRedo: true,
onRefreshFromStore: () => setReloadKey((k) => k + 1),
}
setLogosSlot(slot)
return () => setLogosSlot(null)
}, [
setLogosSlot,
saveStatus,
onSave,
editor,
recollectionId,
])
if (!recollectionId) {
return (
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center p-8 text-muted-foreground">
<p className="text-sm">No recollection selected.</p>
</div>
)
}
if (!editor) {
return (
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center p-8 text-muted-foreground">
<p className="text-sm">Loading</p>
</div>
)
}
patchBlockNoteRefWarning()
const getSlashMenuItems = useCallback(
async (query: string) => {
const defaultItems = getDefaultReactSlashMenuItems(editor)
const fluxItem = {
title: 'Insert from Flux',
subtext: 'Insert output from a Flux rendering node',
icon: <FluxIcon className="size-4" />,
onItemClick: () => {
const pos = editor.getTextCursorPosition()
editor.replaceBlocks([pos.block.id], [{ type: 'fluxOutput', props: {} }])
},
aliases: ['flux', 'output', 'render'] as const,
group: 'Flux',
}
const all = [...defaultItems, fluxItem]
const q = query.trim().toLowerCase()
if (!q) return all
return all.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
(item.aliases && item.aliases.some((a: string) => a.toLowerCase().includes(q)))
)
},
[editor]
)
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
<div className="mx-auto w-full max-w-3xl p-4">
<h1 className="mb-4 truncate font-serif text-2xl font-semibold tracking-tight text-foreground">
{title}
</h1>
<BlockNoteViewWrapper
editor={editor as any}
theme={theme}
className="min-h-full w-full"
slashMenu={false}
>
<SuggestionMenuController triggerCharacter="/" getItems={getSlashMenuItems} />
</BlockNoteViewWrapper>
</div>
</div>
)
}

View File

@@ -0,0 +1,140 @@
/**
* BlockNote block "Flux output": insert output from Flux rendering nodes into Logos.
* Empty state: placeholder + picker over getRenderOutputCache(recollectionId).
* Filled state: when nodeId is set, display is live from the cache (updates when the rendering node changes in Flux);
* otherwise or when cache entry is missing, show stored content (static).
*/
import React, { useCallback, useState } from 'react'
import { useParams } from 'react-router-dom'
import { createReactBlockSpec } from '@blocknote/react'
import type { ReactCustomBlockRenderProps } from '@blocknote/react'
import { getRenderOutputCache, type RenderOutputCacheEntry } from '../../recollectionStore'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
const fluxOutputBlockConfig = {
type: 'fluxOutput' as const,
propSchema: {
nodeId: {
default: '',
},
contentType: {
default: 'image' as const,
values: ['image', 'html'] as const,
},
content: {
default: '',
},
label: {
default: '',
},
},
content: 'none' as const,
}
function FluxOutputBlockContent({
block,
editor,
contentRef,
}: ReactCustomBlockRenderProps<'fluxOutput', typeof fluxOutputBlockConfig.propSchema, 'none'>) {
const { recollectionId } = useParams<{ recollectionId: string }>()
const [pickerOpen, setPickerOpen] = useState(false)
const nodeId = block.props.nodeId ?? ''
const storedContent = block.props.content ?? ''
const storedContentType = block.props.contentType ?? 'image'
const storedLabel = block.props.label ?? ''
const handleSelect = useCallback(
(entry: RenderOutputCacheEntry) => {
editor.updateBlock(block.id, {
props: {
nodeId: entry.nodeId,
contentType: entry.type,
content: entry.content,
label: entry.label,
},
})
setPickerOpen(false)
},
[editor, block.id]
)
const entries = recollectionId ? getRenderOutputCache(recollectionId) : []
// Resolve display from cache when block is tied to a node (live); otherwise use stored props (static).
const liveEntry = nodeId && recollectionId ? entries.find((e) => e.nodeId === nodeId) : null
const content = liveEntry ? liveEntry.content : storedContent
const rawContentType = liveEntry ? liveEntry.type : storedContentType
const label = liveEntry ? liveEntry.label : storedLabel
// If content is SVG but type was stored as html, show as image (fixes incorrect cache or legacy data).
const isSvgContent = Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
const contentType = rawContentType === 'image' || isSvgContent ? 'image' : 'html'
// Empty: show placeholder + picker when nothing inserted yet
if (!content) {
return (
<div ref={contentRef} className="min-h-[80px] rounded-md border border-dashed border-muted-foreground/30 bg-muted/30 p-4">
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
Insert from Flux
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start">
{entries.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">
No Flux outputs yet. In Flux, run a graph with a rendering node; its output will appear here automatically.
</div>
) : (
<ul className="max-h-64 overflow-auto py-2">
{entries.map((entry) => (
<li key={entry.nodeId}>
<button
type="button"
className="w-full px-4 py-2 text-left text-sm hover:bg-muted"
onClick={() => handleSelect(entry)}
>
{entry.label || entry.nodeId}
<span className="ml-2 text-muted-foreground">({entry.type})</span>
</button>
</li>
))}
</ul>
)}
</PopoverContent>
</Popover>
</div>
)
}
// Filled: show image or HTML
if (contentType === 'image') {
const src =
content.startsWith('data:') ? content : `data:image/svg+xml;utf8,${encodeURIComponent(content)}`
return (
<div ref={contentRef} className="min-h-[40px]">
{label ? <p className="mb-1 text-xs text-muted-foreground">{label}</p> : null}
<img src={src} alt={label || 'Flux output'} className="max-w-full rounded border object-contain" />
</div>
)
}
// HTML: render in iframe to limit script execution (XSS safety)
return (
<div ref={contentRef} className="min-h-[40px]">
{label ? <p className="mb-1 text-xs text-muted-foreground">{label}</p> : null}
<iframe
title={label || 'Flux HTML output'}
srcDoc={content}
className="min-h-[120px] w-full rounded border border-border bg-background"
sandbox="allow-same-origin"
/>
</div>
)
}
export const createFluxOutputBlock = () =>
createReactBlockSpec(fluxOutputBlockConfig, {
render: FluxOutputBlockContent,
})

View File

@@ -0,0 +1,17 @@
/**
* BlockNote schema for Logos: default blocks + Flux output block.
*/
import { BlockNoteSchema, defaultBlockSpecs } from '@blocknote/core'
import { createFluxOutputBlock } from './blocks/fluxOutputBlock'
const fluxOutputBlock = createFluxOutputBlock()
export const logosSchema = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
fluxOutput: fluxOutputBlock(),
},
})
export type LogosSchema = typeof logosSchema

View File

@@ -0,0 +1,24 @@
/**
* Per-recollection graph persistence. Re-exports from recollectionStore for backward compatibility.
* New code should use recollectionStore (getGraph, setGraph, removeRecollectionData) directly.
*/
import {
getGraph,
setGraph,
removeRecollectionData,
RECOLLECTION_FILE_EXT,
RECOLLECTION_VERSION,
type StoredGraphState,
} from './recollectionStore'
export type { StoredGraphState }
export { RECOLLECTION_FILE_EXT, RECOLLECTION_VERSION }
export function getGraphStorageKey(recollectionId: string): string {
return `zui_graph_${recollectionId}`
}
export const loadGraphFromStorage = getGraph
export const saveGraphToStorage = setGraph
export const removeGraphFromStorage = removeRecollectionData

View File

@@ -0,0 +1,115 @@
/**
* Per-recollection persistence: graph (Flux) and logos (BlockNote) state.
* Single API for loading, saving, and removing all data for a recollection.
*/
import type { StoredGraphState } from '@/lib/graph/state'
export type { StoredGraphState }
/** BlockNote document: array of blocks (PartialBlock). Stored as JSON. */
export type StoredLogosContent = Record<string, unknown>[]
/** One entry in the render-output cache (one per rendering node, overwritten on each update). */
export type RenderOutputCacheEntry = {
nodeId: string
label: string
type: 'image' | 'html'
content: string
}
export const RECOLLECTION_FILE_EXT = '.zui.json'
export const RECOLLECTION_VERSION = 1
const GRAPH_KEY_PREFIX = 'zui_graph_'
const LOGOS_KEY_PREFIX = 'zui_logos_'
const RENDER_CACHE_KEY_PREFIX = 'zui_render_cache_'
function getGraphKey(recollectionId: string): string {
return `${GRAPH_KEY_PREFIX}${recollectionId}`
}
function getLogosKey(recollectionId: string): string {
return `${LOGOS_KEY_PREFIX}${recollectionId}`
}
function getRenderCacheKey(recollectionId: string): string {
return `${RENDER_CACHE_KEY_PREFIX}${recollectionId}`
}
export function getGraph(recollectionId: string): StoredGraphState | null {
try {
const raw = localStorage.getItem(getGraphKey(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 setGraph(recollectionId: string, state: StoredGraphState): void {
localStorage.setItem(getGraphKey(recollectionId), JSON.stringify(state))
}
export function getLogosContent(recollectionId: string): StoredLogosContent | null {
try {
const raw = localStorage.getItem(getLogosKey(recollectionId))
if (!raw) return null
const data = JSON.parse(raw) as unknown
if (!Array.isArray(data)) return null
if (!data.every((item) => item != null && typeof item === 'object')) return null
return data as StoredLogosContent
} catch {
return null
}
}
export function setLogosContent(recollectionId: string, content: StoredLogosContent): void {
localStorage.setItem(getLogosKey(recollectionId), JSON.stringify(content))
}
/** Render output cache: one entry per rendering node (keyed by nodeId). Used by Logos "Insert from Flux" block. */
export function getRenderOutputCache(recollectionId: string): RenderOutputCacheEntry[] {
try {
const raw = localStorage.getItem(getRenderCacheKey(recollectionId))
if (!raw) return []
const data = JSON.parse(raw) as unknown
if (!Array.isArray(data)) return []
return data.filter(
(item): item is RenderOutputCacheEntry =>
item != null &&
typeof item === 'object' &&
typeof (item as RenderOutputCacheEntry).nodeId === 'string' &&
typeof (item as RenderOutputCacheEntry).label === 'string' &&
((item as RenderOutputCacheEntry).type === 'image' || (item as RenderOutputCacheEntry).type === 'html') &&
typeof (item as RenderOutputCacheEntry).content === 'string'
) as RenderOutputCacheEntry[]
} catch {
return []
}
}
export function upsertRenderOutputEntry(recollectionId: string, entry: RenderOutputCacheEntry): void {
const entries = getRenderOutputCache(recollectionId)
const byNodeId = new Map(entries.map((e) => [e.nodeId, e]))
byNodeId.set(entry.nodeId, entry)
localStorage.setItem(
getRenderCacheKey(recollectionId),
JSON.stringify(Array.from(byNodeId.values()))
)
}
/** Removes both graph, logos, and render cache data for the recollection. */
export function removeRecollectionData(recollectionId: string): void {
localStorage.removeItem(getGraphKey(recollectionId))
localStorage.removeItem(getLogosKey(recollectionId))
localStorage.removeItem(getRenderCacheKey(recollectionId))
}

View File

@@ -92,7 +92,7 @@ export function CodeEditor({
}, [value, textareaId])
return (
<div ref={containerRef} className="code-editor" style={{ minHeight: 0 }}>
<div ref={containerRef} className="code-editor min-h-0 h-full overflow-auto" style={{ minHeight: 0 }}>
<Editor
value={value}
onValueChange={handleValueChange}

View File

@@ -75,11 +75,14 @@ export function BaseNode({
data-selected={selected}
data-path-role={connectionPathRole ?? undefined}
style={appliedStyle}
tabIndex={0}
tabIndex={selected ? 0 : -1}
{...props}
>
<div
className="min-h-0 flex-1 flex flex-col overflow-hidden"
className={cn(
"min-h-0 flex-1 flex flex-col overflow-hidden",
!selected && "pointer-events-none",
)}
style={{ contain: "layout" }}
>
{children}
@@ -96,7 +99,9 @@ export function BaseNode({
handleClassName="base-node-resize-handle nodrag nopan"
/>
)}
{!isFullscreenInstance && handles}
{!isFullscreenInstance && (
<div className={cn(!selected && "pointer-events-none")}>{handles}</div>
)}
</div>
);
}
@@ -198,7 +203,7 @@ export function BaseNodeContent({
return (
<div
data-slot="base-node-content"
className={cn("min-h-0 flex-1 flex flex-col overflow-auto", className)}
className={cn("min-h-0 flex-1 flex flex-col", className)}
{...props}
/>
);

View File

@@ -13,7 +13,7 @@ export function getAgentNodeDescriptor(): NodeTypeDescriptor {
.idPrefix('agt_')
.withInputOutput(true, true)
.classification('archon')
.allowedSourceTypes(['config', 'variable', 'data'])
.allowedSourceTypes(['config', 'variable', 'data', 'render'])
.allowedTargetTypes(['render'])
.help(NODE_HELP.agent)
.menu('Agent', <Bot className={ICON_CLASS} />)

View File

@@ -25,7 +25,7 @@ import {
BaseNodeFooter,
BaseNodeHeaderRow,
} from '@/components/graph/BaseNode'
import { Code2, Database, ScrollText, Variable } from 'lucide-react'
import { Code2, Database, ScrollText, Sparkles, Variable } from 'lucide-react'
import {
MenubarItem,
MenubarSeparator,
@@ -34,6 +34,7 @@ import {
MenubarSubContent,
MenubarSubTrigger,
} from '@/components/ui/menubar'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Kbd } from '@/components/ui/kbd'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
@@ -78,7 +79,16 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
() => getConnectedNodesByType(nodes, sourceIds, 'data'),
[nodes, sourceIds]
)
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0 || connectedDataNodes.length > 0
const connectedRenderNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'render'),
[nodes, sourceIds]
)
const hasDependencies =
connectedConfigNodes.length > 0 ||
connectedVariableNodes.length > 0 ||
connectedFunctionNodes.length > 0 ||
connectedDataNodes.length > 0 ||
connectedRenderNodes.length > 0
const setConfigType = useCallback(
(newTypeId: ConfigTypeId) => {
@@ -133,6 +143,18 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
[insertAt]
)
const insertRenderReference = useCallback(
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
const isMarkdownImage =
configTypeId === 'markdown' && (sourceNode.data?.outputMode ?? 'image') === 'image'
const snippet = isMarkdownImage
? `![Rendered]({{ ${sourceNode.id} }})`
: `{{ ${sourceNode.id} }}`
insertAt(snippet, mode)
},
[insertAt, configTypeId]
)
const variableIds = useMemo(() => connectedVariableNodes.map((n: any) => n.id), [connectedVariableNodes])
const functionIds = useMemo(() => connectedFunctionNodes.map((n: any) => n.id), [connectedFunctionNodes])
const configTitles = useMemo(
@@ -296,6 +318,40 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem>
))}
{connectedRenderNodes.map((n: any) => {
const renderInputDisabled =
configTypeId === 'plantuml' || configTypeId === 'wireframe'
const item = (
<MenubarItem
key={n.id}
className="text-xs flex items-center gap-2 group"
disabled={renderInputDisabled}
onClick={() =>
!renderInputDisabled && insertRenderReference(n, 'cursor')
}
>
<Sparkles className="size-3.5 shrink-0" />
{n.id}
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity">
<Kbd>Insert</Kbd>
</MenubarShortcut>
</MenubarItem>
)
return renderInputDisabled ? (
<TooltipProvider key={n.id} delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{item}</TooltipTrigger>
<TooltipContent side="right">
Rendering output is not available for Diagram and
Wireframe configs. Use a Markdown config to embed
render output.
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
item
)
})}
</>
) : (
<span className="text-xs text-muted-foreground px-2 py-1">Connect nodes to insert references</span>
@@ -307,7 +363,7 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
/>
</div>
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-auto border-t border-input" style={{ minHeight: editorHeight }}>
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input" style={{ minHeight: editorHeight }}>
<CodeEditor
textareaId={editorId}
value={content}

View File

@@ -68,7 +68,7 @@ export function getConfigNodeDescriptor(): NodeTypeDescriptor {
.idPrefix('cfg_')
.withInputOutput(true, true)
.classification('psyche')
.allowedSourceTypes(['config', 'variable', 'function', 'data'])
.allowedSourceTypes(['config', 'variable', 'function', 'data', 'render'])
.allowedTargetTypes(['config', 'render', 'agent'])
.help(NODE_HELP.config)
.menu('Config', <ScrollText className={ICON_CLASS} />)

View File

@@ -74,6 +74,10 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
})
nunjucksContext[src.id] = filteredRows
}
if (src?.type === 'render') {
nunjucksContext[src.id] =
((src.data as Record<string, unknown>)?.cachedOutputValue as string | undefined) ?? ''
}
}
const functionIdsToRegister = new Set<string>()

View File

@@ -23,7 +23,7 @@ import {
MenubarSubTrigger,
} from '@/components/ui/menubar'
import { Sparkles, Play, ChevronDown, Loader2, RotateCw } from 'lucide-react'
import { InputHandle } from '@/components/graph/NodeHandles'
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import {
@@ -50,16 +50,14 @@ export type { RenderingNodeData }
type Props = AbstractNodeProps<RenderingNodeData>
type ViewMode = 'preview' | 'raw'
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const flowUIContext = useContext(FlowUIContext)
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
const state = useRenderingNodeState(id, data)
const outputMode = state.outputMode
const [viewMode, setViewMode] = useState<ViewMode>('preview')
const [viewportFocused, setViewportFocused] = useState(false)
const dimensions =
@@ -68,7 +66,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
: undefined
const { theme } = useTheme()
const [rawEditorHeight, rawEditorContainerRef] = useResizeHeight(180, [viewMode])
const [rawEditorHeight, rawEditorContainerRef] = useResizeHeight(180, [outputMode])
const showEmpty =
state.incomingIds.length === 0 &&
@@ -151,7 +149,12 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
resizable
nodeId={id}
selected={selected}
handles={<InputHandle id="ain" nodeId={id} />}
handles={
<>
<InputHandle id="ain" nodeId={id} />
<OutputHandle id="out" />
</>
}
>
<BaseNodeHeaderRow
icon={<Sparkles className="size-4" />}
@@ -264,17 +267,17 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
<>
<MenubarCheckboxItem
className="text-xs"
checked={viewMode === 'preview'}
onCheckedChange={(checked) => checked && setViewMode('preview')}
checked={outputMode === 'image'}
onCheckedChange={(checked) => checked && state.setOutputMode('image')}
>
Preview
Image
</MenubarCheckboxItem>
<MenubarCheckboxItem
className="text-xs"
checked={viewMode === 'raw'}
onCheckedChange={(checked) => checked && setViewMode('raw')}
checked={outputMode === 'string'}
onCheckedChange={(checked) => checked && state.setOutputMode('string')}
>
Raw
String
</MenubarCheckboxItem>
{getNodeType(state.sourceNodeType ?? '')?.getOutputMenuContent?.(state.rawLanguage, { state, nodeId: id })}
</>
@@ -306,7 +309,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
</Empty>
) : state.error ? (
errorUi
) : viewMode === 'raw' ? (
) : outputMode === 'string' ? (
<RawOutputView
state={state}
height={rawEditorHeight}
@@ -321,12 +324,12 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
<BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="render">
{viewMode === 'raw'
{outputMode === 'string'
? state.rawDisplayContent
? `Raw · ${state.rawDisplayContent.length} chars`
? `String · ${state.rawDisplayContent.length} chars`
: '—'
: state.renderedContent
? `${state.outputLabel} · ${state.renderedContent.length} chars`
? `Image · ${state.renderedContent.length} chars`
: state.error
? 'Error'
: '—'}

View File

@@ -15,9 +15,10 @@ export function getRenderNodeDescriptor(): NodeTypeDescriptor {
{ viewportWidth: 1200, viewportHeight: 800 }
)
.idPrefix('rnd_')
.withInputOutput(true, false)
.withInputOutput(true, true)
.classification('pneuma')
.allowedSourceTypes(['config', 'agent'])
.allowedTargetTypes(['config', 'agent'])
.help(NODE_HELP.render)
.menu('Renderer', <Sparkles className={ICON_CLASS} />)
.connectionLabelByStatus({ default: 'listening', updating: 'giving life', paused: 'pending', error: 'corrupted' })

View File

@@ -9,6 +9,7 @@ import { useAbstractNode } from '@/lib/graph/abstractNode'
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { useOptionalRecollectionActions } from '@/app/recollections/RecollectionActionsContext'
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
import {
@@ -24,20 +25,28 @@ import { buildSourceSignatures, type NodeLike, type EdgeLike } from '@/lib/graph
import { getNodeDisplayStatus, type NodeDisplayStatus } from '@/lib/graph/state'
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
export type OutputMode = 'image' | 'string'
export type RenderingNodeData = {
viewportWidth?: number
viewportHeight?: number
updateMode?: 'auto' | 'manual'
runTrigger?: number
lastRunSourceSignature?: string
/** Controls which view is shown and what the node emits to downstream (Image = markdown embed, String = resolved text). */
outputMode?: OutputMode
cachedRenderedContent?: string
cachedResolvedContent?: string
cachedReasoningContent?: string
/** Value exposed to config/agent when they reference this node (e.g. {{ renderId }}). Set on pipeline completion. */
cachedOutputValue?: string
}
const DEFAULT_VIEWPORT_WIDTH = 1200
const DEFAULT_VIEWPORT_HEIGHT = 800
const RENDER_DEBOUNCE_MS = 250
/** Skip auto-run when we have cache and effect runs soon after mount (node became visible). */
const VISIBILITY_GRACE_MS = 400
/** Lifecycle state to pass to useSyncConnectionStatus so edge status (updating/error) stays in sync. */
export type RenderingNodeLifecycle = {
@@ -94,6 +103,10 @@ export type RenderingNodeState = {
/** Source node type id (e.g. 'config', 'agent') for Output menu from descriptor. */
sourceNodeType: string | null
/** Output mode (Image vs String); controls view and emitted cachedOutputValue. */
outputMode: OutputMode
setOutputMode: (mode: OutputMode) => void
}
export function useRenderingNodeState(
@@ -120,6 +133,9 @@ export function useRenderingNodeState(
nodesEdgesRef.current = { nodes, edges }
const { aiConnection } = usePlatform()
const recollectionActions = useOptionalRecollectionActions()
const recollectionActionsRef = useRef(recollectionActions)
recollectionActionsRef.current = recollectionActions
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
@@ -138,6 +154,11 @@ export function useRenderingNodeState(
)
const effectiveUpdateMode = (data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto') as 'auto' | 'manual'
const runTrigger = data?.runTrigger ?? 0
const outputMode: OutputMode = (data?.outputMode ?? 'image') as OutputMode
const setOutputMode = useCallback(
(mode: OutputMode) => updateData({ outputMode: mode }),
[updateData]
)
const isAgentSource = srcNode?.type === 'agent'
const agentOutputMarkdown = isAgentSource
? ((srcNode?.data as { outputMarkdown?: string })?.outputMarkdown ?? '')
@@ -194,9 +215,13 @@ export function useRenderingNodeState(
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastManualRunTriggerRef = useRef(0)
const manualRunTriggerSyncedRef = useRef(false)
/** In auto mode, skip running when we have cache and we're in the grace window after mount (node became visible). */
const mountTimeRef = useRef<number>(0)
const updateDataRef = useRef(updateData)
updateDataRef.current = updateData
const outputModeRef = useRef(outputMode)
outputModeRef.current = outputMode
const setNodesRef = useRef(setNodes)
setNodesRef.current = setNodes
const aiConnectionRef = useRef(aiConnection)
@@ -214,6 +239,7 @@ export function useRenderingNodeState(
useSyncConnectionStatus(id, lifecycle)
useEffect(() => {
if (mountTimeRef.current === 0) mountTimeRef.current = Date.now()
if (incomingIds.length === 0) {
setRenderedContent(null)
setResolvedContent(null)
@@ -258,6 +284,29 @@ export function useRenderingNodeState(
lastManualRunTriggerRef.current = runTrigger
}
// Auto mode: do not run when node just became visible (remount with cache). No pipeline run, no path/addTrigger.
if (effectiveUpdateMode === 'auto') {
const hasCache = Boolean(
(data?.cachedRenderedContent ?? data?.cachedResolvedContent) as string | undefined
)
const signatureUnchanged =
data?.lastRunSourceSignature != null && data.lastRunSourceSignature === sourceSignature
const withinVisibilityGrace =
mountTimeRef.current > 0 && Date.now() - mountTimeRef.current < VISIBILITY_GRACE_MS
if (hasCache && (signatureUnchanged || withinVisibilityGrace)) {
// Keep Logos cache in sync with node's cached output so the picker shows the latest when not re-running.
const cached = (data?.cachedRenderedContent as string | undefined) ?? ''
const isSvgContent = Boolean(cached.trim() && /<svg[\s>]/i.test(cached.trim()))
recollectionActionsRef.current?.upsertRenderOutputToLogos?.({
nodeId: id,
label: id,
type: isSvgContent ? 'image' : 'html',
content: cached,
})
return
}
}
runIdRef.current += 1
const thisRunId = runIdRef.current
const signatureForThisRun = sourceSignature
@@ -277,6 +326,7 @@ export function useRenderingNodeState(
cachedRenderedContent: undefined,
cachedResolvedContent: undefined,
cachedReasoningContent: undefined,
cachedOutputValue: undefined,
})
try {
// Pipeline: 1) Resolve (source) → 2) Render (output type) → 3) Cache
@@ -307,10 +357,28 @@ export function useRenderingNodeState(
if (thisRunId !== runIdRef.current) return
setRenderedContent(htmlOrSvg)
setError(null)
// Use the rendering node's id as the label (same as the node name shown in Flux header).
// Infer image from SVG content so we never store 'html' for diagram output.
const isSvgContent = Boolean(htmlOrSvg?.trim() && /<svg[\s>]/i.test(htmlOrSvg.trim()))
const cacheType = typeRenderer.outputType === 'image' || isSvgContent ? 'image' : 'html'
recollectionActionsRef.current?.upsertRenderOutputToLogos?.({
nodeId: id,
label: id,
type: cacheType,
content: htmlOrSvg ?? '',
})
const mode = outputModeRef.current
const cachedOutputValue =
mode === 'string'
? resolved
: mode === 'image' && htmlOrSvg?.trim() && /<svg[\s>]/i.test(htmlOrSvg.trim())
? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(htmlOrSvg)}`
: ''
updateDataRef.current({
cachedRenderedContent: htmlOrSvg,
cachedResolvedContent: resolved,
cachedReasoningContent: reasoning ?? '',
cachedOutputValue,
...(isManualMode ? { lastRunSourceSignature: signatureForThisRun } : {}),
})
} catch (err: unknown) {
@@ -363,6 +431,8 @@ export function useRenderingNodeState(
}
// Content updates only when connected-node data changes (sourceSignature) or explicit run/viewport.
// React Flow updates (position, selection, context ref churn) do not trigger re-runs.
// recollectionActions is intentionally omitted: we use recollectionActionsRef so slot/context
// identity changes (e.g. after setFluxSlot) do not re-trigger this effect and cause an auto-run loop.
}, [
id,
srcId,
@@ -529,5 +599,7 @@ export function useRenderingNodeState(
emptyStateAction,
sourceData,
sourceNodeType,
outputMode,
setOutputMode,
}
}

View File

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

View File

@@ -0,0 +1,46 @@
# Icon registry
Custom SVG icons as React components. Icons use `fill="currentColor"` so they match text color in light/dark themes.
**Usage:** `import { RecollectionsIcon } from '@/lib/icons'` then `<RecollectionsIcon className="size-4" />`.
---
## Adding a new custom icon
You do **not** need a separate `.svg` file. Everything lives in the registry.
### 1. Get path data from your SVG
From the SVG you have:
- **viewBox** e.g. `"0 0 24 24"` from `<svg viewBox="...">`
- **paths** the `d` attribute of each `<path>`. One or more strings.
Example: `<path d="M12 2L2 7v10l10 5 10-5V7L12 2z"/>` → use `'M12 2L2 7v10l10 5 10-5V7L12 2z'`.
### 2. Edit `registry.tsx` only
1. **Add an entry to `ICONS`** (same shape as `recollections`):
```ts
myFeature: {
viewBox: '0 0 24 24',
paths: ['M12 2L2 7v10l10 5 10-5V7L12 2z'],
},
```
2. **Export a component** (one line):
```ts
export const MyFeatureIcon = createIcon('myFeature')
```
### 3. Use it
```tsx
import { MyFeatureIcon } from '@/lib/icons'
<MyFeatureIcon className="size-4" />
```
Thats it. No new files, no public SVG. All icons are defined in `registry.tsx`.

View File

@@ -0,0 +1,35 @@
/**
* Base component for registry SVG icons. Renders an inline <svg> with one or more <path> elements.
* Use this when defining a new icon so all icons share the same props and behavior.
*/
import React from 'react'
import type { IconProps } from './types'
export type SvgIconData = {
viewBox: string
paths: string[]
}
type SvgIconProps = IconProps & {
viewBox: string
paths: string[]
}
export function SvgIcon({ viewBox, paths, className, title, fill = 'currentColor' }: SvgIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={viewBox}
fill={fill}
className={className}
aria-hidden={!title}
{...(title ? { 'aria-label': title } : {})}
>
{title && <title>{title}</title>}
{paths.map((d, i) => (
<path key={i} d={d} />
))}
</svg>
)
}

View File

@@ -0,0 +1,15 @@
/**
* Custom SVG icon registry. Import icons as components.
*
* import { RecollectionsIcon } from '@/lib/icons'
* <RecollectionsIcon className="size-4" />
*
* To add a new icon: edit registry.tsx (add to ICONS + export a createIcon('key') component).
* See README.md in this folder.
*/
export { SvgIcon } from './SvgIcon'
export type { IconProps } from './types'
export type { SvgIconData } from './SvgIcon'
export { RecollectionsIcon, FluxIcon, LogosIcon } from './registry'

View File

@@ -0,0 +1,50 @@
/**
* Custom SVG icon registry. All icons are defined here.
*
* To add a new icon:
* 1. Add a new entry to ICONS (viewBox + paths; paths = array of <path d="..."> values).
* 2. Export a component: export const MyIcon = createIcon('myIcon')
*
* Usage: import { RecollectionsIcon } from '@/lib/icons'
*/
import React from 'react'
import { SvgIcon } from './SvgIcon'
import type { IconProps } from './types'
export type IconDefinition = {
viewBox: string
paths: string[]
}
const ICONS: Record<string, IconDefinition> = {
recollections: {
viewBox: '0 0 136.94762 136.94878',
paths: [
'm 66.371161,0.03390048 c -2.311523,0.07281 -4.641626,0.264753 -6.982424,0.578125 C 21.999631,5.6183495 -4.3606806,40.0826 0.59967533,77.47726 5.5599054,114.87286 39.993587,141.27485 77.394601,136.36007 114.85985,131.43673 141.3174,96.93037 136.34772,59.4714 131.68966,24.35368 101.04394,-1.0582045 66.371161,0.03390048 Z M 61.566472,3.2760895 c -0.0095,0.001 -0.01971,0.003 -0.0293,0.004 -0.0098,9.2e-4 -0.01964,9.9e-4 -0.0293,0.002 z m 9.699219,9.5449205 c 19.50377,0.55671 35.127149,11.15304 44.126949,26.2207 6.93841,11.61643 9.68597,25.81012 6.91406,40.09375 C 120.71152,73.34663 118.17437,68.0619 114.67585,63.45187 106.8245,53.10609 94.721751,46.13507 80.130921,42.52023 l -0.27148,-0.0684 -0.27734,-0.0352 c -28.151269,-3.7155 -55.054642,8.83634 -64.988286,36.95117 -0.02494,0.0706 -0.02478,0.14601 -0.04883,0.2168 C 11.655501,63.71409 15.42168,46.23136 25.22272,33.90491 l 0.01367,-0.0156 0.01367,-0.0176 C 34.377229,22.19422 47.860414,14.74021 62.603576,13.22132 l 0.03711,-0.004 0.03711,-0.004 c 2.936655,-0.34652 5.801645,-0.47211 8.587895,-0.39258 z m -1.29102,41.87109 c 12.95901,0.70222 25.06263,6.49265 32.783209,15.08008 7.28112,8.09864 10.79834,18.33767 8.07226,30 C 101.03587,90.14454 89.910281,84.99651 75.345771,82.73117 l -0.19531,-0.0312 -0.19922,-0.0156 c -20.733068,-1.55643 -35.022716,5.335 -48.671879,17.58594 -0.06457,-0.37677 -0.142507,-0.53509 -0.21289,-0.99805 l -0.002,-0.0215 -0.0039,-0.0215 c -1.539122,-9.56158 0.856493,-19.33174 6.644531,-27.0957 l 0.0059,-0.008 0.0059,-0.006 C 40.835342,61.14748 50.93512,56.87803 64.613388,54.7094 c 1.796703,-0.10041 3.587583,-0.11367 5.361323,-0.0176 z m 1.43946,40.8086 c 12.76679,0.43942 22.46301,6.41284 32.015619,15.60351 -8.516209,7.46779 -18.131509,11.6796 -29.921869,12.62305 l -0.0156,0.002 -0.0137,0.002 C 57.880343,125.06798 44.881286,121.11357 33.449284,111.37969 41.537216,101.9635 52.025551,97.38866 65.0528,95.70782 c 2.205241,-0.20547 4.323551,-0.27717 6.361331,-0.20703 z m 2.89648,38.19336 c 9.8e-4,-2.3e-4 0.003,1.6e-4 0.004,0 0,0 0.002,0 0.002,0 0.001,-1.1e-4 0.003,-1e-5 0.004,0 l -0.0117,0.002 z',
],
},
flux: {
viewBox: '0 0 128.21893 138.56171',
paths: [
'M 65.374983,8.1088677e-4 60.701153,0.03011089 60.415993,4.6961249 c -0.55179,9.0557191 -2.83786,11.9493291 -6.79687,14.5195291 -3.95902,2.5702 -10.54806,4.20232 -18.3418,7.4082 -34.98520956,14.39284 -47.28717,62.07586 -21.55664,90.648436 0,0 0.002,0.002 0.002,0.002 0,0 0.002,0.002 0.002,0.002 10e-4,10e-4 9.6e-4,0.002 0.002,0.004 10.83353,12.16108 26.49706,20.323 43.10742,21.14844 1.82408,0.0913 3.65036,0.13596 5.47656,0.13281 h 0.0508 l 0.0488,-0.002 C 100.64938,137.72435 128.68569,105.7552 128.213,68.397434 128.00565,52.017994 121.01832,35.173324 109.838,22.170874 98.657673,9.1684149 82.994953,-0.10807511 65.375113,9.5388677e-4 Z M 72.351543,14.047684 c 3.97414,0.85717 7.73706,2.00996 8.76562,3.47266 l 0.0332,0.0488 0.0351,0.0469 c 6.89636,9.33072 6.88878,19.61502 2.61328,28.80664 -4.27549,9.19163 -13.07048,16.8718 -23.43164,19.5332 -19.35646,4.97273 -27.00478,5.36045 -43.54101,18.77539 l -0.27149,0.22071 -0.23828,0.25586 c -1.01013,1.08488 -1.80757,2.18133 -2.68164,3.27148 -0.86509,-4.20543 -1.08073,-8.40113 -0.47461,-13.26562 1.90196,-15.26728 13.08901,-31.08978 27.28125,-36.76563 6.67107,-2.66751 15.44131,-4.10549 23.06445,-10.32226 h 0.002 v -0.002 c 4.55186,-3.71381 7.379,-8.78935 8.84375,-14.07617 z m 26.69531,15.76172 c 3.560237,3.98608 6.818267,8.37635 8.771477,11.92773 l 0.006,0.0117 0.008,0.0117 c 7.49286,13.45031 9.35625,29.3145 5.18554,44.13672 -3.99106,14.176786 -13.477257,26.166346 -26.355467,33.312496 -13.35902,7.26239 -26.86378,8.03142 -41.45117,3.71289 -11.9737,-4.11608 -18.89818,-9.93158 -25.79883,-19.92578 5.60813,-9.268456 17.76283,-18.626576 27.17774,-20.341796 10.91193,-1.9874 22.02927,-5.01594 31.47461,-10.92578 9.44534,-5.90984 17.12434,-14.98149 20.23828,-27.86914 1.05706,-4.37454 1.23242,-9.22164 0.74414,-14.05078 z',
],
},
logos: {
viewBox: '0 0 146.33972 145.15062',
paths: [
'M 73.027332,7.1405223e-8 69.925772,5.16211 c -5.52098,9.188434 -14.41144,26.253674 -19.48828,32.402344 0.0491,-0.0595 -2.98075,2.63874 -6.69531,5.3418 -3.71456,2.70305 -8.45619,5.96709 -13.31055,9.22265 -9.70871,6.51113 -20.01065,13.07222 -23.4648407,15.12305 L -8.724555e-6,71.388674 6.8222613,75.761714 c 9.0907907,5.82534 18.1443907,11.70934 27.1582007,17.65235 l 0.0156,0.01 0.0176,0.0117 c 4.00269,2.59317 8.36085,5.13445 11.86328,7.523436 0.23407,0.45064 0.38789,0.80708 0.73633,1.46094 0.71978,1.35064 1.66548,3.08636 2.76367,5.08594 2.19639,3.99914 5.00416,9.04144 7.78711,14.00195 2.78296,4.96051 5.53921,9.83667 7.63282,13.50195 1.0468,1.83264 1.92645,3.36087 2.57031,4.46094 0.32193,0.55003 0.58219,0.98887 0.79297,1.33398 0.10539,0.17256 0.19404,0.31506 0.30273,0.48047 0.0543,0.0827 0.10819,0.16699 0.2168,0.31446 0.1086,0.14746 -0.44724,0.078 1.28515,1.23437 l 3.47071,2.31641 3.78711,-3.91993 13.84961,-23.21679 c 0.91939,-1.54094 2.60442,-4.51911 4.34179,-7.35938 0.86869,-1.42013 1.74513,-2.79264 2.47266,-3.83593 0.58635,-0.84084 1.23011,-1.53521 1.26758,-1.61524 0,0 0.002,-0.002 0.002,-0.002 0,0 0,-0.002 0,-0.002 0,0 0,-0.002 0,-0.002 0,0 -0.002,0 -0.002,0 0,0 -0.002,0 -0.002,0 0,0 -0.002,0 -0.002,0 C 110.91313,95.490664 128.18052,86.436874 141.2792,76.263754 l 5.06054,-3.92969 -5.04296,-3.95312 c -9.38527,-7.35596 -20.29484,-13.54071 -29.4336,-19.82227 -2.33755,-1.60623 -5.3246,-3.37974 -8.0625,-5.19531 -2.73789,-1.81558 -5.222368,-3.83146 -5.923828,-4.70703 -0.44105,-0.55116 -2.07282,-3.15619 -3.80664,-6.27539 -1.7342,-3.11992 -3.75432,-6.92382 -5.76953,-10.69141 -2.01521,-3.76759 -4.02165,-7.49617 -5.78711,-10.55078 -1.76546,-3.05461 -2.58933,-5.01617 -4.98242,-7.140627 z M 74.419912,23.173824 c 1.17627,1.99532 2.38635,4.05673 3.68555,6.39649 1.02962,1.85427 1.28165,2.47838 2.24805,4.29297 -1.93131,-0.43835 -3.97357,-0.82076 -6.42578,-1.04297 l -0.48633,-0.0449 -0.48633,0.0508 c -1.65299,0.17332 -3.16247,0.42313 -4.60938,0.7207 2.03534,-3.47033 4.0086,-6.8998 6.07422,-10.37305 z m 0.084,22.8711 c 12.48904,0.43314 21.30922,9.51036 23.13672,22.39648 1.01116,7.46859 -0.96496,15.02931 -5.50196,21.04688 v 0.002 c -4.21095,5.59226 -8.86516,8.02542 -15.82031,9.11719 -8.31129,0.0281 -14.27689,-2.81072 -18.55664,-7.17188 -4.33772,-4.42023 -6.90239,-10.56445 -7.40625,-16.96289 -1.00248,-12.73032 5.43459,-25.61273 21.60352,-28.39844 0.86423,-0.0494 1.71231,-0.0582 2.54492,-0.0293 z m -35.95703,16.11719 c -1.30582,5.40995 -1.66749,11.01933 -0.61133,16.82031 0.0652,0.70615 0.22574,1.26763 0.35937,1.87109 -0.73651,-0.50535 -1.53019,-1.01175 -2.17187,-1.47265 -1.38484,-0.99469 -2.10979,-1.58445 -3.06446,-2.20508 l -0.0195,-0.0117 -0.8789,-0.56055 -0.0781,-0.0449 c -2.38147,-1.40078 -5.03264,-3.28099 -7.61328,-5.03906 1.79854,-1.25798 3.57872,-2.53921 5.39258,-3.77539 2.85143,-1.94332 5.79752,-3.70317 8.68555,-5.58203 z m 71.302728,0.62304 c 2.39205,1.59547 4.80964,3.252 7.11719,4.59375 2.03836,1.18513 4.75208,3.12784 7.25976,4.88672 -3.47791,2.39487 -7.00474,4.84452 -10.39257,7.03125 l -0.002,0.002 -0.002,0.002 c -1.52473,0.98648 -2.99987,1.93804 -4.50195,2.91992 1.66778,-6.01121 2.23579,-12.37241 0.52148,-19.43555 z M 66.058592,110.64257 c 4.81115,1.23001 9.49114,1.35292 13.97852,0.56836 -0.76396,1.32958 -1.60165,2.70012 -2.29492,3.93946 -1.46148,2.59299 -3.03458,5.16218 -4.57422,7.8125 -1.75297,-2.96478 -2.93487,-4.85528 -5.01758,-8.53321 -0.76423,-1.34958 -1.36576,-2.48098 -2.0918,-3.78711 z',
],
},
}
function createIcon(key: keyof typeof ICONS) {
const { viewBox, paths } = ICONS[key]
return function IconComponent(props: IconProps) {
return <SvgIcon viewBox={viewBox} paths={paths} {...props} />
}
}
export const RecollectionsIcon = createIcon('recollections')
export const FluxIcon = createIcon('flux')
export const LogosIcon = createIcon('logos')

View File

@@ -0,0 +1,13 @@
/**
* Shared props for SVG icon components in the icon registry.
* All icons inherit text color via fill="currentColor" by default.
*/
export type IconProps = {
/** Optional class name (e.g. for size: "size-4", "w-5 h-5") */
className?: string
/** Optional title for accessibility; omit for decorative icons (use aria-hidden on parent) */
title?: string
/** Override fill; default is "currentColor" */
fill?: string
}

View File

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

View File

@@ -1,4 +1,6 @@
@import "shadcn/dist/tailwind.css";
@import "@blocknote/core/fonts/inter.css";
@import "@blocknote/shadcn/style.css";
@tailwind base;
@tailwind components;
@@ -393,4 +395,40 @@ pre {
.code-editor .token.entity,
.code-editor .token.url {
background: none;
}
}
/* BlockNote (Logos editor): font sizes to match app typography (Tailwind text-smtext-lg scale) */
.logos-blocknote .bn-editor {
font-size: 0.875rem; /* text-sm */
line-height: 1.5;
}
.logos-blocknote .bn-inline-content {
font-size: inherit;
line-height: inherit;
}
.logos-blocknote [data-content-type="heading"][data-level="1"] .bn-inline-content {
font-size: 1.25rem; /* text-xl */
font-weight: 600;
line-height: 1.3;
}
.logos-blocknote [data-content-type="heading"][data-level="2"] .bn-inline-content {
font-size: 1.125rem; /* text-lg */
font-weight: 600;
line-height: 1.35;
}
.logos-blocknote [data-content-type="heading"][data-level="3"] .bn-inline-content {
font-size: 1rem; /* text-base */
font-weight: 600;
line-height: 1.4;
}
.logos-blocknote [data-content-type="paragraph"] .bn-inline-content {
font-size: 0.875rem;
}
.logos-blocknote [data-content-type="bulletListItem"] .bn-inline-content,
.logos-blocknote [data-content-type="numberedListItem"] .bn-inline-content {
font-size: 0.875rem;
}
.logos-blocknote [data-content-type="codeBlock"] .bn-inline-content,
.logos-blocknote [data-content-type="code"] .bn-inline-content {
font-size: 0.8125rem; /* slightly smaller for code */
}

View File

@@ -1,7 +1,7 @@
/* Tailwind configuration for Vite + React */
module.exports = {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx,js,jsx}'],
content: ['./index.html', './src/**/*.{ts,tsx,js,jsx}', './node_modules/@blocknote/shadcn/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
borderRadius: {