From 449d2cb38242cc757ed4ff5ffd6306086225912d Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 15 Mar 2026 14:43:31 +0100 Subject: [PATCH] feat: logos and flux --- frontend/src/app/canvas/CanvasPage.tsx | 73 ++++--- .../app/recollections/RecollectionLayout.tsx | 43 ++++ .../app/recollections/RecollectionMenubar.tsx | 59 ++++++ .../RecollectionMenubarContext.tsx | 36 ++++ .../flux/FluxMenubarContent.tsx} | 184 +++++++----------- .../flux/FluxRoute.tsx} | 19 +- .../src/app/recollections/logos/LogosPage.tsx | 21 ++ frontend/src/main.tsx | 10 +- 8 files changed, 285 insertions(+), 160 deletions(-) create mode 100644 frontend/src/app/recollections/RecollectionLayout.tsx create mode 100644 frontend/src/app/recollections/RecollectionMenubar.tsx create mode 100644 frontend/src/app/recollections/RecollectionMenubarContext.tsx rename frontend/src/app/{canvas/CanvasMenubar.tsx => recollections/flux/FluxMenubarContent.tsx} (60%) rename frontend/src/app/{canvas/CanvasRoute.tsx => recollections/flux/FluxRoute.tsx} (50%) create mode 100644 frontend/src/app/recollections/logos/LogosPage.tsx diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx index 37875b6..169aa15 100644 --- a/frontend/src/app/canvas/CanvasPage.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -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 { useRecollectionMenubar } from '@/app/recollections/RecollectionMenubarContext' +import { FluxMenubarContent } from '@/app/recollections/flux/FluxMenubarContent' import { createContextualNode } from '@/app/canvas/ContextualZoomNode' import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext' import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts' @@ -427,6 +428,52 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) { flowActionsRef.current?.pasteAtViewportCenter?.() }, []) + const { setCustomContent } = useRecollectionMenubar() + useEffect(() => { + setCustomContent( + { + save() + toast.success('Saved') + } + : undefined + } + canSave={Boolean(recollectionId)} + 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?.()} + /> + ) + return () => setCustomContent(null) + }, [ + setCustomContent, + recollectionId, + saveStatus, + canUndo, + canRedo, + selectedNodes.length, + save, + undo, + redo, + handleDuplicate, + handleCopy, + handlePaste, + handleImportRecollection, + handleExportRecollection, + ]) + const graphContextValue = useMemo( () => ({ setNodes, setEdges, graphRef, edges }), [setNodes, setEdges, edges] @@ -646,30 +693,6 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) { onChange={onImportFileChange} aria-hidden /> - { - save() - toast.success('Saved') - } - : undefined - } - canSave={Boolean(recollectionId)} - 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?.()} - />
diff --git a/frontend/src/app/recollections/RecollectionLayout.tsx b/frontend/src/app/recollections/RecollectionLayout.tsx new file mode 100644 index 0000000..dce0eba --- /dev/null +++ b/frontend/src/app/recollections/RecollectionLayout.tsx @@ -0,0 +1,43 @@ +/** + * Layout for a single recollection: shared menubar (with optional custom content from active page) and outlet. + */ + +import React, { useEffect } from 'react' +import { Link, Outlet, useParams } from 'react-router-dom' +import { usePlatform } from '@/app/kosmos/KosmosContext' +import { RecollectionMenubarProvider } from './RecollectionMenubarContext' +import { RecollectionMenubar } from './RecollectionMenubar' + +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 ( +
+

Recollection not found.

+ + Back to recollections + +
+ ) + } + + return ( + +
+ +
+ +
+
+
+ ) +} diff --git a/frontend/src/app/recollections/RecollectionMenubar.tsx b/frontend/src/app/recollections/RecollectionMenubar.tsx new file mode 100644 index 0000000..3be451a --- /dev/null +++ b/frontend/src/app/recollections/RecollectionMenubar.tsx @@ -0,0 +1,59 @@ +/** + * Shared base menubar for a recollection: Back, optional custom content (from context), Logos/Flux switcher. + */ + +import React from 'react' +import { Link, NavLink, useParams } from 'react-router-dom' +import { usePlatform } from '@/app/kosmos/KosmosContext' +import { ArrowLeft, BookOpen, Workflow } from 'lucide-react' +import { useRecollectionMenubar } from './RecollectionMenubarContext' + +export function RecollectionMenubar() { + const { recollectionId } = useParams<{ recollectionId: string }>() + const { recollections } = usePlatform() + const { customContent } = useRecollectionMenubar() + + const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null + const base = recollectionId ? `/recollections/${recollectionId}` : '' + const defaultMiddle = recollection ? ( + {recollection.name} + ) : null + + return ( +
+ + + +
+ {customContent ?? defaultMiddle} +
+ {base && ( + + )} +
+ ) +} diff --git a/frontend/src/app/recollections/RecollectionMenubarContext.tsx b/frontend/src/app/recollections/RecollectionMenubarContext.tsx new file mode 100644 index 0000000..7b809c9 --- /dev/null +++ b/frontend/src/app/recollections/RecollectionMenubarContext.tsx @@ -0,0 +1,36 @@ +/** + * Context for the recollection menubar: pages (Logos, Flux) can register custom content + * to show in the middle of the shared menubar. + */ + +import React, { createContext, useCallback, useContext, useState } from 'react' + +export type RecollectionMenubarContextValue = { + /** Content to show in the middle of the menubar (menus, title, etc.). Set to null to use default (recollection name only). */ + customContent: React.ReactNode + setCustomContent: (content: React.ReactNode) => void +} + +const RecollectionMenubarContext = createContext(null) + +export function RecollectionMenubarProvider({ children }: { children: React.ReactNode }) { + const [customContent, setCustomContentState] = useState(null) + const setCustomContent = useCallback((content: React.ReactNode) => { + setCustomContentState(() => content) + }, []) + const value: RecollectionMenubarContextValue = React.useMemo( + () => ({ customContent, setCustomContent }), + [customContent, setCustomContent] + ) + return ( + + {children} + + ) +} + +export function useRecollectionMenubar(): RecollectionMenubarContextValue { + const ctx = useContext(RecollectionMenubarContext) + if (!ctx) throw new Error('useRecollectionMenubar must be used within RecollectionMenubarProvider') + return ctx +} diff --git a/frontend/src/app/canvas/CanvasMenubar.tsx b/frontend/src/app/recollections/flux/FluxMenubarContent.tsx similarity index 60% rename from frontend/src/app/canvas/CanvasMenubar.tsx rename to frontend/src/app/recollections/flux/FluxMenubarContent.tsx index 9f8b9c6..ba72061 100644 --- a/frontend/src/app/canvas/CanvasMenubar.tsx +++ b/frontend/src/app/recollections/flux/FluxMenubarContent.tsx @@ -1,9 +1,10 @@ /** - * Menubar for the canvas page: Recollection (Import/Export), Edit (Undo/Redo, Duplicate/Copy/Paste, Rename), View (Fit View, Minimap). + * Flux-specific menubar content: Recollection/Edit/View menus and centered title with save status. + * Registered with the recollection menubar when the Flux page is active. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Link, useParams } from 'react-router-dom' +import { useParams } from 'react-router-dom' import { Menubar, MenubarContent, @@ -14,17 +15,16 @@ import { } 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 { 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 = { +export type FluxMenubarContentProps = { onImport: () => void onExport: () => void onSave?: () => void canSave?: boolean - /** Shown next to recollection title: unsaved | saving | saved */ saveStatus?: SaveStatus undo: () => void redo: () => void @@ -47,7 +47,7 @@ function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) { return ev.key.toLowerCase() === want.key && !!mod && !!ev.shiftKey === want.shiftKey } -export function CanvasMenubar({ +export function FluxMenubarContent({ onImport, onExport, onSave, @@ -63,7 +63,7 @@ export function CanvasMenubar({ canDuplicate = false, canCopy = false, onFitView, -}: CanvasMenubarProps) { +}: FluxMenubarContentProps) { const { recollectionId } = useParams<{ recollectionId: string }>() const { recollections, renameRecollection } = usePlatform() const recollectionName = useMemo( @@ -110,7 +110,6 @@ export function CanvasMenubar({ if (isRenamingRecollection) { setRenameValue(recollectionName ?? '') ignoreNextBlurRef.current = true - // Delay focus so the Recollection dropdown can close first and not steal focus back (which would trigger blur) const t = setTimeout(() => { renameInputRef.current?.focus() renameInputRef.current?.select() @@ -126,9 +125,7 @@ export function CanvasMenubar({ setIsRenamingRecollection(false) }, [recollectionId, renameRecollection, renameValue]) - const cancelRename = useCallback(() => { - setIsRenamingRecollection(false) - }, []) + const cancelRename = useCallback(() => setIsRenamingRecollection(false), []) const handleRenameBlur = useCallback(() => { if (ignoreNextBlurRef.current) { @@ -169,19 +166,12 @@ export function CanvasMenubar({ }, [undo, redo, canUndo, canRedo, onSave, canSave]) return ( -
- - - - +
+ Recollection - {recollectionId && ( + {recollectionId && ( <> {onSave != null && ( <> @@ -189,18 +179,13 @@ export function CanvasMenubar({ Save - - ⌘S - + ⌘S )} - setIsRenamingRecollection(true)} - className="gap-2" - > + setIsRenamingRecollection(true)} className="gap-2"> Rename @@ -223,53 +208,33 @@ export function CanvasMenubar({ Undo - - - ⌘ + Z - - + ⌘ + Z Redo - - - ⌘ + ⇧ + Z - - + ⌘ + ⇧ + Z {(onDuplicate != null || onCopy != null || onPaste != null) && } {onDuplicate != null && ( Duplicate - - - ⌘D - - + ⌘D )} {onCopy != null && ( Copy - - - ⌘C - - + ⌘C )} {onPaste != null && ( Paste - - - ⌘V - - + ⌘V )} @@ -280,78 +245,59 @@ export function CanvasMenubar({ {onFitView && ( Fit View - - - ⌘0 - - + ⌘0 )} {recollectionId && ( -
- {isRenamingRecollection ? ( - 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="Recollection name" - /> - ) : ( - <> - - {recollectionName ?? 'Untitled'} - - - - - - {saveStatus === 'saving' && ( - - )} - {saveStatus === 'unsaved' && ( - - )} - {saveStatus !== 'unsaved' && (saveStatus === 'saved' || showSavedBriefly) && ( - - )} - - - - {saveStatus === 'saving' - ? 'Saving…' - : saveStatus === 'unsaved' - ? 'Unsaved changes' - : 'All changes saved'} - - - - - )} +
+
+ {isRenamingRecollection ? ( + 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="Recollection name" + /> + ) : ( + <> + + {recollectionName ?? 'Untitled'} + + + + + + {saveStatus === 'saving' && } + {saveStatus === 'unsaved' && } + {saveStatus !== 'unsaved' && (saveStatus === 'saved' || showSavedBriefly) && ( + + )} + + + + {saveStatus === 'saving' ? 'Saving…' : saveStatus === 'unsaved' ? 'Unsaved changes' : 'All changes saved'} + + + + + )} +
)}
diff --git a/frontend/src/app/canvas/CanvasRoute.tsx b/frontend/src/app/recollections/flux/FluxRoute.tsx similarity index 50% rename from frontend/src/app/canvas/CanvasRoute.tsx rename to frontend/src/app/recollections/flux/FluxRoute.tsx index 71947b9..84fd8bd 100644 --- a/frontend/src/app/canvas/CanvasRoute.tsx +++ b/frontend/src/app/recollections/flux/FluxRoute.tsx @@ -1,35 +1,26 @@ /** - * Route wrapper for the canvas: resolves recollectionId from URL and updates lastEditedAt on open. + * 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 './CanvasPage' +import { CanvasPage } from '@/app/canvas/CanvasPage' import { usePlatform } from '@/app/kosmos/KosmosContext' -export function CanvasRoute() { +export function FluxRoute() { const { recollectionId } = useParams<{ recollectionId: string }>() - const { recollections, updateLastEdited } = usePlatform() + const { updateLastEdited } = usePlatform() const updateLastEditedRef = useRef(updateLastEdited) updateLastEditedRef.current = updateLastEdited - const recollection = recollections.find((p) => p.id === recollectionId) - useEffect(() => { if (recollectionId) updateLastEditedRef.current(recollectionId) }, [recollectionId]) if (!recollectionId) return null - if (!recollection) { - return ( -
-

Recollection not found.

-
- ) - } return ( -
+
) diff --git a/frontend/src/app/recollections/logos/LogosPage.tsx b/frontend/src/app/recollections/logos/LogosPage.tsx new file mode 100644 index 0000000..3d0d1db --- /dev/null +++ b/frontend/src/app/recollections/logos/LogosPage.tsx @@ -0,0 +1,21 @@ +/** + * Logos page: default view for a recollection. Placeholder for now. + */ + +import React, { useEffect } from 'react' +import { useRecollectionMenubar } from '../RecollectionMenubarContext' + +export function LogosPage() { + const { setCustomContent } = useRecollectionMenubar() + useEffect(() => { + setCustomContent(null) + return () => setCustomContent(null) + }, [setCustomContent]) + + return ( +
+

Logos

+

This page is empty for now.

+
+ ) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8b7da35..366283d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -7,7 +7,9 @@ import { registerBuiltinNodes } from './lib/graph/registerBuiltinNodes' import { registerBuiltinConfigTypes } from './lib/graph/configTypes' import { KosmosPage } from './app/kosmos/KosmosPage' import { RecollectionsPage } from './app/recollections/RecollectionsPage' -import { CanvasRoute } from './app/canvas/CanvasRoute' +import { RecollectionLayout } from './app/recollections/RecollectionLayout' +import { LogosPage } from './app/recollections/logos/LogosPage' +import { FluxRoute } from './app/recollections/flux/FluxRoute' import './lib/prismSetup' import 'prismjs/themes/prism.css' import './styles.css' @@ -24,7 +26,11 @@ createRoot(document.getElementById('root')!).render( }> } /> } /> - } /> + }> + } /> + } /> + } /> +