/** * Context for shared recollection actions: two slots (flux and logos) and layout-level import/export. * 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 { useLocation, useParams } from 'react-router-dom' import type { SaveStatus } from '@/app/canvas/useCanvasGraph' import { getGraph, setGraph, getLogosContent, setLogosContent, upsertRenderOutputEntry, RECOLLECTION_FILE_EXT, RECOLLECTION_VERSION, type StoredGraphState, type StoredLogosContent, type RenderOutputCacheEntry, } from '../state/recollectionStore' export type { RenderOutputCacheEntry } import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' import { backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils' import { toast } from 'sonner' export type { SaveStatus } /** Parsed recollection file format (import/export). */ export type RecollectionFilePayload = { version?: number graph?: { nodes: unknown[]; edges: unknown[] } logos?: StoredLogosContent } export type FluxSlot = { saveStatus: SaveStatus onSave: () => void canSave: boolean undo: () => void redo: () => void canUndo: boolean canRedo: boolean onRefreshFromStore: (graph: StoredGraphState) => void onDuplicate?: () => void onCopy?: () => void onPaste?: () => void canDuplicate?: boolean canCopy?: boolean onFitView?: () => void } export type LogosSlot = { saveStatus: SaveStatus onSave: () => void canSave: boolean undo: () => void redo: () => void canUndo: boolean canRedo: boolean onRefreshFromStore: () => void } export type RecollectionActionsContextValue = { flux: FluxSlot | null logos: LogosSlot | null setFluxSlot: (slot: FluxSlot | null) => void setLogosSlot: (slot: LogosSlot | null) => void /** Whether the Flux view is active (pathname ends with /flux). */ isFluxActive: boolean /** Whether the Logos view is active. */ isLogosActive: boolean /** Active slot (flux or logos by pathname). */ activeSlot: FluxSlot | LogosSlot | null onImport: () => void onExport: () => void /** Upsert a rendering node's output into the cache so Logos "Insert from Flux" block can show it. */ upsertRenderOutputToLogos: (entry: RenderOutputCacheEntry) => void } const RecollectionActionsContext = createContext(null) function validateAndWritePayload( recollectionId: string, payload: RecollectionFilePayload, backfillEdges: (nodes: AppNode[], edges: AppEdge[]) => AppEdge[] ): { graph?: StoredGraphState; logos?: StoredLogosContent } { const result: { graph?: StoredGraphState; logos?: StoredLogosContent } = {} if (payload.graph && Array.isArray(payload.graph.nodes) && Array.isArray(payload.graph.edges)) { const nodes = payload.graph.nodes as AppNode[] const edges = backfillEdges(nodes, payload.graph.edges as AppEdge[]) const graphState: StoredGraphState = { version: payload.version ?? RECOLLECTION_VERSION, nodes, edges, } setGraph(recollectionId, graphState) result.graph = graphState } if (payload.logos != null && Array.isArray(payload.logos)) { if (payload.logos.every((item) => item != null && typeof item === 'object')) { setLogosContent(recollectionId, payload.logos as StoredLogosContent) result.logos = payload.logos as StoredLogosContent } } return result } export function RecollectionActionsProvider({ children }: { children: React.ReactNode }) { const { recollectionId } = useParams<{ recollectionId: string }>() const { pathname } = useLocation() const [flux, setFluxSlotState] = useState(null) const [logos, setLogosSlotState] = useState(null) const importInputRef = useRef(null) const fluxRef = useRef(null) const logosRef = useRef(null) const pathnameRef = useRef(pathname) fluxRef.current = flux logosRef.current = logos pathnameRef.current = pathname const isFluxActive = pathname.endsWith('/flux') const isLogosActive = pathname.endsWith('/logos') || /\/recollections\/[^/]+\/?$/.test(pathname) const activeSlot = isFluxActive ? flux : isLogosActive ? logos : null const setFluxSlot = useCallback((slot: FluxSlot | null) => { setFluxSlotState(() => slot) }, []) const setLogosSlot = useCallback((slot: LogosSlot | null) => { setLogosSlotState(() => slot) }, []) const onExport = useCallback(() => { if (!recollectionId) return const graph = getGraph(recollectionId) const logosContent = getLogosContent(recollectionId) const payload: RecollectionFilePayload = { version: RECOLLECTION_VERSION, ...(graph && { graph: { nodes: graph.nodes, edges: graph.edges } }), ...(logosContent && { logos: logosContent }), } const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `recollection${RECOLLECTION_FILE_EXT}` a.click() URL.revokeObjectURL(url) toast.success('Recollection exported') }, [recollectionId]) const onImport = useCallback(() => { importInputRef.current?.click() }, []) const upsertRenderOutputToLogos = useCallback( (entry: RenderOutputCacheEntry) => { if (recollectionId) upsertRenderOutputEntry(recollectionId, entry) }, [recollectionId] ) const onImportFileChange = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0] e.target.value = '' if (!file || !recollectionId) return const reader = new FileReader() reader.onload = () => { try { const text = reader.result as string const payload = JSON.parse(text) as RecollectionFilePayload if (!payload || typeof payload !== 'object') { toast.error('Invalid file: not valid JSON') return } const written = validateAndWritePayload(recollectionId, payload, (nodes, edges) => backfillEdgeTargetTypes(nodes, edges) ) const currentPath = pathnameRef.current const fluxActive = currentPath.endsWith('/flux') const logosActive = currentPath.endsWith('/logos') || /\/recollections\/[^/]+\/?$/.test(currentPath) if (written.graph && fluxActive && fluxRef.current?.onRefreshFromStore) { fluxRef.current.onRefreshFromStore(written.graph) } if ((written.graph != null || written.logos != null) && logosActive && logosRef.current?.onRefreshFromStore) { logosRef.current.onRefreshFromStore() } if (payload.version != null && payload.version > RECOLLECTION_VERSION) { toast.error('Recollection was created with a newer app version') } else { toast.success('Recollection loaded') } } catch { toast.error('Invalid file: not valid JSON') } } reader.readAsText(file) }, [recollectionId] ) const value: RecollectionActionsContextValue = React.useMemo( () => ({ flux, logos, setFluxSlot, setLogosSlot, isFluxActive, isLogosActive, activeSlot, onImport, onExport, upsertRenderOutputToLogos, }), [flux, logos, setFluxSlot, setLogosSlot, isFluxActive, isLogosActive, activeSlot, onImport, onExport, upsertRenderOutputToLogos] ) return ( {children} ) } export function useRecollectionActions(): RecollectionActionsContextValue { const ctx = useContext(RecollectionActionsContext) if (!ctx) throw new Error('useRecollectionActions must be used within RecollectionActionsProvider') return ctx } /** Returns the context value or null when outside RecollectionActionsProvider. Use when the consumer may render outside recollection layout. */ export function useOptionalRecollectionActions(): RecollectionActionsContextValue | null { return useContext(RecollectionActionsContext) }