Compare commits
14 Commits
a1ad4d52cf
...
7c7d470f15
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c7d470f15 | |||
| c2db0b33d6 | |||
| f67c491deb | |||
| 45c387c9e6 | |||
| 4d673ab556 | |||
| 290217791f | |||
| 929359b4bc | |||
| a8bd579043 | |||
| 202462bad7 | |||
| 449d2cb382 | |||
| 04c039bdde | |||
| 2326bbe035 | |||
| ce58673d18 | |||
| bcb9c5946d |
@@ -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.
|
||||
|
||||
2901
frontend/package-lock.json
generated
2901
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
Right‑click 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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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[])
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
@@ -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]
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
27
frontend/src/app/recollections/FlippingCardView.tsx
Normal file
27
frontend/src/app/recollections/FlippingCardView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
243
frontend/src/app/recollections/RecollectionActionsContext.tsx
Normal file
243
frontend/src/app/recollections/RecollectionActionsContext.tsx
Normal 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)
|
||||
}
|
||||
145
frontend/src/app/recollections/RecollectionEditViewMenus.tsx
Normal file
145
frontend/src/app/recollections/RecollectionEditViewMenus.tsx
Normal 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
|
||||
}
|
||||
51
frontend/src/app/recollections/RecollectionLayout.tsx
Normal file
51
frontend/src/app/recollections/RecollectionLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
frontend/src/app/recollections/RecollectionMenubar.tsx
Normal file
39
frontend/src/app/recollections/RecollectionMenubar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
187
frontend/src/app/recollections/RecollectionTitleContent.tsx
Normal file
187
frontend/src/app/recollections/RecollectionTitleContent.tsx
Normal 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)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
88
frontend/src/app/recollections/RecollectionViewSwitcher.tsx
Normal file
88
frontend/src/app/recollections/RecollectionViewSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
94
frontend/src/app/recollections/RenameRecollectionDialog.tsx
Normal file
94
frontend/src/app/recollections/RenameRecollectionDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
frontend/src/app/recollections/flux/FluxRoute.tsx
Normal file
27
frontend/src/app/recollections/flux/FluxRoute.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
192
frontend/src/app/recollections/logos/LogosPage.tsx
Normal file
192
frontend/src/app/recollections/logos/LogosPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
140
frontend/src/app/recollections/logos/blocks/fluxOutputBlock.tsx
Normal file
140
frontend/src/app/recollections/logos/blocks/fluxOutputBlock.tsx
Normal 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,
|
||||
})
|
||||
17
frontend/src/app/recollections/logos/logosSchema.ts
Normal file
17
frontend/src/app/recollections/logos/logosSchema.ts
Normal 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
|
||||
24
frontend/src/app/recollections/recollectionGraphStorage.ts
Normal file
24
frontend/src/app/recollections/recollectionGraphStorage.ts
Normal 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
|
||||
115
frontend/src/app/recollections/recollectionStore.ts
Normal file
115
frontend/src/app/recollections/recollectionStore.ts
Normal 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))
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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
|
||||
? ``
|
||||
: `{{ ${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}
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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'
|
||||
: '—'}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
46
frontend/src/lib/icons/README.md
Normal file
46
frontend/src/lib/icons/README.md
Normal 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" />
|
||||
```
|
||||
|
||||
That’s it. No new files, no public SVG. All icons are defined in `registry.tsx`.
|
||||
35
frontend/src/lib/icons/SvgIcon.tsx
Normal file
35
frontend/src/lib/icons/SvgIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
frontend/src/lib/icons/index.ts
Normal file
15
frontend/src/lib/icons/index.ts
Normal 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'
|
||||
50
frontend/src/lib/icons/registry.tsx
Normal file
50
frontend/src/lib/icons/registry.tsx
Normal 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')
|
||||
13
frontend/src/lib/icons/types.ts
Normal file
13
frontend/src/lib/icons/types.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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-sm–text-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 */
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user