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,20 +205,57 @@ 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 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-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 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">
|
||||
{activePage?.title ?? recollection?.name ?? 'Untitled'}
|
||||
</h1>
|
||||
<BlockNoteViewWrapper
|
||||
editor={editor as any}
|
||||
theme={theme}
|
||||
className="min-h-full w-full"
|
||||
slashMenu={false}
|
||||
>
|
||||
<SuggestionMenuController triggerCharacter="/" getItems={getSlashMenuItems} />
|
||||
</BlockNoteViewWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user