feat: multiple scenes
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user