From 3de9a3307471169fba85f5cd62ed7d981d736051 Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 16 Mar 2026 16:33:11 +0100 Subject: [PATCH] feat: multiple pages --- .../src/app/recollections/logos/LogosPage.tsx | 164 ++++-- .../app/recollections/logos/LogosSidebar.tsx | 480 ++++++++++++++++++ .../recollections/state/recollectionStore.ts | 85 +++- 3 files changed, 681 insertions(+), 48 deletions(-) create mode 100644 frontend/src/app/recollections/logos/LogosSidebar.tsx diff --git a/frontend/src/app/recollections/logos/LogosPage.tsx b/frontend/src/app/recollections/logos/LogosPage.tsx index e88daf4..663d1ea 100644 --- a/frontend/src/app/recollections/logos/LogosPage.tsx +++ b/frontend/src/app/recollections/logos/LogosPage.tsx @@ -1,6 +1,6 @@ /** * Logos page: BlockNote editor for the recollection. Content persisted in recollection store. - * Layout and styling aligned with Flux (same flex/overflow, bg-background, theme). + * Left sidebar for page/subpage hierarchy (one level). Layout and styling aligned with Flux. */ import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef } from 'react' @@ -11,8 +11,19 @@ import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuContro import { BlockNoteView } from '@blocknote/shadcn' import { useTheme } from '@/lib/themeContext' import { useRecollectionActions } from '../layout/RecollectionActionsContext' -import { getLogosContent, setLogosContent, type StoredLogosContent } from '../state/recollectionStore' +import { + getLogosContent, + getLogosPageTree, + setLogosPageTree, + getLogosContentForPage, + setLogosContentForPage, + removeLogosPageContent, + type StoredLogosContent, + type LogosPageMeta, + type LogosPageId, +} from '../state/recollectionStore' import { logosSchema } from './logosSchema' +import { LogosSidebar } from './LogosSidebar' import { toast } from 'sonner' /** Wraps BlockNoteView so refs go to a div, not the function component (avoids ref warning). */ @@ -48,39 +59,81 @@ function patchBlockNoteRefWarning() { } } +const MAIN_PAGE_ID: LogosPageId = 'main' + 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 [tree, setTree] = useState([]) + const [activePageId, setActivePageId] = useState(null) const [reloadKey, setReloadKey] = useState(0) const saveTimeoutRef = useRef | null>(null) const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved') + // Load page tree and run migration when empty (treat existing single doc as "Main" page). + useEffect(() => { + if (!recollectionId) return + let t = getLogosPageTree(recollectionId) + if (t.length === 0) { + const legacy = getLogosContent(recollectionId) + const main: LogosPageMeta = { id: MAIN_PAGE_ID, title: 'Main', parentId: null, position: 0 } + setLogosContentForPage(recollectionId, MAIN_PAGE_ID, legacy ?? []) + setLogosPageTree(recollectionId, [main]) + t = [main] + } + setTree(t) + setActivePageId((prev) => { + const firstId = t[0]?.id ?? null + if (prev != null && t.some((p) => p.id === prev)) return prev + return firstId + }) + }, [recollectionId]) + + // Persist tree when sidebar changes it. + useEffect(() => { + if (!recollectionId || tree.length === 0) return + setLogosPageTree(recollectionId, tree) + }, [recollectionId, tree]) + + const handleTreeChange = useCallback((newTree: LogosPageMeta[]) => { + setTree(newTree) + }, []) + + const handleDeletePage = useCallback( + (pageId: LogosPageId) => { + if (recollectionId) removeLogosPageContent(recollectionId, pageId) + }, + [recollectionId] + ) + const initialContent = useMemo( - () => (recollectionId ? getLogosContent(recollectionId) ?? undefined : undefined), - [recollectionId, reloadKey] + () => + recollectionId && activePageId + ? getLogosContentForPage(recollectionId, activePageId) ?? undefined + : undefined, + [recollectionId, activePageId, reloadKey] ) const editor = useCreateBlockNote( { schema: logosSchema, initialContent }, - [recollectionId, reloadKey] + [recollectionId, activePageId, reloadKey] ) const persistContent = useCallback(() => { - if (!recollectionId || !editor) return + if (!recollectionId || !activePageId || !editor) return setSaveStatus('saving') try { const doc = editor.document const serialized = JSON.parse(JSON.stringify(doc)) as StoredLogosContent - setLogosContent(recollectionId, serialized) + setLogosContentForPage(recollectionId, activePageId, serialized) setSaveStatus('saved') } catch { setSaveStatus('unsaved') } - }, [recollectionId, editor]) + }, [recollectionId, activePageId, editor]) const onSave = useCallback(() => { persistContent() @@ -88,7 +141,7 @@ export function LogosPage() { }, [persistContent]) useEffect(() => { - if (!editor || !recollectionId) return + if (!editor || !recollectionId || !activePageId) return const handleChange = () => { setSaveStatus('unsaved') if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current) @@ -104,10 +157,10 @@ export function LogosPage() { saveTimeoutRef.current = null } } - }, [editor, recollectionId, persistContent]) + }, [editor, recollectionId, activePageId, persistContent]) useEffect(() => { - if (!editor || !recollectionId) return + if (!editor || !recollectionId || !activePageId) return const slot = { saveStatus, onSave, @@ -120,29 +173,9 @@ export function LogosPage() { } setLogosSlot(slot) return () => setLogosSlot(null) - }, [ - setLogosSlot, - saveStatus, - onSave, - editor, - recollectionId, - ]) + }, [setLogosSlot, saveStatus, onSave, editor, recollectionId, activePageId]) - if (!recollectionId) { - return ( -
-

