feat: redesign navs

This commit is contained in:
2026-03-16 22:28:30 +01:00
parent 5bce586db6
commit 96cb3701ba
18 changed files with 501 additions and 432 deletions

View File

@@ -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)

View File

@@ -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 (
<Menubar className="shrink-0 rounded-none border-0 bg-transparent p-0 shadow-none">
const hasFluxOnly =
fluxSlot &&
(fluxSlot.onDuplicate != null || fluxSlot.onCopy != null || fluxSlot.onPaste != null)
const menus = useMemo(
() => (
<Menubar className="h-9 shrink-0 rounded-none border-0 bg-transparent p-0 shadow-none">
<MenubarMenu>
<MenubarTrigger className="font-normal text-muted-foreground">Edit</MenubarTrigger>
<MenubarTrigger className="h-9 font-normal text-muted-foreground">Edit</MenubarTrigger>
<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" />
Undo
<span className="ml-auto pl-4">
@@ -65,7 +69,11 @@ export function RecollectionEditViewMenus() {
</KbdGroup>
</span>
</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" />
Redo
<span className="ml-auto pl-4">
@@ -74,13 +82,13 @@ export function RecollectionEditViewMenus() {
</KbdGroup>
</span>
</MenubarItem>
{hasFluxOnly && (
{hasFluxOnly && fluxSlot && (
<>
<MenubarSeparator />
{fluxSlot!.onDuplicate != null && (
{fluxSlot.onDuplicate != null && (
<MenubarItem
onClick={fluxSlot!.onDuplicate}
disabled={!fluxSlot!.canDuplicate}
onClick={fluxSlot.onDuplicate}
disabled={!fluxSlot.canDuplicate}
className="gap-2"
>
<CopyPlus className="h-4 w-4" />
@@ -92,10 +100,10 @@ export function RecollectionEditViewMenus() {
</span>
</MenubarItem>
)}
{fluxSlot!.onCopy != null && (
{fluxSlot.onCopy != null && (
<MenubarItem
onClick={fluxSlot!.onCopy}
disabled={!fluxSlot!.canCopy}
onClick={fluxSlot.onCopy}
disabled={!fluxSlot.canCopy}
className="gap-2"
>
<Copy className="h-4 w-4" />
@@ -107,8 +115,8 @@ export function RecollectionEditViewMenus() {
</span>
</MenubarItem>
)}
{fluxSlot!.onPaste != null && (
<MenubarItem onClick={fluxSlot!.onPaste} className="gap-2">
{fluxSlot.onPaste != null && (
<MenubarItem onClick={fluxSlot.onPaste} className="gap-2">
<ClipboardPaste className="h-4 w-4" />
Paste
<span className="ml-auto pl-4">
@@ -123,7 +131,7 @@ export function RecollectionEditViewMenus() {
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="font-normal text-muted-foreground">View</MenubarTrigger>
<MenubarTrigger className="h-9 font-normal text-muted-foreground">View</MenubarTrigger>
<MenubarContent>
{fluxSlot?.onFitView && (
<MenubarItem onClick={fluxSlot.onFitView} className="gap-2">
@@ -138,8 +146,9 @@ export function RecollectionEditViewMenus() {
</MenubarContent>
</MenubarMenu>
</Menubar>
)
}, [activeSlot, fluxSlot])
),
[activeSlot, fluxSlot, hasFluxOnly]
)
return menus
}

View File

@@ -76,7 +76,7 @@ export function RecollectionFileMenu() {
return (
<>
<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
</DropdownMenuTrigger>
<DropdownMenuPortal>

View File

@@ -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 ? (
<span className="truncate text-sm font-medium text-muted-foreground">{recollection.name}</span>
) : null
const mode = getMode(pathname)
const base = recollectionId ? `/recollections/${recollectionId}` : ''
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="flex items-center gap-2">
<Link
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"
>
<ArrowLeft className="size-4" />
</Link>
<RecollectionViewSwitcher />
{base && (
<Breadcrumb>
<BreadcrumbList className="text-xs">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link
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"
>
<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>
</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 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">
{titleContent ?? defaultTitle}
<div className="pointer-events-auto flex min-w-0 items-center gap-2">
<RecollectionViewSwitcher />
<RecollectionFileMenu />
<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 className="ml-auto flex shrink-0 items-center gap-2 pl-2">
<RecollectionFileMenu />
<RecollectionEditViewMenus />
</div>
</div>
)
}

View File

@@ -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
}

View File

@@ -0,0 +1,380 @@
/**
* 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,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
sidebarMenuButtonVariants,
} from '@/components/ui/sidebar'
import {
FileText,
MoreHorizontal,
Pencil,
Plus,
Trash2,
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'
function generatePageId(): LogosPageId {
return `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
function sortByPosition(a: LogosPageMeta, b: LogosPageMeta) {
return a.position - b.position
}
export function RecollectionSidebar() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const { pathname } = useLocation()
const navigate = useNavigate()
const {
tree,
activePageId,
handleSelectPage,
handleTreeChange,
handleDeletePage,
} = useRecollectionSidebar()
const [renameTarget, setRenameTarget] = useState<LogosPageMeta | null>(null)
const [renameTitle, setRenameTitle] = useState('')
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 updatePage = useCallback(
(id: LogosPageId, patch: Partial<Pick<LogosPageMeta, 'title' | 'position'>>) => {
const next = tree.map((p) => (p.id === id ? { ...p, ...patch } : p))
handleTreeChange(next)
},
[tree, handleTreeChange]
)
const addPage = useCallback(() => {
const maxPos = Math.max(0, ...pages.map((p) => p.position), -1)
const newPage: LogosPageMeta = {
id: generatePageId(),
title: DEFAULT_PAGE_TITLE,
parentId: null,
position: maxPos + 1,
}
handleTreeChange([...tree, newPage])
setRenameTarget(newPage)
setRenameTitle(newPage.title)
handleSelectPage(newPage.id)
navigate(`${baseLogos}?page=${encodeURIComponent(newPage.id)}`)
}, [tree, pages, handleTreeChange, handleSelectPage, baseLogos, navigate])
const startRename = useCallback((page: LogosPageMeta) => {
setRenameTarget(page)
setRenameTitle(page.title)
}, [])
const commitRename = useCallback(() => {
if (renameTarget && renameTitle.trim()) {
updatePage(renameTarget.id, { title: renameTitle.trim() })
}
setRenameTarget(null)
setRenameTitle('')
}, [renameTarget, renameTitle, updatePage])
const movePage = useCallback(
(id: LogosPageId, delta: number) => {
const page = tree.find((p) => p.id === id)
if (!page) return
const siblings = tree
.filter((p) => p.parentId === page.parentId)
.sort(sortByPosition)
const idx = siblings.findIndex((p) => p.id === id)
if (idx < 0) return
const newIdx = Math.max(0, Math.min(siblings.length - 1, idx + delta))
if (newIdx === idx) return
const reordered = siblings.slice()
const [removed] = reordered.splice(idx, 1)
reordered.splice(newIdx, 0, removed)
const withNewPositions = tree.map((p) => {
const i = reordered.findIndex((r) => r.id === p.id)
return i >= 0 ? { ...p, position: i } : p
})
handleTreeChange(withNewPositions)
},
[tree, handleTreeChange]
)
const removePage = useCallback(
(page: LogosPageMeta) => {
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) {
handleSelectPage(first.id)
navigate(`${baseLogos}?page=${encodeURIComponent(first.id)}`)
} else {
navigate(baseLogos)
}
}
setDeleteTarget(null)
},
[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(
(page: LogosPageMeta, options: { canMoveUp: boolean; canMoveDown: boolean }) => {
const { canMoveUp, canMoveDown } = options
return (
<span
data-sidebar="menu-action"
className={cn(
'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0'
)}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-3.5" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem
onClick={() => {
startRename(page)
onPageClick(page.id)
}}
>
<Pencil className="mr-2 size-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => movePage(page.id, -1)} disabled={!canMoveUp}>
<ArrowUp className="mr-2 size-3.5" />
Move up
</DropdownMenuItem>
<DropdownMenuItem onClick={() => movePage(page.id, 1)} disabled={!canMoveDown}>
<ArrowDown className="mr-2 size-3.5" />
Move down
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setDeleteTarget(page)} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</span>
)
},
[startRename, onPageClick, movePage]
)
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 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">Pages</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
onClick={addPage}
>
<Plus className="size-3.5" />
New page
</Button>
</div>
<SidebarContent className="flex-1 overflow-y-auto border-0 bg-transparent">
<SidebarGroup>
<SidebarGroupLabel>Logos</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{pages.length === 0 ? (
<div className="px-2 py-2 text-center text-xs text-sidebar-foreground/70">
<Button
type="button"
variant="link"
className="h-auto p-0 text-xs text-sidebar-foreground/70 hover:text-sidebar-foreground"
onClick={addPage}
>
Create your first page
</Button>
</div>
) : (
pages.map((page) => {
const canMoveUp = pages.indexOf(page) > 0
const canMoveDown = pages.indexOf(page) < pages.length - 1
const isActive = pathname.startsWith(baseLogos) && !isKatalogosView && activePageId === page.id
return (
<SidebarMenuItem key={page.id}>
<button
type="button"
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
onClick={() => onPageClick(page.id)}
>
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
<span className="truncate">{page.title || DEFAULT_PAGE_TITLE}</span>
</button>
{renderPageActions(page, { canMoveUp, canMoveDown })}
</SidebarMenuItem>
)
})
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Katalogos</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<button
type="button"
data-active={isKatalogosView}
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
onClick={() => navigate(`${base}/logos/katalogos`)}
>
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
<span className="truncate">Katalogos</span>
</button>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</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>
<Dialog
open={!!renameTarget}
onOpenChange={(open) => {
if (!open) {
setRenameTarget(null)
setRenameTitle('')
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Rename page</DialogTitle>
</DialogHeader>
<input
type="text"
className="w-full rounded border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={renameTitle}
onChange={(e) => setRenameTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commitRename()
if (e.key === 'Escape') {
setRenameTarget(null)
setRenameTitle('')
}
}}
aria-label="Page title"
/>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={() => { setRenameTarget(null); setRenameTitle('') }}>
Cancel
</Button>
<Button type="button" onClick={commitRename}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete page?</DialogTitle>
</DialogHeader>
{deleteTarget && (
<p className="text-sm text-muted-foreground">
&quot;{deleteTarget.title}&quot; will be permanently removed.
</p>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={() => deleteTarget && removePage(deleteTarget)}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -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>
)
}

View File

@@ -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
}

View File

@@ -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 (
<div className="flex shrink-0 items-center gap-2">
<div className="flex h-9 shrink-0 items-center">
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggle}
aria-label={mode === 'logos' ? 'Switch to Flux' : 'Switch to Logos'}
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"
onClick={cycle}
aria-label={tooltipText}
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
className="flex w-full flex-col transition-transform duration-200 ease-out"
style={{
height: '200%',
transform: mode === 'logos' ? 'translateY(0)' : 'translateY(-50%)',
}}
>
<div className="flex h-9 w-full items-center justify-center">
<LogosIcon className="size-3.5 shrink-0 text-muted-foreground" />
</div>
<div className="flex h-9 w-full items-center justify-center">
<FluxIcon className="size-3.5 shrink-0 text-muted-foreground" />
</div>
<div
className="flex flex-col transition-transform duration-200 ease-out"
style={{ transform: `translateY(${translateY}rem)` }}
>
<div className="flex size-8 flex-shrink-0 items-center justify-center [&_svg]:shrink-0">
<FileText className="size-3.5 text-muted-foreground" />
</div>
<div className="flex size-8 flex-shrink-0 items-center justify-center [&_svg]:shrink-0">
<LayoutGrid className="size-3.5 text-muted-foreground" />
</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>
</button>
@@ -91,21 +89,6 @@ export function RecollectionViewSwitcher() {
<TooltipContent side="bottom">{tooltipText}</TooltipContent>
</Tooltip>
</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>
)
}

View File

@@ -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'

View 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`
}