diff --git a/frontend/src/app/NotFoundPage.tsx b/frontend/src/app/NotFoundPage.tsx new file mode 100644 index 0000000..409818c --- /dev/null +++ b/frontend/src/app/NotFoundPage.tsx @@ -0,0 +1,21 @@ +/** + * 404 page for unknown routes. Shown when no route matches. + */ + +import React from 'react' +import { Link } from 'react-router-dom' +import { Button } from '@/components/ui/button' + +export function NotFoundPage() { + return ( +
+

Page not found

+

+ The page you’re looking for doesn’t exist or has been moved. +

+ +
+ ) +} diff --git a/frontend/src/app/kosmos/KosmosPage.tsx b/frontend/src/app/kosmos/KosmosPage.tsx index 4557870..8722f8f 100644 --- a/frontend/src/app/kosmos/KosmosPage.tsx +++ b/frontend/src/app/kosmos/KosmosPage.tsx @@ -17,9 +17,11 @@ function KosmosLayoutInner() { if (recollectionId) recordRecollectionAccess(recollectionId) }, [recollectionId, recordRecollectionAccess]) + const showSidebar = !recollectionId + return ( - + {showSidebar && }
diff --git a/frontend/src/app/kosmos/KosmosSidebar.tsx b/frontend/src/app/kosmos/KosmosSidebar.tsx index 0603d1a..24ce8f0 100644 --- a/frontend/src/app/kosmos/KosmosSidebar.tsx +++ b/frontend/src/app/kosmos/KosmosSidebar.tsx @@ -38,7 +38,6 @@ export function KosmosSidebar() { const navigate = useNavigate() const location = useLocation() const { recollectionId: selectedRecollectionId } = useParams<{ recollectionId: string }>() - const handleSelectRecollection = useCallback((id: string) => navigate(`/recollections/${id}`), [navigate]) const handleCreateRecollection = useCallback( (recollection: Parameters[0]) => { @@ -149,13 +148,11 @@ export function KosmosSidebar() { const isActive = selectedRecollectionId === recollection.id return ( - handleSelectRecollection(recollection.id)} - > - - {recollection.name} + + + + {recollection.name} + ) diff --git a/frontend/src/app/recollections/RecollectionLayout.tsx b/frontend/src/app/recollections/RecollectionLayout.tsx index 3161975..cc46d05 100644 --- a/frontend/src/app/recollections/RecollectionLayout.tsx +++ b/frontend/src/app/recollections/RecollectionLayout.tsx @@ -3,15 +3,17 @@ */ import React, { useEffect } from 'react' -import { Link, Outlet, useParams } from 'react-router-dom' +import { Outlet, useParams, useNavigate } from 'react-router-dom' +import { toast } from 'sonner' import { usePlatform } from '@/app/kosmos/KosmosContext' -import { RecollectionMenubarProvider } from './layout/RecollectionMenubarContext' import { RecollectionActionsProvider } from './layout/RecollectionActionsContext' -import { RecollectionTitleContent } from './layout/RecollectionTitleContent' +import { RecollectionSidebarProvider } from './layout/RecollectionSidebarContext' import { RecollectionMenubar } from './layout/RecollectionMenubar' +import { RecollectionSidebar } from './layout/RecollectionSidebar' export function RecollectionLayout() { const { recollectionId } = useParams<{ recollectionId: string }>() + const navigate = useNavigate() const { recollections, recordRecollectionAccess } = usePlatform() useEffect(() => { @@ -20,29 +22,44 @@ export function RecollectionLayout() { const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null + useEffect(() => { + if (!recollectionId) return + if (recollection) return + toast.error('Recollection not found') + navigate('/recollections', { replace: true }) + }, [recollectionId, recollection, navigate]) + + // Keyboard shortcut: Escape to go back to recollections list + useEffect(() => { + if (!recollection) return + const onKeyDown = (ev: KeyboardEvent) => { + if (ev.key !== 'Escape') return + if ((ev.target as HTMLElement)?.closest('input, textarea, [contenteditable="true"]')) return + ev.preventDefault() + navigate('/recollections') + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [recollection, navigate]) + if (!recollectionId) return null if (!recollection) { - return ( -
-

Recollection not found.

- - Back to recollections - -
- ) + return null } return ( - - - + +
-
- +
+ +
+ +
- - + + ) } diff --git a/frontend/src/app/recollections/RecollectionsPage.tsx b/frontend/src/app/recollections/RecollectionsPage.tsx index 10dfaad..026971f 100644 --- a/frontend/src/app/recollections/RecollectionsPage.tsx +++ b/frontend/src/app/recollections/RecollectionsPage.tsx @@ -260,7 +260,10 @@ function RecollectionActionsMenu({ export type ViewMode = 'table' | 'cards' +const RECOLLECTIONS_SCROLL_KEY = 'recollections-list-scroll' + export function RecollectionsPage() { + const scrollContainerRef = React.useRef(null) const { orderedRecollections, deleteRecollection, renameRecollection, createRecollection, restoreRecollection } = usePlatform() const navigate = useNavigate() const [viewMode, setViewMode] = useState('cards') @@ -329,6 +332,28 @@ export function RecollectionsPage() { } }, [duplicateTarget]) + // Restore scroll position when returning to the list + React.useEffect(() => { + const el = scrollContainerRef.current + if (!el) return + try { + const saved = sessionStorage.getItem(RECOLLECTIONS_SCROLL_KEY) + if (saved !== null) { + const top = parseInt(saved, 10) + if (Number.isFinite(top)) requestAnimationFrame(() => { el.scrollTop = top }) + } + } catch { + /* ignore */ + } + return () => { + try { + sessionStorage.setItem(RECOLLECTIONS_SCROLL_KEY, String(el.scrollTop)) + } catch { + /* ignore */ + } + } + }, []) + const handleDeleteOpen = useCallback((recollection: Recollection) => { setDeleteTarget(recollection) }, []) @@ -487,7 +512,7 @@ export function RecollectionsPage() { return (
-
+

diff --git a/frontend/src/app/recollections/layout/RecollectionActionsContext.tsx b/frontend/src/app/recollections/layout/RecollectionActionsContext.tsx index 41f829d..45979cb 100644 --- a/frontend/src/app/recollections/layout/RecollectionActionsContext.tsx +++ b/frontend/src/app/recollections/layout/RecollectionActionsContext.tsx @@ -3,7 +3,7 @@ * Consumers use pathname to pick the active slot for title (save status, Save) and Edit/View menus (undo, redo, etc.). */ -import React, { createContext, useCallback, useContext, useRef, useState } from 'react' +import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' import { useLocation, useParams } from 'react-router-dom' import type { SaveStatus } from '@/app/canvas/useCanvasGraph' import { @@ -130,6 +130,22 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac setLogosSlotState(() => slot) }, []) + useEffect(() => { + const onKeyDown = (ev: KeyboardEvent) => { + const mod = ev.ctrlKey || ev.metaKey + if (mod && ev.key.toLowerCase() === 's') { + const slot = activeSlot + if (slot?.onSave && slot?.canSave) { + ev.preventDefault() + ev.stopPropagation() + slot.onSave() + } + } + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [activeSlot]) + const onExport = useCallback(() => { if (!recollectionId) return const graph = getGraph(recollectionId) diff --git a/frontend/src/app/recollections/layout/RecollectionEditViewMenus.tsx b/frontend/src/app/recollections/layout/RecollectionEditViewMenus.tsx index 087d5f4..bc4237f 100644 --- a/frontend/src/app/recollections/layout/RecollectionEditViewMenus.tsx +++ b/frontend/src/app/recollections/layout/RecollectionEditViewMenus.tsx @@ -46,17 +46,21 @@ export function RecollectionEditViewMenus() { return () => window.removeEventListener('keydown', onKeyDown, true) }, [activeSlot]) - const menus = useMemo(() => { - if (!activeSlot) return null - const hasFluxOnly = - fluxSlot && - (fluxSlot.onDuplicate != null || fluxSlot.onCopy != null || fluxSlot.onPaste != null) - return ( - + const hasFluxOnly = + fluxSlot && + (fluxSlot.onDuplicate != null || fluxSlot.onCopy != null || fluxSlot.onPaste != null) + + const menus = useMemo( + () => ( + - Edit + Edit - + activeSlot?.undo()} + disabled={!activeSlot?.canUndo} + className="gap-2" + > Undo @@ -65,7 +69,11 @@ export function RecollectionEditViewMenus() { - + activeSlot?.redo()} + disabled={!activeSlot?.canRedo} + className="gap-2" + > Redo @@ -74,13 +82,13 @@ export function RecollectionEditViewMenus() { - {hasFluxOnly && ( + {hasFluxOnly && fluxSlot && ( <> - {fluxSlot!.onDuplicate != null && ( + {fluxSlot.onDuplicate != null && ( @@ -92,10 +100,10 @@ export function RecollectionEditViewMenus() { )} - {fluxSlot!.onCopy != null && ( + {fluxSlot.onCopy != null && ( @@ -107,8 +115,8 @@ export function RecollectionEditViewMenus() { )} - {fluxSlot!.onPaste != null && ( - + {fluxSlot.onPaste != null && ( + Paste @@ -123,7 +131,7 @@ export function RecollectionEditViewMenus() { - View + View {fluxSlot?.onFitView && ( @@ -138,8 +146,9 @@ export function RecollectionEditViewMenus() { - ) - }, [activeSlot, fluxSlot]) + ), + [activeSlot, fluxSlot, hasFluxOnly] + ) return menus } diff --git a/frontend/src/app/recollections/layout/RecollectionFileMenu.tsx b/frontend/src/app/recollections/layout/RecollectionFileMenu.tsx index 9e77f2b..fdb0b50 100644 --- a/frontend/src/app/recollections/layout/RecollectionFileMenu.tsx +++ b/frontend/src/app/recollections/layout/RecollectionFileMenu.tsx @@ -76,7 +76,7 @@ export function RecollectionFileMenu() { return ( <> - + File diff --git a/frontend/src/app/recollections/layout/RecollectionMenubar.tsx b/frontend/src/app/recollections/layout/RecollectionMenubar.tsx index cf8aa47..1807210 100644 --- a/frontend/src/app/recollections/layout/RecollectionMenubar.tsx +++ b/frontend/src/app/recollections/layout/RecollectionMenubar.tsx @@ -1,47 +1,114 @@ /** - * Recollection menubar: Back | title + save status | registered menus (middle). + * Recollection menubar: Back | breadcrumbs (left) | view switcher + File + Edit + save status (center). */ import React from 'react' -import { Link, useParams } from 'react-router-dom' +import { Link, useLocation, useParams } from 'react-router-dom' import { usePlatform } from '@/app/kosmos/KosmosContext' -import { ArrowLeft } from 'lucide-react' -import { useRecollectionMenubar } from './RecollectionMenubarContext' +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { CheckCircle2, CircleDot, Loader2 } from 'lucide-react' import { RecollectionEditViewMenus } from './RecollectionEditViewMenus' import { RecollectionViewSwitcher } from './RecollectionViewSwitcher' import { RecollectionFileMenu } from './RecollectionFileMenu' +import { useRecollectionActions } from './RecollectionActionsContext' +import { getMode, viewLabel } from './recollectionNav' export function RecollectionMenubar() { const { recollectionId } = useParams<{ recollectionId: string }>() + const { pathname } = useLocation() const { recollections } = usePlatform() - const { titleContent } = useRecollectionMenubar() + const { activeSlot } = useRecollectionActions() + const saveStatus = activeSlot?.saveStatus ?? null const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null - const defaultTitle = recollection ? ( - {recollection.name} - ) : null + const mode = getMode(pathname) + const base = recollectionId ? `/recollections/${recollectionId}` : '' return (
- - - - + {base && ( + + + + + + + + + + + + + {recollection?.name ?? 'Recollection'} + + + + + {viewLabel(mode)} + + + + )}
-
- {titleContent ?? defaultTitle} +
+ + + + {/* Fixed-width slot so layout does not jump when saveStatus is null (e.g. on Katalogos) */} + + {saveStatus != null && ( + + + + + {saveStatus === 'saving' && } + {saveStatus === 'unsaved' && } + {saveStatus === 'saved' && } + + + + {saveStatus === 'saving' ? 'Saving…' : saveStatus === 'unsaved' ? 'Unsaved changes' : 'All changes saved'} + + + + )} +
-
- - -
) } diff --git a/frontend/src/app/recollections/layout/RecollectionMenubarContext.tsx b/frontend/src/app/recollections/layout/RecollectionMenubarContext.tsx deleted file mode 100644 index 715f05e..0000000 --- a/frontend/src/app/recollections/layout/RecollectionMenubarContext.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Context for the recollection menubar: - * - Title (next to back): optional title + status, e.g. recollection name and save state. - * - Middle: shared Edit/View menus (RecollectionEditViewMenus), no longer registrable. - */ - -import React, { createContext, useCallback, useContext, useState } from 'react' - -export type RecollectionMenubarContextValue = { - /** Content next to the back button (e.g. title + save status, with optional dropdown). Null = use default recollection name. */ - titleContent: React.ReactNode - setTitleContent: (content: React.ReactNode) => void -} - -const RecollectionMenubarContext = createContext(null) - -export function RecollectionMenubarProvider({ children }: { children: React.ReactNode }) { - const [titleContent, setTitleContentState] = useState(null) - const setTitleContent = useCallback((content: React.ReactNode) => { - setTitleContentState(() => content) - }, []) - const value: RecollectionMenubarContextValue = React.useMemo( - () => ({ titleContent, setTitleContent }), - [titleContent, setTitleContent] - ) - 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/recollections/logos/LogosSidebar.tsx b/frontend/src/app/recollections/layout/RecollectionSidebar.tsx similarity index 77% rename from frontend/src/app/recollections/logos/LogosSidebar.tsx rename to frontend/src/app/recollections/layout/RecollectionSidebar.tsx index 7b407d6..368bef0 100644 --- a/frontend/src/app/recollections/logos/LogosSidebar.tsx +++ b/frontend/src/app/recollections/layout/RecollectionSidebar.tsx @@ -1,8 +1,9 @@ /** - * Logos sidebar: single-level pages under Logos (no subpages). Sidebar-07 style nav with Logos + Katalogos sections. + * Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes. */ import React, { useCallback, useState } from 'react' +import { useParams, useLocation, useNavigate } from 'react-router-dom' import { DropdownMenu, DropdownMenuContent, @@ -36,7 +37,9 @@ import { ArrowUp, ArrowDown, } from 'lucide-react' +import { FluxIcon } from '@/lib/icons' import { cn } from '@/lib/utils' +import { useRecollectionSidebar } from './RecollectionSidebarContext' import type { LogosPageId, LogosPageMeta } from '../state/recollectionStore' const DEFAULT_PAGE_TITLE = 'Untitled' @@ -49,41 +52,35 @@ 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 - /** When true, the Katalogos item is selected. */ - isKatalogosView?: boolean - /** Called when user selects the Katalogos item (parent should navigate to .../logos/katalogos). */ - onSelectKatalogos?: () => void -} +export function RecollectionSidebar() { + const { recollectionId } = useParams<{ recollectionId: string }>() + const { pathname } = useLocation() + const navigate = useNavigate() + const { + tree, + activePageId, + handleSelectPage, + handleTreeChange, + handleDeletePage, + } = useRecollectionSidebar() -export function LogosSidebar({ - recollectionId, - tree, - onTreeChange, - activePageId, - onSelectPage, - onDeletePage, - isKatalogosView = false, - onSelectKatalogos, -}: LogosSidebarProps) { const [renameTarget, setRenameTarget] = useState(null) const [renameTitle, setRenameTitle] = useState('') const [deleteTarget, setDeleteTarget] = useState(null) + const base = recollectionId ? `/recollections/${recollectionId}` : '' + const baseLogos = `${base}/logos` + const isKatalogosView = pathname.endsWith('/logos/katalogos') + const isFluxView = pathname.endsWith('/flux') + const pages = tree.filter((p) => p.parentId === null).sort(sortByPosition) const updatePage = useCallback( (id: LogosPageId, patch: Partial>) => { const next = tree.map((p) => (p.id === id ? { ...p, ...patch } : p)) - onTreeChange(next) + handleTreeChange(next) }, - [tree, onTreeChange] + [tree, handleTreeChange] ) const addPage = useCallback(() => { @@ -94,11 +91,12 @@ export function LogosSidebar({ parentId: null, position: maxPos + 1, } - onTreeChange([...tree, newPage]) + handleTreeChange([...tree, newPage]) setRenameTarget(newPage) setRenameTitle(newPage.title) - onSelectPage(newPage.id) - }, [tree, pages, onTreeChange, onSelectPage]) + handleSelectPage(newPage.id) + navigate(`${baseLogos}?page=${encodeURIComponent(newPage.id)}`) + }, [tree, pages, handleTreeChange, handleSelectPage, baseLogos, navigate]) const startRename = useCallback((page: LogosPageMeta) => { setRenameTarget(page) @@ -131,23 +129,36 @@ export function LogosSidebar({ const i = reordered.findIndex((r) => r.id === p.id) return i >= 0 ? { ...p, position: i } : p }) - onTreeChange(withNewPositions) + handleTreeChange(withNewPositions) }, - [tree, onTreeChange] + [tree, handleTreeChange] ) const removePage = useCallback( (page: LogosPageMeta) => { - onTreeChange(tree.filter((p) => p.id !== page.id)) - onDeletePage?.(page.id) + handleTreeChange(tree.filter((p) => p.id !== page.id)) + handleDeletePage(page.id) if (activePageId === page.id) { const remaining = tree.filter((p) => p.id !== page.id).sort(sortByPosition) const first = remaining[0] - if (first) onSelectPage(first.id) + if (first) { + handleSelectPage(first.id) + navigate(`${baseLogos}?page=${encodeURIComponent(first.id)}`) + } else { + navigate(baseLogos) + } } setDeleteTarget(null) }, - [tree, activePageId, onTreeChange, onDeletePage, onSelectPage] + [tree, activePageId, handleTreeChange, handleDeletePage, handleSelectPage, baseLogos, navigate] + ) + + const onPageClick = useCallback( + (pageId: LogosPageId) => { + handleSelectPage(pageId) + navigate(`${baseLogos}?page=${encodeURIComponent(pageId)}`) + }, + [handleSelectPage, baseLogos, navigate] ) const renderPageActions = useCallback( @@ -178,7 +189,7 @@ export function LogosSidebar({ { startRename(page) - onSelectPage(page.id) + onPageClick(page.id) }} > @@ -203,15 +214,16 @@ export function LogosSidebar({ ) }, - [startRename, onSelectPage, movePage] + [startRename, onPageClick, movePage] ) return ( -
+
- - Pages - + Pages + + + + > + handleSelectPage: (id: LogosPageId) => void + handleTreeChange: (tree: LogosPageMeta[]) => void + handleDeletePage: (pageId: LogosPageId) => void +} + +const RecollectionSidebarContext = createContext(null) + +export function useRecollectionSidebar(): RecollectionSidebarContextValue { + const ctx = useContext(RecollectionSidebarContext) + if (!ctx) throw new Error('useRecollectionSidebar must be used within RecollectionSidebarProvider') + return ctx +} + +export function RecollectionSidebarProvider({ children }: { children: React.ReactNode }) { + const { recollectionId } = useParams<{ recollectionId: string }>() + const [searchParams, setSearchParams] = useSearchParams() + const [tree, setTree] = useState([]) + const [activePageId, setActivePageId] = useState(null) + + const baseLogosPath = recollectionId ? `/recollections/${recollectionId}/logos` : '' + + const handleSelectPage = useCallback( + (id: LogosPageId) => { + setActivePageId(id) + if (recollectionId) { + setSearchParams({ page: id }, { replace: true }) + } + }, + [recollectionId, setSearchParams] + ) + + const handleTreeChange = useCallback((newTree: LogosPageMeta[]) => { + setTree(newTree) + }, []) + + const handleDeletePage = useCallback( + (pageId: LogosPageId) => { + if (recollectionId) removeLogosPageContent(recollectionId, pageId) + }, + [recollectionId] + ) + + // 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) + const pageFromUrl = searchParams.get('page') + const validIdFromUrl = pageFromUrl && t.some((p) => p.id === pageFromUrl) ? pageFromUrl : null + setActivePageId((prev) => { + if (validIdFromUrl) return validIdFromUrl + const firstId = t[0]?.id ?? null + if (prev != null && t.some((p) => p.id === prev)) return prev + return firstId + }) + }, [recollectionId, searchParams]) + + // Persist tree when sidebar changes it. + useEffect(() => { + if (!recollectionId || tree.length === 0) return + setLogosPageTree(recollectionId, tree) + }, [recollectionId, tree]) + + const value: RecollectionSidebarContextValue = { + tree, + activePageId, + setTree, + handleSelectPage, + handleTreeChange, + handleDeletePage, + } + + return ( + + {children} + + ) +} diff --git a/frontend/src/app/recollections/layout/RecollectionTitleContent.tsx b/frontend/src/app/recollections/layout/RecollectionTitleContent.tsx deleted file mode 100644 index 1c69be4..0000000 --- a/frontend/src/app/recollections/layout/RecollectionTitleContent.tsx +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Shared title + save status block (same for Logos and Flux). Gets save status and Save from - * the active slot (RecollectionActionsContext); Import/Export from layout-level handlers. - */ - -import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useParams } from 'react-router-dom' -import { usePlatform } from '@/app/kosmos/KosmosContext' -import { useRecollectionMenubar } from './RecollectionMenubarContext' -import { useRecollectionActions } from './RecollectionActionsContext' -import { CheckCircle2, CircleDot, Loader2 } from 'lucide-react' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' - -const SAVE_KEYS = { key: 's', shiftKey: false } - -function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) { - const mod = ev.ctrlKey || ev.metaKey - return ev.key.toLowerCase() === want.key && !!mod && !!ev.shiftKey === want.shiftKey -} - -/** Registers the shared title + save dropdown as titleContent. Renders nothing. */ -export function RecollectionTitleContent() { - const { setTitleContent } = useRecollectionMenubar() - const { activeSlot } = useRecollectionActions() - const { recollectionId } = useParams<{ recollectionId: string }>() - const { recollections } = usePlatform() - const recollectionName = useMemo( - () => (recollectionId ? recollections.find((p) => p.id === recollectionId)?.name ?? null : null), - [recollectionId, recollections] - ) - - const [showSavedBriefly, setShowSavedBriefly] = useState(false) - const savedBrieflyTimerRef = useRef | null>(null) - const prevSaveStatusRef = useRef('saved') - const saveStatus = activeSlot?.saveStatus ?? 'saved' - const onSave = activeSlot?.onSave - const canSave = activeSlot?.canSave ?? false - - useEffect(() => { - if (saveStatus === 'unsaved') { - setShowSavedBriefly(false) - if (savedBrieflyTimerRef.current) { - clearTimeout(savedBrieflyTimerRef.current) - savedBrieflyTimerRef.current = null - } - } else if (prevSaveStatusRef.current === 'saving' && saveStatus === 'saved') { - setShowSavedBriefly(true) - if (savedBrieflyTimerRef.current) clearTimeout(savedBrieflyTimerRef.current) - savedBrieflyTimerRef.current = setTimeout(() => { - savedBrieflyTimerRef.current = null - setShowSavedBriefly(false) - }, 2500) - } - prevSaveStatusRef.current = saveStatus - }, [saveStatus]) - - useEffect(() => { - return () => { - if (savedBrieflyTimerRef.current) clearTimeout(savedBrieflyTimerRef.current) - } - }, []) - - useEffect(() => { - const onKeyDown = (ev: KeyboardEvent) => { - if (matchKey(ev, SAVE_KEYS) && onSave && canSave) { - ev.preventDefault() - ev.stopPropagation() - onSave() - } - } - window.addEventListener('keydown', onKeyDown, true) - return () => window.removeEventListener('keydown', onKeyDown, true) - }, [onSave, canSave]) - - const trigger = useMemo( - () => ( - - - {recollectionName ?? 'Untitled'} - - - - - - {saveStatus === 'saving' && } - {saveStatus === 'unsaved' && } - {saveStatus !== 'unsaved' && (saveStatus === 'saved' || showSavedBriefly) && ( - - )} - - - - {saveStatus === 'saving' ? 'Saving…' : saveStatus === 'unsaved' ? 'Unsaved changes' : 'All changes saved'} - - - - - ), - [recollectionName, saveStatus, showSavedBriefly] - ) - - const titleNode = useMemo( - () => ( - - {trigger} - - ), - [trigger] - ) - - useLayoutEffect(() => { - setTitleContent(titleNode) - return () => setTitleContent(null) - }, [titleNode, setTitleContent]) - - return null -} diff --git a/frontend/src/app/recollections/layout/RecollectionViewSwitcher.tsx b/frontend/src/app/recollections/layout/RecollectionViewSwitcher.tsx index 3a46190..7527dad 100644 --- a/frontend/src/app/recollections/layout/RecollectionViewSwitcher.tsx +++ b/frontend/src/app/recollections/layout/RecollectionViewSwitcher.tsx @@ -1,12 +1,16 @@ /** - * Circular view switcher: icon and title use the same vertical elevator-style animation. - * Click toggles; tooltip includes shortcut. + * View switcher: circle with current view icon; click cycles Logos → Katalogos → Flux. + * Keyboard shortcut: ⌘⇧↑ / ⌘⇧↓ to cycle view. */ -import React, { useCallback, useEffect } from 'react' +import React, { useCallback, useEffect, useMemo } from 'react' import { useLocation, useNavigate, useParams } from 'react-router-dom' -import { FluxIcon, LogosIcon } from '@/lib/icons' +import { FluxIcon } from '@/lib/icons' +import { FileText, LayoutGrid } from 'lucide-react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { getMode, pathForMode, RECOLLECTION_VIEW_MODES, viewLabel } from './recollectionNav' + +const ICON_SLOT_SIZE_REM = 2 // size-8 = 2rem function shortcutLabel() { const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform) @@ -19,21 +23,19 @@ export function RecollectionViewSwitcher() { const navigate = useNavigate() const base = recollectionId ? `/recollections/${recollectionId}` : '' - type Mode = 'logos' | 'flux' - const mode: Mode = pathname.endsWith('/flux') ? 'flux' : 'logos' + const mode = useMemo(() => getMode(pathname), [pathname]) + const modeIndex = RECOLLECTION_VIEW_MODES.indexOf(mode) const goRelative = useCallback( (delta: 1 | -1) => { if (!base) return - const order: Mode[] = ['logos', 'flux'] - const currentIndex = order.indexOf(mode) - const nextMode = order[(currentIndex + (delta === 1 ? 1 : order.length - 1)) % order.length] - navigate(`${base}/${nextMode}`) + const nextIndex = (modeIndex + delta + RECOLLECTION_VIEW_MODES.length) % RECOLLECTION_VIEW_MODES.length + navigate(pathForMode(base, RECOLLECTION_VIEW_MODES[nextIndex])) }, - [base, mode, navigate] + [base, modeIndex, navigate] ) - const toggle = useCallback(() => { + const cycle = useCallback(() => { goRelative(1) }, [goRelative]) @@ -43,11 +45,8 @@ export function RecollectionViewSwitcher() { const isShortcut = isMod && ev.shiftKey && (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') if (!isShortcut) return ev.preventDefault() - if (ev.key === 'ArrowDown') { - goRelative(1) - } else if (ev.key === 'ArrowUp') { - goRelative(-1) - } + if (ev.key === 'ArrowDown') goRelative(1) + else if (ev.key === 'ArrowUp') goRelative(-1) } window.addEventListener('keydown', onKeyDown, true) return () => window.removeEventListener('keydown', onKeyDown, true) @@ -55,35 +54,34 @@ export function RecollectionViewSwitcher() { if (!base) return null - const shortcut = shortcutLabel() - const tooltipText = - mode === 'logos' ? `Logos · Next: Flux (${shortcut})` : `Flux · Next: Logos (${shortcut})` + const tooltipText = `${viewLabel(mode)} (${shortcutLabel()})` + const translateY = -modeIndex * ICON_SLOT_SIZE_REM return ( -
+
@@ -91,21 +89,6 @@ export function RecollectionViewSwitcher() { {tooltipText} -
-
- - Logos - - - Flux - -
-
) } diff --git a/frontend/src/app/recollections/layout/RenameRecollectionDialog.tsx b/frontend/src/app/recollections/layout/RenameRecollectionDialog.tsx index 9118590..46c255d 100644 --- a/frontend/src/app/recollections/layout/RenameRecollectionDialog.tsx +++ b/frontend/src/app/recollections/layout/RenameRecollectionDialog.tsx @@ -1,5 +1,5 @@ /** - * Shared modal for renaming a recollection. Used by RecollectionsPage and RecollectionTitleContent. + * Shared modal for renaming a recollection. Used by RecollectionsPage and RecollectionFileMenu. */ import React, { useCallback, useEffect, useRef, useState } from 'react' diff --git a/frontend/src/app/recollections/layout/recollectionNav.ts b/frontend/src/app/recollections/layout/recollectionNav.ts new file mode 100644 index 0000000..9f76d7e --- /dev/null +++ b/frontend/src/app/recollections/layout/recollectionNav.ts @@ -0,0 +1,25 @@ +/** + * Recollection navigation helpers: current view mode from pathname, labels for breadcrumbs. + */ + +export type RecollectionViewMode = 'logos' | 'katalogos' | 'flux' + +export function getMode(pathname: string): RecollectionViewMode { + if (pathname.endsWith('/flux')) return 'flux' + if (pathname.endsWith('/logos/katalogos')) return 'katalogos' + return 'logos' +} + +export function viewLabel(mode: RecollectionViewMode): string { + if (mode === 'katalogos') return 'Katalogos' + if (mode === 'flux') return 'Flux' + return 'Logos' +} + +export const RECOLLECTION_VIEW_MODES: RecollectionViewMode[] = ['logos', 'katalogos', 'flux'] + +export function pathForMode(base: string, mode: RecollectionViewMode): string { + if (mode === 'flux') return `${base}/flux` + if (mode === 'katalogos') return `${base}/logos/katalogos` + return `${base}/logos` +} diff --git a/frontend/src/app/recollections/logos/LogosPage.tsx b/frontend/src/app/recollections/logos/LogosPage.tsx index d4bc8b9..1ffc169 100644 --- a/frontend/src/app/recollections/logos/LogosPage.tsx +++ b/frontend/src/app/recollections/logos/LogosPage.tsx @@ -1,29 +1,23 @@ /** - * Logos page: BlockNote editor for the recollection. Content persisted in recollection store. - * Left sidebar for page/subpage hierarchy (one level). Layout and styling aligned with Flux. + * Logos page: BlockNote editor for the recollection. Content only; sidebar is at layout level. + * Content persisted in recollection store. */ import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef } from 'react' -import { useParams, useLocation, useNavigate } from 'react-router-dom' +import { useParams, useLocation, useSearchParams } from 'react-router-dom' import { usePlatform } from '@/app/kosmos/KosmosContext' import { FluxIcon } from '@/lib/icons' import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuController } from '@blocknote/react' import { BlockNoteView } from '@blocknote/shadcn' import { useTheme } from '@/lib/themeContext' import { useRecollectionActions } from '../layout/RecollectionActionsContext' +import { useRecollectionSidebar } from '../layout/RecollectionSidebarContext' 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 { KatalogosPage } from '../katalogos/KatalogosPage' import { toast } from 'sonner' @@ -60,77 +54,30 @@ function patchBlockNoteRefWarning() { } } -const MAIN_PAGE_ID: LogosPageId = 'main' - export function LogosPage() { const { recollectionId } = useParams<{ recollectionId: string }>() const { pathname } = useLocation() - const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() const { recollections } = usePlatform() const { theme } = useTheme() const { setLogosSlot } = useRecollectionActions() + const { tree, activePageId } = useRecollectionSidebar() const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null - const [tree, setTree] = useState([]) - const [activePageId, setActivePageId] = useState(null) const [reloadKey, setReloadKey] = useState(0) - - const isKatalogosView = pathname.endsWith('/logos/katalogos') - const baseLogosPath = recollectionId ? `/recollections/${recollectionId}/logos` : '' - - const handleSelectPage = useCallback( - (id: LogosPageId) => { - setActivePageId(id) - if (recollectionId) navigate(`${baseLogosPath}`) - }, - [recollectionId, baseLogosPath, navigate] - ) - - const handleSelectKatalogos = useCallback(() => { - if (recollectionId) navigate(`${baseLogosPath}/katalogos`) - }, [recollectionId, baseLogosPath, navigate]) 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). + const isKatalogosView = pathname.endsWith('/logos/katalogos') + + // When on Logos (not Katalogos) with a selected page but no page param, sync URL so refresh keeps the 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] - ) + if (isKatalogosView || !activePageId || searchParams.get('page') === activePageId) return + setSearchParams({ page: activePageId }, { replace: true }) + }, [isKatalogosView, activePageId, searchParams, setSearchParams]) const initialContent = useMemo(() => { if (!recollectionId || !activePageId) return undefined const content = getLogosContentForPage(recollectionId, activePageId) - // BlockNote requires a non-empty array of blocks; use undefined for default empty doc. if (!content || !Array.isArray(content) || content.length === 0) return undefined return content }, [recollectionId, activePageId, reloadKey]) @@ -226,7 +173,6 @@ export function LogosPage() { [editor] ) - // Render paths (no hooks below this point). if (!recollectionId) { return (
@@ -235,53 +181,33 @@ export function LogosPage() { ) } - const sidebarProps = { - recollectionId: recollectionId!, - tree, - onTreeChange: handleTreeChange, - activePageId, - onSelectPage: handleSelectPage, - onDeletePage: handleDeletePage, - isKatalogosView, - onSelectKatalogos: handleSelectKatalogos, - } - if (isKatalogosView) { return ( -
- -
- -
+
+
) } if (activePageId == null || !editor) { return ( -
- -
- {tree.length === 0 ? 'Loading…' : 'Select a page'} -
+
+ {tree.length === 0 ? 'Loading…' : 'Select a page'}
) } return ( -
- -
-
- - - -
+
+
+ + +
) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index de9d06a..4d31e98 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -10,6 +10,7 @@ import { RecollectionsPage } from './app/recollections/RecollectionsPage' import { RecollectionLayout } from './app/recollections/RecollectionLayout' import { LogosPage } from './app/recollections/logos/LogosPage' import { FluxRoute } from './app/recollections/flux/FluxRoute' +import { NotFoundPage } from './app/NotFoundPage' import './lib/prismSetup' import 'prismjs/themes/prism.css' import './styles.css' @@ -31,8 +32,10 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> + } />