feat: artifacts
This commit is contained in:
@@ -76,12 +76,60 @@ const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
|
||||
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
|
||||
})
|
||||
|
||||
function FlowFitViewOnLoad() {
|
||||
function FlowFitViewOnLoad({ disabled }: { disabled?: boolean }) {
|
||||
const nodesInitialized = useNodesInitialized()
|
||||
const { fitView } = useReactFlow()
|
||||
React.useEffect(() => {
|
||||
if (nodesInitialized) fitView?.({ duration: 200 })
|
||||
}, [nodesInitialized, fitView])
|
||||
if (disabled || !nodesInitialized) return
|
||||
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
|
||||
}
|
||||
|
||||
@@ -152,9 +200,11 @@ function FullscreenNodeContent({ node }: { node: AppNode }) {
|
||||
export type CanvasPageProps = {
|
||||
/** Optional recollection id for per-recollection graph loading */
|
||||
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 { showMinimap } = usePlatform()
|
||||
const {
|
||||
@@ -688,7 +738,8 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
||||
)}
|
||||
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
||||
<ViewportDisplayProvider>
|
||||
<FlowFitViewOnLoad />
|
||||
<FlowFitViewOnLoad disabled={Boolean(focusNodeId)} />
|
||||
<FocusNodeOnLoad focusNodeId={focusNodeId} />
|
||||
<FlowKeyboardShortcuts />
|
||||
<ReactFlow
|
||||
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* 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 { useParams } from 'react-router-dom'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import { CanvasPage } from '@/app/canvas/CanvasPage'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
|
||||
export function FluxRoute() {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const [searchParams] = useSearchParams()
|
||||
const { updateLastEdited } = usePlatform()
|
||||
const updateLastEditedRef = useRef(updateLastEdited)
|
||||
updateLastEditedRef.current = updateLastEdited
|
||||
@@ -17,11 +19,13 @@ export function FluxRoute() {
|
||||
if (recollectionId) updateLastEditedRef.current(recollectionId)
|
||||
}, [recollectionId])
|
||||
|
||||
const focusNodeId = searchParams.get('focusNode') ?? undefined
|
||||
|
||||
if (!recollectionId) return null
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,125 @@
|
||||
/**
|
||||
* Katalogos page: third view for a recollection.
|
||||
* Simple placeholder layout, matching Logos/Flux flex and typography.
|
||||
* Shows live \"Artifacts\" from Flux rendering nodes in a card grid.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
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-3xl p-4">
|
||||
<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</p>
|
||||
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/10 p-4 text-sm text-muted-foreground">
|
||||
Katalogos view coming soon.
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -22,8 +22,8 @@ export function RecollectionMenubar() {
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="flex h-9 w-full shrink-0 items-center justify-between gap-2 overflow-visible border-b border-border/40 bg-background px-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<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">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to="/recollections"
|
||||
aria-label="Back to recollections"
|
||||
@@ -31,12 +31,14 @@ export function RecollectionMenubar() {
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
{titleContent ?? defaultTitle}
|
||||
</div>
|
||||
<RecollectionViewSwitcher />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 pl-2">
|
||||
<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 />
|
||||
</div>
|
||||
|
||||
@@ -150,15 +150,15 @@ export function LogosPage() {
|
||||
async (query: string) => {
|
||||
const defaultItems = getDefaultReactSlashMenuItems(editor)
|
||||
const fluxItem = {
|
||||
title: 'Insert from Flux',
|
||||
subtext: 'Insert output from a Flux rendering node',
|
||||
title: 'Insert Artifact',
|
||||
subtext: 'Insert an Artifact produced by a Flux rendering node',
|
||||
icon: <FluxIcon className="size-4" />,
|
||||
onItemClick: () => {
|
||||
const pos = editor.getTextCursorPosition()
|
||||
editor.replaceBlocks([pos.block.id], [{ type: 'fluxOutput', props: {} }])
|
||||
},
|
||||
aliases: ['flux', 'output', 'render'] as const,
|
||||
group: 'Flux',
|
||||
aliases: ['artifact', 'flux', 'output', 'render'] as const,
|
||||
group: 'Artifacts',
|
||||
}
|
||||
const all = [...defaultItems, fluxItem]
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
@@ -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).
|
||||
* 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).
|
||||
* Shows live/static status and time since last update.
|
||||
*/
|
||||
|
||||
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 type { ReactCustomBlockRenderProps } from '@blocknote/react'
|
||||
import { getRenderOutputCache, type RenderOutputCacheEntry } from '../../state/recollectionStore'
|
||||
import {
|
||||
getRenderOutputCache,
|
||||
formatTimeSinceLastUpdate,
|
||||
type RenderOutputCacheEntry,
|
||||
} from '../../state/recollectionStore'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
||||
@@ -39,6 +44,7 @@ function FluxOutputBlockContent({
|
||||
contentRef,
|
||||
}: ReactCustomBlockRenderProps<'fluxOutput', typeof fluxOutputBlockConfig.propSchema, 'none'>) {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const nodeId = block.props.nodeId ?? ''
|
||||
const storedContent = block.props.content ?? ''
|
||||
@@ -60,35 +66,47 @@ function FluxOutputBlockContent({
|
||||
[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).
|
||||
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 rawContentType = liveEntry ? liveEntry.type : storedContentType
|
||||
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).
|
||||
const isSvgContent = Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
|
||||
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
|
||||
if (!content) {
|
||||
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}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
Insert from Flux
|
||||
Insert Artifact
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="start">
|
||||
{entries.length === 0 ? (
|
||||
{artifacts.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<ul className="max-h-64 overflow-auto py-2">
|
||||
{entries.map((entry) => (
|
||||
{artifacts.map((entry) => (
|
||||
<li key={entry.nodeId}>
|
||||
<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') {
|
||||
const src =
|
||||
content.startsWith('data:') ? content : `data:image/svg+xml;utf8,${encodeURIComponent(content)}`
|
||||
return (
|
||||
<div ref={contentRef} className="min-h-[40px]">
|
||||
{label ? <p className="mb-1 text-xs text-muted-foreground">{label}</p> : null}
|
||||
<img src={src} alt={label || 'Flux output'} className="max-w-full rounded border object-contain" />
|
||||
<div
|
||||
ref={contentRef}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// HTML: render in iframe to limit script execution (XSS safety)
|
||||
return (
|
||||
<div ref={contentRef} className="min-h-[40px]">
|
||||
{label ? <p className="mb-1 text-xs text-muted-foreground">{label}</p> : null}
|
||||
<iframe
|
||||
title={label || 'Flux HTML output'}
|
||||
srcDoc={content}
|
||||
className="min-h-[120px] w-full rounded border border-border bg-background"
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="min-h-[40px] overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-sm"
|
||||
>
|
||||
{header}
|
||||
<div className="p-2">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,19 @@ export type RenderOutputCacheEntry = {
|
||||
label: string
|
||||
type: 'image' | 'html'
|
||||
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'
|
||||
|
||||
@@ -302,6 +302,7 @@ export function useRenderingNodeState(
|
||||
label: id,
|
||||
type: isSvgContent ? 'image' : 'html',
|
||||
content: cached,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -366,6 +367,7 @@ export function useRenderingNodeState(
|
||||
label: id,
|
||||
type: cacheType,
|
||||
content: htmlOrSvg ?? '',
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
const mode = outputModeRef.current
|
||||
const cachedOutputValue =
|
||||
|
||||
Reference in New Issue
Block a user