feat: multiple scenes

This commit is contained in:
2026-03-29 01:53:15 +01:00
parent 9607ff3478
commit 7ced2172af
15 changed files with 503 additions and 48 deletions

View File

@@ -12,11 +12,16 @@ import {
getLogosContent,
setLogosContent,
upsertRenderOutputEntry,
getFluxSceneTree,
setFluxSceneTree,
getSceneGraph,
setSceneGraph,
RECOLLECTION_FILE_EXT,
RECOLLECTION_VERSION,
type StoredGraphState,
type StoredLogosContent,
type RenderOutputCacheEntry,
type FluxSceneMeta,
} from '../state/recollectionStore'
export type { RenderOutputCacheEntry }
@@ -31,6 +36,8 @@ export type RecollectionFilePayload = {
version?: number
graph?: { nodes: unknown[]; edges: unknown[] }
logos?: StoredLogosContent
/** Flux scenes (Phase 2+). Each entry has scene metadata + graph. */
scenes?: Array<{ id: string; title: string; position: number; graph: { nodes: unknown[]; edges: unknown[] } }>
}
export type FluxSlot = {
@@ -104,6 +111,26 @@ function validateAndWritePayload(
result.logos = payload.logos as StoredLogosContent
}
}
// Import scenes
if (payload.scenes && Array.isArray(payload.scenes)) {
const sceneMetas: FluxSceneMeta[] = []
for (const s of payload.scenes) {
if (!s || typeof s.id !== 'string' || typeof s.title !== 'string') continue
sceneMetas.push({ id: s.id, title: s.title, position: s.position ?? 0 })
if (s.graph && Array.isArray(s.graph.nodes) && Array.isArray(s.graph.edges)) {
const nodes = s.graph.nodes as AppNode[]
const edges = backfillEdges(nodes, s.graph.edges as AppEdge[])
setSceneGraph(recollectionId, s.id, {
version: payload.version ?? RECOLLECTION_VERSION,
nodes,
edges,
})
}
}
if (sceneMetas.length > 0) {
setFluxSceneTree(recollectionId, sceneMetas)
}
}
return result
}
@@ -151,10 +178,17 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac
if (!recollectionId) return
const graph = getGraph(recollectionId)
const logosContent = getLogosContent(recollectionId)
// Export scenes
const sceneTree = getFluxSceneTree(recollectionId)
const scenes = sceneTree.map((s) => {
const sg = getSceneGraph(recollectionId, s.id)
return { ...s, graph: sg ? { nodes: sg.nodes, edges: sg.edges } : { nodes: [], edges: [] } }
})
const payload: RecollectionFilePayload = {
version: RECOLLECTION_VERSION,
...(graph && { graph: { nodes: graph.nodes, edges: graph.edges } }),
...(logosContent && { logos: logosContent }),
...(scenes.length > 0 && { scenes }),
}
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)

View File

@@ -2,13 +2,14 @@
* Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes.
*/
import React, { useCallback } from 'react'
import React, { useCallback, useState } from 'react'
import { useParams, useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Plus, FileText } from 'lucide-react'
import { Plus, FileText, Pencil, Trash2, Check, X } 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 {
SidebarContent,
@@ -87,25 +88,150 @@ export function RecollectionSidebar() {
</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">Canvas</span>
</button>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<FluxScenesSection base={base} isFluxView={isFluxView} />
</SidebarContent>
</div>
)
}
// ---------------------------------------------------------------------------
// Flux Scenes Section
// ---------------------------------------------------------------------------
function FluxScenesSection({ base, isFluxView }: { base: string; isFluxView: boolean }) {
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)
}
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>
)
}