Compare commits
5 Commits
e64e29d773
...
be0f743970
| Author | SHA1 | Date | |
|---|---|---|---|
| be0f743970 | |||
| 3de9a33074 | |||
| 9d38b1df2b | |||
| 9f56b728c0 | |||
| f6193bf180 |
@@ -36,8 +36,8 @@ import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
|
|||||||
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
|
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
|
||||||
import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore'
|
import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore'
|
||||||
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||||
import { useRecollectionActions } from '@/app/recollections/RecollectionActionsContext'
|
import { useRecollectionActions } from '@/app/recollections/layout/RecollectionActionsContext'
|
||||||
import type { StoredGraphState } from '@/app/recollections/recollectionStore'
|
import type { StoredGraphState } from '@/app/recollections/state/recollectionStore'
|
||||||
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
|
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
|
||||||
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
|
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
|
||||||
import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts'
|
import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts'
|
||||||
@@ -67,7 +67,7 @@ import {
|
|||||||
} from '@/lib/graph/nodeRegistry'
|
} from '@/lib/graph/nodeRegistry'
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
|
import { RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
||||||
|
|
||||||
const SNAP_GRID: [number, number] = [15, 15]
|
const SNAP_GRID: [number, number] = [15, 15]
|
||||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||||
@@ -76,12 +76,60 @@ const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
|
|||||||
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
|
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
|
||||||
})
|
})
|
||||||
|
|
||||||
function FlowFitViewOnLoad() {
|
function FlowFitViewOnLoad({ disabled }: { disabled?: boolean }) {
|
||||||
const nodesInitialized = useNodesInitialized()
|
const nodesInitialized = useNodesInitialized()
|
||||||
const { fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (nodesInitialized) fitView?.({ duration: 200 })
|
if (disabled || !nodesInitialized) return
|
||||||
}, [nodesInitialized, fitView])
|
fitView?.({ duration: 200 })
|
||||||
|
}, [disabled, nodesInitialized, fitView])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function FocusNodeOnLoad({ focusNodeId }: { focusNodeId?: string }) {
|
||||||
|
const nodesInitialized = useNodesInitialized()
|
||||||
|
const { getNodes, setCenter, screenToFlowPosition, project } = useReactFlow()
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!focusNodeId || !nodesInitialized) return
|
||||||
|
// Small delay so node DOM and layout are fully ready before centering/zooming.
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const nodes = getNodes()
|
||||||
|
const node = nodes.find((n) => n.id === focusNodeId)
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
|
const el = document.querySelector(
|
||||||
|
`.react-flow__node[data-id="${focusNodeId}"]`
|
||||||
|
) as HTMLElement | null
|
||||||
|
|
||||||
|
if (el) {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const screenCenter = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
|
||||||
|
const toFlow = screenToFlowPosition ?? project
|
||||||
|
if (toFlow) {
|
||||||
|
const flowCenter = toFlow(screenCenter)
|
||||||
|
setCenter(flowCenter.x, flowCenter.y, { duration: 400, zoom: 1.8 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: center using node position + dimensions from React Flow state.
|
||||||
|
const anyNode = node as Node & {
|
||||||
|
positionAbsolute?: { x: number; y: number }
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
const basePos = anyNode.positionAbsolute ?? anyNode.position ?? { x: 0, y: 0 }
|
||||||
|
const width = anyNode.width ?? 0
|
||||||
|
const height = anyNode.height ?? 0
|
||||||
|
const centerX = basePos.x + width / 2
|
||||||
|
const centerY = basePos.y + height / 2
|
||||||
|
setCenter(centerX, centerY, { duration: 400, zoom: 1.8 })
|
||||||
|
}, 150)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [focusNodeId, nodesInitialized, getNodes, setCenter, screenToFlowPosition, project])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,9 +200,11 @@ function FullscreenNodeContent({ node }: { node: AppNode }) {
|
|||||||
export type CanvasPageProps = {
|
export type CanvasPageProps = {
|
||||||
/** Optional recollection id for per-recollection graph loading */
|
/** Optional recollection id for per-recollection graph loading */
|
||||||
recollectionId?: string
|
recollectionId?: string
|
||||||
|
/** Optional node id to focus when the canvas loads (e.g. from Katalogos artifacts). */
|
||||||
|
focusNodeId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const { showMinimap } = usePlatform()
|
const { showMinimap } = usePlatform()
|
||||||
const {
|
const {
|
||||||
@@ -688,7 +738,8 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
|||||||
)}
|
)}
|
||||||
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
||||||
<ViewportDisplayProvider>
|
<ViewportDisplayProvider>
|
||||||
<FlowFitViewOnLoad />
|
<FlowFitViewOnLoad disabled={Boolean(focusNodeId)} />
|
||||||
|
<FocusNodeOnLoad focusNodeId={focusNodeId} />
|
||||||
<FlowKeyboardShortcuts />
|
<FlowKeyboardShortcuts />
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
|
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
|
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
|
||||||
import { loadGraphFromStorage } from '@/app/recollections/recollectionGraphStorage'
|
import { loadGraphFromStorage } from '@/app/recollections/state/recollectionGraphStorage'
|
||||||
|
|
||||||
/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */
|
/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */
|
||||||
export function backfillEdgeTargetTypes(
|
export function backfillEdgeTargetTypes(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
||||||
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
||||||
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
|
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
|
|
||||||
export type SaveStatus = 'saved' | 'unsaved' | 'saving'
|
export type SaveStatus = 'saved' | 'unsaved' | 'saving'
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||||
import type { Recollection } from './types'
|
import type { Recollection } from './types'
|
||||||
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
|
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
||||||
|
|
||||||
const STORAGE_KEY = 'zui_platform_recollections'
|
const STORAGE_KEY = 'zui_platform_recollections'
|
||||||
const ORDER_STORAGE_KEY = 'zui_platform_recollection_order'
|
const ORDER_STORAGE_KEY = 'zui_platform_recollection_order'
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
/**
|
|
||||||
* Recollection view switcher: Logos or Flux based on pathname.
|
|
||||||
* Single container, conditional render — only the active view is mounted (no CSS flip).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react'
|
|
||||||
import { useLocation, useParams } from 'react-router-dom'
|
|
||||||
import { LogosPage } from './logos/LogosPage'
|
|
||||||
import { FluxRoute } from './flux/FluxRoute'
|
|
||||||
|
|
||||||
export function FlippingCardView() {
|
|
||||||
const { pathname } = useLocation()
|
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
|
||||||
const isFlux = pathname.endsWith('/flux')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
||||||
{isFlux ? (
|
|
||||||
recollectionId ? (
|
|
||||||
<FluxRoute />
|
|
||||||
) : null
|
|
||||||
) : (
|
|
||||||
<LogosPage />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Layout for a single recollection: shared menubar and flipping card (Logos front / Flux back).
|
* Layout for a single recollection: shared menubar and nested routes for Logos / Katalogos / Flux.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, Outlet, useParams } from 'react-router-dom'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { RecollectionMenubarProvider } from './RecollectionMenubarContext'
|
import { RecollectionMenubarProvider } from './layout/RecollectionMenubarContext'
|
||||||
import { RecollectionActionsProvider } from './RecollectionActionsContext'
|
import { RecollectionActionsProvider } from './layout/RecollectionActionsContext'
|
||||||
import { RecollectionTitleContent } from './RecollectionTitleContent'
|
import { RecollectionTitleContent } from './layout/RecollectionTitleContent'
|
||||||
import { RecollectionMenubar } from './RecollectionMenubar'
|
import { RecollectionMenubar } from './layout/RecollectionMenubar'
|
||||||
import { FlippingCardView } from './FlippingCardView'
|
|
||||||
|
|
||||||
export function RecollectionLayout() {
|
export function RecollectionLayout() {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
@@ -40,7 +39,7 @@ export function RecollectionLayout() {
|
|||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<RecollectionMenubar />
|
<RecollectionMenubar />
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<FlippingCardView />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</RecollectionActionsProvider>
|
</RecollectionActionsProvider>
|
||||||
|
|||||||
@@ -63,12 +63,12 @@ import {
|
|||||||
removeGraphFromStorage,
|
removeGraphFromStorage,
|
||||||
RECOLLECTION_FILE_EXT,
|
RECOLLECTION_FILE_EXT,
|
||||||
RECOLLECTION_VERSION,
|
RECOLLECTION_VERSION,
|
||||||
} from './recollectionGraphStorage'
|
} from './state/recollectionGraphStorage'
|
||||||
import { getLogosContent, setLogosContent } from './recollectionStore'
|
import { getLogosContent, setLogosContent } from './state/recollectionStore'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { NewRecollectionDialog } from '@/app/kosmos/NewRecollectionDialog'
|
import { NewRecollectionDialog } from '@/app/kosmos/NewRecollectionDialog'
|
||||||
import { RecollectionsPageBackground } from './RecollectionsPageBackground'
|
import { RecollectionsPageBackground } from './layout/RecollectionsPageBackground'
|
||||||
import { RenameRecollectionDialog } from './RenameRecollectionDialog'
|
import { RenameRecollectionDialog } from './layout/RenameRecollectionDialog'
|
||||||
import type { Recollection } from '@/app/kosmos/types'
|
import type { Recollection } from '@/app/kosmos/types'
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [10, 25, 50] as const
|
const PAGE_SIZE_OPTIONS = [10, 25, 50] as const
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Flux route: renders the graph canvas (CanvasPage) for the current recollection.
|
* Flux route: renders the graph canvas (CanvasPage) for the current recollection.
|
||||||
|
* Supports optional focusNode query param to center on a specific node.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useRef } from 'react'
|
import React, { useEffect, useRef } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { CanvasPage } from '@/app/canvas/CanvasPage'
|
import { CanvasPage } from '@/app/canvas/CanvasPage'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
|
|
||||||
export function FluxRoute() {
|
export function FluxRoute() {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
const { updateLastEdited } = usePlatform()
|
const { updateLastEdited } = usePlatform()
|
||||||
const updateLastEditedRef = useRef(updateLastEdited)
|
const updateLastEditedRef = useRef(updateLastEdited)
|
||||||
updateLastEditedRef.current = updateLastEdited
|
updateLastEditedRef.current = updateLastEdited
|
||||||
@@ -17,11 +19,13 @@ export function FluxRoute() {
|
|||||||
if (recollectionId) updateLastEditedRef.current(recollectionId)
|
if (recollectionId) updateLastEditedRef.current(recollectionId)
|
||||||
}, [recollectionId])
|
}, [recollectionId])
|
||||||
|
|
||||||
|
const focusNodeId = searchParams.get('focusNode') ?? undefined
|
||||||
|
|
||||||
if (!recollectionId) return null
|
if (!recollectionId) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<CanvasPage key={recollectionId} recollectionId={recollectionId} />
|
<CanvasPage key={recollectionId} recollectionId={recollectionId} focusNodeId={focusNodeId} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
127
frontend/src/app/recollections/katalogos/KatalogosPage.tsx
Normal file
127
frontend/src/app/recollections/katalogos/KatalogosPage.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Katalogos page: third view for a recollection.
|
||||||
|
* Shows live \"Artifacts\" from Flux rendering nodes in a card grid.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
|
import { getRenderOutputCache, formatTimeSinceLastUpdate } from '../state/recollectionStore'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export function KatalogosPage() {
|
||||||
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const { recollections } = usePlatform()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
||||||
|
const title = recollection?.name ?? 'Untitled'
|
||||||
|
|
||||||
|
const artifacts = useMemo(
|
||||||
|
() => (recollectionId ? getRenderOutputCache(recollectionId) : []),
|
||||||
|
[recollectionId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleViewInFlux = (nodeId: string) => {
|
||||||
|
if (!recollectionId) return
|
||||||
|
navigate(`/recollections/${recollectionId}/flux?focusNode=${encodeURIComponent(nodeId)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
|
||||||
|
<div className="mx-auto w-full max-w-5xl p-4">
|
||||||
|
<h1 className="mb-2 truncate font-serif text-2xl font-semibold tracking-tight text-foreground">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="mb-6 text-sm text-muted-foreground">
|
||||||
|
Katalogos · Live artifacts produced by Flux rendering nodes for this recollection.
|
||||||
|
</p>
|
||||||
|
{artifacts.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/10 p-4 text-sm text-muted-foreground">
|
||||||
|
No artifacts yet. In Flux, run a graph with a rendering node; its output will be cached as an artifact and
|
||||||
|
appear here, as well as in Logos blocks that insert artifacts.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{artifacts.map((artifact) => {
|
||||||
|
const label = artifact.label || artifact.nodeId
|
||||||
|
const isImage =
|
||||||
|
artifact.type === 'image' ||
|
||||||
|
Boolean(artifact.content?.trim() && /<svg[\\s>]/i.test(artifact.content.trim()))
|
||||||
|
const imageSrc =
|
||||||
|
isImage && artifact.content
|
||||||
|
? artifact.content.startsWith('data:')
|
||||||
|
? artifact.content
|
||||||
|
: `data:image/svg+xml;utf8,${encodeURIComponent(artifact.content)}`
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={artifact.nodeId}
|
||||||
|
className="flex h-full flex-col rounded-lg border border-border bg-card text-card-foreground shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{label}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">Node: {artifact.nodeId}</p>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-emerald-500">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2 px-3 py-3">
|
||||||
|
{isImage && imageSrc ? (
|
||||||
|
<div className="flex min-h-[120px] items-center justify-center overflow-hidden rounded-md border bg-muted">
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={label || 'Artifact image'}
|
||||||
|
className="max-h-48 w-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : artifact.content ? (
|
||||||
|
<div className="min-h-[120px] overflow-hidden rounded-md border border-border bg-background">
|
||||||
|
<iframe
|
||||||
|
title={label || 'Artifact HTML output'}
|
||||||
|
srcDoc={artifact.content}
|
||||||
|
className="h-40 w-full"
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-[80px] items-center justify-center rounded-md border border-dashed border-muted-foreground/40 bg-muted/40 px-3 text-xs text-muted-foreground">
|
||||||
|
No preview available.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 bg-card px-3 py-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
Type: <span className="font-medium">{artifact.type}</span>
|
||||||
|
</p>
|
||||||
|
{artifact.updatedAt != null && (
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
· {formatTimeSinceLastUpdate(artifact.updatedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => handleViewInFlux(artifact.nodeId)}
|
||||||
|
>
|
||||||
|
View in Flux
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
type StoredGraphState,
|
type StoredGraphState,
|
||||||
type StoredLogosContent,
|
type StoredLogosContent,
|
||||||
type RenderOutputCacheEntry,
|
type RenderOutputCacheEntry,
|
||||||
} from './recollectionStore'
|
} from '../state/recollectionStore'
|
||||||
|
|
||||||
export type { RenderOutputCacheEntry }
|
export type { RenderOutputCacheEntry }
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
102
frontend/src/app/recollections/layout/RecollectionFileMenu.tsx
Normal file
102
frontend/src/app/recollections/layout/RecollectionFileMenu.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* File menu for a recollection (emanation).
|
||||||
|
* Hosts Save, Rename, Import, Export that used to live under the title dropdown.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||||
|
import { useRecollectionActions } from './RecollectionActionsContext'
|
||||||
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
|
import { RenameRecollectionDialog } from './RenameRecollectionDialog'
|
||||||
|
import { Download, FolderOpen, Pencil, Save } from 'lucide-react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
export function RecollectionFileMenu() {
|
||||||
|
const { activeSlot, onImport, onExport } = useRecollectionActions()
|
||||||
|
const { recollections, renameRecollection } = usePlatform()
|
||||||
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
|
||||||
|
const [renameModalOpen, setRenameModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const onSave = activeSlot?.onSave
|
||||||
|
const canSave = activeSlot?.canSave ?? false
|
||||||
|
|
||||||
|
const recollectionName = useMemo(
|
||||||
|
() => (recollectionId ? recollections.find((p) => p.id === recollectionId)?.name ?? '' : ''),
|
||||||
|
[recollectionId, recollections]
|
||||||
|
)
|
||||||
|
|
||||||
|
const menuItems = useMemo(
|
||||||
|
() => (
|
||||||
|
<>
|
||||||
|
{recollectionId && onSave != null && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem onClick={onSave} disabled={!canSave} className="gap-2">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
Save
|
||||||
|
<span className="ml-auto pl-4">
|
||||||
|
<KbdGroup>
|
||||||
|
<Kbd>⌘S</Kbd>
|
||||||
|
</KbdGroup>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{recollectionId && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem onClick={() => setRenameModalOpen(true)} className="gap-2">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem onClick={onImport} className="gap-2">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Import…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onExport} className="gap-2">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Export…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[recollectionId, onSave, canSave, onImport, onExport]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className="rounded-sm px-2 py-1 text-sm font-normal text-muted-foreground outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent">
|
||||||
|
File
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuContent align="start" className="z-[200] min-w-[12rem]">
|
||||||
|
{menuItems}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenu>
|
||||||
|
<RenameRecollectionDialog
|
||||||
|
open={renameModalOpen}
|
||||||
|
onOpenChange={setRenameModalOpen}
|
||||||
|
recollectionId={recollectionId ?? ''}
|
||||||
|
initialName={recollectionName}
|
||||||
|
recollections={recollections}
|
||||||
|
onRename={(id, newName) => {
|
||||||
|
renameRecollection(id, newName)
|
||||||
|
setRenameModalOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ import { ArrowLeft } from 'lucide-react'
|
|||||||
import { useRecollectionMenubar } from './RecollectionMenubarContext'
|
import { useRecollectionMenubar } from './RecollectionMenubarContext'
|
||||||
import { RecollectionEditViewMenus } from './RecollectionEditViewMenus'
|
import { RecollectionEditViewMenus } from './RecollectionEditViewMenus'
|
||||||
import { RecollectionViewSwitcher } from './RecollectionViewSwitcher'
|
import { RecollectionViewSwitcher } from './RecollectionViewSwitcher'
|
||||||
|
import { RecollectionFileMenu } from './RecollectionFileMenu'
|
||||||
|
|
||||||
export function RecollectionMenubar() {
|
export function RecollectionMenubar() {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
@@ -21,21 +22,26 @@ export function RecollectionMenubar() {
|
|||||||
) : null
|
) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-9 w-full shrink-0 items-center gap-2 overflow-visible border-b border-border/40 bg-background px-2">
|
<div className="relative flex h-9 w-full shrink-0 items-center gap-2 overflow-visible border-b border-border/40 bg-background px-2">
|
||||||
<Link
|
<div className="flex items-center gap-2">
|
||||||
to="/recollections"
|
<Link
|
||||||
aria-label="Back to recollections"
|
to="/recollections"
|
||||||
className="flex shrink-0 items-center rounded-sm px-2 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
|
aria-label="Back to recollections"
|
||||||
>
|
className="flex shrink-0 items-center rounded-sm px-2 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
<ArrowLeft className="size-4" />
|
>
|
||||||
</Link>
|
<ArrowLeft className="size-4" />
|
||||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
</Link>
|
||||||
{titleContent ?? defaultTitle}
|
<RecollectionViewSwitcher />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-visible">
|
<div className="pointer-events-none absolute left-1/2 top-1/2 flex min-w-0 -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
||||||
|
<div className="pointer-events-auto flex min-w-0 items-center gap-1.5">
|
||||||
|
{titleContent ?? defaultTitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-2 pl-2">
|
||||||
|
<RecollectionFileMenu />
|
||||||
<RecollectionEditViewMenus />
|
<RecollectionEditViewMenus />
|
||||||
</div>
|
</div>
|
||||||
<RecollectionViewSwitcher />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3,22 +3,12 @@
|
|||||||
* the active slot (RecollectionActionsContext); Import/Export from layout-level handlers.
|
* the active slot (RecollectionActionsContext); Import/Export from layout-level handlers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
|
||||||
import { RenameRecollectionDialog } from './RenameRecollectionDialog'
|
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { useRecollectionMenubar } from './RecollectionMenubarContext'
|
import { useRecollectionMenubar } from './RecollectionMenubarContext'
|
||||||
import { useRecollectionActions } from './RecollectionActionsContext'
|
import { useRecollectionActions } from './RecollectionActionsContext'
|
||||||
import { CheckCircle2, ChevronDown, CircleDot, Download, FolderOpen, Loader2, Pencil, Save } from 'lucide-react'
|
import { CheckCircle2, CircleDot, Loader2 } from 'lucide-react'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
const SAVE_KEYS = { key: 's', shiftKey: false }
|
const SAVE_KEYS = { key: 's', shiftKey: false }
|
||||||
@@ -31,15 +21,14 @@ function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
|
|||||||
/** Registers the shared title + save dropdown as titleContent. Renders nothing. */
|
/** Registers the shared title + save dropdown as titleContent. Renders nothing. */
|
||||||
export function RecollectionTitleContent() {
|
export function RecollectionTitleContent() {
|
||||||
const { setTitleContent } = useRecollectionMenubar()
|
const { setTitleContent } = useRecollectionMenubar()
|
||||||
const { activeSlot, onImport, onExport } = useRecollectionActions()
|
const { activeSlot } = useRecollectionActions()
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
const { recollections, renameRecollection } = usePlatform()
|
const { recollections } = usePlatform()
|
||||||
const recollectionName = useMemo(
|
const recollectionName = useMemo(
|
||||||
() => (recollectionId ? recollections.find((p) => p.id === recollectionId)?.name ?? null : null),
|
() => (recollectionId ? recollections.find((p) => p.id === recollectionId)?.name ?? null : null),
|
||||||
[recollectionId, recollections]
|
[recollectionId, recollections]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [renameModalOpen, setRenameModalOpen] = useState(false)
|
|
||||||
const [showSavedBriefly, setShowSavedBriefly] = useState(false)
|
const [showSavedBriefly, setShowSavedBriefly] = useState(false)
|
||||||
const savedBrieflyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const savedBrieflyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const prevSaveStatusRef = useRef<string>('saved')
|
const prevSaveStatusRef = useRef<string>('saved')
|
||||||
@@ -116,72 +105,19 @@ export function RecollectionTitleContent() {
|
|||||||
[recollectionName, saveStatus, showSavedBriefly]
|
[recollectionName, saveStatus, showSavedBriefly]
|
||||||
)
|
)
|
||||||
|
|
||||||
const dropdownContent = useMemo(() => (
|
const titleNode = useMemo(
|
||||||
<>
|
() => (
|
||||||
{recollectionId && (
|
<span className="flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm">
|
||||||
<>
|
|
||||||
{onSave != null && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={onSave}
|
|
||||||
disabled={!canSave}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
Save
|
|
||||||
<span className="ml-auto pl-4"><KbdGroup><Kbd>⌘S</Kbd></KbdGroup></span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem onClick={() => setRenameModalOpen(true)} className="gap-2">
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
Rename
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem onClick={onImport} className="gap-2">
|
|
||||||
<FolderOpen className="h-4 w-4" />
|
|
||||||
Import…
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={onExport} className="gap-2">
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
Export…
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
), [recollectionId, onSave, canSave, onImport, onExport])
|
|
||||||
|
|
||||||
const titleNode = useMemo(() => (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger className="flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent">
|
|
||||||
{trigger}
|
{trigger}
|
||||||
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
|
</span>
|
||||||
</DropdownMenuTrigger>
|
),
|
||||||
<DropdownMenuPortal>
|
[trigger]
|
||||||
<DropdownMenuContent align="start" className="z-[200] min-w-[12rem]">
|
)
|
||||||
{dropdownContent}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenuPortal>
|
|
||||||
</DropdownMenu>
|
|
||||||
), [trigger, dropdownContent])
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setTitleContent(titleNode)
|
setTitleContent(titleNode)
|
||||||
return () => setTitleContent(null)
|
return () => setTitleContent(null)
|
||||||
}, [titleNode, setTitleContent])
|
}, [titleNode, setTitleContent])
|
||||||
|
|
||||||
return (
|
return null
|
||||||
<RenameRecollectionDialog
|
|
||||||
open={renameModalOpen}
|
|
||||||
onOpenChange={setRenameModalOpen}
|
|
||||||
recollectionId={recollectionId ?? ''}
|
|
||||||
initialName={recollectionName ?? ''}
|
|
||||||
recollections={recollections}
|
|
||||||
onRename={(id, newName) => {
|
|
||||||
renameRecollection(id, newName)
|
|
||||||
setRenameModalOpen(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -5,21 +5,12 @@
|
|||||||
|
|
||||||
import React, { useCallback, useEffect } from 'react'
|
import React, { useCallback, useEffect } from 'react'
|
||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { FluxIcon, LogosIcon } from '@/lib/icons'
|
import { FluxIcon, LogosIcon, RecollectionsIcon } from '@/lib/icons'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
const VIEW_TOGGLE_KEYS = { key: 'v', shiftKey: true }
|
|
||||||
|
|
||||||
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
|
|
||||||
const mod = ev.ctrlKey || ev.metaKey
|
|
||||||
return ev.key.toLowerCase() === want.key && !!mod && ev.shiftKey === want.shiftKey
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortcutLabel() {
|
function shortcutLabel() {
|
||||||
if (typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform)) {
|
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform)
|
||||||
return '⌘⇧V'
|
return isMac ? '⌘⇧↑ / ⌘⇧↓' : 'Ctrl+Shift+↑ / Ctrl+Shift+↓'
|
||||||
}
|
|
||||||
return 'Ctrl+Shift+V'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RecollectionViewSwitcher() {
|
export function RecollectionViewSwitcher() {
|
||||||
@@ -28,30 +19,53 @@ export function RecollectionViewSwitcher() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||||
|
|
||||||
const isFlux = pathname.endsWith('/flux')
|
type Mode = 'logos' | 'katalogos' | 'flux'
|
||||||
|
const mode: Mode = pathname.endsWith('/flux')
|
||||||
|
? 'flux'
|
||||||
|
: pathname.endsWith('/katalogos')
|
||||||
|
? 'katalogos'
|
||||||
|
: 'logos'
|
||||||
|
|
||||||
|
const goRelative = useCallback(
|
||||||
|
(delta: 1 | -1) => {
|
||||||
|
if (!base) return
|
||||||
|
const order: Mode[] = ['logos', 'katalogos', 'flux']
|
||||||
|
const currentIndex = order.indexOf(mode)
|
||||||
|
const nextMode = order[(currentIndex + (delta === 1 ? 1 : order.length - 1)) % order.length]
|
||||||
|
navigate(`${base}/${nextMode}`)
|
||||||
|
},
|
||||||
|
[base, mode, navigate]
|
||||||
|
)
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
if (!base) return
|
goRelative(1)
|
||||||
navigate(isFlux ? `${base}/logos` : `${base}/flux`)
|
}, [goRelative])
|
||||||
}, [base, isFlux, navigate])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (ev: KeyboardEvent) => {
|
const onKeyDown = (ev: KeyboardEvent) => {
|
||||||
if (matchKey(ev, VIEW_TOGGLE_KEYS)) {
|
const isMod = ev.ctrlKey || ev.metaKey
|
||||||
ev.preventDefault()
|
const isShortcut = isMod && ev.shiftKey && (ev.key === 'ArrowDown' || ev.key === 'ArrowUp')
|
||||||
toggle()
|
if (!isShortcut) return
|
||||||
|
ev.preventDefault()
|
||||||
|
if (ev.key === 'ArrowDown') {
|
||||||
|
goRelative(1)
|
||||||
|
} else if (ev.key === 'ArrowUp') {
|
||||||
|
goRelative(-1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKeyDown, true)
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||||
}, [toggle])
|
}, [goRelative])
|
||||||
|
|
||||||
if (!base) return null
|
if (!base) return null
|
||||||
|
|
||||||
const shortcut = shortcutLabel()
|
const shortcut = shortcutLabel()
|
||||||
const tooltipText = isFlux
|
const tooltipText =
|
||||||
? `Flux · Switch to Logos (${shortcut})`
|
mode === 'logos'
|
||||||
: `Logos · Switch to Flux (${shortcut})`
|
? `Logos · Next: Katalogos (${shortcut})`
|
||||||
|
: mode === 'katalogos'
|
||||||
|
? `Katalogos · Next: Flux (${shortcut})`
|
||||||
|
: `Flux · Next: Logos (${shortcut})`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
@@ -61,20 +75,34 @@ export function RecollectionViewSwitcher() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-label={isFlux ? 'Switch to Logos' : 'Switch to Flux'}
|
aria-label={
|
||||||
|
mode === 'logos'
|
||||||
|
? 'Switch to Katalogos'
|
||||||
|
: mode === 'katalogos'
|
||||||
|
? 'Switch to Flux'
|
||||||
|
: 'Switch to Logos'
|
||||||
|
}
|
||||||
className="h-7 w-7 shrink-0 overflow-hidden rounded-full border border-border/60 bg-background shadow-md transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="h-7 w-7 shrink-0 overflow-hidden rounded-full border border-border/60 bg-background shadow-md transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<div className="h-full w-full overflow-hidden">
|
<div className="h-full w-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="flex w-full flex-col transition-transform duration-200 ease-out"
|
className="flex w-full flex-col transition-transform duration-200 ease-out"
|
||||||
style={{
|
style={{
|
||||||
height: '200%',
|
height: '300%',
|
||||||
transform: isFlux ? 'translateY(-50%)' : 'translateY(0)',
|
transform:
|
||||||
|
mode === 'logos'
|
||||||
|
? 'translateY(0)'
|
||||||
|
: mode === 'katalogos'
|
||||||
|
? 'translateY(-33.3333%)'
|
||||||
|
: 'translateY(-66.6667%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex h-9 w-full items-center justify-center">
|
<div className="flex h-9 w-full items-center justify-center">
|
||||||
<LogosIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
<LogosIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex h-9 w-full items-center justify-center">
|
||||||
|
<RecollectionsIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
<div className="flex h-9 w-full items-center justify-center">
|
<div className="flex h-9 w-full items-center justify-center">
|
||||||
<FluxIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
<FluxIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
@@ -88,11 +116,21 @@ export function RecollectionViewSwitcher() {
|
|||||||
<div className="h-9 min-w-[3.5rem] overflow-hidden">
|
<div className="h-9 min-w-[3.5rem] overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="flex w-full flex-col transition-transform duration-200 ease-out"
|
className="flex w-full flex-col transition-transform duration-200 ease-out"
|
||||||
style={{ transform: isFlux ? 'translateY(-50%)' : 'translateY(0)' }}
|
style={{
|
||||||
|
transform:
|
||||||
|
mode === 'logos'
|
||||||
|
? 'translateY(0)'
|
||||||
|
: mode === 'katalogos'
|
||||||
|
? 'translateY(-33.3333%)'
|
||||||
|
: 'translateY(-66.6667%)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex h-9 flex-shrink-0 items-center text-sm font-medium text-foreground">
|
<span className="flex h-9 flex-shrink-0 items-center text-sm font-medium text-foreground">
|
||||||
Logos
|
Logos
|
||||||
</span>
|
</span>
|
||||||
|
<span className="flex h-9 flex-shrink-0 items-center text-sm font-medium text-foreground">
|
||||||
|
Katalogos
|
||||||
|
</span>
|
||||||
<span className="flex h-9 flex-shrink-0 items-center text-sm font-medium text-foreground">
|
<span className="flex h-9 flex-shrink-0 items-center text-sm font-medium text-foreground">
|
||||||
Flux
|
Flux
|
||||||
</span>
|
</span>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Logos page: BlockNote editor for the recollection. Content persisted in recollection store.
|
* Logos page: BlockNote editor for the recollection. Content persisted in recollection store.
|
||||||
* Layout and styling aligned with Flux (same flex/overflow, bg-background, theme).
|
* Left sidebar for page/subpage hierarchy (one level). Layout and styling aligned with Flux.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef } from 'react'
|
||||||
@@ -10,9 +10,20 @@ import { FluxIcon } from '@/lib/icons'
|
|||||||
import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuController } from '@blocknote/react'
|
import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuController } from '@blocknote/react'
|
||||||
import { BlockNoteView } from '@blocknote/shadcn'
|
import { BlockNoteView } from '@blocknote/shadcn'
|
||||||
import { useTheme } from '@/lib/themeContext'
|
import { useTheme } from '@/lib/themeContext'
|
||||||
import { useRecollectionActions } from '../RecollectionActionsContext'
|
import { useRecollectionActions } from '../layout/RecollectionActionsContext'
|
||||||
import { getLogosContent, setLogosContent, type StoredLogosContent } from '../recollectionStore'
|
import {
|
||||||
|
getLogosContent,
|
||||||
|
getLogosPageTree,
|
||||||
|
setLogosPageTree,
|
||||||
|
getLogosContentForPage,
|
||||||
|
setLogosContentForPage,
|
||||||
|
removeLogosPageContent,
|
||||||
|
type StoredLogosContent,
|
||||||
|
type LogosPageMeta,
|
||||||
|
type LogosPageId,
|
||||||
|
} from '../state/recollectionStore'
|
||||||
import { logosSchema } from './logosSchema'
|
import { logosSchema } from './logosSchema'
|
||||||
|
import { LogosSidebar } from './LogosSidebar'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
/** Wraps BlockNoteView so refs go to a div, not the function component (avoids ref warning). */
|
/** Wraps BlockNoteView so refs go to a div, not the function component (avoids ref warning). */
|
||||||
@@ -48,39 +59,81 @@ function patchBlockNoteRefWarning() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAIN_PAGE_ID: LogosPageId = 'main'
|
||||||
|
|
||||||
export function LogosPage() {
|
export function LogosPage() {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
const { recollections } = usePlatform()
|
const { recollections } = usePlatform()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const { setLogosSlot } = useRecollectionActions()
|
const { setLogosSlot } = useRecollectionActions()
|
||||||
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
||||||
const title = recollection?.name ?? 'Untitled'
|
const [tree, setTree] = useState<LogosPageMeta[]>([])
|
||||||
|
const [activePageId, setActivePageId] = useState<LogosPageId | null>(null)
|
||||||
const [reloadKey, setReloadKey] = useState(0)
|
const [reloadKey, setReloadKey] = useState(0)
|
||||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved')
|
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved')
|
||||||
|
|
||||||
const initialContent = useMemo(
|
// Load page tree and run migration when empty (treat existing single doc as "Main" page).
|
||||||
() => (recollectionId ? getLogosContent(recollectionId) ?? undefined : undefined),
|
useEffect(() => {
|
||||||
[recollectionId, reloadKey]
|
if (!recollectionId) return
|
||||||
|
let t = getLogosPageTree(recollectionId)
|
||||||
|
if (t.length === 0) {
|
||||||
|
const legacy = getLogosContent(recollectionId)
|
||||||
|
const main: LogosPageMeta = { id: MAIN_PAGE_ID, title: 'Main', parentId: null, position: 0 }
|
||||||
|
setLogosContentForPage(recollectionId, MAIN_PAGE_ID, legacy ?? [])
|
||||||
|
setLogosPageTree(recollectionId, [main])
|
||||||
|
t = [main]
|
||||||
|
}
|
||||||
|
setTree(t)
|
||||||
|
setActivePageId((prev) => {
|
||||||
|
const firstId = t[0]?.id ?? null
|
||||||
|
if (prev != null && t.some((p) => p.id === prev)) return prev
|
||||||
|
return firstId
|
||||||
|
})
|
||||||
|
}, [recollectionId])
|
||||||
|
|
||||||
|
// Persist tree when sidebar changes it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recollectionId || tree.length === 0) return
|
||||||
|
setLogosPageTree(recollectionId, tree)
|
||||||
|
}, [recollectionId, tree])
|
||||||
|
|
||||||
|
const handleTreeChange = useCallback((newTree: LogosPageMeta[]) => {
|
||||||
|
setTree(newTree)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDeletePage = useCallback(
|
||||||
|
(pageId: LogosPageId) => {
|
||||||
|
if (recollectionId) removeLogosPageContent(recollectionId, pageId)
|
||||||
|
},
|
||||||
|
[recollectionId]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const initialContent = useMemo(() => {
|
||||||
|
if (!recollectionId || !activePageId) return undefined
|
||||||
|
const content = getLogosContentForPage(recollectionId, activePageId)
|
||||||
|
// BlockNote requires a non-empty array of blocks; use undefined for default empty doc.
|
||||||
|
if (!content || !Array.isArray(content) || content.length === 0) return undefined
|
||||||
|
return content
|
||||||
|
}, [recollectionId, activePageId, reloadKey])
|
||||||
|
|
||||||
const editor = useCreateBlockNote(
|
const editor = useCreateBlockNote(
|
||||||
{ schema: logosSchema, initialContent },
|
{ schema: logosSchema, initialContent },
|
||||||
[recollectionId, reloadKey]
|
[recollectionId, activePageId, reloadKey]
|
||||||
)
|
)
|
||||||
|
|
||||||
const persistContent = useCallback(() => {
|
const persistContent = useCallback(() => {
|
||||||
if (!recollectionId || !editor) return
|
if (!recollectionId || !activePageId || !editor) return
|
||||||
setSaveStatus('saving')
|
setSaveStatus('saving')
|
||||||
try {
|
try {
|
||||||
const doc = editor.document
|
const doc = editor.document
|
||||||
const serialized = JSON.parse(JSON.stringify(doc)) as StoredLogosContent
|
const serialized = JSON.parse(JSON.stringify(doc)) as StoredLogosContent
|
||||||
setLogosContent(recollectionId, serialized)
|
setLogosContentForPage(recollectionId, activePageId, serialized)
|
||||||
setSaveStatus('saved')
|
setSaveStatus('saved')
|
||||||
} catch {
|
} catch {
|
||||||
setSaveStatus('unsaved')
|
setSaveStatus('unsaved')
|
||||||
}
|
}
|
||||||
}, [recollectionId, editor])
|
}, [recollectionId, activePageId, editor])
|
||||||
|
|
||||||
const onSave = useCallback(() => {
|
const onSave = useCallback(() => {
|
||||||
persistContent()
|
persistContent()
|
||||||
@@ -88,7 +141,7 @@ export function LogosPage() {
|
|||||||
}, [persistContent])
|
}, [persistContent])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || !recollectionId) return
|
if (!editor || !recollectionId || !activePageId) return
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
setSaveStatus('unsaved')
|
setSaveStatus('unsaved')
|
||||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
||||||
@@ -104,10 +157,10 @@ export function LogosPage() {
|
|||||||
saveTimeoutRef.current = null
|
saveTimeoutRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [editor, recollectionId, persistContent])
|
}, [editor, recollectionId, activePageId, persistContent])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || !recollectionId) return
|
if (!editor || !recollectionId || !activePageId) return
|
||||||
const slot = {
|
const slot = {
|
||||||
saveStatus,
|
saveStatus,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -120,29 +173,9 @@ export function LogosPage() {
|
|||||||
}
|
}
|
||||||
setLogosSlot(slot)
|
setLogosSlot(slot)
|
||||||
return () => setLogosSlot(null)
|
return () => setLogosSlot(null)
|
||||||
}, [
|
}, [setLogosSlot, saveStatus, onSave, editor, recollectionId, activePageId])
|
||||||
setLogosSlot,
|
|
||||||
saveStatus,
|
|
||||||
onSave,
|
|
||||||
editor,
|
|
||||||
recollectionId,
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!recollectionId) {
|
const activePage = tree.find((p) => p.id === activePageId)
|
||||||
return (
|
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center p-8 text-muted-foreground">
|
|
||||||
<p className="text-sm">No recollection selected.</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!editor) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center p-8 text-muted-foreground">
|
|
||||||
<p className="text-sm">Loading…</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
patchBlockNoteRefWarning()
|
patchBlockNoteRefWarning()
|
||||||
|
|
||||||
@@ -150,15 +183,15 @@ export function LogosPage() {
|
|||||||
async (query: string) => {
|
async (query: string) => {
|
||||||
const defaultItems = getDefaultReactSlashMenuItems(editor)
|
const defaultItems = getDefaultReactSlashMenuItems(editor)
|
||||||
const fluxItem = {
|
const fluxItem = {
|
||||||
title: 'Insert from Flux',
|
title: 'Insert Artifact',
|
||||||
subtext: 'Insert output from a Flux rendering node',
|
subtext: 'Insert an Artifact produced by a Flux rendering node',
|
||||||
icon: <FluxIcon className="size-4" />,
|
icon: <FluxIcon className="size-4" />,
|
||||||
onItemClick: () => {
|
onItemClick: () => {
|
||||||
const pos = editor.getTextCursorPosition()
|
const pos = editor.getTextCursorPosition()
|
||||||
editor.replaceBlocks([pos.block.id], [{ type: 'fluxOutput', props: {} }])
|
editor.replaceBlocks([pos.block.id], [{ type: 'fluxOutput', props: {} }])
|
||||||
},
|
},
|
||||||
aliases: ['flux', 'output', 'render'] as const,
|
aliases: ['artifact', 'flux', 'output', 'render'] as const,
|
||||||
group: 'Flux',
|
group: 'Artifacts',
|
||||||
}
|
}
|
||||||
const all = [...defaultItems, fluxItem]
|
const all = [...defaultItems, fluxItem]
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
@@ -172,20 +205,57 @@ export function LogosPage() {
|
|||||||
[editor]
|
[editor]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Render paths (no hooks below this point).
|
||||||
|
if (!recollectionId) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center p-8 text-muted-foreground">
|
||||||
|
<p className="text-sm">No recollection selected.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activePageId == null || !editor) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-1 flex-col bg-background">
|
||||||
|
<LogosSidebar
|
||||||
|
recollectionId={recollectionId}
|
||||||
|
tree={tree}
|
||||||
|
onTreeChange={handleTreeChange}
|
||||||
|
activePageId={activePageId}
|
||||||
|
onSelectPage={setActivePageId}
|
||||||
|
onDeletePage={handleDeletePage}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{tree.length === 0 ? 'Loading…' : 'Select a page'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
|
<div className="flex h-full min-h-0 flex-1 flex-row bg-background">
|
||||||
<div className="mx-auto w-full max-w-3xl p-4">
|
<LogosSidebar
|
||||||
<h1 className="mb-4 truncate font-serif text-2xl font-semibold tracking-tight text-foreground">
|
recollectionId={recollectionId}
|
||||||
{title}
|
tree={tree}
|
||||||
</h1>
|
onTreeChange={handleTreeChange}
|
||||||
<BlockNoteViewWrapper
|
activePageId={activePageId}
|
||||||
editor={editor as any}
|
onSelectPage={setActivePageId}
|
||||||
theme={theme}
|
onDeletePage={handleDeletePage}
|
||||||
className="min-h-full w-full"
|
/>
|
||||||
slashMenu={false}
|
<div className="flex min-w-0 flex-1 flex-col overflow-auto">
|
||||||
>
|
<div className="mx-auto w-full max-w-3xl p-4">
|
||||||
<SuggestionMenuController triggerCharacter="/" getItems={getSlashMenuItems} />
|
<h1 className="mb-4 truncate font-serif text-2xl font-semibold tracking-tight text-foreground">
|
||||||
</BlockNoteViewWrapper>
|
{activePage?.title ?? recollection?.name ?? 'Untitled'}
|
||||||
|
</h1>
|
||||||
|
<BlockNoteViewWrapper
|
||||||
|
editor={editor as any}
|
||||||
|
theme={theme}
|
||||||
|
className="min-h-full w-full"
|
||||||
|
slashMenu={false}
|
||||||
|
>
|
||||||
|
<SuggestionMenuController triggerCharacter="/" getItems={getSlashMenuItems} />
|
||||||
|
</BlockNoteViewWrapper>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
480
frontend/src/app/recollections/logos/LogosSidebar.tsx
Normal file
480
frontend/src/app/recollections/logos/LogosSidebar.tsx
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
/**
|
||||||
|
* Logos sidebar: one-level page/subpage hierarchy. Create, rename, delete, reorder.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { LogosPageId, LogosPageMeta } from '../state/recollectionStore'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_TITLE = 'Untitled'
|
||||||
|
|
||||||
|
function generatePageId(): LogosPageId {
|
||||||
|
return `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortByPosition(a: LogosPageMeta, b: LogosPageMeta) {
|
||||||
|
return a.position - b.position
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogosSidebarProps = {
|
||||||
|
recollectionId: string
|
||||||
|
tree: LogosPageMeta[]
|
||||||
|
onTreeChange: (tree: LogosPageMeta[]) => void
|
||||||
|
activePageId: LogosPageId | null
|
||||||
|
onSelectPage: (id: LogosPageId) => void
|
||||||
|
onDeletePage?: (pageId: LogosPageId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogosSidebar({
|
||||||
|
recollectionId,
|
||||||
|
tree,
|
||||||
|
onTreeChange,
|
||||||
|
activePageId,
|
||||||
|
onSelectPage,
|
||||||
|
onDeletePage,
|
||||||
|
}: LogosSidebarProps) {
|
||||||
|
const [expandedPages, setExpandedPages] = useState<Set<LogosPageId>>(new Set())
|
||||||
|
const expandedInitializedRef = useRef(false)
|
||||||
|
const [editingId, setEditingId] = useState<LogosPageId | null>(null)
|
||||||
|
const [editTitle, setEditTitle] = useState('')
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<LogosPageMeta | null>(null)
|
||||||
|
|
||||||
|
const pages = tree.filter((p) => p.parentId === null).sort(sortByPosition)
|
||||||
|
|
||||||
|
// Default: expand parent pages that have subpages so children are visible on first load.
|
||||||
|
useEffect(() => {
|
||||||
|
if (tree.length === 0 || expandedInitializedRef.current) return
|
||||||
|
expandedInitializedRef.current = true
|
||||||
|
const parentIdsWithChildren = tree
|
||||||
|
.filter((p) => p.parentId === null && tree.some((s) => s.parentId === p.id))
|
||||||
|
.map((p) => p.id)
|
||||||
|
if (parentIdsWithChildren.length > 0) {
|
||||||
|
setExpandedPages((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
parentIdsWithChildren.forEach((id) => next.add(id))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [tree])
|
||||||
|
|
||||||
|
const toggleExpanded = useCallback((id: LogosPageId) => {
|
||||||
|
setExpandedPages((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updatePage = useCallback(
|
||||||
|
(id: LogosPageId, patch: Partial<Pick<LogosPageMeta, 'title' | 'position'>>) => {
|
||||||
|
const next = tree.map((p) => (p.id === id ? { ...p, ...patch } : p))
|
||||||
|
onTreeChange(next)
|
||||||
|
},
|
||||||
|
[tree, onTreeChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const addPage = useCallback(() => {
|
||||||
|
const maxPos = Math.max(0, ...pages.map((p) => p.position), -1)
|
||||||
|
const newPage: LogosPageMeta = {
|
||||||
|
id: generatePageId(),
|
||||||
|
title: DEFAULT_PAGE_TITLE,
|
||||||
|
parentId: null,
|
||||||
|
position: maxPos + 1,
|
||||||
|
}
|
||||||
|
onTreeChange([...tree, newPage])
|
||||||
|
setEditingId(newPage.id)
|
||||||
|
setEditTitle(newPage.title)
|
||||||
|
onSelectPage(newPage.id)
|
||||||
|
}, [tree, pages, onTreeChange, onSelectPage])
|
||||||
|
|
||||||
|
const addSubpage = useCallback(
|
||||||
|
(parentId: LogosPageId) => {
|
||||||
|
const siblings = tree.filter((p) => p.parentId === parentId).sort(sortByPosition)
|
||||||
|
const maxPos = siblings.length === 0 ? 0 : Math.max(...siblings.map((p) => p.position)) + 1
|
||||||
|
const newPage: LogosPageMeta = {
|
||||||
|
id: generatePageId(),
|
||||||
|
title: DEFAULT_PAGE_TITLE,
|
||||||
|
parentId,
|
||||||
|
position: maxPos,
|
||||||
|
}
|
||||||
|
onTreeChange([...tree, newPage])
|
||||||
|
setExpandedPages((prev) => new Set(prev).add(parentId))
|
||||||
|
setEditingId(newPage.id)
|
||||||
|
setEditTitle(newPage.title)
|
||||||
|
onSelectPage(newPage.id)
|
||||||
|
},
|
||||||
|
[tree, onTreeChange, onSelectPage]
|
||||||
|
)
|
||||||
|
|
||||||
|
const startRename = useCallback((page: LogosPageMeta) => {
|
||||||
|
setEditingId(page.id)
|
||||||
|
setEditTitle(page.title)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const commitRename = useCallback(() => {
|
||||||
|
if (editingId && editTitle.trim()) {
|
||||||
|
updatePage(editingId, { title: editTitle.trim() })
|
||||||
|
}
|
||||||
|
setEditingId(null)
|
||||||
|
setEditTitle('')
|
||||||
|
}, [editingId, editTitle, updatePage])
|
||||||
|
|
||||||
|
const movePage = useCallback(
|
||||||
|
(id: LogosPageId, delta: number) => {
|
||||||
|
const page = tree.find((p) => p.id === id)
|
||||||
|
if (!page) return
|
||||||
|
const siblings = tree
|
||||||
|
.filter((p) => p.parentId === page.parentId)
|
||||||
|
.sort(sortByPosition)
|
||||||
|
const idx = siblings.findIndex((p) => p.id === id)
|
||||||
|
if (idx < 0) return
|
||||||
|
const newIdx = Math.max(0, Math.min(siblings.length - 1, idx + delta))
|
||||||
|
if (newIdx === idx) return
|
||||||
|
const reordered = siblings.slice()
|
||||||
|
const [removed] = reordered.splice(idx, 1)
|
||||||
|
reordered.splice(newIdx, 0, removed)
|
||||||
|
const withNewPositions = tree.map((p) => {
|
||||||
|
const i = reordered.findIndex((r) => r.id === p.id)
|
||||||
|
return i >= 0 ? { ...p, position: i } : p
|
||||||
|
})
|
||||||
|
onTreeChange(withNewPositions)
|
||||||
|
},
|
||||||
|
[tree, onTreeChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const removePage = useCallback(
|
||||||
|
(page: LogosPageMeta) => {
|
||||||
|
const toRemove = [page.id, ...tree.filter((p) => p.parentId === page.id).map((p) => p.id)]
|
||||||
|
onTreeChange(tree.filter((p) => !toRemove.includes(p.id)))
|
||||||
|
toRemove.forEach((id) => onDeletePage?.(id))
|
||||||
|
if (activePageId && toRemove.includes(activePageId)) {
|
||||||
|
const remaining = tree.filter((p) => !toRemove.includes(p.id)).sort(sortByPosition)
|
||||||
|
const first = remaining[0]
|
||||||
|
if (first) onSelectPage(first.id)
|
||||||
|
}
|
||||||
|
setDeleteTarget(null)
|
||||||
|
},
|
||||||
|
[tree, activePageId, onTreeChange, onDeletePage, onSelectPage]
|
||||||
|
)
|
||||||
|
|
||||||
|
const subpages = (parentId: LogosPageId) =>
|
||||||
|
tree.filter((p) => p.parentId === parentId).sort(sortByPosition)
|
||||||
|
|
||||||
|
const isPage = (p: LogosPageMeta) => p.parentId === null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-64 shrink-0 flex-col border-r border-border bg-muted/30">
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-2 py-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Pages
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 px-2 text-xs"
|
||||||
|
onClick={addPage}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
New page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
|
{pages.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||||
|
No pages yet.
|
||||||
|
<br />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0 text-xs"
|
||||||
|
onClick={addPage}
|
||||||
|
>
|
||||||
|
Create your first page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-0.5 px-1">
|
||||||
|
{pages.map((page) => {
|
||||||
|
const subs = subpages(page.id)
|
||||||
|
const expanded = expandedPages.has(page.id)
|
||||||
|
const isActive = activePageId === page.id
|
||||||
|
return (
|
||||||
|
<li key={page.id} className="flex flex-col gap-0.5">
|
||||||
|
<PageRow
|
||||||
|
page={page}
|
||||||
|
isSubpage={false}
|
||||||
|
isActive={isActive}
|
||||||
|
isEditing={editingId === page.id}
|
||||||
|
editTitle={editTitle}
|
||||||
|
onEditTitleChange={setEditTitle}
|
||||||
|
onCommitRename={commitRename}
|
||||||
|
onStartRename={() => startRename(page)}
|
||||||
|
onSelect={() => onSelectPage(page.id)}
|
||||||
|
onExpandToggle={() => toggleExpanded(page.id)}
|
||||||
|
expanded={expanded}
|
||||||
|
hasSubpages={subs.length > 0}
|
||||||
|
onAddSubpage={() => addSubpage(page.id)}
|
||||||
|
onMoveUp={() => movePage(page.id, -1)}
|
||||||
|
onMoveDown={() => movePage(page.id, 1)}
|
||||||
|
onDelete={() => setDeleteTarget(page)}
|
||||||
|
canMoveUp={pages.indexOf(page) > 0}
|
||||||
|
canMoveDown={pages.indexOf(page) < pages.length - 1}
|
||||||
|
/>
|
||||||
|
{expanded && (
|
||||||
|
<ul className="ml-3 flex flex-col gap-0.5 border-l border-border/60 pl-2">
|
||||||
|
{subs.map((sub, i) => {
|
||||||
|
const subActive = activePageId === sub.id
|
||||||
|
const subEditing = editingId === sub.id
|
||||||
|
return (
|
||||||
|
<li key={sub.id}>
|
||||||
|
<PageRow
|
||||||
|
page={sub}
|
||||||
|
isSubpage
|
||||||
|
isActive={subActive}
|
||||||
|
isEditing={subEditing}
|
||||||
|
editTitle={editTitle}
|
||||||
|
onEditTitleChange={setEditTitle}
|
||||||
|
onCommitRename={commitRename}
|
||||||
|
onStartRename={() => startRename(sub)}
|
||||||
|
onSelect={() => onSelectPage(sub.id)}
|
||||||
|
onAddSubpage={undefined}
|
||||||
|
onMoveUp={() => movePage(sub.id, -1)}
|
||||||
|
onMoveDown={() => movePage(sub.id, 1)}
|
||||||
|
onDelete={() => setDeleteTarget(sub)}
|
||||||
|
canMoveUp={i > 0}
|
||||||
|
canMoveDown={i < subs.length - 1}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{deleteTarget && isPage(deleteTarget)
|
||||||
|
? 'Delete page and subpages?'
|
||||||
|
: 'Delete subpage?'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{deleteTarget && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isPage(deleteTarget) && subpages(deleteTarget.id).length > 0 ? (
|
||||||
|
<>
|
||||||
|
"{deleteTarget.title}" and its {subpages(deleteTarget.id).length}{' '}
|
||||||
|
subpage(s) will be permanently removed.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<> "{deleteTarget.title}" will be permanently removed.</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteTarget && removePage(deleteTarget)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageRowProps = {
|
||||||
|
page: LogosPageMeta
|
||||||
|
isSubpage: boolean
|
||||||
|
isActive: boolean
|
||||||
|
isEditing: boolean
|
||||||
|
editTitle: string
|
||||||
|
onEditTitleChange: (v: string) => void
|
||||||
|
onCommitRename: () => void
|
||||||
|
onStartRename: () => void
|
||||||
|
onSelect: () => void
|
||||||
|
onExpandToggle?: () => void
|
||||||
|
expanded?: boolean
|
||||||
|
hasSubpages?: boolean
|
||||||
|
onAddSubpage?: () => void
|
||||||
|
onMoveUp: () => void
|
||||||
|
onMoveDown: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
canMoveUp: boolean
|
||||||
|
canMoveDown: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageRow({
|
||||||
|
page,
|
||||||
|
isSubpage,
|
||||||
|
isActive,
|
||||||
|
isEditing,
|
||||||
|
editTitle,
|
||||||
|
onEditTitleChange,
|
||||||
|
onCommitRename,
|
||||||
|
onStartRename,
|
||||||
|
onSelect,
|
||||||
|
onExpandToggle,
|
||||||
|
expanded,
|
||||||
|
hasSubpages,
|
||||||
|
onAddSubpage,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
onDelete,
|
||||||
|
canMoveUp,
|
||||||
|
canMoveDown,
|
||||||
|
}: PageRowProps) {
|
||||||
|
const isParentRow = onExpandToggle != null
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') onCommitRename()
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onEditTitleChange(page.title)
|
||||||
|
onCommitRename()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-0.5 rounded-md pr-1',
|
||||||
|
isActive && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isParentRow ? (
|
||||||
|
// VSCode-like "twisty gutter" so labels align nicely.
|
||||||
|
<span className="flex h-8 w-4 shrink-0 items-center justify-center">
|
||||||
|
{hasSubpages ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex size-4 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
onExpandToggle?.()
|
||||||
|
}}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-label={expanded ? 'Collapse subpages' : 'Expand subpages'}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="size-3.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="size-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="inline-block size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : isSubpage ? (
|
||||||
|
<span className="flex h-8 w-4 shrink-0 items-center justify-center">
|
||||||
|
<span className="inline-block size-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{isSubpage && <span className="w-2 shrink-0" />}
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-7 min-w-0 flex-1 rounded border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={editTitle}
|
||||||
|
onChange={(e) => onEditTitleChange(e.target.value)}
|
||||||
|
onBlur={onCommitRename}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
autoFocus
|
||||||
|
aria-label="Page title"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 py-1.5 text-left text-sm hover:bg-accent/80"
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{page.title || DEFAULT_PAGE_TITLE}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isEditing && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-3.5" />
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenuItem onClick={() => { onStartRename(); onSelect(); }}>
|
||||||
|
<Pencil className="mr-2 size-3.5" />
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{onAddSubpage != null && (
|
||||||
|
<DropdownMenuItem onClick={onAddSubpage}>
|
||||||
|
<Plus className="mr-2 size-3.5" />
|
||||||
|
New subpage
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={onMoveUp} disabled={!canMoveUp}>
|
||||||
|
<ArrowUp className="mr-2 size-3.5" />
|
||||||
|
Move up
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onMoveDown} disabled={!canMoveDown}>
|
||||||
|
<ArrowDown className="mr-2 size-3.5" />
|
||||||
|
Move down
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="mr-2 size-3.5" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* BlockNote block "Flux output": insert output from Flux rendering nodes into Logos.
|
* BlockNote block "Flux output": insert Artifacts produced by Flux rendering nodes into Logos.
|
||||||
* Empty state: placeholder + picker over getRenderOutputCache(recollectionId).
|
* Empty state: placeholder + picker over getRenderOutputCache(recollectionId).
|
||||||
* Filled state: when nodeId is set, display is live from the cache (updates when the rendering node changes in Flux);
|
* Filled state: when nodeId is set, display is live from the cache (updates when the rendering node changes in Flux);
|
||||||
* otherwise or when cache entry is missing, show stored content (static).
|
* otherwise or when cache entry is missing, show stored content (static).
|
||||||
|
* Shows live/static status and time since last update.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useState } from 'react'
|
import React, { useCallback, useState } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { createReactBlockSpec } from '@blocknote/react'
|
import { createReactBlockSpec } from '@blocknote/react'
|
||||||
import type { ReactCustomBlockRenderProps } from '@blocknote/react'
|
import type { ReactCustomBlockRenderProps } from '@blocknote/react'
|
||||||
import { getRenderOutputCache, type RenderOutputCacheEntry } from '../../recollectionStore'
|
import {
|
||||||
|
getRenderOutputCache,
|
||||||
|
formatTimeSinceLastUpdate,
|
||||||
|
type RenderOutputCacheEntry,
|
||||||
|
} from '../../state/recollectionStore'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
|
||||||
@@ -39,6 +44,7 @@ function FluxOutputBlockContent({
|
|||||||
contentRef,
|
contentRef,
|
||||||
}: ReactCustomBlockRenderProps<'fluxOutput', typeof fluxOutputBlockConfig.propSchema, 'none'>) {
|
}: ReactCustomBlockRenderProps<'fluxOutput', typeof fluxOutputBlockConfig.propSchema, 'none'>) {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
const nodeId = block.props.nodeId ?? ''
|
const nodeId = block.props.nodeId ?? ''
|
||||||
const storedContent = block.props.content ?? ''
|
const storedContent = block.props.content ?? ''
|
||||||
@@ -60,35 +66,47 @@ function FluxOutputBlockContent({
|
|||||||
[editor, block.id]
|
[editor, block.id]
|
||||||
)
|
)
|
||||||
|
|
||||||
const entries = recollectionId ? getRenderOutputCache(recollectionId) : []
|
const artifacts = recollectionId ? getRenderOutputCache(recollectionId) : []
|
||||||
|
|
||||||
// Resolve display from cache when block is tied to a node (live); otherwise use stored props (static).
|
// Resolve display from cache when block is tied to a node (live); otherwise use stored props (static).
|
||||||
const liveEntry = nodeId && recollectionId ? entries.find((e) => e.nodeId === nodeId) : null
|
const liveEntry = nodeId && recollectionId ? artifacts.find((e) => e.nodeId === nodeId) : null
|
||||||
const content = liveEntry ? liveEntry.content : storedContent
|
const content = liveEntry ? liveEntry.content : storedContent
|
||||||
const rawContentType = liveEntry ? liveEntry.type : storedContentType
|
const rawContentType = liveEntry ? liveEntry.type : storedContentType
|
||||||
const label = liveEntry ? liveEntry.label : storedLabel
|
const label = liveEntry ? liveEntry.label : storedLabel
|
||||||
|
const isLive = Boolean(liveEntry)
|
||||||
|
const timeSince = liveEntry?.updatedAt != null ? formatTimeSinceLastUpdate(liveEntry.updatedAt) : ''
|
||||||
// If content is SVG but type was stored as html, show as image (fixes incorrect cache or legacy data).
|
// If content is SVG but type was stored as html, show as image (fixes incorrect cache or legacy data).
|
||||||
const isSvgContent = Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
|
const isSvgContent = Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
|
||||||
const contentType = rawContentType === 'image' || isSvgContent ? 'image' : 'html'
|
const contentType = rawContentType === 'image' || isSvgContent ? 'image' : 'html'
|
||||||
|
|
||||||
|
const handleViewInFlux = useCallback(() => {
|
||||||
|
if (recollectionId && nodeId) {
|
||||||
|
navigate(`/recollections/${recollectionId}/flux?focusNode=${encodeURIComponent(nodeId)}`)
|
||||||
|
}
|
||||||
|
}, [recollectionId, nodeId, navigate])
|
||||||
|
|
||||||
// Empty: show placeholder + picker when nothing inserted yet
|
// Empty: show placeholder + picker when nothing inserted yet
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return (
|
return (
|
||||||
<div ref={contentRef} className="min-h-[80px] rounded-md border border-dashed border-muted-foreground/30 bg-muted/30 p-4">
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="min-h-[80px] rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 p-4"
|
||||||
|
>
|
||||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
Insert from Flux
|
Insert Artifact
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-80 p-0" align="start">
|
<PopoverContent className="w-80 p-0" align="start">
|
||||||
{entries.length === 0 ? (
|
{artifacts.length === 0 ? (
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
No Flux outputs yet. In Flux, run a graph with a rendering node; its output will appear here automatically.
|
No Artifacts yet. In Flux, run a graph with a rendering node; its output will be cached as an Artifact
|
||||||
|
and appear here and in Katalogos automatically.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="max-h-64 overflow-auto py-2">
|
<ul className="max-h-64 overflow-auto py-2">
|
||||||
{entries.map((entry) => (
|
{artifacts.map((entry) => (
|
||||||
<li key={entry.nodeId}>
|
<li key={entry.nodeId}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -108,28 +126,70 @@ function FluxOutputBlockContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filled: show image or HTML
|
// Filled: card layout with header (label + live/static status + time) and content
|
||||||
|
const header = (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 bg-muted/30 px-3 py-1.5 rounded-t-lg">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{label ? <span className="truncate text-xs font-medium text-foreground">{label}</span> : null}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isLive
|
||||||
|
? 'inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'inline-flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLive && <span className="h-1 w-1 shrink-0 rounded-full bg-emerald-500" aria-hidden />}
|
||||||
|
{isLive ? 'Live' : 'Static'}
|
||||||
|
</span>
|
||||||
|
{timeSince ? (
|
||||||
|
<span className="text-[10px] text-muted-foreground">{timeSince}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{nodeId && recollectionId ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={handleViewInFlux}
|
||||||
|
>
|
||||||
|
View in Flux
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
if (contentType === 'image') {
|
if (contentType === 'image') {
|
||||||
const src =
|
const src =
|
||||||
content.startsWith('data:') ? content : `data:image/svg+xml;utf8,${encodeURIComponent(content)}`
|
content.startsWith('data:') ? content : `data:image/svg+xml;utf8,${encodeURIComponent(content)}`
|
||||||
return (
|
return (
|
||||||
<div ref={contentRef} className="min-h-[40px]">
|
<div
|
||||||
{label ? <p className="mb-1 text-xs text-muted-foreground">{label}</p> : null}
|
ref={contentRef}
|
||||||
<img src={src} alt={label || 'Flux output'} className="max-w-full rounded border object-contain" />
|
className="min-h-[40px] overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-sm"
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
<div className="p-2">
|
||||||
|
<img src={src} alt={label || 'Artifact'} className="max-w-full rounded-md border border-border/60 object-contain" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML: render in iframe to limit script execution (XSS safety)
|
// HTML: render in iframe to limit script execution (XSS safety)
|
||||||
return (
|
return (
|
||||||
<div ref={contentRef} className="min-h-[40px]">
|
<div
|
||||||
{label ? <p className="mb-1 text-xs text-muted-foreground">{label}</p> : null}
|
ref={contentRef}
|
||||||
<iframe
|
className="min-h-[40px] overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-sm"
|
||||||
title={label || 'Flux HTML output'}
|
>
|
||||||
srcDoc={content}
|
{header}
|
||||||
className="min-h-[120px] w-full rounded border border-border bg-background"
|
<div className="p-2">
|
||||||
sandbox="allow-same-origin"
|
<iframe
|
||||||
/>
|
title={label || 'Artifact HTML'}
|
||||||
|
srcDoc={content}
|
||||||
|
className="min-h-[120px] w-full rounded-md border border-border/60 bg-background"
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,38 @@ export type { StoredGraphState }
|
|||||||
/** BlockNote document: array of blocks (PartialBlock). Stored as JSON. */
|
/** BlockNote document: array of blocks (PartialBlock). Stored as JSON. */
|
||||||
export type StoredLogosContent = Record<string, unknown>[]
|
export type StoredLogosContent = Record<string, unknown>[]
|
||||||
|
|
||||||
|
/** Id for a Logos page or subpage. */
|
||||||
|
export type LogosPageId = string
|
||||||
|
|
||||||
|
/** Metadata for one page or subpage in the Logos hierarchy (one level: page → subpages). */
|
||||||
|
export type LogosPageMeta = {
|
||||||
|
id: LogosPageId
|
||||||
|
title: string
|
||||||
|
/** null = top-level page; non-null = subpage under that page. */
|
||||||
|
parentId: LogosPageId | null
|
||||||
|
/** Order among siblings (same parent). */
|
||||||
|
position: number
|
||||||
|
}
|
||||||
|
|
||||||
/** One entry in the render-output cache (one per rendering node, overwritten on each update). */
|
/** One entry in the render-output cache (one per rendering node, overwritten on each update). */
|
||||||
export type RenderOutputCacheEntry = {
|
export type RenderOutputCacheEntry = {
|
||||||
nodeId: string
|
nodeId: string
|
||||||
label: string
|
label: string
|
||||||
type: 'image' | 'html'
|
type: 'image' | 'html'
|
||||||
content: string
|
content: string
|
||||||
|
/** Timestamp (ms) when this entry was last updated. */
|
||||||
|
updatedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a short "time since" string for display (e.g. "Just now", "2 min ago"). */
|
||||||
|
export function formatTimeSinceLastUpdate(updatedAt: number | undefined): string {
|
||||||
|
if (updatedAt == null || typeof updatedAt !== 'number') return ''
|
||||||
|
const delta = Date.now() - updatedAt
|
||||||
|
if (delta < 15_000) return 'Just now'
|
||||||
|
if (delta < 60_000) return `${Math.round(delta / 1000)}s ago`
|
||||||
|
if (delta < 3600_000) return `${Math.round(delta / 60_000)} min ago`
|
||||||
|
if (delta < 86400_000) return `${Math.round(delta / 3600_000)}h ago`
|
||||||
|
return `${Math.round(delta / 86400_000)}d ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RECOLLECTION_FILE_EXT = '.zui.json'
|
export const RECOLLECTION_FILE_EXT = '.zui.json'
|
||||||
@@ -23,6 +49,8 @@ export const RECOLLECTION_VERSION = 1
|
|||||||
|
|
||||||
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
||||||
const LOGOS_KEY_PREFIX = 'zui_logos_'
|
const LOGOS_KEY_PREFIX = 'zui_logos_'
|
||||||
|
const LOGOS_PAGE_TREE_PREFIX = 'zui_logos_pagetree_'
|
||||||
|
const LOGOS_PAGE_CONTENT_PREFIX = 'zui_logos_page_'
|
||||||
const RENDER_CACHE_KEY_PREFIX = 'zui_render_cache_'
|
const RENDER_CACHE_KEY_PREFIX = 'zui_render_cache_'
|
||||||
|
|
||||||
function getGraphKey(recollectionId: string): string {
|
function getGraphKey(recollectionId: string): string {
|
||||||
@@ -33,6 +61,14 @@ function getLogosKey(recollectionId: string): string {
|
|||||||
return `${LOGOS_KEY_PREFIX}${recollectionId}`
|
return `${LOGOS_KEY_PREFIX}${recollectionId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLogosPageTreeKey(recollectionId: string): string {
|
||||||
|
return `${LOGOS_PAGE_TREE_PREFIX}${recollectionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogosPageContentKey(recollectionId: string, pageId: LogosPageId): string {
|
||||||
|
return `${LOGOS_PAGE_CONTENT_PREFIX}${recollectionId}_${pageId}`
|
||||||
|
}
|
||||||
|
|
||||||
function getRenderCacheKey(recollectionId: string): string {
|
function getRenderCacheKey(recollectionId: string): string {
|
||||||
return `${RENDER_CACHE_KEY_PREFIX}${recollectionId}`
|
return `${RENDER_CACHE_KEY_PREFIX}${recollectionId}`
|
||||||
}
|
}
|
||||||
@@ -76,6 +112,61 @@ export function setLogosContent(recollectionId: string, content: StoredLogosCont
|
|||||||
localStorage.setItem(getLogosKey(recollectionId), JSON.stringify(content))
|
localStorage.setItem(getLogosKey(recollectionId), JSON.stringify(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Logos page tree: ordered list of page metas (pages and subpages). */
|
||||||
|
export function getLogosPageTree(recollectionId: string): LogosPageMeta[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(getLogosPageTreeKey(recollectionId))
|
||||||
|
if (!raw) return []
|
||||||
|
const data = JSON.parse(raw) as unknown
|
||||||
|
if (!Array.isArray(data)) return []
|
||||||
|
return data.filter(
|
||||||
|
(item): item is LogosPageMeta =>
|
||||||
|
item != null &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
typeof (item as LogosPageMeta).id === 'string' &&
|
||||||
|
typeof (item as LogosPageMeta).title === 'string' &&
|
||||||
|
((item as LogosPageMeta).parentId === null || typeof (item as LogosPageMeta).parentId === 'string') &&
|
||||||
|
typeof (item as LogosPageMeta).position === 'number'
|
||||||
|
) as LogosPageMeta[]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLogosPageTree(recollectionId: string, tree: LogosPageMeta[]): void {
|
||||||
|
localStorage.setItem(getLogosPageTreeKey(recollectionId), JSON.stringify(tree))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-page Logos content (for page/subpage hierarchy). */
|
||||||
|
export function getLogosContentForPage(
|
||||||
|
recollectionId: string,
|
||||||
|
pageId: LogosPageId
|
||||||
|
): StoredLogosContent | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(getLogosPageContentKey(recollectionId, pageId))
|
||||||
|
if (!raw) return null
|
||||||
|
const data = JSON.parse(raw) as unknown
|
||||||
|
if (!Array.isArray(data)) return null
|
||||||
|
if (!data.every((item) => item != null && typeof item === 'object')) return null
|
||||||
|
return data as StoredLogosContent
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLogosContentForPage(
|
||||||
|
recollectionId: string,
|
||||||
|
pageId: LogosPageId,
|
||||||
|
content: StoredLogosContent
|
||||||
|
): void {
|
||||||
|
localStorage.setItem(getLogosPageContentKey(recollectionId, pageId), JSON.stringify(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove stored content for a single page (e.g. when deleting that page). */
|
||||||
|
export function removeLogosPageContent(recollectionId: string, pageId: LogosPageId): void {
|
||||||
|
localStorage.removeItem(getLogosPageContentKey(recollectionId, pageId))
|
||||||
|
}
|
||||||
|
|
||||||
/** Render output cache: one entry per rendering node (keyed by nodeId). Used by Logos "Insert from Flux" block. */
|
/** Render output cache: one entry per rendering node (keyed by nodeId). Used by Logos "Insert from Flux" block. */
|
||||||
export function getRenderOutputCache(recollectionId: string): RenderOutputCacheEntry[] {
|
export function getRenderOutputCache(recollectionId: string): RenderOutputCacheEntry[] {
|
||||||
try {
|
try {
|
||||||
@@ -107,9 +198,14 @@ export function upsertRenderOutputEntry(recollectionId: string, entry: RenderOut
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Removes both graph, logos, and render cache data for the recollection. */
|
/** Removes both graph, logos (legacy + page tree + all per-page content), and render cache for the recollection. */
|
||||||
export function removeRecollectionData(recollectionId: string): void {
|
export function removeRecollectionData(recollectionId: string): void {
|
||||||
localStorage.removeItem(getGraphKey(recollectionId))
|
localStorage.removeItem(getGraphKey(recollectionId))
|
||||||
localStorage.removeItem(getLogosKey(recollectionId))
|
localStorage.removeItem(getLogosKey(recollectionId))
|
||||||
|
const tree = getLogosPageTree(recollectionId)
|
||||||
|
for (const page of tree) {
|
||||||
|
removeLogosPageContent(recollectionId, page.id)
|
||||||
|
}
|
||||||
|
localStorage.removeItem(getLogosPageTreeKey(recollectionId))
|
||||||
localStorage.removeItem(getRenderCacheKey(recollectionId))
|
localStorage.removeItem(getRenderCacheKey(recollectionId))
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@ import { useAbstractNode } from '@/lib/graph/abstractNode'
|
|||||||
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||||
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { useOptionalRecollectionActions } from '@/app/recollections/RecollectionActionsContext'
|
import { useOptionalRecollectionActions } from '@/app/recollections/layout/RecollectionActionsContext'
|
||||||
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
||||||
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
||||||
import {
|
import {
|
||||||
@@ -302,6 +302,7 @@ export function useRenderingNodeState(
|
|||||||
label: id,
|
label: id,
|
||||||
type: isSvgContent ? 'image' : 'html',
|
type: isSvgContent ? 'image' : 'html',
|
||||||
content: cached,
|
content: cached,
|
||||||
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -366,6 +367,7 @@ export function useRenderingNodeState(
|
|||||||
label: id,
|
label: id,
|
||||||
type: cacheType,
|
type: cacheType,
|
||||||
content: htmlOrSvg ?? '',
|
content: htmlOrSvg ?? '',
|
||||||
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
const mode = outputModeRef.current
|
const mode = outputModeRef.current
|
||||||
const cachedOutputValue =
|
const cachedOutputValue =
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { registerBuiltinConfigTypes } from './lib/graph/configTypes'
|
|||||||
import { KosmosPage } from './app/kosmos/KosmosPage'
|
import { KosmosPage } from './app/kosmos/KosmosPage'
|
||||||
import { RecollectionsPage } from './app/recollections/RecollectionsPage'
|
import { RecollectionsPage } from './app/recollections/RecollectionsPage'
|
||||||
import { RecollectionLayout } from './app/recollections/RecollectionLayout'
|
import { RecollectionLayout } from './app/recollections/RecollectionLayout'
|
||||||
|
import { LogosPage } from './app/recollections/logos/LogosPage'
|
||||||
|
import { FluxRoute } from './app/recollections/flux/FluxRoute'
|
||||||
|
import { KatalogosPage } from './app/recollections/katalogos/KatalogosPage'
|
||||||
import './lib/prismSetup'
|
import './lib/prismSetup'
|
||||||
import 'prismjs/themes/prism.css'
|
import 'prismjs/themes/prism.css'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
@@ -26,8 +29,9 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path="recollections" element={<RecollectionsPage />} />
|
<Route path="recollections" element={<RecollectionsPage />} />
|
||||||
<Route path="recollections/:recollectionId" element={<RecollectionLayout />}>
|
<Route path="recollections/:recollectionId" element={<RecollectionLayout />}>
|
||||||
<Route index element={<Navigate to="logos" replace />} />
|
<Route index element={<Navigate to="logos" replace />} />
|
||||||
<Route path="logos" element={<></>} />
|
<Route path="logos" element={<LogosPage />} />
|
||||||
<Route path="flux" element={<></>} />
|
<Route path="katalogos" element={<KatalogosPage />} />
|
||||||
|
<Route path="flux" element={<FluxRoute />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
Reference in New Issue
Block a user