feat: add logos editor
This commit is contained in:
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"
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
RECOLLECTION_FILE_EXT,
|
RECOLLECTION_FILE_EXT,
|
||||||
RECOLLECTION_VERSION,
|
RECOLLECTION_VERSION,
|
||||||
} from './recollectionGraphStorage'
|
} from './recollectionGraphStorage'
|
||||||
|
import { getLogosContent, setLogosContent } from './recollectionStore'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { NewRecollectionDialog } from '@/app/kosmos/NewRecollectionDialog'
|
import { NewRecollectionDialog } from '@/app/kosmos/NewRecollectionDialog'
|
||||||
import { RecollectionsPageBackground } from './RecollectionsPageBackground'
|
import { RecollectionsPageBackground } from './RecollectionsPageBackground'
|
||||||
@@ -419,6 +420,10 @@ export function RecollectionsPage() {
|
|||||||
if (graph && (graph.nodes.length > 0 || graph.edges.length > 0)) {
|
if (graph && (graph.nodes.length > 0 || graph.edges.length > 0)) {
|
||||||
saveGraphToStorage(newId, { version: RECOLLECTION_VERSION, nodes: graph.nodes, edges: graph.edges })
|
saveGraphToStorage(newId, { version: RECOLLECTION_VERSION, nodes: graph.nodes, edges: graph.edges })
|
||||||
}
|
}
|
||||||
|
const logosContent = getLogosContent(duplicateTarget.id)
|
||||||
|
if (logosContent && logosContent.length > 0) {
|
||||||
|
setLogosContent(newId, logosContent)
|
||||||
|
}
|
||||||
toast.success('Recollection duplicated')
|
toast.success('Recollection duplicated')
|
||||||
setDuplicateTarget(null)
|
setDuplicateTarget(null)
|
||||||
setDuplicateName('')
|
setDuplicateName('')
|
||||||
|
|||||||
@@ -1,15 +1,44 @@
|
|||||||
/**
|
/**
|
||||||
* Logos page: default view for a recollection. Placeholder for now.
|
* 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, { useEffect } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation, useParams } from 'react-router-dom'
|
||||||
|
import { useCreateBlockNote } from '@blocknote/react'
|
||||||
|
import { BlockNoteView } from '@blocknote/shadcn'
|
||||||
|
import { useTheme } from '@/lib/themeContext'
|
||||||
import { useRecollectionMenubar } from '../RecollectionMenubarContext'
|
import { useRecollectionMenubar } from '../RecollectionMenubarContext'
|
||||||
|
import { getLogosContent, setLogosContent, type StoredLogosContent } from '../recollectionStore'
|
||||||
|
|
||||||
|
const SAVE_DEBOUNCE_MS = 400
|
||||||
|
|
||||||
export function LogosPage() {
|
export function LogosPage() {
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const { theme } = useTheme()
|
||||||
const { setCustomContent } = useRecollectionMenubar()
|
const { setCustomContent } = useRecollectionMenubar()
|
||||||
const isLogosActive = pathname.endsWith('/logos') || pathname.match(/\/recollections\/[^/]+\/?$/)
|
const isLogosActive = pathname.endsWith('/logos') || pathname.match(/\/recollections\/[^/]+\/?$/)
|
||||||
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const initialContent = useMemo(
|
||||||
|
() => (recollectionId ? getLogosContent(recollectionId) ?? undefined : undefined),
|
||||||
|
[recollectionId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const editor = useCreateBlockNote({ initialContent }, [recollectionId])
|
||||||
|
|
||||||
|
const persistContent = useCallback(() => {
|
||||||
|
if (!recollectionId || !editor) return
|
||||||
|
try {
|
||||||
|
const doc = editor.document
|
||||||
|
const serialized = JSON.parse(JSON.stringify(doc)) as StoredLogosContent
|
||||||
|
setLogosContent(recollectionId, serialized)
|
||||||
|
} catch {
|
||||||
|
// ignore serialize errors
|
||||||
|
}
|
||||||
|
}, [recollectionId, editor])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLogosActive) return
|
if (!isLogosActive) return
|
||||||
@@ -17,10 +46,69 @@ export function LogosPage() {
|
|||||||
return () => setCustomContent(null)
|
return () => setCustomContent(null)
|
||||||
}, [isLogosActive, setCustomContent])
|
}, [isLogosActive, setCustomContent])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || !recollectionId) return
|
||||||
|
const handleChange = () => {
|
||||||
|
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])
|
||||||
|
|
||||||
|
if (!recollectionId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center p-8 text-muted-foreground">
|
<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">Logos</p>
|
<p className="text-sm">No recollection selected.</p>
|
||||||
<p className="mt-1 text-xs">This page is empty for now.</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 handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
|
||||||
|
const el = scrollContainerRef.current
|
||||||
|
if (!el) return
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = el
|
||||||
|
const canScrollUp = scrollTop > 0
|
||||||
|
const canScrollDown = scrollTop < scrollHeight - clientHeight
|
||||||
|
const scrollingDown = e.deltaY > 0
|
||||||
|
const scrollingUp = e.deltaY < 0
|
||||||
|
if ((scrollingDown && canScrollDown) || (scrollingUp && canScrollUp)) {
|
||||||
|
e.preventDefault()
|
||||||
|
el.scrollBy({ top: e.deltaY, behavior: 'auto' })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="logos-page flex h-full min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
||||||
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden"
|
||||||
|
onWheelCapture={handleWheel}
|
||||||
|
>
|
||||||
|
<div className="logos-editor bn-shadcn mx-auto w-full max-w-3xl px-6 py-8">
|
||||||
|
<BlockNoteView
|
||||||
|
editor={editor}
|
||||||
|
theme={theme}
|
||||||
|
className="min-h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Per-recollection graph persistence (localStorage).
|
* Per-recollection graph persistence. Re-exports from recollectionStore for backward compatibility.
|
||||||
* Saves and loads StoredGraphState (version + nodes + edges). Used by useCanvasGraph and export.
|
* New code should use recollectionStore (getGraph, setGraph, removeRecollectionData) directly.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { StoredGraphState } from '@/lib/graph/state'
|
import {
|
||||||
|
getGraph,
|
||||||
|
setGraph,
|
||||||
|
removeRecollectionData,
|
||||||
|
RECOLLECTION_FILE_EXT,
|
||||||
|
RECOLLECTION_VERSION,
|
||||||
|
type StoredGraphState,
|
||||||
|
} from './recollectionStore'
|
||||||
|
|
||||||
export type { StoredGraphState }
|
export type { StoredGraphState }
|
||||||
export const RECOLLECTION_FILE_EXT = '.zui.json'
|
export { RECOLLECTION_FILE_EXT, RECOLLECTION_VERSION }
|
||||||
export const RECOLLECTION_VERSION = 1
|
|
||||||
|
|
||||||
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
|
||||||
|
|
||||||
export function getGraphStorageKey(recollectionId: string): string {
|
export function getGraphStorageKey(recollectionId: string): string {
|
||||||
return `${GRAPH_KEY_PREFIX}${recollectionId}`
|
return `zui_graph_${recollectionId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadGraphFromStorage(recollectionId: string): StoredGraphState | null {
|
export const loadGraphFromStorage = getGraph
|
||||||
try {
|
export const saveGraphToStorage = setGraph
|
||||||
const raw = localStorage.getItem(getGraphStorageKey(recollectionId))
|
export const removeGraphFromStorage = removeRecollectionData
|
||||||
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(recollectionId: string, state: StoredGraphState): void {
|
|
||||||
localStorage.setItem(getGraphStorageKey(recollectionId), JSON.stringify(state))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeGraphFromStorage(recollectionId: string): void {
|
|
||||||
localStorage.removeItem(getGraphStorageKey(recollectionId))
|
|
||||||
}
|
|
||||||
|
|||||||
70
frontend/src/app/recollections/recollectionStore.ts
Normal file
70
frontend/src/app/recollections/recollectionStore.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* 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>[]
|
||||||
|
|
||||||
|
export const RECOLLECTION_FILE_EXT = '.zui.json'
|
||||||
|
export const RECOLLECTION_VERSION = 1
|
||||||
|
|
||||||
|
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
||||||
|
const LOGOS_KEY_PREFIX = 'zui_logos_'
|
||||||
|
|
||||||
|
function getGraphKey(recollectionId: string): string {
|
||||||
|
return `${GRAPH_KEY_PREFIX}${recollectionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogosKey(recollectionId: string): string {
|
||||||
|
return `${LOGOS_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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes both graph and logos data for the recollection. */
|
||||||
|
export function removeRecollectionData(recollectionId: string): void {
|
||||||
|
localStorage.removeItem(getGraphKey(recollectionId))
|
||||||
|
localStorage.removeItem(getLogosKey(recollectionId))
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
@import "shadcn/dist/tailwind.css";
|
@import "shadcn/dist/tailwind.css";
|
||||||
|
@import "@blocknote/core/fonts/inter.css";
|
||||||
|
@import "@blocknote/shadcn/style.css";
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@@ -431,3 +433,17 @@ pre {
|
|||||||
.recollection-flip-face-back {
|
.recollection-flip-face-back {
|
||||||
transform: rotateY(-180deg);
|
transform: rotateY(-180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Logos page: BlockNote editor aligned with Flux (bg, theme). Document-style column. */
|
||||||
|
.logos-page {
|
||||||
|
background: hsl(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.logos-editor.bn-shadcn {
|
||||||
|
--bn-editor-background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logos-editor .bn-editor {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/* Tailwind configuration for Vite + React */
|
/* Tailwind configuration for Vite + React */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: ['class'],
|
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: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
|
|||||||
Reference in New Issue
Block a user