feat: sidebar
This commit is contained in:
@@ -7,8 +7,7 @@ import { Outlet, useParams, useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { RecollectionActionsProvider } from './layout/RecollectionActionsContext'
|
||||
import { RecollectionSidebarProvider } from './layout/RecollectionSidebarContext'
|
||||
import { FluxSceneProvider } from './flux/FluxSceneContext'
|
||||
import { WorkspaceTreeProvider } from './layout/WorkspaceTreeContext'
|
||||
import { RecollectionMenubar } from './layout/RecollectionMenubar'
|
||||
import { RecollectionSidebar } from './layout/RecollectionSidebar'
|
||||
|
||||
@@ -50,19 +49,17 @@ export function RecollectionLayout() {
|
||||
|
||||
return (
|
||||
<RecollectionActionsProvider>
|
||||
<RecollectionSidebarProvider>
|
||||
<FluxSceneProvider>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<RecollectionMenubar />
|
||||
<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 />
|
||||
</div>
|
||||
<WorkspaceTreeProvider>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<RecollectionMenubar />
|
||||
<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 />
|
||||
</div>
|
||||
</div>
|
||||
</FluxSceneProvider>
|
||||
</RecollectionSidebarProvider>
|
||||
</div>
|
||||
</WorkspaceTreeProvider>
|
||||
</RecollectionActionsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, { useEffect, useRef } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import { CanvasPage } from '@/app/canvas/CanvasPage'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { useFluxScenes } from './FluxSceneContext'
|
||||
import { useFluxScenes } from '../layout/WorkspaceTreeContext'
|
||||
|
||||
export function FluxRoute() {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
|
||||
@@ -1,237 +1,275 @@
|
||||
/**
|
||||
* Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes.
|
||||
* Recollection sidebar: VS Code-style collapsible/resizable sections.
|
||||
* - Workspace: unified tree (pages, scenes, folders)
|
||||
* - Katalogos: artifacts & run history
|
||||
* Horizontally resizable via right-edge drag. Sections vertically resizable via divider drag.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, FileText, Pencil, Trash2, Check, X } from 'lucide-react'
|
||||
import { Plus, FileText, Folder, ChevronDown, ChevronRight, Search, X, ChevronsDownUp, ChevronsUpDown } from 'lucide-react'
|
||||
import { FluxIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
||||
import { useOptionalFluxScenes } from '../flux/FluxSceneContext'
|
||||
import { TreeBrowser } from './TreeBrowser'
|
||||
import { useWorkspaceTree } from './WorkspaceTreeContext'
|
||||
import { TreeBrowser, type TreeBrowserHandle } from './TreeBrowser'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
sidebarMenuButtonVariants,
|
||||
} from '@/components/ui/sidebar'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
export function RecollectionSidebar() {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||
const baseLogos = `${base}/logos`
|
||||
const isKatalogosView = pathname.endsWith('/logos/katalogos')
|
||||
const isFluxView = pathname.endsWith('/flux')
|
||||
const MIN_WIDTH = 180
|
||||
const MAX_WIDTH = 480
|
||||
const DEFAULT_WIDTH = 240
|
||||
const SECTION_HEADER_H = 28
|
||||
const MIN_SECTION_H = SECTION_HEADER_H + 32 // header + one row
|
||||
|
||||
const handleAddPage = useCallback(() => {
|
||||
// Add a new top-level page
|
||||
const maxPos = Math.max(0, ...tree.filter((p) => p.parentId === null).map((p) => p.position), -1)
|
||||
const newPage = {
|
||||
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
title: 'Untitled',
|
||||
parentId: null,
|
||||
position: maxPos + 1,
|
||||
}
|
||||
handleTreeChange([...tree, newPage])
|
||||
handleSelectPage(newPage.id)
|
||||
navigate(`${baseLogos}?page=${encodeURIComponent(newPage.id)}`)
|
||||
}, [tree, handleTreeChange, handleSelectPage, baseLogos, navigate])
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section header (VS Code style: uppercase label, chevron, actions on hover)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionHeader({
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
actions?: React.ReactNode
|
||||
}) {
|
||||
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}
|
||||
className="group/header flex h-7 shrink-0 items-center gap-1 px-2 select-none cursor-pointer border-b border-sidebar-border/50 bg-sidebar hover:bg-sidebar-accent/40 transition-colors"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<SidebarContent className="flex-1 overflow-y-auto border-0 bg-transparent">
|
||||
<SidebarGroup>
|
||||
<div className="flex items-center justify-between gap-2 py-1.5">
|
||||
<SidebarGroupLabel className="py-0">Logos</SidebarGroupLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
onClick={handleAddPage}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
New page
|
||||
</Button>
|
||||
</div>
|
||||
<SidebarGroupContent>
|
||||
<TreeBrowser />
|
||||
</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">Artifacts</span>
|
||||
</button>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<FluxScenesSection base={base} isFluxView={isFluxView} />
|
||||
</SidebarContent>
|
||||
{open
|
||||
? <ChevronDown className="size-3 shrink-0 opacity-50" />
|
||||
: <ChevronRight className="size-3 shrink-0 opacity-50" />}
|
||||
<span className="flex-1 min-w-0 truncate text-[11px] font-semibold uppercase tracking-wider opacity-70">
|
||||
{title}
|
||||
</span>
|
||||
{actions && (
|
||||
<div
|
||||
className="flex shrink-0 items-center opacity-0 group-hover/header:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flux Scenes Section
|
||||
// Katalogos section content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FluxScenesSection({ base, isFluxView }: { base: string; isFluxView: boolean }) {
|
||||
function KatalogosContent() {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const fluxScenes = useOptionalFluxScenes()
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
|
||||
// Fallback: single "Canvas" link when provider isn't mounted
|
||||
if (!fluxScenes) {
|
||||
return (
|
||||
<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">Canvas</span>
|
||||
</button>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
const { scenes, activeSceneId, handleSelectScene, handleAddScene, handleRenameScene, handleDeleteScene } = fluxScenes
|
||||
|
||||
const startRename = (id: string, currentTitle: string) => {
|
||||
setRenamingId(id)
|
||||
setRenameValue(currentTitle)
|
||||
}
|
||||
|
||||
const commitRename = () => {
|
||||
if (renamingId && renameValue.trim()) {
|
||||
handleRenameScene(renamingId, renameValue.trim())
|
||||
}
|
||||
setRenamingId(null)
|
||||
}
|
||||
|
||||
const cancelRename = () => {
|
||||
setRenamingId(null)
|
||||
}
|
||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||
const isKatalogosView = pathname.endsWith('/logos/katalogos')
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<div className="flex items-center justify-between gap-2 py-1.5">
|
||||
<SidebarGroupLabel className="py-0">Flux</SidebarGroupLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
onClick={() => {
|
||||
const id = handleAddScene()
|
||||
navigate(`${base}/flux?scene=${encodeURIComponent(id)}`)
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
New scene
|
||||
</Button>
|
||||
</div>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{scenes.map((scene) => {
|
||||
const isActive = isFluxView && scene.id === activeSceneId
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={scene.id}>
|
||||
{renamingId === scene.id ? (
|
||||
<div className="flex w-full items-center gap-1 px-2 py-1">
|
||||
<input
|
||||
autoFocus
|
||||
className="h-6 flex-1 min-w-0 rounded border border-sidebar-border bg-sidebar px-1.5 text-xs outline-none focus:ring-1 focus:ring-sidebar-ring"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitRename()
|
||||
if (e.key === 'Escape') cancelRename()
|
||||
}}
|
||||
onBlur={commitRename}
|
||||
/>
|
||||
<button type="button" onClick={commitRename} className="shrink-0 text-sidebar-foreground/70 hover:text-sidebar-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={cancelRename} className="shrink-0 text-sidebar-foreground/70 hover:text-sidebar-foreground">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="group flex w-full items-center">
|
||||
<button
|
||||
type="button"
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'flex-1 min-w-0')}
|
||||
onClick={() => {
|
||||
handleSelectScene(scene.id)
|
||||
navigate(`${base}/flux?scene=${encodeURIComponent(scene.id)}`)
|
||||
}}
|
||||
>
|
||||
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||
<span className="truncate">{scene.title}</span>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity pr-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); startRename(scene.id, scene.title) }}
|
||||
className="rounded p-0.5 text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent"
|
||||
title="Rename scene"
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
{scenes.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteScene(scene.id) }}
|
||||
className="rounded p-0.5 text-sidebar-foreground/50 hover:text-destructive hover:bg-sidebar-accent"
|
||||
title="Delete scene"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<div className="flex flex-col gap-0.5 p-1 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
data-active={isKatalogosView || undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-sm px-2 h-[30px] text-[13px] w-full text-left transition-colors',
|
||||
'hover:bg-sidebar-accent/60',
|
||||
isKatalogosView && 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
|
||||
)}
|
||||
onClick={() => navigate(`${base}/logos/katalogos`)}
|
||||
>
|
||||
<FileText className="size-[15px] shrink-0 opacity-60" />
|
||||
<span className="truncate">Artifacts</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RecollectionSidebar() {
|
||||
const { handleAddNode } = useWorkspaceTree()
|
||||
|
||||
// Search & tree ref
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
const treeBrowserRef = useRef<TreeBrowserHandle>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Horizontal resize
|
||||
const [width, setWidth] = useState(DEFAULT_WIDTH)
|
||||
const onHResizeStart = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault()
|
||||
const startX = e.clientX
|
||||
const startW = width
|
||||
const onMove = (ev: PointerEvent) => setWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startW + (ev.clientX - startX))))
|
||||
const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = '' }
|
||||
document.addEventListener('pointermove', onMove)
|
||||
document.addEventListener('pointerup', onUp)
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [width])
|
||||
|
||||
// Section collapse
|
||||
const [workspaceOpen, setWorkspaceOpen] = useState(true)
|
||||
const [katalogosOpen, setKatalogosOpen] = useState(true)
|
||||
|
||||
// Vertical section resize (drag the divider between sections)
|
||||
// workspaceFlex: fraction of available space for workspace section (0.2–0.8)
|
||||
const [workspaceFlex, setWorkspaceFlex] = useState(0.75)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const onVResizeStart = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault()
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const startY = e.clientY
|
||||
const startFlex = workspaceFlex
|
||||
const containerH = container.clientHeight
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const delta = ev.clientY - startY
|
||||
const newFlex = startFlex + delta / containerH
|
||||
setWorkspaceFlex(Math.min(0.85, Math.max(0.15, newFlex)))
|
||||
}
|
||||
const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = '' }
|
||||
document.addEventListener('pointermove', onMove)
|
||||
document.addEventListener('pointerup', onUp)
|
||||
document.body.style.cursor = 'row-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [workspaceFlex])
|
||||
|
||||
const bothOpen = workspaceOpen && katalogosOpen
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-full shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground overflow-hidden"
|
||||
style={{ width }}
|
||||
>
|
||||
<div ref={containerRef} className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Workspace section */}
|
||||
<div
|
||||
className="flex flex-col overflow-hidden"
|
||||
style={workspaceOpen && bothOpen ? { flex: `${workspaceFlex} 1 0%`, minHeight: MIN_SECTION_H } : workspaceOpen ? { flex: '1 1 0%', minHeight: MIN_SECTION_H } : undefined}
|
||||
>
|
||||
<SectionHeader
|
||||
title="Workspace"
|
||||
open={workspaceOpen}
|
||||
onToggle={() => setWorkspaceOpen((v) => !v)}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors"
|
||||
title="Search"
|
||||
onClick={() => { setSearchVisible((v) => { if (!v) setTimeout(() => searchInputRef.current?.focus(), 0); return !v }); if (searchVisible) setSearchQuery('') }}
|
||||
>
|
||||
<Search className="size-3 opacity-70" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors"
|
||||
title="Collapse all"
|
||||
onClick={() => treeBrowserRef.current?.closeAll()}
|
||||
>
|
||||
<ChevronsDownUp className="size-3 opacity-70" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors">
|
||||
<Plus className="size-3 opacity-70" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onClick={() => handleAddNode('page')}>
|
||||
<FileText className="mr-2 size-3.5" /> Page
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAddNode('scene')}>
|
||||
<FluxIcon className="mr-2 size-3.5" /> Scene
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAddNode('folder')}>
|
||||
<Folder className="mr-2 size-3.5" /> Folder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{workspaceOpen && (
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
{searchVisible && (
|
||||
<div className="flex items-center gap-1 px-1.5 py-1 shrink-0">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') { setSearchQuery(''); setSearchVisible(false) } }}
|
||||
className="h-6 text-xs pl-2 pr-6 bg-sidebar-accent/30 border-sidebar-border/50 focus:bg-sidebar-accent/50"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button type="button" className="absolute right-1 top-1/2 -translate-y-1/2 flex size-4 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent" onClick={() => setSearchQuery('')}>
|
||||
<X className="size-2.5 opacity-60" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<TreeBrowser ref={treeBrowserRef} searchQuery={searchQuery} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vertical resize divider (only when both sections open) */}
|
||||
{bothOpen && (
|
||||
<div
|
||||
className="h-px shrink-0 cursor-row-resize bg-sidebar-border/50 hover:bg-primary/30 active:bg-primary/40 transition-colors"
|
||||
onPointerDown={onVResizeStart}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Katalogos section */}
|
||||
<div
|
||||
className="flex flex-col overflow-hidden"
|
||||
style={katalogosOpen && bothOpen ? { flex: `${1 - workspaceFlex} 1 0%`, minHeight: MIN_SECTION_H } : katalogosOpen ? { flex: '1 1 0%', minHeight: MIN_SECTION_H } : undefined}
|
||||
>
|
||||
<SectionHeader
|
||||
title="Katalogos"
|
||||
open={katalogosOpen}
|
||||
onToggle={() => setKatalogosOpen((v) => !v)}
|
||||
/>
|
||||
{katalogosOpen && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<KatalogosContent />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizontal resize handle */}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/20 active:bg-primary/30 transition-colors z-10"
|
||||
onPointerDown={onHResizeStart}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
/**
|
||||
* TreeBrowser: A tree browser component using react-arborist for managing
|
||||
* Logos pages in a hierarchical folder structure.
|
||||
*
|
||||
* Features:
|
||||
* - Drag and drop reordering
|
||||
* - Folder expansion/collapse
|
||||
* - Context menu for actions
|
||||
* - Shadcn theme integration
|
||||
* - Inline editing for page titles
|
||||
* - Smooth animations
|
||||
* - Search functionality
|
||||
* - Expand/collapse all
|
||||
* TreeBrowser: Unified workspace tree (pages, scenes, folders).
|
||||
* Notion/VS Code inspired design. Only folders accept children.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import React, { useCallback, useMemo, useRef } from 'react'
|
||||
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||
import {
|
||||
Tree,
|
||||
NodeApi,
|
||||
NodeRendererProps,
|
||||
RowRendererProps,
|
||||
TreeApi,
|
||||
@@ -32,13 +21,10 @@ import {
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Search,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
X,
|
||||
GripVertical,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FluxIcon } from '@/lib/icons'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -46,489 +32,332 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||
import type { LogosPageMeta } from '../state/recollectionStore'
|
||||
import { removeLogosPageContent } from '../state/recollectionStore'
|
||||
import { useWorkspaceTree } from './WorkspaceTreeContext'
|
||||
import type { WorkspaceNodeMeta } from '../state/recollectionStore'
|
||||
|
||||
// Tree node: page metadata plus tree shape (react-arborist `node.data` is this whole object).
|
||||
type TreeNode = LogosPageMeta & {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree node type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TreeNode = WorkspaceNodeMeta & {
|
||||
children?: TreeNode[]
|
||||
isFolder: boolean
|
||||
}
|
||||
|
||||
// Convert flat tree to hierarchical structure
|
||||
function buildTree(pages: LogosPageMeta[]): TreeNode[] {
|
||||
const pageMap = new Map<string, TreeNode>()
|
||||
function buildTree(nodes: WorkspaceNodeMeta[]): TreeNode[] {
|
||||
const nodeMap = new Map<string, TreeNode>()
|
||||
const rootNodes: TreeNode[] = []
|
||||
|
||||
// First pass: create all nodes
|
||||
pages.forEach((page) => {
|
||||
pageMap.set(page.id, {
|
||||
...page,
|
||||
children: [],
|
||||
isFolder: false,
|
||||
})
|
||||
})
|
||||
for (const n of nodes) nodeMap.set(n.id, { ...n, children: n.kind === 'folder' ? [] : undefined })
|
||||
|
||||
// Second pass: build parent-child relationships
|
||||
pages.forEach((page) => {
|
||||
const node = pageMap.get(page.id)!
|
||||
if (page.parentId === null) {
|
||||
rootNodes.push(node)
|
||||
for (const n of nodes) {
|
||||
const tn = nodeMap.get(n.id)!
|
||||
if (n.parentId === null) {
|
||||
rootNodes.push(tn)
|
||||
} else {
|
||||
const parent = pageMap.get(page.parentId)
|
||||
if (parent) {
|
||||
parent.children?.push(node)
|
||||
parent.isFolder = true
|
||||
}
|
||||
const parent = nodeMap.get(n.parentId)
|
||||
if (parent?.children) parent.children.push(tn)
|
||||
else rootNodes.push(tn) // orphan fallback
|
||||
}
|
||||
})
|
||||
|
||||
// Sort children by position
|
||||
function sortNodes(nodes: TreeNode[]) {
|
||||
nodes.sort((a, b) => a.position - b.position)
|
||||
nodes.forEach((node) => sortNodes(node.children || []))
|
||||
}
|
||||
sortNodes(rootNodes)
|
||||
|
||||
const sort = (ns: TreeNode[]) => {
|
||||
ns.sort((a, b) => {
|
||||
// Folders first, then by position
|
||||
const aIsFolder = a.kind === 'folder' ? 0 : 1
|
||||
const bIsFolder = b.kind === 'folder' ? 0 : 1
|
||||
if (aIsFolder !== bIsFolder) return aIsFolder - bIsFolder
|
||||
return a.position - b.position
|
||||
})
|
||||
ns.forEach((n) => { if (n.children) sort(n.children) })
|
||||
}
|
||||
sort(rootNodes)
|
||||
return rootNodes
|
||||
}
|
||||
|
||||
// Memoize the buildTree result to avoid unnecessary re-creation
|
||||
function useTreeNodes(tree: LogosPageMeta[]) {
|
||||
return useMemo(() => buildTree(tree), [tree])
|
||||
}
|
||||
|
||||
// Inline editable title component
|
||||
function EditableTitle({
|
||||
title,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
title: string
|
||||
onSave: (newTitle: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
onSave(inputRef.current?.value || '')
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline title editor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EditableTitle({ title, onSave, onCancel }: { title: string; onSave: (v: string) => void; onCancel: () => void }) {
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
React.useEffect(() => { ref.current?.focus(); ref.current?.select() }, [])
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
ref={ref}
|
||||
type="text"
|
||||
defaultValue={title}
|
||||
onBlur={(e) => onSave(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full rounded px-1 py-0.5 text-sm outline-none ring-2 ring-ring focus:ring-2 transition-all duration-200 bg-background"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') onSave(ref.current?.value || ''); else if (e.key === 'Escape') onCancel() }}
|
||||
className="w-full rounded-sm px-1 py-0.5 text-[13px] outline-none ring-1 ring-primary/50 bg-background"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ROW_INDENT_PX = 20
|
||||
const ROW_GUTTER_PX = 8
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Line cursor = “insert as sibling at this indent” (not “drop into folder”).
|
||||
* Triangle + dashed rail read as a slot between rows; matches sidebar theme.
|
||||
*/
|
||||
const LogosDropCursor = React.memo(function LogosDropCursor({ top, left, indent }: CursorProps) {
|
||||
const INDENT = 10
|
||||
const GUTTER = 2
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drop cursor (line between rows)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DropCursor = React.memo(function DropCursor({ top, left, indent }: CursorProps) {
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute z-20 flex items-center gap-1.5"
|
||||
style={{
|
||||
top: top - 5,
|
||||
left: left + ROW_GUTTER_PX,
|
||||
right: Math.max(indent, ROW_GUTTER_PX),
|
||||
}}
|
||||
className="pointer-events-none absolute z-20 flex items-center"
|
||||
style={{ top: top - 1, left: left + GUTTER + 4, right: GUTTER + 4 }}
|
||||
>
|
||||
<span className="flex h-3 w-2.5 shrink-0 items-center justify-center text-primary drop-shadow-sm">
|
||||
<svg width="9" height="10" viewBox="0 0 9 10" fill="currentColor" aria-hidden>
|
||||
<path d="M0 5 L9 1.5 L9 8.5 Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="flex h-3 min-w-[2rem] flex-1 items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'h-0 w-full border-t-2 border-dashed border-primary',
|
||||
'shadow-[0_1px_0_0_hsl(var(--primary)/0.35)]',
|
||||
'motion-safe:animate-[tree-drop-line-pulse_1.8s_ease-in-out_infinite]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="size-1.5 rounded-full bg-primary shrink-0" />
|
||||
<div className="h-0.5 flex-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Drag ref on a compact handle at the start of the row; handle is a floating pill shown on row hover
|
||||
* (always visible on coarse pointers). Title/icon strip is click-to-navigate only.
|
||||
*/
|
||||
function LogosTreeNode({ node, dragHandle, style }: NodeRendererProps<TreeNode>) {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node icon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||
const baseLogos = `${base}/logos`
|
||||
function NodeIcon({ kind, isOpen }: { kind: string; isOpen?: boolean }) {
|
||||
const cls = 'size-[15px] shrink-0 opacity-60'
|
||||
if (kind === 'scene') return <FluxIcon className={cls} />
|
||||
if (kind === 'folder') return isOpen ? <FolderOpen className={cls} /> : <Folder className={cls} />
|
||||
return <FileText className={cls} />
|
||||
}
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
node.toggle()
|
||||
},
|
||||
[node]
|
||||
)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree node renderer (Notion/VS Code style)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!node.state.isEditing && !node.state.isDragging) {
|
||||
handleSelectPage(node.data.id)
|
||||
navigate(`${baseLogos}?page=${encodeURIComponent(node.data.id)}`)
|
||||
}
|
||||
},
|
||||
[handleSelectPage, node.data.id, baseLogos, navigate, node.state.isEditing, node.state.isDragging]
|
||||
)
|
||||
function WorkspaceTreeNode({ node, dragHandle, style }: NodeRendererProps<TreeNode>) {
|
||||
const { handleSelectItem, handleAddNode, handleRenameNode, handleDeleteNode } = useWorkspaceTree()
|
||||
|
||||
const handleRename = useCallback(() => {
|
||||
node.edit()
|
||||
}, [node])
|
||||
const isFolder = node.data.kind === 'folder'
|
||||
const hasChildren = isFolder && (node.children?.length || 0) > 0
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
const toDelete = new Set<string>()
|
||||
function collectDescendants(id: string) {
|
||||
toDelete.add(id)
|
||||
tree.forEach((p) => {
|
||||
if (p.parentId === id) collectDescendants(p.id)
|
||||
})
|
||||
}
|
||||
collectDescendants(node.data.id)
|
||||
if (recollectionId) {
|
||||
toDelete.forEach((pageId) => {
|
||||
removeLogosPageContent(recollectionId, pageId)
|
||||
})
|
||||
}
|
||||
handleTreeChange(tree.filter((p) => !toDelete.has(p.id)))
|
||||
}, [tree, handleTreeChange, node.data.id, recollectionId])
|
||||
|
||||
const handleAddChild = useCallback(() => {
|
||||
const maxPos = Math.max(
|
||||
0,
|
||||
...tree.filter((p) => p.parentId === node.data.id).map((p) => p.position),
|
||||
-1
|
||||
)
|
||||
const newPage: LogosPageMeta = {
|
||||
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
title: 'Untitled',
|
||||
parentId: node.data.id,
|
||||
position: maxPos + 1,
|
||||
}
|
||||
handleTreeChange([...tree, newPage])
|
||||
if (!node.isOpen) node.open()
|
||||
}, [tree, handleTreeChange, node])
|
||||
|
||||
const hasChildren = (node.children?.length || 0) > 0
|
||||
const pageTitle = node.data.title || 'Untitled'
|
||||
const handleClick = useCallback(() => {
|
||||
if (node.state.isEditing || node.state.isDragging) return
|
||||
if (isFolder) node.toggle()
|
||||
else handleSelectItem(node.data.id)
|
||||
}, [handleSelectItem, node, isFolder])
|
||||
|
||||
// The entire row is the drag handle — Notion style
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
{/* Floating drag pill: expands on row hover; coarse pointers keep it tappable */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center overflow-hidden',
|
||||
'transition-[width,opacity] duration-200 ease-out',
|
||||
'w-0 opacity-0 group-hover:w-4 group-hover:opacity-100',
|
||||
'[@media(pointer:coarse)]:w-5 [@media(pointer:coarse)]:opacity-100',
|
||||
node.state.isDragging && 'w-4 opacity-100 [@media(pointer:coarse)]:w-5',
|
||||
node.state.isEditing && 'pointer-events-none w-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={dragHandle}
|
||||
style={{ ...style, paddingLeft: 0 }}
|
||||
title={node.state.isEditing ? undefined : 'Drag to reorder'}
|
||||
className={cn(
|
||||
'logos-tree-drag-handle group/drag',
|
||||
'relative flex h-6 w-4 shrink-0 touch-none items-center justify-center rounded-md',
|
||||
'[@media(pointer:coarse)]:h-7 [@media(pointer:coarse)]:w-5',
|
||||
'border border-sidebar-border/50 bg-sidebar-accent/40',
|
||||
'text-sidebar-foreground/70',
|
||||
'transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out',
|
||||
'hover:border-sidebar-border hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
'hover:shadow-sm',
|
||||
'active:scale-[0.97] active:cursor-grabbing',
|
||||
'cursor-grab outline-none',
|
||||
'dark:border-sidebar-border/55 dark:bg-sidebar-accent/35 dark:text-sidebar-foreground/75',
|
||||
'dark:hover:bg-sidebar-accent dark:hover:text-sidebar-accent-foreground',
|
||||
node.state.isDragging &&
|
||||
'cursor-grabbing border-primary/55 bg-primary/18 text-primary shadow-sm ring-1 ring-primary/30 dark:border-primary/50 dark:bg-primary/22 dark:text-primary'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical
|
||||
className={cn(
|
||||
'size-3 shrink-0 text-current transition-opacity duration-150 opacity-85 group-hover/drag:opacity-100',
|
||||
node.state.isDragging && 'opacity-100'
|
||||
)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChildren ? (
|
||||
<div
|
||||
ref={dragHandle}
|
||||
style={{ ...style, paddingLeft: 0 }}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-center gap-0.5 select-none overflow-hidden',
|
||||
node.state.isEditing ? 'cursor-text' : node.state.isDragging ? 'cursor-grabbing' : 'cursor-default',
|
||||
node.state.isDragging && 'opacity-40'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Chevron (folders only) */}
|
||||
{isFolder ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||
onClick={handleToggle}
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); node.toggle() }}
|
||||
>
|
||||
{node.isOpen ? (
|
||||
<ChevronDown className="size-3.5 text-sidebar-foreground/70" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 text-sidebar-foreground/70" />
|
||||
)}
|
||||
{node.isOpen
|
||||
? <ChevronDown className="size-3 opacity-50" />
|
||||
: <ChevronRight className="size-3 opacity-50" />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-4 shrink-0" />
|
||||
<div className="w-5 shrink-0" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-[28px] min-w-0 flex-1 items-center gap-1 rounded-md px-0.5 -mx-0.5',
|
||||
node.state.isEditing ? 'cursor-text' : 'cursor-pointer hover:bg-sidebar-accent/40'
|
||||
{/* Icon */}
|
||||
<NodeIcon kind={node.data.kind} isOpen={node.isOpen} />
|
||||
|
||||
{/* Title */}
|
||||
<div className="min-w-0 flex-1 overflow-hidden pl-1.5">
|
||||
{node.state.isEditing ? (
|
||||
<EditableTitle
|
||||
title={node.data.title}
|
||||
onSave={(v) => { if (v.trim()) handleRenameNode(node.data.id, v.trim()); node.submit(v) }}
|
||||
onCancel={() => node.reset()}
|
||||
/>
|
||||
) : (
|
||||
<span className="block truncate text-[13px] leading-tight cursor-pointer" title={node.data.title}>{node.data.title || 'Untitled'}</span>
|
||||
)}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{node.state.isEditing ? (
|
||||
<EditableTitle
|
||||
title={node.data.title}
|
||||
onSave={(newTitle) => {
|
||||
if (newTitle.trim()) {
|
||||
handleTreeChange(
|
||||
tree.map((p) => (p.id === node.data.id ? { ...p, title: newTitle.trim() } : p))
|
||||
)
|
||||
}
|
||||
node.submit(newTitle)
|
||||
}}
|
||||
onCancel={() => node.reset()}
|
||||
/>
|
||||
) : (
|
||||
<span className="block truncate select-none">{pageTitle}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddChild()
|
||||
}}
|
||||
title="Add child page"
|
||||
{/* Hover actions */}
|
||||
{!node.state.isEditing && (
|
||||
<div
|
||||
className="flex shrink-0 items-center cursor-default opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5 text-sidebar-foreground/70" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-3.5 text-sidebar-foreground/70" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem onClick={handleRename}>
|
||||
<Pencil className="mr-2 size-3.5" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleDelete} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="mr-2 size-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{/* Plus — only on folders (add inside) or any node (add sibling) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors">
|
||||
<Plus className="size-3 opacity-60" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onClick={() => { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('page', pid); if (isFolder && !node.isOpen) node.open() }}>
|
||||
<FileText className="mr-2 size-3.5" /> Page
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('scene', pid); if (isFolder && !node.isOpen) node.open() }}>
|
||||
<FluxIcon className="mr-2 size-3.5" /> Scene
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('folder', pid); if (isFolder && !node.isOpen) node.open() }}>
|
||||
<Folder className="mr-2 size-3.5" /> Folder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* More */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors">
|
||||
<MoreHorizontal className="size-3 opacity-60" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onClick={() => node.edit()}>
|
||||
<Pencil className="mr-2 size-3.5" /> Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleDeleteNode(node.data.id)} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="mr-2 size-3.5" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Drop target + layout shell; `children` must be the Node renderer (drag layer). */
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TreeRow({ node, innerRef, attrs, children }: RowRendererProps<TreeNode>) {
|
||||
const { pathname } = useLocation()
|
||||
const { activePageId } = useRecollectionSidebar()
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||
const baseLogos = `${base}/logos`
|
||||
const isActive = pathname.startsWith(baseLogos) && activePageId === node.data.id
|
||||
const level = node.level
|
||||
const indentPx = ROW_GUTTER_PX + level * ROW_INDENT_PX
|
||||
const { activeItemId } = useWorkspaceTree()
|
||||
const isActive = activeItemId === node.data.id
|
||||
const indentPx = GUTTER + node.level * INDENT
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={innerRef}
|
||||
{...attrs}
|
||||
style={{
|
||||
...attrs.style,
|
||||
paddingLeft: `${indentPx}px`,
|
||||
}}
|
||||
style={{ ...attrs.style, paddingLeft: `${indentPx}px` }}
|
||||
className={cn(
|
||||
'group relative flex items-center gap-1 py-1 pr-2 text-sm rounded-md transition-[box-shadow,background-color,ring-color] duration-150',
|
||||
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
|
||||
node.state.isDragging && 'cursor-grabbing opacity-45',
|
||||
// Folder drop target: index === null in library — reads as “open container”, not a line between rows
|
||||
node.state.willReceiveDrop &&
|
||||
'cursor-copy bg-sidebar-accent/30 ring-2 ring-inset ring-dashed ring-sidebar-ring/80 shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border))]'
|
||||
'group relative flex items-center pr-1 h-[30px] text-[13px] rounded-sm mx-1',
|
||||
'transition-colors duration-100',
|
||||
'hover:bg-sidebar-accent/60',
|
||||
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground font-medium',
|
||||
node.state.isDragging && 'opacity-40',
|
||||
node.state.willReceiveDrop && node.data.kind === 'folder' &&
|
||||
'bg-sidebar-accent/40 ring-1 ring-primary/40 cursor-copy'
|
||||
)}
|
||||
title={node.state.willReceiveDrop ? 'Release to drop inside this folder' : undefined}
|
||||
>
|
||||
{level > 0 && (
|
||||
{/* VS Code-style indent guides */}
|
||||
{Array.from({ length: node.level }, (_, i) => (
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 top-0 border-l-2 border-sidebar-border/45"
|
||||
style={{ left: `${ROW_GUTTER_PX + (level - 1) * ROW_INDENT_PX + ROW_INDENT_PX / 2}px` }}
|
||||
key={i}
|
||||
className="pointer-events-none absolute top-0 bottom-0 w-px bg-sidebar-border/40"
|
||||
style={{ left: `${GUTTER + i * INDENT + INDENT / 2}px` }}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TreeBrowser() {
|
||||
const { tree, handleTreeChange } = useRecollectionSidebar()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
// ---------------------------------------------------------------------------
|
||||
// TreeBrowser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TreeBrowserHandle = {
|
||||
openAll: () => void
|
||||
closeAll: () => void
|
||||
}
|
||||
|
||||
export type TreeBrowserProps = {
|
||||
searchQuery?: string
|
||||
}
|
||||
|
||||
export const TreeBrowser = React.forwardRef<TreeBrowserHandle, TreeBrowserProps>(
|
||||
function TreeBrowser({ searchQuery = '' }, ref) {
|
||||
const { tree, handleTreeChange } = useWorkspaceTree()
|
||||
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
|
||||
const [treeContainerHeight, treeContainerRef] = useResizeHeight(600)
|
||||
const [treeContainerHeight, treeContainerRef] = useResizeHeight(200)
|
||||
|
||||
// Build tree from flat structure
|
||||
const treeNodes = useTreeNodes(tree)
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
openAll: () => treeRef.current?.openAll(),
|
||||
closeAll: () => treeRef.current?.closeAll(),
|
||||
}), [])
|
||||
|
||||
const treeNodes = useMemo(() => buildTree(tree), [tree])
|
||||
|
||||
// Filter tree based on search query
|
||||
const filteredTree = useMemo(() => {
|
||||
if (!searchQuery.trim()) return treeNodes
|
||||
|
||||
const searchTerm = searchQuery.toLowerCase()
|
||||
|
||||
function filterNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
const result: TreeNode[] = []
|
||||
for (const node of nodes) {
|
||||
const matches = node.title.toLowerCase().includes(searchTerm)
|
||||
const children = filterNodes(node.children || [])
|
||||
|
||||
if (matches || children.length > 0) {
|
||||
result.push({
|
||||
...node,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
})
|
||||
}
|
||||
const term = searchQuery.toLowerCase()
|
||||
const filter = (nodes: TreeNode[]): TreeNode[] => {
|
||||
const out: TreeNode[] = []
|
||||
for (const n of nodes) {
|
||||
const matches = n.title.toLowerCase().includes(term)
|
||||
const kids = n.children ? filter(n.children) : []
|
||||
if (matches || kids.length > 0) out.push({ ...n, children: kids.length > 0 ? kids : n.children ? [] : undefined })
|
||||
}
|
||||
return result
|
||||
return out
|
||||
}
|
||||
|
||||
return filterNodes(treeNodes)
|
||||
return filter(treeNodes)
|
||||
}, [treeNodes, searchQuery])
|
||||
|
||||
// Get all folder IDs for initial open state
|
||||
const initialOpenState = useMemo(() => {
|
||||
const folderIds: Record<string, boolean> = {}
|
||||
function collectIds(nodes: TreeNode[]) {
|
||||
nodes.forEach((node) => {
|
||||
if (node.isFolder) {
|
||||
folderIds[node.id] = true
|
||||
collectIds(node.children || [])
|
||||
}
|
||||
})
|
||||
}
|
||||
collectIds(treeNodes)
|
||||
return folderIds
|
||||
const ids: Record<string, boolean> = {}
|
||||
const collect = (ns: TreeNode[]) => ns.forEach((n) => { if (n.kind === 'folder') { ids[n.id] = true; if (n.children) collect(n.children) } })
|
||||
collect(treeNodes)
|
||||
return ids
|
||||
}, [treeNodes])
|
||||
|
||||
// Handle drag and drop reordering (react-arborist onMove)
|
||||
const handleMove = useCallback(
|
||||
({
|
||||
dragIds,
|
||||
parentId: newParentId,
|
||||
index,
|
||||
}: {
|
||||
dragIds: string[]
|
||||
parentId: string | null
|
||||
index: number
|
||||
}) => {
|
||||
({ dragIds, parentId: newParentId, index }: { dragIds: string[]; parentId: string | null; index: number }) => {
|
||||
const dragId = dragIds[0]
|
||||
if (!dragId) return
|
||||
|
||||
const dragged = tree.find((p) => p.id === dragId)
|
||||
if (newParentId !== null) {
|
||||
const target = tree.find((n) => n.id === newParentId)
|
||||
if (target && target.kind !== 'folder') return
|
||||
}
|
||||
const dragged = tree.find((n) => n.id === dragId)
|
||||
if (!dragged) return
|
||||
|
||||
const oldParentId = dragged.parentId
|
||||
const moved: LogosPageMeta = { ...dragged, parentId: newParentId }
|
||||
const moved: WorkspaceNodeMeta = { ...dragged, parentId: newParentId }
|
||||
|
||||
const newSiblings = tree
|
||||
.filter((p) => p.parentId === newParentId && p.id !== dragId)
|
||||
.filter((n) => n.parentId === newParentId && n.id !== dragId)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
newSiblings.splice(index, 0, moved)
|
||||
|
||||
const updates = new Map<string, LogosPageMeta>()
|
||||
newSiblings.forEach((p, i) => {
|
||||
updates.set(p.id, { ...p, parentId: newParentId, position: i })
|
||||
})
|
||||
const updates = new Map<string, WorkspaceNodeMeta>()
|
||||
newSiblings.forEach((n, i) => updates.set(n.id, { ...n, parentId: newParentId, position: i }))
|
||||
|
||||
if (oldParentId !== newParentId) {
|
||||
tree
|
||||
.filter((p) => p.parentId === oldParentId && p.id !== dragId)
|
||||
.filter((n) => n.parentId === oldParentId && n.id !== dragId)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.forEach((p, i) => {
|
||||
updates.set(p.id, { ...p, position: i })
|
||||
})
|
||||
.forEach((n, i) => updates.set(n.id, { ...n, position: i }))
|
||||
}
|
||||
|
||||
handleTreeChange(tree.map((p) => updates.get(p.id) ?? p))
|
||||
handleTreeChange(tree.map((n) => updates.get(n.id) ?? n))
|
||||
},
|
||||
[tree, handleTreeChange]
|
||||
)
|
||||
|
||||
// Expand all folders
|
||||
const handleExpandAll = useCallback(() => {
|
||||
treeRef.current?.openAll()
|
||||
}, [])
|
||||
|
||||
// Collapse all folders
|
||||
const handleCollapseAll = useCallback(() => {
|
||||
treeRef.current?.closeAll()
|
||||
}, [])
|
||||
|
||||
// Clear search
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery('')
|
||||
}, [])
|
||||
|
||||
// Fixed prop shape: react-arborist TreeProvider uses [...Object.values(treeProps), …] as useMemo deps;
|
||||
// varying key counts on the props object trigger "dependency array changed size" warnings.
|
||||
const arboristTreeProps = useMemo(
|
||||
() => ({
|
||||
data: filteredTree,
|
||||
@@ -536,74 +365,23 @@ export function TreeBrowser() {
|
||||
childrenAccessor: 'children' as const,
|
||||
width: '100%' as const,
|
||||
height: Math.max(treeContainerHeight, 100),
|
||||
rowHeight: 32,
|
||||
indent: ROW_INDENT_PX,
|
||||
rowHeight: 30,
|
||||
indent: INDENT,
|
||||
renderRow: TreeRow,
|
||||
initialOpenState,
|
||||
onMove: handleMove,
|
||||
disableDrag: Boolean(searchQuery),
|
||||
disableDrop: Boolean(searchQuery),
|
||||
className: 'react-arborist-tree',
|
||||
renderCursor: LogosDropCursor,
|
||||
children: LogosTreeNode,
|
||||
renderCursor: DropCursor,
|
||||
children: WorkspaceTreeNode,
|
||||
}),
|
||||
[filteredTree, initialOpenState, handleMove, searchQuery, treeContainerHeight]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
{/* Search Toolbar */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-sidebar-foreground/50 pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search pages..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-7 pr-7 h-7 text-xs bg-sidebar-accent/50 border-sidebar-border"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 h-7 w-7"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
title="Expand/Collapse options"
|
||||
>
|
||||
<ChevronsUpDown className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleExpandAll}>
|
||||
<ChevronsDownUp className="mr-2 size-3.5" />
|
||||
Expand all
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCollapseAll}>
|
||||
<ChevronsUpDown className="mr-2 size-3.5" />
|
||||
Collapse all
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<div ref={treeContainerRef} className="flex-1 min-h-0 overflow-hidden">
|
||||
<Tree ref={treeRef} {...arboristTreeProps} />
|
||||
</div>
|
||||
<div ref={treeContainerRef} className="h-full min-h-0 overflow-hidden">
|
||||
<Tree ref={treeRef} {...arboristTreeProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
265
frontend/src/app/recollections/layout/WorkspaceTreeContext.tsx
Normal file
265
frontend/src/app/recollections/layout/WorkspaceTreeContext.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Unified workspace tree context: pages, scenes, and folders in a single tree.
|
||||
* Replaces both RecollectionSidebarContext and FluxSceneContext.
|
||||
* Exports backward-compat shim hooks for existing consumers.
|
||||
*/
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
ensureWorkspaceTree,
|
||||
setWorkspaceTree,
|
||||
removeLogosPageContent,
|
||||
removeSceneGraph,
|
||||
DEFAULT_SCENE_ID,
|
||||
type WorkspaceNodeMeta,
|
||||
type WorkspaceNodeId,
|
||||
type WorkspaceNodeKind,
|
||||
type LogosPageMeta,
|
||||
type FluxSceneMeta,
|
||||
type FluxSceneId,
|
||||
} from '../state/recollectionStore'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WorkspaceTreeContextValue = {
|
||||
tree: WorkspaceNodeMeta[]
|
||||
setTree: React.Dispatch<React.SetStateAction<WorkspaceNodeMeta[]>>
|
||||
|
||||
activeItemId: WorkspaceNodeId | null
|
||||
activeItemKind: WorkspaceNodeKind | null
|
||||
|
||||
handleSelectItem: (id: WorkspaceNodeId) => void
|
||||
handleTreeChange: (tree: WorkspaceNodeMeta[]) => void
|
||||
|
||||
handleAddNode: (kind: WorkspaceNodeKind, parentId?: WorkspaceNodeId | null, title?: string) => WorkspaceNodeId
|
||||
handleRenameNode: (id: WorkspaceNodeId, title: string) => void
|
||||
handleDeleteNode: (id: WorkspaceNodeId) => void
|
||||
}
|
||||
|
||||
const WorkspaceTreeContext = createContext<WorkspaceTreeContextValue | null>(null)
|
||||
|
||||
export function useWorkspaceTree(): WorkspaceTreeContextValue {
|
||||
const ctx = useContext(WorkspaceTreeContext)
|
||||
if (!ctx) throw new Error('useWorkspaceTree must be used within WorkspaceTreeProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let nodeCounter = 0
|
||||
|
||||
export function WorkspaceTreeProvider({ children }: { children: React.ReactNode }) {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const [tree, setTree] = useState<WorkspaceNodeMeta[]>([])
|
||||
const [activeItemId, setActiveItemId] = useState<WorkspaceNodeId | null>(null)
|
||||
|
||||
// Load tree on recollection change
|
||||
useEffect(() => {
|
||||
if (!recollectionId) return
|
||||
const t = ensureWorkspaceTree(recollectionId)
|
||||
setTree(t)
|
||||
}, [recollectionId])
|
||||
|
||||
// Sync activeItemId from URL
|
||||
useEffect(() => {
|
||||
if (!recollectionId || tree.length === 0) return
|
||||
const pageFromUrl = searchParams.get('page')
|
||||
const sceneFromUrl = searchParams.get('scene')
|
||||
setActiveItemId((prev) => {
|
||||
if (pageFromUrl && tree.some((n) => n.id === pageFromUrl && n.kind === 'page')) return pageFromUrl
|
||||
if (sceneFromUrl && tree.some((n) => n.id === sceneFromUrl && n.kind === 'scene')) return sceneFromUrl
|
||||
if (prev != null && tree.some((n) => n.id === prev)) return prev
|
||||
// Default to first page or scene
|
||||
const firstContent = tree.find((n) => n.kind === 'page' || n.kind === 'scene')
|
||||
return firstContent?.id ?? null
|
||||
})
|
||||
}, [recollectionId, searchParams, tree])
|
||||
|
||||
// Persist tree on changes
|
||||
useEffect(() => {
|
||||
if (!recollectionId || tree.length === 0) return
|
||||
setWorkspaceTree(recollectionId, tree)
|
||||
}, [recollectionId, tree])
|
||||
|
||||
const activeItemKind = useMemo(() => {
|
||||
if (!activeItemId) return null
|
||||
return tree.find((n) => n.id === activeItemId)?.kind ?? null
|
||||
}, [activeItemId, tree])
|
||||
|
||||
const handleSelectItem = useCallback(
|
||||
(id: WorkspaceNodeId) => {
|
||||
if (!recollectionId) return
|
||||
const node = tree.find((n) => n.id === id)
|
||||
if (!node) return
|
||||
setActiveItemId(id)
|
||||
const base = `/recollections/${recollectionId}`
|
||||
if (node.kind === 'page') {
|
||||
navigate(`${base}/logos?page=${encodeURIComponent(id)}`)
|
||||
} else if (node.kind === 'scene') {
|
||||
navigate(`${base}/flux?scene=${encodeURIComponent(id)}`)
|
||||
}
|
||||
// folder: no navigation, just select
|
||||
},
|
||||
[recollectionId, tree, navigate]
|
||||
)
|
||||
|
||||
const handleTreeChange = useCallback((newTree: WorkspaceNodeMeta[]) => {
|
||||
setTree(newTree)
|
||||
}, [])
|
||||
|
||||
const handleAddNode = useCallback(
|
||||
(kind: WorkspaceNodeKind, parentId?: WorkspaceNodeId | null, title?: string) => {
|
||||
nodeCounter += 1
|
||||
const prefix = kind === 'page' ? 'page' : kind === 'scene' ? 'scene' : 'folder'
|
||||
const id = `${prefix}_${Date.now()}_${nodeCounter}`
|
||||
const siblings = tree.filter((n) => n.parentId === (parentId ?? null))
|
||||
const maxPos = siblings.reduce((max, n) => Math.max(max, n.position), -1)
|
||||
const defaultTitle = kind === 'page' ? 'Untitled' : kind === 'scene' ? `Scene ${tree.filter((n) => n.kind === 'scene').length + 1}` : 'New Folder'
|
||||
const node: WorkspaceNodeMeta = {
|
||||
id,
|
||||
title: title ?? defaultTitle,
|
||||
kind,
|
||||
parentId: parentId ?? null,
|
||||
position: maxPos + 1,
|
||||
}
|
||||
setTree((prev) => [...prev, node])
|
||||
if (kind !== 'folder') {
|
||||
handleSelectItem(id)
|
||||
}
|
||||
return id
|
||||
},
|
||||
[tree, handleSelectItem]
|
||||
)
|
||||
|
||||
const handleRenameNode = useCallback(
|
||||
(id: WorkspaceNodeId, title: string) => {
|
||||
setTree((prev) => prev.map((n) => (n.id === id ? { ...n, title } : n)))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleDeleteNode = useCallback(
|
||||
(id: WorkspaceNodeId) => {
|
||||
if (!recollectionId) return
|
||||
// Collect this node and all descendants
|
||||
const toDelete = new Set<string>()
|
||||
const collect = (nodeId: string) => {
|
||||
toDelete.add(nodeId)
|
||||
for (const child of tree.filter((n) => n.parentId === nodeId)) {
|
||||
collect(child.id)
|
||||
}
|
||||
}
|
||||
collect(id)
|
||||
|
||||
// Clean up storage for deleted nodes
|
||||
for (const nodeId of toDelete) {
|
||||
const node = tree.find((n) => n.id === nodeId)
|
||||
if (!node) continue
|
||||
if (node.kind === 'page') removeLogosPageContent(recollectionId, nodeId)
|
||||
if (node.kind === 'scene') removeSceneGraph(recollectionId, nodeId)
|
||||
}
|
||||
|
||||
setTree((prev) => {
|
||||
const next = prev.filter((n) => !toDelete.has(n.id))
|
||||
// If we deleted the active item, select the first remaining content node
|
||||
if (activeItemId && toDelete.has(activeItemId) && next.length > 0) {
|
||||
const firstContent = next.find((n) => n.kind === 'page' || n.kind === 'scene')
|
||||
if (firstContent) handleSelectItem(firstContent.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[recollectionId, tree, activeItemId, handleSelectItem]
|
||||
)
|
||||
|
||||
const value: WorkspaceTreeContextValue = {
|
||||
tree,
|
||||
setTree,
|
||||
activeItemId,
|
||||
activeItemKind,
|
||||
handleSelectItem,
|
||||
handleTreeChange,
|
||||
handleAddNode,
|
||||
handleRenameNode,
|
||||
handleDeleteNode,
|
||||
}
|
||||
|
||||
return <WorkspaceTreeContext.Provider value={value}>{children}</WorkspaceTreeContext.Provider>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward-compat shim: useRecollectionSidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type RecollectionSidebarContextValue = {
|
||||
tree: LogosPageMeta[]
|
||||
activePageId: string | null
|
||||
setTree: React.Dispatch<React.SetStateAction<WorkspaceNodeMeta[]>>
|
||||
handleSelectPage: (id: string) => void
|
||||
handleTreeChange: (tree: WorkspaceNodeMeta[]) => void
|
||||
handleDeletePage: (pageId: string) => void
|
||||
}
|
||||
|
||||
export function useRecollectionSidebar(): RecollectionSidebarContextValue {
|
||||
const ctx = useWorkspaceTree()
|
||||
return useMemo(() => ({
|
||||
tree: ctx.tree.filter((n) => n.kind === 'page').map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
parentId: n.parentId,
|
||||
position: n.position,
|
||||
})),
|
||||
activePageId: ctx.activeItemKind === 'page' ? ctx.activeItemId : null,
|
||||
setTree: ctx.setTree,
|
||||
handleSelectPage: ctx.handleSelectItem,
|
||||
handleTreeChange: ctx.handleTreeChange,
|
||||
handleDeletePage: ctx.handleDeleteNode,
|
||||
}), [ctx])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward-compat shim: useFluxScenes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FluxSceneContextValue = {
|
||||
scenes: FluxSceneMeta[]
|
||||
activeSceneId: FluxSceneId
|
||||
setScenes: React.Dispatch<React.SetStateAction<WorkspaceNodeMeta[]>>
|
||||
handleSelectScene: (id: FluxSceneId) => void
|
||||
handleAddScene: (title?: string) => FluxSceneId
|
||||
handleRenameScene: (id: FluxSceneId, title: string) => void
|
||||
handleDeleteScene: (id: FluxSceneId) => void
|
||||
handleReorderScenes: (scenes: FluxSceneMeta[]) => void
|
||||
}
|
||||
|
||||
export function useFluxScenes(): FluxSceneContextValue {
|
||||
const ctx = useWorkspaceTree()
|
||||
return useMemo(() => ({
|
||||
scenes: ctx.tree.filter((n) => n.kind === 'scene').map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
position: n.position,
|
||||
})),
|
||||
activeSceneId: ctx.activeItemKind === 'scene' && ctx.activeItemId ? ctx.activeItemId : DEFAULT_SCENE_ID,
|
||||
setScenes: ctx.setTree,
|
||||
handleSelectScene: ctx.handleSelectItem,
|
||||
handleAddScene: (title?: string) => ctx.handleAddNode('scene', null, title),
|
||||
handleRenameScene: ctx.handleRenameNode,
|
||||
handleDeleteScene: ctx.handleDeleteNode,
|
||||
handleReorderScenes: () => {},
|
||||
}), [ctx])
|
||||
}
|
||||
|
||||
export function useOptionalFluxScenes(): FluxSceneContextValue | null {
|
||||
const ctx = useContext(WorkspaceTreeContext)
|
||||
if (!ctx) return null
|
||||
return useFluxScenes()
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuContro
|
||||
import { BlockNoteView } from '@blocknote/shadcn'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import { useRecollectionActions } from '../layout/RecollectionActionsContext'
|
||||
import { useRecollectionSidebar } from '../layout/RecollectionSidebarContext'
|
||||
import { useRecollectionSidebar } from '../layout/WorkspaceTreeContext'
|
||||
import {
|
||||
getLogosContentForPage,
|
||||
setLogosContentForPage,
|
||||
|
||||
@@ -60,9 +60,26 @@ export type FluxSceneMeta = {
|
||||
|
||||
export const DEFAULT_SCENE_ID: FluxSceneId = '_default'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified Workspace Tree (pages + scenes + folders in one tree)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WorkspaceNodeKind = 'page' | 'scene' | 'folder'
|
||||
export type WorkspaceNodeId = string
|
||||
|
||||
/** Unified node in the workspace tree. Replaces separate LogosPageMeta and FluxSceneMeta. */
|
||||
export type WorkspaceNodeMeta = {
|
||||
id: WorkspaceNodeId
|
||||
title: string
|
||||
kind: WorkspaceNodeKind
|
||||
parentId: WorkspaceNodeId | null
|
||||
position: number
|
||||
}
|
||||
|
||||
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
||||
const FLUX_SCENE_TREE_PREFIX = 'zui_flux_scenes_'
|
||||
const FLUX_SCENE_GRAPH_PREFIX = 'zui_flux_scene_'
|
||||
const WORKSPACE_TREE_PREFIX = 'zui_workspace_tree_'
|
||||
const LOGOS_KEY_PREFIX = 'zui_logos_'
|
||||
const LOGOS_PAGE_TREE_PREFIX = 'zui_logos_pagetree_'
|
||||
const LOGOS_PAGE_CONTENT_PREFIX = 'zui_logos_page_'
|
||||
@@ -81,6 +98,10 @@ function getFluxSceneGraphKey(recollectionId: string, sceneId: FluxSceneId): str
|
||||
return `${FLUX_SCENE_GRAPH_PREFIX}${recollectionId}_${sceneId}`
|
||||
}
|
||||
|
||||
function getWorkspaceTreeKey(recollectionId: string): string {
|
||||
return `${WORKSPACE_TREE_PREFIX}${recollectionId}`
|
||||
}
|
||||
|
||||
function getLogosKey(recollectionId: string): string {
|
||||
return `${LOGOS_KEY_PREFIX}${recollectionId}`
|
||||
}
|
||||
@@ -255,6 +276,92 @@ export function ensureFluxSceneTree(recollectionId: string): FluxSceneMeta[] {
|
||||
return tree
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified Workspace Tree Storage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getWorkspaceTree(recollectionId: string): WorkspaceNodeMeta[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(getWorkspaceTreeKey(recollectionId))
|
||||
if (!raw) return []
|
||||
const data = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(data)) return []
|
||||
return data.filter(
|
||||
(item): item is WorkspaceNodeMeta =>
|
||||
item != null &&
|
||||
typeof item === 'object' &&
|
||||
typeof (item as WorkspaceNodeMeta).id === 'string' &&
|
||||
typeof (item as WorkspaceNodeMeta).title === 'string' &&
|
||||
typeof (item as WorkspaceNodeMeta).kind === 'string' &&
|
||||
((item as WorkspaceNodeMeta).parentId === null || typeof (item as WorkspaceNodeMeta).parentId === 'string') &&
|
||||
typeof (item as WorkspaceNodeMeta).position === 'number'
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function setWorkspaceTree(recollectionId: string, tree: WorkspaceNodeMeta[]): void {
|
||||
localStorage.setItem(getWorkspaceTreeKey(recollectionId), JSON.stringify(tree))
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize workspace tree for a recollection. Migrates existing separate
|
||||
* page tree + scene tree into a unified workspace tree on first access.
|
||||
*/
|
||||
export function ensureWorkspaceTree(recollectionId: string): WorkspaceNodeMeta[] {
|
||||
const existing = getWorkspaceTree(recollectionId)
|
||||
if (existing.length > 0) return existing
|
||||
|
||||
const merged: WorkspaceNodeMeta[] = []
|
||||
|
||||
// Migrate Logos pages
|
||||
let pages = getLogosPageTree(recollectionId)
|
||||
if (pages.length === 0) {
|
||||
// Check for legacy single-document Logos content
|
||||
const legacy = getLogosContent(recollectionId)
|
||||
const mainPage: LogosPageMeta = { id: 'main', title: 'Main', parentId: null, position: 0 }
|
||||
setLogosContentForPage(recollectionId, 'main', legacy ?? [])
|
||||
setLogosPageTree(recollectionId, [mainPage])
|
||||
pages = [mainPage]
|
||||
}
|
||||
for (const page of pages) {
|
||||
merged.push({
|
||||
id: page.id,
|
||||
title: page.title,
|
||||
kind: 'page',
|
||||
parentId: page.parentId,
|
||||
position: page.position,
|
||||
})
|
||||
}
|
||||
|
||||
// Migrate Flux scenes
|
||||
const scenes = getFluxSceneTree(recollectionId)
|
||||
if (scenes.length === 0) {
|
||||
// Migrate legacy single graph to default scene
|
||||
const legacyGraph = getGraph(recollectionId)
|
||||
const defaultScene: WorkspaceNodeMeta = { id: DEFAULT_SCENE_ID, title: 'Main', kind: 'scene', parentId: null, position: merged.length }
|
||||
merged.push(defaultScene)
|
||||
if (legacyGraph) {
|
||||
setSceneGraph(recollectionId, DEFAULT_SCENE_ID, legacyGraph)
|
||||
}
|
||||
} else {
|
||||
const maxRootPos = merged.filter((n) => n.parentId === null).reduce((max, n) => Math.max(max, n.position), -1)
|
||||
for (const scene of scenes) {
|
||||
merged.push({
|
||||
id: scene.id,
|
||||
title: scene.title,
|
||||
kind: 'scene',
|
||||
parentId: null,
|
||||
position: maxRootPos + 1 + scene.position,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
setWorkspaceTree(recollectionId, merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logos Storage
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -387,6 +494,8 @@ export function removeRecollectionData(recollectionId: string): void {
|
||||
removeLogosPageContent(recollectionId, page.id)
|
||||
}
|
||||
localStorage.removeItem(getLogosPageTreeKey(recollectionId))
|
||||
// Workspace tree
|
||||
localStorage.removeItem(getWorkspaceTreeKey(recollectionId))
|
||||
// Render cache
|
||||
localStorage.removeItem(getRenderCacheKey(recollectionId))
|
||||
}
|
||||
|
||||
@@ -418,6 +418,7 @@ pre {
|
||||
.react-arborist-tree {
|
||||
background: transparent !important;
|
||||
font-size: 0.875rem;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.react-arborist-tree * {
|
||||
|
||||
Reference in New Issue
Block a user