feat: redesign navs
This commit is contained in:
21
frontend/src/app/NotFoundPage.tsx
Normal file
21
frontend/src/app/NotFoundPage.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex min-h-dvh flex-col items-center justify-center gap-6 bg-background p-8">
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Page not found</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
The page you’re looking for doesn’t exist or has been moved.
|
||||||
|
</p>
|
||||||
|
<Button asChild variant="default">
|
||||||
|
<Link to="/recollections">Back to Recollections</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,9 +17,11 @@ function KosmosLayoutInner() {
|
|||||||
if (recollectionId) recordRecollectionAccess(recollectionId)
|
if (recollectionId) recordRecollectionAccess(recollectionId)
|
||||||
}, [recollectionId, recordRecollectionAccess])
|
}, [recollectionId, recordRecollectionAccess])
|
||||||
|
|
||||||
|
const showSidebar = !recollectionId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||||
<KosmosSidebar />
|
{showSidebar && <KosmosSidebar />}
|
||||||
<div className="relative flex h-dvh min-h-0 w-full flex-1 flex-col overflow-hidden bg-background">
|
<div className="relative flex h-dvh min-h-0 w-full flex-1 flex-col overflow-hidden bg-background">
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export function KosmosSidebar() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { recollectionId: selectedRecollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId: selectedRecollectionId } = useParams<{ recollectionId: string }>()
|
||||||
const handleSelectRecollection = useCallback((id: string) => navigate(`/recollections/${id}`), [navigate])
|
|
||||||
|
|
||||||
const handleCreateRecollection = useCallback(
|
const handleCreateRecollection = useCallback(
|
||||||
(recollection: Parameters<typeof createRecollection>[0]) => {
|
(recollection: Parameters<typeof createRecollection>[0]) => {
|
||||||
@@ -149,13 +148,11 @@ export function KosmosSidebar() {
|
|||||||
const isActive = selectedRecollectionId === recollection.id
|
const isActive = selectedRecollectionId === recollection.id
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={recollection.id}>
|
<SidebarMenuItem key={recollection.id}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton asChild tooltip={recollection.name} isActive={isActive}>
|
||||||
tooltip={recollection.name}
|
<Link to={`/recollections/${recollection.id}`}>
|
||||||
isActive={isActive}
|
|
||||||
onClick={() => handleSelectRecollection(recollection.id)}
|
|
||||||
>
|
|
||||||
<Icon className="size-4" />
|
<Icon className="size-4" />
|
||||||
<span>{recollection.name}</span>
|
<span>{recollection.name}</span>
|
||||||
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect } from 'react'
|
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 { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { RecollectionMenubarProvider } from './layout/RecollectionMenubarContext'
|
|
||||||
import { RecollectionActionsProvider } from './layout/RecollectionActionsContext'
|
import { RecollectionActionsProvider } from './layout/RecollectionActionsContext'
|
||||||
import { RecollectionTitleContent } from './layout/RecollectionTitleContent'
|
import { RecollectionSidebarProvider } from './layout/RecollectionSidebarContext'
|
||||||
import { RecollectionMenubar } from './layout/RecollectionMenubar'
|
import { RecollectionMenubar } from './layout/RecollectionMenubar'
|
||||||
|
import { RecollectionSidebar } from './layout/RecollectionSidebar'
|
||||||
|
|
||||||
export function RecollectionLayout() {
|
export function RecollectionLayout() {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
const { recollections, recordRecollectionAccess } = usePlatform()
|
const { recollections, recordRecollectionAccess } = usePlatform()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -20,29 +22,44 @@ export function RecollectionLayout() {
|
|||||||
|
|
||||||
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
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 (!recollectionId) return null
|
||||||
if (!recollection) {
|
if (!recollection) {
|
||||||
return (
|
return null
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
|
|
||||||
<p className="text-sm text-muted-foreground">Recollection not found.</p>
|
|
||||||
<Link to="/recollections" className="text-sm text-primary hover:underline">
|
|
||||||
Back to recollections
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RecollectionMenubarProvider>
|
|
||||||
<RecollectionActionsProvider>
|
<RecollectionActionsProvider>
|
||||||
<RecollectionTitleContent />
|
<RecollectionSidebarProvider>
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<RecollectionMenubar />
|
<RecollectionMenubar />
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="flex min-h-0 flex-1 flex-row overflow-hidden">
|
||||||
|
<RecollectionSidebar />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</RecollectionSidebarProvider>
|
||||||
</RecollectionActionsProvider>
|
</RecollectionActionsProvider>
|
||||||
</RecollectionMenubarProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,7 +260,10 @@ function RecollectionActionsMenu({
|
|||||||
|
|
||||||
export type ViewMode = 'table' | 'cards'
|
export type ViewMode = 'table' | 'cards'
|
||||||
|
|
||||||
|
const RECOLLECTIONS_SCROLL_KEY = 'recollections-list-scroll'
|
||||||
|
|
||||||
export function RecollectionsPage() {
|
export function RecollectionsPage() {
|
||||||
|
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
|
||||||
const { orderedRecollections, deleteRecollection, renameRecollection, createRecollection, restoreRecollection } = usePlatform()
|
const { orderedRecollections, deleteRecollection, renameRecollection, createRecollection, restoreRecollection } = usePlatform()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||||
@@ -329,6 +332,28 @@ export function RecollectionsPage() {
|
|||||||
}
|
}
|
||||||
}, [duplicateTarget])
|
}, [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) => {
|
const handleDeleteOpen = useCallback((recollection: Recollection) => {
|
||||||
setDeleteTarget(recollection)
|
setDeleteTarget(recollection)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -487,7 +512,7 @@ export function RecollectionsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative flex flex-1 flex-col min-h-0">
|
<div className="relative flex flex-1 flex-col min-h-0">
|
||||||
<RecollectionsPageBackground className="absolute inset-0 pointer-events-none" />
|
<RecollectionsPageBackground className="absolute inset-0 pointer-events-none" />
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 overflow-auto">
|
<div ref={scrollContainerRef} className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 overflow-auto">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<h1 className="p-3 scroll-m-20 text-4xl font-extrabold tracking-tight text-balance font-serif">
|
<h1 className="p-3 scroll-m-20 text-4xl font-extrabold tracking-tight text-balance font-serif">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Consumers use pathname to pick the active slot for title (save status, Save) and Edit/View menus (undo, redo, etc.).
|
* 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 { useLocation, useParams } from 'react-router-dom'
|
||||||
import type { SaveStatus } from '@/app/canvas/useCanvasGraph'
|
import type { SaveStatus } from '@/app/canvas/useCanvasGraph'
|
||||||
import {
|
import {
|
||||||
@@ -130,6 +130,22 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac
|
|||||||
setLogosSlotState(() => slot)
|
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(() => {
|
const onExport = useCallback(() => {
|
||||||
if (!recollectionId) return
|
if (!recollectionId) return
|
||||||
const graph = getGraph(recollectionId)
|
const graph = getGraph(recollectionId)
|
||||||
|
|||||||
@@ -46,17 +46,21 @@ export function RecollectionEditViewMenus() {
|
|||||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||||
}, [activeSlot])
|
}, [activeSlot])
|
||||||
|
|
||||||
const menus = useMemo(() => {
|
|
||||||
if (!activeSlot) return null
|
|
||||||
const hasFluxOnly =
|
const hasFluxOnly =
|
||||||
fluxSlot &&
|
fluxSlot &&
|
||||||
(fluxSlot.onDuplicate != null || fluxSlot.onCopy != null || fluxSlot.onPaste != null)
|
(fluxSlot.onDuplicate != null || fluxSlot.onCopy != null || fluxSlot.onPaste != null)
|
||||||
return (
|
|
||||||
<Menubar className="shrink-0 rounded-none border-0 bg-transparent p-0 shadow-none">
|
const menus = useMemo(
|
||||||
|
() => (
|
||||||
|
<Menubar className="h-9 shrink-0 rounded-none border-0 bg-transparent p-0 shadow-none">
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
<MenubarTrigger className="font-normal text-muted-foreground">Edit</MenubarTrigger>
|
<MenubarTrigger className="h-9 font-normal text-muted-foreground">Edit</MenubarTrigger>
|
||||||
<MenubarContent>
|
<MenubarContent>
|
||||||
<MenubarItem onClick={activeSlot.undo} disabled={!activeSlot.canUndo} className="gap-2">
|
<MenubarItem
|
||||||
|
onClick={() => activeSlot?.undo()}
|
||||||
|
disabled={!activeSlot?.canUndo}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
<Undo2 className="h-4 w-4" />
|
<Undo2 className="h-4 w-4" />
|
||||||
Undo
|
Undo
|
||||||
<span className="ml-auto pl-4">
|
<span className="ml-auto pl-4">
|
||||||
@@ -65,7 +69,11 @@ export function RecollectionEditViewMenus() {
|
|||||||
</KbdGroup>
|
</KbdGroup>
|
||||||
</span>
|
</span>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
<MenubarItem onClick={activeSlot.redo} disabled={!activeSlot.canRedo} className="gap-2">
|
<MenubarItem
|
||||||
|
onClick={() => activeSlot?.redo()}
|
||||||
|
disabled={!activeSlot?.canRedo}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
<Redo2 className="h-4 w-4" />
|
<Redo2 className="h-4 w-4" />
|
||||||
Redo
|
Redo
|
||||||
<span className="ml-auto pl-4">
|
<span className="ml-auto pl-4">
|
||||||
@@ -74,13 +82,13 @@ export function RecollectionEditViewMenus() {
|
|||||||
</KbdGroup>
|
</KbdGroup>
|
||||||
</span>
|
</span>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
{hasFluxOnly && (
|
{hasFluxOnly && fluxSlot && (
|
||||||
<>
|
<>
|
||||||
<MenubarSeparator />
|
<MenubarSeparator />
|
||||||
{fluxSlot!.onDuplicate != null && (
|
{fluxSlot.onDuplicate != null && (
|
||||||
<MenubarItem
|
<MenubarItem
|
||||||
onClick={fluxSlot!.onDuplicate}
|
onClick={fluxSlot.onDuplicate}
|
||||||
disabled={!fluxSlot!.canDuplicate}
|
disabled={!fluxSlot.canDuplicate}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<CopyPlus className="h-4 w-4" />
|
<CopyPlus className="h-4 w-4" />
|
||||||
@@ -92,10 +100,10 @@ export function RecollectionEditViewMenus() {
|
|||||||
</span>
|
</span>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
)}
|
)}
|
||||||
{fluxSlot!.onCopy != null && (
|
{fluxSlot.onCopy != null && (
|
||||||
<MenubarItem
|
<MenubarItem
|
||||||
onClick={fluxSlot!.onCopy}
|
onClick={fluxSlot.onCopy}
|
||||||
disabled={!fluxSlot!.canCopy}
|
disabled={!fluxSlot.canCopy}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
@@ -107,8 +115,8 @@ export function RecollectionEditViewMenus() {
|
|||||||
</span>
|
</span>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
)}
|
)}
|
||||||
{fluxSlot!.onPaste != null && (
|
{fluxSlot.onPaste != null && (
|
||||||
<MenubarItem onClick={fluxSlot!.onPaste} className="gap-2">
|
<MenubarItem onClick={fluxSlot.onPaste} className="gap-2">
|
||||||
<ClipboardPaste className="h-4 w-4" />
|
<ClipboardPaste className="h-4 w-4" />
|
||||||
Paste
|
Paste
|
||||||
<span className="ml-auto pl-4">
|
<span className="ml-auto pl-4">
|
||||||
@@ -123,7 +131,7 @@ export function RecollectionEditViewMenus() {
|
|||||||
</MenubarContent>
|
</MenubarContent>
|
||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
<MenubarTrigger className="font-normal text-muted-foreground">View</MenubarTrigger>
|
<MenubarTrigger className="h-9 font-normal text-muted-foreground">View</MenubarTrigger>
|
||||||
<MenubarContent>
|
<MenubarContent>
|
||||||
{fluxSlot?.onFitView && (
|
{fluxSlot?.onFitView && (
|
||||||
<MenubarItem onClick={fluxSlot.onFitView} className="gap-2">
|
<MenubarItem onClick={fluxSlot.onFitView} className="gap-2">
|
||||||
@@ -138,8 +146,9 @@ export function RecollectionEditViewMenus() {
|
|||||||
</MenubarContent>
|
</MenubarContent>
|
||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
</Menubar>
|
</Menubar>
|
||||||
|
),
|
||||||
|
[activeSlot, fluxSlot, hasFluxOnly]
|
||||||
)
|
)
|
||||||
}, [activeSlot, fluxSlot])
|
|
||||||
|
|
||||||
return menus
|
return menus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function RecollectionFileMenu() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger className="rounded-sm px-2 py-1 text-sm font-normal text-muted-foreground outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent">
|
<DropdownMenuTrigger className="flex h-9 shrink-0 items-center rounded-sm px-2 py-0 text-sm font-normal text-muted-foreground outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent">
|
||||||
File
|
File
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
|
|||||||
@@ -1,46 +1,113 @@
|
|||||||
/**
|
/**
|
||||||
* 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 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 { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { ArrowLeft } from 'lucide-react'
|
import {
|
||||||
import { useRecollectionMenubar } from './RecollectionMenubarContext'
|
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 { RecollectionEditViewMenus } from './RecollectionEditViewMenus'
|
||||||
import { RecollectionViewSwitcher } from './RecollectionViewSwitcher'
|
import { RecollectionViewSwitcher } from './RecollectionViewSwitcher'
|
||||||
import { RecollectionFileMenu } from './RecollectionFileMenu'
|
import { RecollectionFileMenu } from './RecollectionFileMenu'
|
||||||
|
import { useRecollectionActions } from './RecollectionActionsContext'
|
||||||
|
import { getMode, viewLabel } from './recollectionNav'
|
||||||
|
|
||||||
export function RecollectionMenubar() {
|
export function RecollectionMenubar() {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const { pathname } = useLocation()
|
||||||
const { recollections } = usePlatform()
|
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 recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
||||||
const defaultTitle = recollection ? (
|
const mode = getMode(pathname)
|
||||||
<span className="truncate text-sm font-medium text-muted-foreground">{recollection.name}</span>
|
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||||
) : null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-9 w-full shrink-0 items-center gap-2 overflow-visible border-b border-border/40 bg-background px-2">
|
<div className="relative flex h-9 w-full shrink-0 items-center gap-2 overflow-visible border-b border-border/40 bg-background px-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{base && (
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList className="text-xs">
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink asChild>
|
||||||
<Link
|
<Link
|
||||||
to="/recollections"
|
to="/recollections"
|
||||||
aria-label="Back to recollections"
|
aria-label="Back to recollections"
|
||||||
className="flex shrink-0 items-center rounded-sm px-2 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
|
className="flex shrink-0 items-center rounded-sm px-2 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="size-4" />
|
<span
|
||||||
|
className="size-4 shrink-0 rounded-[2px] opacity-90"
|
||||||
|
style={{
|
||||||
|
maskImage: 'url(/app-icon.svg)',
|
||||||
|
maskRepeat: 'no-repeat',
|
||||||
|
maskPosition: 'center',
|
||||||
|
maskSize: 'contain',
|
||||||
|
WebkitMaskImage: 'url(/app-icon.svg)',
|
||||||
|
WebkitMaskRepeat: 'no-repeat',
|
||||||
|
WebkitMaskPosition: 'center',
|
||||||
|
WebkitMaskSize: 'contain',
|
||||||
|
backgroundColor: 'currentColor',
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<RecollectionViewSwitcher />
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink asChild>
|
||||||
|
<Link to={base}>{recollection?.name ?? 'Recollection'}</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage>{viewLabel(mode)}</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-none absolute left-1/2 top-1/2 flex min-w-0 -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
<div className="pointer-events-none absolute left-1/2 top-1/2 flex min-w-0 -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
||||||
<div className="pointer-events-auto flex min-w-0 items-center gap-1.5">
|
<div className="pointer-events-auto flex min-w-0 items-center gap-2">
|
||||||
{titleContent ?? defaultTitle}
|
<RecollectionViewSwitcher />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-2 pl-2">
|
|
||||||
<RecollectionFileMenu />
|
<RecollectionFileMenu />
|
||||||
<RecollectionEditViewMenus />
|
<RecollectionEditViewMenus />
|
||||||
|
{/* Fixed-width slot so layout does not jump when saveStatus is null (e.g. on Katalogos) */}
|
||||||
|
<span
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center text-muted-foreground"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={
|
||||||
|
saveStatus === 'saving' ? 'Saving' : saveStatus === 'unsaved' ? 'Unsaved changes' : saveStatus === 'saved' ? 'All changes saved' : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{saveStatus != null && (
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="flex size-5 items-center justify-center">
|
||||||
|
{saveStatus === 'saving' && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
|
||||||
|
{saveStatus === 'unsaved' && <CircleDot className="size-3.5" aria-hidden />}
|
||||||
|
{saveStatus === 'saved' && <CheckCircle2 className="size-3.5 text-muted-foreground/70" aria-hidden />}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{saveStatus === 'saving' ? 'Saving…' : saveStatus === 'unsaved' ? 'Unsaved changes' : 'All changes saved'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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<RecollectionMenubarContextValue | null>(null)
|
|
||||||
|
|
||||||
export function RecollectionMenubarProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [titleContent, setTitleContentState] = useState<React.ReactNode>(null)
|
|
||||||
const setTitleContent = useCallback((content: React.ReactNode) => {
|
|
||||||
setTitleContentState(() => content)
|
|
||||||
}, [])
|
|
||||||
const value: RecollectionMenubarContextValue = React.useMemo(
|
|
||||||
() => ({ titleContent, setTitleContent }),
|
|
||||||
[titleContent, setTitleContent]
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<RecollectionMenubarContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</RecollectionMenubarContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRecollectionMenubar(): RecollectionMenubarContextValue {
|
|
||||||
const ctx = useContext(RecollectionMenubarContext)
|
|
||||||
if (!ctx) throw new Error('useRecollectionMenubar must be used within RecollectionMenubarProvider')
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
@@ -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 React, { useCallback, useState } from 'react'
|
||||||
|
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -36,7 +37,9 @@ import {
|
|||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { FluxIcon } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
||||||
import type { LogosPageId, LogosPageMeta } from '../state/recollectionStore'
|
import type { LogosPageId, LogosPageMeta } from '../state/recollectionStore'
|
||||||
|
|
||||||
const DEFAULT_PAGE_TITLE = 'Untitled'
|
const DEFAULT_PAGE_TITLE = 'Untitled'
|
||||||
@@ -49,41 +52,35 @@ function sortByPosition(a: LogosPageMeta, b: LogosPageMeta) {
|
|||||||
return a.position - b.position
|
return a.position - b.position
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LogosSidebarProps = {
|
export function RecollectionSidebar() {
|
||||||
recollectionId: string
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
tree: LogosPageMeta[]
|
const { pathname } = useLocation()
|
||||||
onTreeChange: (tree: LogosPageMeta[]) => void
|
const navigate = useNavigate()
|
||||||
activePageId: LogosPageId | null
|
const {
|
||||||
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 LogosSidebar({
|
|
||||||
recollectionId,
|
|
||||||
tree,
|
tree,
|
||||||
onTreeChange,
|
|
||||||
activePageId,
|
activePageId,
|
||||||
onSelectPage,
|
handleSelectPage,
|
||||||
onDeletePage,
|
handleTreeChange,
|
||||||
isKatalogosView = false,
|
handleDeletePage,
|
||||||
onSelectKatalogos,
|
} = useRecollectionSidebar()
|
||||||
}: LogosSidebarProps) {
|
|
||||||
const [renameTarget, setRenameTarget] = useState<LogosPageMeta | null>(null)
|
const [renameTarget, setRenameTarget] = useState<LogosPageMeta | null>(null)
|
||||||
const [renameTitle, setRenameTitle] = useState('')
|
const [renameTitle, setRenameTitle] = useState('')
|
||||||
const [deleteTarget, setDeleteTarget] = useState<LogosPageMeta | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<LogosPageMeta | null>(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 pages = tree.filter((p) => p.parentId === null).sort(sortByPosition)
|
||||||
|
|
||||||
const updatePage = useCallback(
|
const updatePage = useCallback(
|
||||||
(id: LogosPageId, patch: Partial<Pick<LogosPageMeta, 'title' | 'position'>>) => {
|
(id: LogosPageId, patch: Partial<Pick<LogosPageMeta, 'title' | 'position'>>) => {
|
||||||
const next = tree.map((p) => (p.id === id ? { ...p, ...patch } : p))
|
const next = tree.map((p) => (p.id === id ? { ...p, ...patch } : p))
|
||||||
onTreeChange(next)
|
handleTreeChange(next)
|
||||||
},
|
},
|
||||||
[tree, onTreeChange]
|
[tree, handleTreeChange]
|
||||||
)
|
)
|
||||||
|
|
||||||
const addPage = useCallback(() => {
|
const addPage = useCallback(() => {
|
||||||
@@ -94,11 +91,12 @@ export function LogosSidebar({
|
|||||||
parentId: null,
|
parentId: null,
|
||||||
position: maxPos + 1,
|
position: maxPos + 1,
|
||||||
}
|
}
|
||||||
onTreeChange([...tree, newPage])
|
handleTreeChange([...tree, newPage])
|
||||||
setRenameTarget(newPage)
|
setRenameTarget(newPage)
|
||||||
setRenameTitle(newPage.title)
|
setRenameTitle(newPage.title)
|
||||||
onSelectPage(newPage.id)
|
handleSelectPage(newPage.id)
|
||||||
}, [tree, pages, onTreeChange, onSelectPage])
|
navigate(`${baseLogos}?page=${encodeURIComponent(newPage.id)}`)
|
||||||
|
}, [tree, pages, handleTreeChange, handleSelectPage, baseLogos, navigate])
|
||||||
|
|
||||||
const startRename = useCallback((page: LogosPageMeta) => {
|
const startRename = useCallback((page: LogosPageMeta) => {
|
||||||
setRenameTarget(page)
|
setRenameTarget(page)
|
||||||
@@ -131,23 +129,36 @@ export function LogosSidebar({
|
|||||||
const i = reordered.findIndex((r) => r.id === p.id)
|
const i = reordered.findIndex((r) => r.id === p.id)
|
||||||
return i >= 0 ? { ...p, position: i } : p
|
return i >= 0 ? { ...p, position: i } : p
|
||||||
})
|
})
|
||||||
onTreeChange(withNewPositions)
|
handleTreeChange(withNewPositions)
|
||||||
},
|
},
|
||||||
[tree, onTreeChange]
|
[tree, handleTreeChange]
|
||||||
)
|
)
|
||||||
|
|
||||||
const removePage = useCallback(
|
const removePage = useCallback(
|
||||||
(page: LogosPageMeta) => {
|
(page: LogosPageMeta) => {
|
||||||
onTreeChange(tree.filter((p) => p.id !== page.id))
|
handleTreeChange(tree.filter((p) => p.id !== page.id))
|
||||||
onDeletePage?.(page.id)
|
handleDeletePage(page.id)
|
||||||
if (activePageId === page.id) {
|
if (activePageId === page.id) {
|
||||||
const remaining = tree.filter((p) => p.id !== page.id).sort(sortByPosition)
|
const remaining = tree.filter((p) => p.id !== page.id).sort(sortByPosition)
|
||||||
const first = remaining[0]
|
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)
|
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(
|
const renderPageActions = useCallback(
|
||||||
@@ -178,7 +189,7 @@ export function LogosSidebar({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
startRename(page)
|
startRename(page)
|
||||||
onSelectPage(page.id)
|
onPageClick(page.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pencil className="mr-2 size-3.5" />
|
<Pencil className="mr-2 size-3.5" />
|
||||||
@@ -203,15 +214,16 @@ export function LogosSidebar({
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[startRename, onSelectPage, movePage]
|
[startRename, onPageClick, movePage]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-[var(--sidebar-width)] shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground" style={{ '--sidebar-width': '16rem' } as React.CSSProperties}>
|
<div
|
||||||
|
className="flex h-full w-[var(--sidebar-width)] shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground"
|
||||||
|
style={{ '--sidebar-width': '16rem' } as React.CSSProperties}
|
||||||
|
>
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-sidebar-border px-2 py-2">
|
<div className="flex shrink-0 items-center justify-between border-b border-sidebar-border px-2 py-2">
|
||||||
<span className="text-xs font-medium text-sidebar-foreground/70">
|
<span className="text-xs font-medium text-sidebar-foreground/70">Pages</span>
|
||||||
Pages
|
|
||||||
</span>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -225,7 +237,7 @@ export function LogosSidebar({
|
|||||||
</div>
|
</div>
|
||||||
<SidebarContent className="flex-1 overflow-y-auto border-0 bg-transparent">
|
<SidebarContent className="flex-1 overflow-y-auto border-0 bg-transparent">
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Prologos</SidebarGroupLabel>
|
<SidebarGroupLabel>Logos</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{pages.length === 0 ? (
|
{pages.length === 0 ? (
|
||||||
@@ -243,14 +255,14 @@ export function LogosSidebar({
|
|||||||
pages.map((page) => {
|
pages.map((page) => {
|
||||||
const canMoveUp = pages.indexOf(page) > 0
|
const canMoveUp = pages.indexOf(page) > 0
|
||||||
const canMoveDown = pages.indexOf(page) < pages.length - 1
|
const canMoveDown = pages.indexOf(page) < pages.length - 1
|
||||||
const isActive = activePageId === page.id
|
const isActive = pathname.startsWith(baseLogos) && !isKatalogosView && activePageId === page.id
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={page.id}>
|
<SidebarMenuItem key={page.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-active={isActive}
|
data-active={isActive}
|
||||||
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
|
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
|
||||||
onClick={() => onSelectPage(page.id)}
|
onClick={() => onPageClick(page.id)}
|
||||||
>
|
>
|
||||||
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||||
<span className="truncate">{page.title || DEFAULT_PAGE_TITLE}</span>
|
<span className="truncate">{page.title || DEFAULT_PAGE_TITLE}</span>
|
||||||
@@ -272,7 +284,7 @@ export function LogosSidebar({
|
|||||||
type="button"
|
type="button"
|
||||||
data-active={isKatalogosView}
|
data-active={isKatalogosView}
|
||||||
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
|
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
|
||||||
onClick={onSelectKatalogos}
|
onClick={() => navigate(`${base}/logos/katalogos`)}
|
||||||
>
|
>
|
||||||
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||||
<span className="truncate">Katalogos</span>
|
<span className="truncate">Katalogos</span>
|
||||||
@@ -281,6 +293,24 @@ export function LogosSidebar({
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Flux</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-active={isFluxView}
|
||||||
|
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
|
||||||
|
onClick={() => navigate(`${base}/flux`)}
|
||||||
|
>
|
||||||
|
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||||
|
<span className="truncate">Flux</span>
|
||||||
|
</button>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Context for the recollection sidebar: Logos page tree and active page, shared by
|
||||||
|
* RecollectionSidebar and LogosPage. Load/persist and URL sync live here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
||||||
|
import { useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
getLogosContent,
|
||||||
|
getLogosPageTree,
|
||||||
|
setLogosPageTree,
|
||||||
|
setLogosContentForPage,
|
||||||
|
removeLogosPageContent,
|
||||||
|
type LogosPageMeta,
|
||||||
|
type LogosPageId,
|
||||||
|
} from '../state/recollectionStore'
|
||||||
|
|
||||||
|
const MAIN_PAGE_ID: LogosPageId = 'main'
|
||||||
|
|
||||||
|
export type RecollectionSidebarContextValue = {
|
||||||
|
tree: LogosPageMeta[]
|
||||||
|
activePageId: LogosPageId | null
|
||||||
|
setTree: React.Dispatch<React.SetStateAction<LogosPageMeta[]>>
|
||||||
|
handleSelectPage: (id: LogosPageId) => void
|
||||||
|
handleTreeChange: (tree: LogosPageMeta[]) => void
|
||||||
|
handleDeletePage: (pageId: LogosPageId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RecollectionSidebarContext = createContext<RecollectionSidebarContextValue | null>(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<LogosPageMeta[]>([])
|
||||||
|
const [activePageId, setActivePageId] = useState<LogosPageId | null>(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 (
|
||||||
|
<RecollectionSidebarContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</RecollectionSidebarContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
const prevSaveStatusRef = useRef<string>('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(
|
|
||||||
() => (
|
|
||||||
<span className="flex items-center gap-1.5 truncate">
|
|
||||||
<span className="truncate text-sm font-medium font-serif max-w-[180px]">
|
|
||||||
{recollectionName ?? 'Untitled'}
|
|
||||||
</span>
|
|
||||||
<TooltipProvider delayDuration={300}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span
|
|
||||||
className="shrink-0 flex items-center text-muted-foreground cursor-default"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-label={
|
|
||||||
saveStatus === 'saving' ? 'Saving' : saveStatus === 'unsaved' ? 'Unsaved changes' : 'All changes saved'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{saveStatus === 'saving' && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
|
|
||||||
{saveStatus === 'unsaved' && <CircleDot className="size-3.5" aria-hidden />}
|
|
||||||
{saveStatus !== 'unsaved' && (saveStatus === 'saved' || showSavedBriefly) && (
|
|
||||||
<CheckCircle2 className="size-3.5 text-muted-foreground/70" aria-hidden />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
{saveStatus === 'saving' ? 'Saving…' : saveStatus === 'unsaved' ? 'Unsaved changes' : 'All changes saved'}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
[recollectionName, saveStatus, showSavedBriefly]
|
|
||||||
)
|
|
||||||
|
|
||||||
const titleNode = useMemo(
|
|
||||||
() => (
|
|
||||||
<span className="flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm">
|
|
||||||
{trigger}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
[trigger]
|
|
||||||
)
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
setTitleContent(titleNode)
|
|
||||||
return () => setTitleContent(null)
|
|
||||||
}, [titleNode, setTitleContent])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Circular view switcher: icon and title use the same vertical elevator-style animation.
|
* View switcher: circle with current view icon; click cycles Logos → Katalogos → Flux.
|
||||||
* Click toggles; tooltip includes shortcut.
|
* 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 { 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 { 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() {
|
function shortcutLabel() {
|
||||||
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform)
|
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform)
|
||||||
@@ -19,21 +23,19 @@ export function RecollectionViewSwitcher() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||||
|
|
||||||
type Mode = 'logos' | 'flux'
|
const mode = useMemo(() => getMode(pathname), [pathname])
|
||||||
const mode: Mode = pathname.endsWith('/flux') ? 'flux' : 'logos'
|
const modeIndex = RECOLLECTION_VIEW_MODES.indexOf(mode)
|
||||||
|
|
||||||
const goRelative = useCallback(
|
const goRelative = useCallback(
|
||||||
(delta: 1 | -1) => {
|
(delta: 1 | -1) => {
|
||||||
if (!base) return
|
if (!base) return
|
||||||
const order: Mode[] = ['logos', 'flux']
|
const nextIndex = (modeIndex + delta + RECOLLECTION_VIEW_MODES.length) % RECOLLECTION_VIEW_MODES.length
|
||||||
const currentIndex = order.indexOf(mode)
|
navigate(pathForMode(base, RECOLLECTION_VIEW_MODES[nextIndex]))
|
||||||
const nextMode = order[(currentIndex + (delta === 1 ? 1 : order.length - 1)) % order.length]
|
|
||||||
navigate(`${base}/${nextMode}`)
|
|
||||||
},
|
},
|
||||||
[base, mode, navigate]
|
[base, modeIndex, navigate]
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const cycle = useCallback(() => {
|
||||||
goRelative(1)
|
goRelative(1)
|
||||||
}, [goRelative])
|
}, [goRelative])
|
||||||
|
|
||||||
@@ -43,11 +45,8 @@ export function RecollectionViewSwitcher() {
|
|||||||
const isShortcut = isMod && ev.shiftKey && (ev.key === 'ArrowDown' || ev.key === 'ArrowUp')
|
const isShortcut = isMod && ev.shiftKey && (ev.key === 'ArrowDown' || ev.key === 'ArrowUp')
|
||||||
if (!isShortcut) return
|
if (!isShortcut) return
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
if (ev.key === 'ArrowDown') {
|
if (ev.key === 'ArrowDown') goRelative(1)
|
||||||
goRelative(1)
|
else if (ev.key === 'ArrowUp') goRelative(-1)
|
||||||
} else if (ev.key === 'ArrowUp') {
|
|
||||||
goRelative(-1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKeyDown, true)
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||||
@@ -55,35 +54,34 @@ export function RecollectionViewSwitcher() {
|
|||||||
|
|
||||||
if (!base) return null
|
if (!base) return null
|
||||||
|
|
||||||
const shortcut = shortcutLabel()
|
const tooltipText = `${viewLabel(mode)} (${shortcutLabel()})`
|
||||||
const tooltipText =
|
const translateY = -modeIndex * ICON_SLOT_SIZE_REM
|
||||||
mode === 'logos' ? `Logos · Next: Flux (${shortcut})` : `Flux · Next: Logos (${shortcut})`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex h-9 shrink-0 items-center">
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggle}
|
onClick={cycle}
|
||||||
aria-label={mode === 'logos' ? 'Switch to Flux' : 'Switch to Logos'}
|
aria-label={tooltipText}
|
||||||
className="h-7 w-7 shrink-0 overflow-hidden rounded-full border border-border/60 bg-background shadow-md transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="flex size-8 shrink-0 items-start justify-center overflow-hidden rounded-full transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
|
||||||
>
|
>
|
||||||
<div className="h-full w-full overflow-hidden">
|
|
||||||
<div
|
<div
|
||||||
className="flex w-full flex-col transition-transform duration-200 ease-out"
|
className="flex flex-col transition-transform duration-200 ease-out"
|
||||||
style={{
|
style={{ transform: `translateY(${translateY}rem)` }}
|
||||||
height: '200%',
|
|
||||||
transform: mode === 'logos' ? 'translateY(0)' : 'translateY(-50%)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex h-9 w-full items-center justify-center">
|
<div className="flex size-8 flex-shrink-0 items-center justify-center [&_svg]:shrink-0">
|
||||||
<LogosIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
<FileText className="size-3.5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-9 w-full items-center justify-center">
|
<div className="flex size-8 flex-shrink-0 items-center justify-center [&_svg]:shrink-0">
|
||||||
<FluxIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
<LayoutGrid className="size-3.5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex size-8 flex-shrink-0 items-center justify-center [&_svg]:shrink-0">
|
||||||
|
<span className="inline-flex size-3.5 shrink-0 items-center justify-center">
|
||||||
|
<FluxIcon className="size-full text-muted-foreground" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -91,21 +89,6 @@ export function RecollectionViewSwitcher() {
|
|||||||
<TooltipContent side="bottom">{tooltipText}</TooltipContent>
|
<TooltipContent side="bottom">{tooltipText}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<div className="h-9 min-w-[3.5rem] overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="flex w-full flex-col transition-transform duration-200 ease-out"
|
|
||||||
style={{
|
|
||||||
transform: mode === 'logos' ? 'translateY(0)' : 'translateY(-50%)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="flex h-9 flex-shrink-0 items-center text-sm font-medium text-foreground">
|
|
||||||
Logos
|
|
||||||
</span>
|
|
||||||
<span className="flex h-9 flex-shrink-0 items-center text-sm font-medium text-foreground">
|
|
||||||
Flux
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|||||||
25
frontend/src/app/recollections/layout/recollectionNav.ts
Normal file
25
frontend/src/app/recollections/layout/recollectionNav.ts
Normal file
@@ -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`
|
||||||
|
}
|
||||||
@@ -1,29 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Logos page: BlockNote editor for the recollection. Content persisted in recollection store.
|
* Logos page: BlockNote editor for the recollection. Content only; sidebar is at layout level.
|
||||||
* Left sidebar for page/subpage hierarchy (one level). Layout and styling aligned with Flux.
|
* Content persisted in recollection store.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef } from 'react'
|
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 { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { FluxIcon } from '@/lib/icons'
|
import { FluxIcon } from '@/lib/icons'
|
||||||
import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuController } from '@blocknote/react'
|
import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuController } from '@blocknote/react'
|
||||||
import { BlockNoteView } from '@blocknote/shadcn'
|
import { BlockNoteView } from '@blocknote/shadcn'
|
||||||
import { useTheme } from '@/lib/themeContext'
|
import { useTheme } from '@/lib/themeContext'
|
||||||
import { useRecollectionActions } from '../layout/RecollectionActionsContext'
|
import { useRecollectionActions } from '../layout/RecollectionActionsContext'
|
||||||
|
import { useRecollectionSidebar } from '../layout/RecollectionSidebarContext'
|
||||||
import {
|
import {
|
||||||
getLogosContent,
|
|
||||||
getLogosPageTree,
|
|
||||||
setLogosPageTree,
|
|
||||||
getLogosContentForPage,
|
getLogosContentForPage,
|
||||||
setLogosContentForPage,
|
setLogosContentForPage,
|
||||||
removeLogosPageContent,
|
|
||||||
type StoredLogosContent,
|
type StoredLogosContent,
|
||||||
type LogosPageMeta,
|
|
||||||
type LogosPageId,
|
|
||||||
} from '../state/recollectionStore'
|
} from '../state/recollectionStore'
|
||||||
import { logosSchema } from './logosSchema'
|
import { logosSchema } from './logosSchema'
|
||||||
import { LogosSidebar } from './LogosSidebar'
|
|
||||||
import { KatalogosPage } from '../katalogos/KatalogosPage'
|
import { KatalogosPage } from '../katalogos/KatalogosPage'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -60,77 +54,30 @@ function patchBlockNoteRefWarning() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAIN_PAGE_ID: LogosPageId = 'main'
|
|
||||||
|
|
||||||
export function LogosPage() {
|
export function LogosPage() {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const navigate = useNavigate()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const { recollections } = usePlatform()
|
const { recollections } = usePlatform()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const { setLogosSlot } = useRecollectionActions()
|
const { setLogosSlot } = useRecollectionActions()
|
||||||
|
const { tree, activePageId } = useRecollectionSidebar()
|
||||||
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
||||||
const [tree, setTree] = useState<LogosPageMeta[]>([])
|
|
||||||
const [activePageId, setActivePageId] = useState<LogosPageId | null>(null)
|
|
||||||
const [reloadKey, setReloadKey] = useState(0)
|
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<ReturnType<typeof setTimeout> | null>(null)
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved')
|
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(() => {
|
useEffect(() => {
|
||||||
if (!recollectionId) return
|
if (isKatalogosView || !activePageId || searchParams.get('page') === activePageId) return
|
||||||
let t = getLogosPageTree(recollectionId)
|
setSearchParams({ page: activePageId }, { replace: true })
|
||||||
if (t.length === 0) {
|
}, [isKatalogosView, activePageId, searchParams, setSearchParams])
|
||||||
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(() => {
|
const initialContent = useMemo(() => {
|
||||||
if (!recollectionId || !activePageId) return undefined
|
if (!recollectionId || !activePageId) return undefined
|
||||||
const content = getLogosContentForPage(recollectionId, activePageId)
|
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
|
if (!content || !Array.isArray(content) || content.length === 0) return undefined
|
||||||
return content
|
return content
|
||||||
}, [recollectionId, activePageId, reloadKey])
|
}, [recollectionId, activePageId, reloadKey])
|
||||||
@@ -226,7 +173,6 @@ export function LogosPage() {
|
|||||||
[editor]
|
[editor]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Render paths (no hooks below this point).
|
|
||||||
if (!recollectionId) {
|
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">
|
||||||
@@ -235,43 +181,24 @@ export function LogosPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidebarProps = {
|
|
||||||
recollectionId: recollectionId!,
|
|
||||||
tree,
|
|
||||||
onTreeChange: handleTreeChange,
|
|
||||||
activePageId,
|
|
||||||
onSelectPage: handleSelectPage,
|
|
||||||
onDeletePage: handleDeletePage,
|
|
||||||
isKatalogosView,
|
|
||||||
onSelectKatalogos: handleSelectKatalogos,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isKatalogosView) {
|
if (isKatalogosView) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-row bg-background">
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
|
||||||
<LogosSidebar {...sidebarProps} />
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col overflow-auto">
|
|
||||||
<KatalogosPage />
|
<KatalogosPage />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activePageId == null || !editor) {
|
if (activePageId == null || !editor) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col bg-background">
|
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center bg-background text-sm text-muted-foreground">
|
||||||
<LogosSidebar {...sidebarProps} />
|
|
||||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
|
||||||
{tree.length === 0 ? 'Loading…' : 'Select a page'}
|
{tree.length === 0 ? 'Loading…' : 'Select a page'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-row bg-background">
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
|
||||||
<LogosSidebar {...sidebarProps} />
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col overflow-auto">
|
|
||||||
<div className="mx-auto w-full max-w-3xl p-4">
|
<div className="mx-auto w-full max-w-3xl p-4">
|
||||||
<BlockNoteViewWrapper
|
<BlockNoteViewWrapper
|
||||||
editor={editor as any}
|
editor={editor as any}
|
||||||
@@ -283,6 +210,5 @@ export function LogosPage() {
|
|||||||
</BlockNoteViewWrapper>
|
</BlockNoteViewWrapper>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { RecollectionsPage } from './app/recollections/RecollectionsPage'
|
|||||||
import { RecollectionLayout } from './app/recollections/RecollectionLayout'
|
import { RecollectionLayout } from './app/recollections/RecollectionLayout'
|
||||||
import { LogosPage } from './app/recollections/logos/LogosPage'
|
import { LogosPage } from './app/recollections/logos/LogosPage'
|
||||||
import { FluxRoute } from './app/recollections/flux/FluxRoute'
|
import { FluxRoute } from './app/recollections/flux/FluxRoute'
|
||||||
|
import { NotFoundPage } from './app/NotFoundPage'
|
||||||
import './lib/prismSetup'
|
import './lib/prismSetup'
|
||||||
import 'prismjs/themes/prism.css'
|
import 'prismjs/themes/prism.css'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
@@ -31,8 +32,10 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path="logos" element={<LogosPage />} />
|
<Route path="logos" element={<LogosPage />} />
|
||||||
<Route path="logos/katalogos" element={<LogosPage />} />
|
<Route path="logos/katalogos" element={<LogosPage />} />
|
||||||
<Route path="flux" element={<FluxRoute />} />
|
<Route path="flux" element={<FluxRoute />} />
|
||||||
|
<Route path="*" element={<Navigate to="logos" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
<Toaster richColors position="bottom-right" />
|
<Toaster richColors position="bottom-right" />
|
||||||
|
|||||||
Reference in New Issue
Block a user