feat: multiple pages
This commit is contained in:
@@ -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<LogosPageMeta[]>([])
|
||||
const [activePageId, setActivePageId] = useState<LogosPageId | null>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
const activePage = tree.find((p) => p.id === activePageId)
|
||||
|
||||
patchBlockNoteRefWarning()
|
||||
|
||||
@@ -172,11 +205,47 @@ export function LogosPage() {
|
||||
[editor]
|
||||
)
|
||||
|
||||
// Render paths (no hooks below this point).
|
||||
if (!recollectionId) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
|
||||
<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 (activePageId == null || !editor) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col bg-background">
|
||||
<LogosSidebar
|
||||
recollectionId={recollectionId}
|
||||
tree={tree}
|
||||
onTreeChange={handleTreeChange}
|
||||
activePageId={activePageId}
|
||||
onSelectPage={setActivePageId}
|
||||
onDeletePage={handleDeletePage}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{tree.length === 0 ? 'Loading…' : 'Select a page'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 flex-row bg-background">
|
||||
<LogosSidebar
|
||||
recollectionId={recollectionId}
|
||||
tree={tree}
|
||||
onTreeChange={handleTreeChange}
|
||||
activePageId={activePageId}
|
||||
onSelectPage={setActivePageId}
|
||||
onDeletePage={handleDeletePage}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-auto">
|
||||
<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}
|
||||
{activePage?.title ?? recollection?.name ?? 'Untitled'}
|
||||
</h1>
|
||||
<BlockNoteViewWrapper
|
||||
editor={editor as any}
|
||||
@@ -188,5 +257,6 @@ export function LogosPage() {
|
||||
</BlockNoteViewWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
480
frontend/src/app/recollections/logos/LogosSidebar.tsx
Normal file
480
frontend/src/app/recollections/logos/LogosSidebar.tsx
Normal file
@@ -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<Set<LogosPageId>>(new Set())
|
||||
const expandedInitializedRef = useRef(false)
|
||||
const [editingId, setEditingId] = useState<LogosPageId | null>(null)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [deleteTarget, setDeleteTarget] = useState<LogosPageMeta | null>(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<Pick<LogosPageMeta, 'title' | 'position'>>) => {
|
||||
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 (
|
||||
<div className="flex h-full w-64 shrink-0 flex-col border-r border-border bg-muted/30">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-2 py-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Pages
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={addPage}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
New page
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{pages.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
No pages yet.
|
||||
<br />
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto p-0 text-xs"
|
||||
onClick={addPage}
|
||||
>
|
||||
Create your first page
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-0.5 px-1">
|
||||
{pages.map((page) => {
|
||||
const subs = subpages(page.id)
|
||||
const expanded = expandedPages.has(page.id)
|
||||
const isActive = activePageId === page.id
|
||||
return (
|
||||
<li key={page.id} className="flex flex-col gap-0.5">
|
||||
<PageRow
|
||||
page={page}
|
||||
isSubpage={false}
|
||||
isActive={isActive}
|
||||
isEditing={editingId === page.id}
|
||||
editTitle={editTitle}
|
||||
onEditTitleChange={setEditTitle}
|
||||
onCommitRename={commitRename}
|
||||
onStartRename={() => 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 && (
|
||||
<ul className="ml-3 flex flex-col gap-0.5 border-l border-border/60 pl-2">
|
||||
{subs.map((sub, i) => {
|
||||
const subActive = activePageId === sub.id
|
||||
const subEditing = editingId === sub.id
|
||||
return (
|
||||
<li key={sub.id}>
|
||||
<PageRow
|
||||
page={sub}
|
||||
isSubpage
|
||||
isActive={subActive}
|
||||
isEditing={subEditing}
|
||||
editTitle={editTitle}
|
||||
onEditTitleChange={setEditTitle}
|
||||
onCommitRename={commitRename}
|
||||
onStartRename={() => 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}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{deleteTarget && isPage(deleteTarget)
|
||||
? 'Delete page and subpages?'
|
||||
: 'Delete subpage?'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{deleteTarget && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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.</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => deleteTarget && removePage(deleteTarget)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-0.5 rounded-md pr-1',
|
||||
isActive && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{isParentRow ? (
|
||||
// VSCode-like "twisty gutter" so labels align nicely.
|
||||
<span className="flex h-8 w-4 shrink-0 items-center justify-center">
|
||||
{hasSubpages ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-4 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onExpandToggle?.()
|
||||
}}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? 'Collapse subpages' : 'Expand subpages'}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-block size-4" aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
) : isSubpage ? (
|
||||
<span className="flex h-8 w-4 shrink-0 items-center justify-center">
|
||||
<span className="inline-block size-4" aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
{isSubpage && <span className="w-2 shrink-0" />}
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
className="h-7 min-w-0 flex-1 rounded border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={editTitle}
|
||||
onChange={(e) => onEditTitleChange(e.target.value)}
|
||||
onBlur={onCommitRename}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
aria-label="Page title"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 py-1.5 text-left text-sm hover:bg-accent/80"
|
||||
onClick={onSelect}
|
||||
>
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{page.title || DEFAULT_PAGE_TITLE}</span>
|
||||
</button>
|
||||
)}
|
||||
{!isEditing && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
<span className="sr-only">Actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem onClick={() => { onStartRename(); onSelect(); }}>
|
||||
<Pencil className="mr-2 size-3.5" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
{onAddSubpage != null && (
|
||||
<DropdownMenuItem onClick={onAddSubpage}>
|
||||
<Plus className="mr-2 size-3.5" />
|
||||
New subpage
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onMoveUp} disabled={!canMoveUp}>
|
||||
<ArrowUp className="mr-2 size-3.5" />
|
||||
Move up
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onMoveDown} disabled={!canMoveDown}>
|
||||
<ArrowDown className="mr-2 size-3.5" />
|
||||
Move down
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="mr-2 size-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,19 @@ export type { StoredGraphState }
|
||||
/** BlockNote document: array of blocks (PartialBlock). Stored as JSON. */
|
||||
export type StoredLogosContent = Record<string, unknown>[]
|
||||
|
||||
/** 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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user