No recollection selected.

-
- ) - } - - if (!editor) { - return ( -
-

Loading…

-
- ) - } + const activePage = tree.find((p) => p.id === activePageId) patchBlockNoteRefWarning() @@ -172,20 +205,57 @@ export function LogosPage() { [editor] ) + // Render paths (no hooks below this point). + if (!recollectionId) { + return ( +
+

No recollection selected.

+
+ ) + } + + if (activePageId == null || !editor) { + return ( +
+ +
+ {tree.length === 0 ? 'Loading…' : 'Select a page'} +
+
+ ) + } + return ( -
-
-

- {title} -

- - - +
+ +
+
+

+ {activePage?.title ?? recollection?.name ?? 'Untitled'} +

+ + + +
) diff --git a/frontend/src/app/recollections/logos/LogosSidebar.tsx b/frontend/src/app/recollections/logos/LogosSidebar.tsx new file mode 100644 index 0000000..e288422 --- /dev/null +++ b/frontend/src/app/recollections/logos/LogosSidebar.tsx @@ -0,0 +1,480 @@ +/** + * Logos sidebar: one-level page/subpage hierarchy. Create, rename, delete, reorder. + */ + +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + ChevronDown, + ChevronRight, + FileText, + MoreHorizontal, + Pencil, + Plus, + Trash2, + ArrowUp, + ArrowDown, +} from 'lucide-react' +import type { LogosPageId, LogosPageMeta } from '../state/recollectionStore' +import { cn } from '@/lib/utils' + +const DEFAULT_PAGE_TITLE = 'Untitled' + +function generatePageId(): LogosPageId { + return `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` +} + +function sortByPosition(a: LogosPageMeta, b: LogosPageMeta) { + return a.position - b.position +} + +export type LogosSidebarProps = { + recollectionId: string + tree: LogosPageMeta[] + onTreeChange: (tree: LogosPageMeta[]) => void + activePageId: LogosPageId | null + onSelectPage: (id: LogosPageId) => void + onDeletePage?: (pageId: LogosPageId) => void +} + +export function LogosSidebar({ + recollectionId, + tree, + onTreeChange, + activePageId, + onSelectPage, + onDeletePage, +}: LogosSidebarProps) { + const [expandedPages, setExpandedPages] = useState>(new Set()) + const expandedInitializedRef = useRef(false) + const [editingId, setEditingId] = useState(null) + const [editTitle, setEditTitle] = useState('') + const [deleteTarget, setDeleteTarget] = useState(null) + + const pages = tree.filter((p) => p.parentId === null).sort(sortByPosition) + + // Default: expand parent pages that have subpages so children are visible on first load. + useEffect(() => { + if (tree.length === 0 || expandedInitializedRef.current) return + expandedInitializedRef.current = true + const parentIdsWithChildren = tree + .filter((p) => p.parentId === null && tree.some((s) => s.parentId === p.id)) + .map((p) => p.id) + if (parentIdsWithChildren.length > 0) { + setExpandedPages((prev) => { + const next = new Set(prev) + parentIdsWithChildren.forEach((id) => next.add(id)) + return next + }) + } + }, [tree]) + + const toggleExpanded = useCallback((id: LogosPageId) => { + setExpandedPages((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + + const updatePage = useCallback( + (id: LogosPageId, patch: Partial>) => { + const next = tree.map((p) => (p.id === id ? { ...p, ...patch } : p)) + onTreeChange(next) + }, + [tree, onTreeChange] + ) + + const addPage = useCallback(() => { + const maxPos = Math.max(0, ...pages.map((p) => p.position), -1) + const newPage: LogosPageMeta = { + id: generatePageId(), + title: DEFAULT_PAGE_TITLE, + parentId: null, + position: maxPos + 1, + } + onTreeChange([...tree, newPage]) + setEditingId(newPage.id) + setEditTitle(newPage.title) + onSelectPage(newPage.id) + }, [tree, pages, onTreeChange, onSelectPage]) + + const addSubpage = useCallback( + (parentId: LogosPageId) => { + const siblings = tree.filter((p) => p.parentId === parentId).sort(sortByPosition) + const maxPos = siblings.length === 0 ? 0 : Math.max(...siblings.map((p) => p.position)) + 1 + const newPage: LogosPageMeta = { + id: generatePageId(), + title: DEFAULT_PAGE_TITLE, + parentId, + position: maxPos, + } + onTreeChange([...tree, newPage]) + setExpandedPages((prev) => new Set(prev).add(parentId)) + setEditingId(newPage.id) + setEditTitle(newPage.title) + onSelectPage(newPage.id) + }, + [tree, onTreeChange, onSelectPage] + ) + + const startRename = useCallback((page: LogosPageMeta) => { + setEditingId(page.id) + setEditTitle(page.title) + }, []) + + const commitRename = useCallback(() => { + if (editingId && editTitle.trim()) { + updatePage(editingId, { title: editTitle.trim() }) + } + setEditingId(null) + setEditTitle('') + }, [editingId, editTitle, updatePage]) + + const movePage = useCallback( + (id: LogosPageId, delta: number) => { + const page = tree.find((p) => p.id === id) + if (!page) return + const siblings = tree + .filter((p) => p.parentId === page.parentId) + .sort(sortByPosition) + const idx = siblings.findIndex((p) => p.id === id) + if (idx < 0) return + const newIdx = Math.max(0, Math.min(siblings.length - 1, idx + delta)) + if (newIdx === idx) return + const reordered = siblings.slice() + const [removed] = reordered.splice(idx, 1) + reordered.splice(newIdx, 0, removed) + const withNewPositions = tree.map((p) => { + const i = reordered.findIndex((r) => r.id === p.id) + return i >= 0 ? { ...p, position: i } : p + }) + onTreeChange(withNewPositions) + }, + [tree, onTreeChange] + ) + + const removePage = useCallback( + (page: LogosPageMeta) => { + const toRemove = [page.id, ...tree.filter((p) => p.parentId === page.id).map((p) => p.id)] + onTreeChange(tree.filter((p) => !toRemove.includes(p.id))) + toRemove.forEach((id) => onDeletePage?.(id)) + if (activePageId && toRemove.includes(activePageId)) { + const remaining = tree.filter((p) => !toRemove.includes(p.id)).sort(sortByPosition) + const first = remaining[0] + if (first) onSelectPage(first.id) + } + setDeleteTarget(null) + }, + [tree, activePageId, onTreeChange, onDeletePage, onSelectPage] + ) + + const subpages = (parentId: LogosPageId) => + tree.filter((p) => p.parentId === parentId).sort(sortByPosition) + + const isPage = (p: LogosPageMeta) => p.parentId === null + + return ( +
+
+ + Pages + + +
+
+ {pages.length === 0 ? ( +
+ No pages yet. +
+ +
+ ) : ( +
    + {pages.map((page) => { + const subs = subpages(page.id) + const expanded = expandedPages.has(page.id) + const isActive = activePageId === page.id + return ( +
  • + startRename(page)} + onSelect={() => onSelectPage(page.id)} + onExpandToggle={() => toggleExpanded(page.id)} + expanded={expanded} + hasSubpages={subs.length > 0} + onAddSubpage={() => addSubpage(page.id)} + onMoveUp={() => movePage(page.id, -1)} + onMoveDown={() => movePage(page.id, 1)} + onDelete={() => setDeleteTarget(page)} + canMoveUp={pages.indexOf(page) > 0} + canMoveDown={pages.indexOf(page) < pages.length - 1} + /> + {expanded && ( +
      + {subs.map((sub, i) => { + const subActive = activePageId === sub.id + const subEditing = editingId === sub.id + return ( +
    • + startRename(sub)} + onSelect={() => onSelectPage(sub.id)} + onAddSubpage={undefined} + onMoveUp={() => movePage(sub.id, -1)} + onMoveDown={() => movePage(sub.id, 1)} + onDelete={() => setDeleteTarget(sub)} + canMoveUp={i > 0} + canMoveDown={i < subs.length - 1} + /> +
    • + ) + })} +
    + )} +
  • + ) + })} +
+ )} +
+ + !open && setDeleteTarget(null)}> + + + + {deleteTarget && isPage(deleteTarget) + ? 'Delete page and subpages?' + : 'Delete subpage?'} + + + {deleteTarget && ( +

+ {isPage(deleteTarget) && subpages(deleteTarget.id).length > 0 ? ( + <> + "{deleteTarget.title}" and its {subpages(deleteTarget.id).length}{' '} + subpage(s) will be permanently removed. + + ) : ( + <> "{deleteTarget.title}" will be permanently removed. + )} +

+ )} + + + + +
+
+
+ ) +} + +type PageRowProps = { + page: LogosPageMeta + isSubpage: boolean + isActive: boolean + isEditing: boolean + editTitle: string + onEditTitleChange: (v: string) => void + onCommitRename: () => void + onStartRename: () => void + onSelect: () => void + onExpandToggle?: () => void + expanded?: boolean + hasSubpages?: boolean + onAddSubpage?: () => void + onMoveUp: () => void + onMoveDown: () => void + onDelete: () => void + canMoveUp: boolean + canMoveDown: boolean +} + +function PageRow({ + page, + isSubpage, + isActive, + isEditing, + editTitle, + onEditTitleChange, + onCommitRename, + onStartRename, + onSelect, + onExpandToggle, + expanded, + hasSubpages, + onAddSubpage, + onMoveUp, + onMoveDown, + onDelete, + canMoveUp, + canMoveDown, +}: PageRowProps) { + const isParentRow = onExpandToggle != null + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') onCommitRename() + if (e.key === 'Escape') { + onEditTitleChange(page.title) + onCommitRename() + } + } + + return ( +
+ {isParentRow ? ( + // VSCode-like "twisty gutter" so labels align nicely. + + {hasSubpages ? ( + + ) : ( + + )} + + ) : isSubpage ? ( + + + + ) : null} + {isSubpage && } + {isEditing ? ( + onEditTitleChange(e.target.value)} + onBlur={onCommitRename} + onKeyDown={handleKeyDown} + autoFocus + aria-label="Page title" + /> + ) : ( + + )} + {!isEditing && ( + + + + + e.stopPropagation()}> + { onStartRename(); onSelect(); }}> + + Rename + + {onAddSubpage != null && ( + + + New subpage + + )} + + + + Move up + + + + Move down + + + + + Delete + + + + )} +
+ ) +} diff --git a/frontend/src/app/recollections/state/recollectionStore.ts b/frontend/src/app/recollections/state/recollectionStore.ts index c9b19a9..eb0d07d 100644 --- a/frontend/src/app/recollections/state/recollectionStore.ts +++ b/frontend/src/app/recollections/state/recollectionStore.ts @@ -10,6 +10,19 @@ export type { StoredGraphState } /** BlockNote document: array of blocks (PartialBlock). Stored as JSON. */ export type StoredLogosContent = Record[] +/** Id for a Logos page or subpage. */ +export type LogosPageId = string + +/** Metadata for one page or subpage in the Logos hierarchy (one level: page → subpages). */ +export type LogosPageMeta = { + id: LogosPageId + title: string + /** null = top-level page; non-null = subpage under that page. */ + parentId: LogosPageId | null + /** Order among siblings (same parent). */ + position: number +} + /** One entry in the render-output cache (one per rendering node, overwritten on each update). */ export type RenderOutputCacheEntry = { nodeId: string @@ -36,6 +49,8 @@ export const RECOLLECTION_VERSION = 1 const GRAPH_KEY_PREFIX = 'zui_graph_' const LOGOS_KEY_PREFIX = 'zui_logos_' +const LOGOS_PAGE_TREE_PREFIX = 'zui_logos_pagetree_' +const LOGOS_PAGE_CONTENT_PREFIX = 'zui_logos_page_' const RENDER_CACHE_KEY_PREFIX = 'zui_render_cache_' function getGraphKey(recollectionId: string): string { @@ -46,6 +61,14 @@ function getLogosKey(recollectionId: string): string { return `${LOGOS_KEY_PREFIX}${recollectionId}` } +function getLogosPageTreeKey(recollectionId: string): string { + return `${LOGOS_PAGE_TREE_PREFIX}${recollectionId}` +} + +function getLogosPageContentKey(recollectionId: string, pageId: LogosPageId): string { + return `${LOGOS_PAGE_CONTENT_PREFIX}${recollectionId}_${pageId}` +} + function getRenderCacheKey(recollectionId: string): string { return `${RENDER_CACHE_KEY_PREFIX}${recollectionId}` } @@ -89,6 +112,61 @@ export function setLogosContent(recollectionId: string, content: StoredLogosCont localStorage.setItem(getLogosKey(recollectionId), JSON.stringify(content)) } +/** Logos page tree: ordered list of page metas (pages and subpages). */ +export function getLogosPageTree(recollectionId: string): LogosPageMeta[] { + try { + const raw = localStorage.getItem(getLogosPageTreeKey(recollectionId)) + if (!raw) return [] + const data = JSON.parse(raw) as unknown + if (!Array.isArray(data)) return [] + return data.filter( + (item): item is LogosPageMeta => + item != null && + typeof item === 'object' && + typeof (item as LogosPageMeta).id === 'string' && + typeof (item as LogosPageMeta).title === 'string' && + ((item as LogosPageMeta).parentId === null || typeof (item as LogosPageMeta).parentId === 'string') && + typeof (item as LogosPageMeta).position === 'number' + ) as LogosPageMeta[] + } catch { + return [] + } +} + +export function setLogosPageTree(recollectionId: string, tree: LogosPageMeta[]): void { + localStorage.setItem(getLogosPageTreeKey(recollectionId), JSON.stringify(tree)) +} + +/** Per-page Logos content (for page/subpage hierarchy). */ +export function getLogosContentForPage( + recollectionId: string, + pageId: LogosPageId +): StoredLogosContent | null { + try { + const raw = localStorage.getItem(getLogosPageContentKey(recollectionId, pageId)) + 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 setLogosContentForPage( + recollectionId: string, + pageId: LogosPageId, + content: StoredLogosContent +): void { + localStorage.setItem(getLogosPageContentKey(recollectionId, pageId), JSON.stringify(content)) +} + +/** Remove stored content for a single page (e.g. when deleting that page). */ +export function removeLogosPageContent(recollectionId: string, pageId: LogosPageId): void { + localStorage.removeItem(getLogosPageContentKey(recollectionId, pageId)) +} + /** 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 { @@ -120,9 +198,14 @@ export function upsertRenderOutputEntry(recollectionId: string, entry: RenderOut ) } -/** Removes both graph, logos, and render cache data for the recollection. */ +/** Removes both graph, logos (legacy + page tree + all per-page content), and render cache for the recollection. */ export function removeRecollectionData(recollectionId: string): void { localStorage.removeItem(getGraphKey(recollectionId)) localStorage.removeItem(getLogosKey(recollectionId)) + const tree = getLogosPageTree(recollectionId) + for (const page of tree) { + removeLogosPageContent(recollectionId, page.id) + } + localStorage.removeItem(getLogosPageTreeKey(recollectionId)) localStorage.removeItem(getRenderCacheKey(recollectionId)) }