525 lines
22 KiB
TypeScript
525 lines
22 KiB
TypeScript
import { useEffect, useRef, useState, type ChangeEvent, type CSSProperties } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
AlertTriangle,
|
|
Check,
|
|
Download,
|
|
ExternalLink,
|
|
Home,
|
|
Image as ImageIcon,
|
|
Loader2,
|
|
RefreshCw,
|
|
Server,
|
|
Sparkles,
|
|
Upload,
|
|
WandSparkles,
|
|
} from 'lucide-react';
|
|
import { useMarketingStore } from '@/store/useMarketingStore';
|
|
import {
|
|
DREAM_WEAVER_URL,
|
|
checkDreamWeaverHealth,
|
|
fetchDreamWeaverResult,
|
|
getDreamWeaverStatus,
|
|
submitDreamWeaverJob,
|
|
type DreamWeaverHealth,
|
|
type DreamWeaverJobResponse,
|
|
type DreamWeaverStatusResponse,
|
|
} from '@/lib/dreamWeaverApi';
|
|
|
|
const GLASS: CSSProperties = {
|
|
background: 'rgba(8, 10, 18, 0.82)',
|
|
border: '1px solid rgba(59,130,246,0.14)',
|
|
backdropFilter: 'blur(24px)',
|
|
WebkitBackdropFilter: 'blur(24px)',
|
|
boxShadow: '0 0 0 1px rgba(255,255,255,0.04), 0 4px 32px rgba(0,0,0,0.55)',
|
|
};
|
|
|
|
const INNER: CSSProperties = {
|
|
background: 'rgba(255,255,255,0.04)',
|
|
border: '1px solid rgba(255,255,255,0.07)',
|
|
};
|
|
|
|
const ROOM_TYPES = [
|
|
{ id: 'bedroom', label: 'Bedroom' },
|
|
{ id: 'living_room', label: 'Living Room' },
|
|
{ id: 'bathroom', label: 'Bathroom' },
|
|
{ id: 'kitchen', label: 'Kitchen' },
|
|
{ id: 'dining_room', label: 'Dining Room' },
|
|
{ id: 'home_office', label: 'Office' },
|
|
{ id: 'hallway', label: 'Hallway' },
|
|
{ id: 'balcony', label: 'Balcony' },
|
|
] as const;
|
|
|
|
type ProcessingState = 'idle' | 'checking' | 'submitting' | 'rendering' | 'downloading';
|
|
|
|
interface DreamWeaverOutput {
|
|
id: string;
|
|
roomLabel: string;
|
|
keywords: string;
|
|
imageUrl: string;
|
|
createdAt: Date;
|
|
}
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
|
}
|
|
|
|
function isReadyStatus(status: DreamWeaverStatusResponse) {
|
|
const normalized = status.status?.toLowerCase() ?? '';
|
|
return Boolean(status.ready) || ['ready', 'completed', 'complete', 'succeeded', 'success', 'finished'].includes(normalized);
|
|
}
|
|
|
|
function isFailedStatus(status: DreamWeaverStatusResponse) {
|
|
const normalized = status.status?.toLowerCase() ?? '';
|
|
return ['failed', 'error', 'cancelled', 'canceled'].includes(normalized);
|
|
}
|
|
|
|
function statusLabel(state: ProcessingState, health: DreamWeaverHealth | null) {
|
|
if (state === 'checking') return 'Checking gateway';
|
|
if (state === 'submitting') return 'Submitting render';
|
|
if (state === 'rendering') return 'ComfyUI rendering';
|
|
if (state === 'downloading') return 'Fetching result';
|
|
if (!health) return 'Gateway unknown';
|
|
if (health.online && health.routeMounted && health.comfyuiOnline === false) return 'Gateway online · ComfyUI offline';
|
|
if (health.online && health.routeMounted && health.comfyuiOnline && health.checkpointReady === false) return 'Gateway online · Model missing';
|
|
return health.online && health.routeMounted ? 'Gateway online' : 'Gateway offline';
|
|
}
|
|
|
|
function ResultActions({ output }: { output: DreamWeaverOutput }) {
|
|
function downloadResult() {
|
|
const anchor = document.createElement('a');
|
|
anchor.href = output.imageUrl;
|
|
anchor.download = `dream-weaver-${output.id}.png`;
|
|
anchor.click();
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={downloadResult}
|
|
className="h-9 w-9 rounded-xl flex items-center justify-center transition-colors hover:bg-white/10"
|
|
style={INNER}
|
|
title="Download generated image"
|
|
>
|
|
<Download className="w-4 h-4 text-white/75" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => window.open(output.imageUrl, '_blank', 'noopener,noreferrer')}
|
|
className="h-9 w-9 rounded-xl flex items-center justify-center transition-colors hover:bg-white/10"
|
|
style={INNER}
|
|
title="Open generated image"
|
|
>
|
|
<ExternalLink className="w-4 h-4 text-white/75" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CatalystDreamWeaverTab() {
|
|
const { pushLiveEvent } = useMarketingStore();
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const objectUrlsRef = useRef<Set<string>>(new Set());
|
|
|
|
const [sourceFile, setSourceFile] = useState<File | null>(null);
|
|
const [sourcePreview, setSourcePreview] = useState<string | null>(null);
|
|
const [selectedRoomType, setSelectedRoomType] = useState<(typeof ROOM_TYPES)[number]['id']>('bedroom');
|
|
const [keywords, setKeywords] = useState('');
|
|
const [health, setHealth] = useState<DreamWeaverHealth | null>(null);
|
|
const [processingState, setProcessingState] = useState<ProcessingState>('checking');
|
|
const [progress, setProgress] = useState('Checking Dream Weaver gateway...');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [currentOutput, setCurrentOutput] = useState<DreamWeaverOutput | null>(null);
|
|
const [history, setHistory] = useState<DreamWeaverOutput[]>([]);
|
|
|
|
const isProcessing = processingState !== 'idle' && processingState !== 'checking';
|
|
const roomLabel = ROOM_TYPES.find((room) => room.id === selectedRoomType)?.label ?? 'Bedroom';
|
|
|
|
useEffect(() => {
|
|
void refreshHealth();
|
|
return () => {
|
|
objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
|
objectUrlsRef.current.clear();
|
|
};
|
|
}, []);
|
|
|
|
function rememberObjectUrl(blobOrFile: Blob) {
|
|
const url = URL.createObjectURL(blobOrFile);
|
|
objectUrlsRef.current.add(url);
|
|
return url;
|
|
}
|
|
|
|
function setSourceFromFile(file: File) {
|
|
if (!file.type.startsWith('image/')) {
|
|
setError('Dream Weaver needs a source room image.');
|
|
return;
|
|
}
|
|
const previewUrl = rememberObjectUrl(file);
|
|
setSourceFile(file);
|
|
setSourcePreview(previewUrl);
|
|
setCurrentOutput(null);
|
|
setError(null);
|
|
}
|
|
|
|
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
|
const file = event.target.files?.[0];
|
|
if (file) {
|
|
setSourceFromFile(file);
|
|
}
|
|
event.target.value = '';
|
|
}
|
|
|
|
async function refreshHealth() {
|
|
setProcessingState('checking');
|
|
setProgress('Checking Dream Weaver gateway...');
|
|
const nextHealth = await checkDreamWeaverHealth();
|
|
setHealth(nextHealth);
|
|
setProcessingState('idle');
|
|
setProgress(nextHealth.online && nextHealth.routeMounted
|
|
? nextHealth.comfyuiOnline === false
|
|
? `Gateway is online and the Dream Weaver route is mounted. ComfyUI is offline${nextHealth.comfyuiUrl ? ` at ${nextHealth.comfyuiUrl}` : ''}.`
|
|
: nextHealth.checkpointReady === false
|
|
? `ComfyUI is online${nextHealth.comfyuiUrl ? ` at ${nextHealth.comfyuiUrl}` : ''}, but no checkpoint model is installed. Hydrate RealVisXL into ComfyUI/models/checkpoints.`
|
|
: 'Gateway is online and the Dream Weaver route is mounted.'
|
|
: nextHealth.detail ?? 'Dream Weaver gateway is not reachable.');
|
|
}
|
|
|
|
async function pollUntilReady(job: DreamWeaverJobResponse) {
|
|
let latestResultUrl = job.result_url;
|
|
for (let attempt = 1; attempt <= 150; attempt += 1) {
|
|
const status = await getDreamWeaverStatus(job);
|
|
latestResultUrl = status.result_url ?? latestResultUrl;
|
|
setProgress(status.status ? `Render ${status.status} · poll ${attempt}/150` : `Render queued · poll ${attempt}/150`);
|
|
|
|
if (isReadyStatus(status)) {
|
|
return latestResultUrl;
|
|
}
|
|
if (isFailedStatus(status) || status.error) {
|
|
throw new Error(status.error ?? `Dream Weaver render ${status.status ?? 'failed'}.`);
|
|
}
|
|
await sleep(2000);
|
|
}
|
|
throw new Error('Dream Weaver timed out after 5 minutes.');
|
|
}
|
|
|
|
async function generate() {
|
|
if (!sourceFile || isProcessing) return;
|
|
setError(null);
|
|
setCurrentOutput(null);
|
|
|
|
try {
|
|
setProcessingState('submitting');
|
|
setProgress(`Submitting ${roomLabel.toLowerCase()} staging request...`);
|
|
const job = await submitDreamWeaverJob({
|
|
image: sourceFile,
|
|
roomType: selectedRoomType,
|
|
keywords,
|
|
});
|
|
|
|
setProcessingState('rendering');
|
|
setProgress(`Job ${job.job_id} accepted. Waiting for ComfyUI output...`);
|
|
const resultUrl = await pollUntilReady(job);
|
|
|
|
setProcessingState('downloading');
|
|
setProgress('Fetching generated image...');
|
|
const resultBlob = await fetchDreamWeaverResult(job.job_id, resultUrl);
|
|
const imageUrl = rememberObjectUrl(resultBlob);
|
|
const output: DreamWeaverOutput = {
|
|
id: job.job_id,
|
|
roomLabel,
|
|
keywords: keywords.trim(),
|
|
imageUrl,
|
|
createdAt: new Date(),
|
|
};
|
|
|
|
setCurrentOutput(output);
|
|
setHistory((items) => [output, ...items].slice(0, 6));
|
|
setProgress('Dream Weaver render complete.');
|
|
pushLiveEvent({
|
|
id: `dw-${job.job_id}-${Date.now()}`,
|
|
type: 'create',
|
|
campaignName: 'Dream Weaver',
|
|
message: `${roomLabel} staging render completed.`,
|
|
timestamp: new Date(),
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Dream Weaver render failed.';
|
|
setError(message);
|
|
setProgress(message);
|
|
pushLiveEvent({
|
|
id: `dw-error-${Date.now()}`,
|
|
type: 'alert',
|
|
campaignName: 'Dream Weaver',
|
|
message,
|
|
timestamp: new Date(),
|
|
});
|
|
} finally {
|
|
setProcessingState('idle');
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<motion.div
|
|
className="relative rounded-2xl p-5 overflow-hidden"
|
|
style={GLASS}
|
|
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
|
|
>
|
|
<div className="absolute inset-x-0 top-0 h-px pointer-events-none"
|
|
style={{ background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.10), transparent)' }} />
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-white mb-0.5 flex items-center gap-2">
|
|
<WandSparkles className="w-4 h-4 text-blue-400" /> Dream Weaver
|
|
</h3>
|
|
<p className="text-xs" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
|
Room image transformation pipeline using the same Dream Weaver gateway as the iPad app.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-2 rounded-xl px-3 py-2 text-xs" style={INNER}>
|
|
<span className={`h-2 w-2 rounded-full ${health?.online && health.routeMounted ? 'bg-green-400' : 'bg-red-400'}`} />
|
|
<span className="text-white/75">{statusLabel(processingState, health)}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void refreshHealth()}
|
|
className="h-9 w-9 rounded-xl flex items-center justify-center transition-colors hover:bg-white/10"
|
|
style={INNER}
|
|
title="Check Dream Weaver gateway"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 text-white/75 ${processingState === 'checking' ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
<div className="grid grid-cols-1 xl:grid-cols-[0.95fr_1.05fr] gap-5">
|
|
<motion.div
|
|
className="relative rounded-2xl p-5 overflow-hidden"
|
|
style={GLASS}
|
|
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
transition={{ duration: 0.35, delay: 0.05, ease: [0.4, 0, 0.2, 1] }}
|
|
>
|
|
<div className="space-y-5">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
|
<ImageIcon className="w-4 h-4 text-blue-400" /> Source Room
|
|
</h3>
|
|
<p className="text-xs mt-1" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
|
Upload a ground-truth room photograph, choose the target room type, then add optional styling keywords.
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
onDragOver={(event) => event.preventDefault()}
|
|
onDrop={(event) => {
|
|
event.preventDefault();
|
|
const file = event.dataTransfer.files?.[0];
|
|
if (file) setSourceFromFile(file);
|
|
}}
|
|
className="relative w-full min-h-[300px] rounded-2xl overflow-hidden flex items-center justify-center text-left transition-colors hover:border-blue-400/40"
|
|
style={{ ...INNER, background: sourcePreview ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.025)' }}
|
|
>
|
|
{sourcePreview ? (
|
|
<img src={sourcePreview} alt="Selected source room" className="absolute inset-0 h-full w-full object-cover" />
|
|
) : (
|
|
<div className="flex flex-col items-center gap-3 text-center px-6">
|
|
<div className="h-12 w-12 rounded-2xl flex items-center justify-center" style={INNER}>
|
|
<Upload className="h-5 w-5 text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold text-white">Upload room image</p>
|
|
<p className="text-xs mt-1" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
|
Click to browse or drop a photo here.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
|
|
</button>
|
|
|
|
<div>
|
|
<p className="text-xs font-medium uppercase tracking-widest mb-2" style={{ color: 'rgba(148,163,184,0.65)' }}>
|
|
Room type
|
|
</p>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
|
{ROOM_TYPES.map((room) => {
|
|
const selected = selectedRoomType === room.id;
|
|
return (
|
|
<button
|
|
key={room.id}
|
|
type="button"
|
|
onClick={() => setSelectedRoomType(room.id)}
|
|
className="rounded-xl px-3 py-2 text-sm font-medium flex items-center gap-2 transition-colors"
|
|
style={{
|
|
background: selected ? 'rgba(59,130,246,0.18)' : 'rgba(255,255,255,0.04)',
|
|
border: selected ? '1px solid rgba(59,130,246,0.38)' : '1px solid rgba(255,255,255,0.07)',
|
|
color: selected ? '#fff' : 'rgba(226,232,240,0.72)',
|
|
}}
|
|
>
|
|
<Home className="w-3.5 h-3.5 text-blue-400" />
|
|
<span>{room.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium uppercase tracking-widest mb-2 block" style={{ color: 'rgba(148,163,184,0.65)' }}>
|
|
Keywords
|
|
</label>
|
|
<textarea
|
|
value={keywords}
|
|
onChange={(event) => setKeywords(event.target.value)}
|
|
placeholder="gold, marble, luxury, soft daylight"
|
|
rows={3}
|
|
className="w-full resize-none rounded-xl px-3 py-2 text-sm text-white placeholder-white/20 outline-none focus:border-blue-400/50"
|
|
style={INNER}
|
|
/>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{error && (
|
|
<motion.div
|
|
className="rounded-xl p-3 flex items-start gap-2"
|
|
style={{ background: 'rgba(239,68,68,0.12)', border: '1px solid rgba(239,68,68,0.25)' }}
|
|
initial={{ opacity: 0, y: -6 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -6 }}
|
|
>
|
|
<AlertTriangle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
|
|
<p className="text-sm text-red-200 leading-relaxed">{error}</p>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<div className="flex items-center justify-between gap-3 rounded-2xl p-3" style={INNER}>
|
|
<div className="min-w-0">
|
|
<p className="text-xs font-medium text-white truncate">{progress}</p>
|
|
<p className="text-[11px] mt-1 truncate" style={{ color: 'rgba(148,163,184,0.5)' }}>
|
|
Gateway: {DREAM_WEAVER_URL}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void generate()}
|
|
disabled={!sourceFile || isProcessing || health?.routeMounted === false || health?.comfyuiOnline === false || health?.checkpointReady === false}
|
|
className="h-11 px-5 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-45 disabled:cursor-not-allowed transition-colors"
|
|
style={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}
|
|
>
|
|
{isProcessing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
|
Generate
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
className="relative rounded-2xl p-5 overflow-hidden"
|
|
style={GLASS}
|
|
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
transition={{ duration: 0.35, delay: 0.1, ease: [0.4, 0, 0.2, 1] }}
|
|
>
|
|
<div className="flex items-center justify-between gap-3 mb-4">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
|
<WandSparkles className="w-4 h-4 text-blue-400" /> Generated Staging
|
|
</h3>
|
|
<p className="text-xs mt-1" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
|
The result appears here as soon as the gateway marks the job ready.
|
|
</p>
|
|
</div>
|
|
{currentOutput && <ResultActions output={currentOutput} />}
|
|
</div>
|
|
|
|
<div className="relative min-h-[460px] rounded-2xl overflow-hidden flex items-center justify-center" style={INNER}>
|
|
{currentOutput ? (
|
|
<img src={currentOutput.imageUrl} alt={`${currentOutput.roomLabel} Dream Weaver result`} className="absolute inset-0 h-full w-full object-contain bg-black" />
|
|
) : (
|
|
<div className="flex flex-col items-center gap-3 text-center px-8">
|
|
<div className="h-14 w-14 rounded-2xl flex items-center justify-center" style={INNER}>
|
|
{isProcessing ? <Loader2 className="h-6 w-6 text-blue-400 animate-spin" /> : <WandSparkles className="h-6 w-6 text-blue-400" />}
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold text-white">{isProcessing ? 'Dream Weaver is rendering' : 'No generated image yet'}</p>
|
|
<p className="text-xs mt-1 max-w-md" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
|
Upload a source image and generate a staging render to populate this canvas.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{history.length > 0 && (
|
|
<div className="mt-5">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<p className="text-xs font-medium uppercase tracking-widest" style={{ color: 'rgba(148,163,184,0.65)' }}>
|
|
Recent renders
|
|
</p>
|
|
<span className="text-[11px]" style={{ color: 'rgba(148,163,184,0.45)' }}>{history.length}/6</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
{history.map((item) => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => setCurrentOutput(item)}
|
|
className="group rounded-xl overflow-hidden text-left transition-colors hover:border-blue-400/40"
|
|
style={INNER}
|
|
>
|
|
<div className="aspect-[4/3] bg-black overflow-hidden">
|
|
<img src={item.imageUrl} alt={item.roomLabel} className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300" />
|
|
</div>
|
|
<div className="p-3">
|
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-white">
|
|
<Check className="w-3.5 h-3.5 text-green-400" />
|
|
{item.roomLabel}
|
|
</div>
|
|
<p className="text-[11px] mt-1 truncate" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
|
{item.keywords || item.createdAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</div>
|
|
|
|
<motion.div
|
|
className="relative rounded-2xl p-5 overflow-hidden"
|
|
style={GLASS}
|
|
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
transition={{ duration: 0.35, delay: 0.15, ease: [0.4, 0, 0.2, 1] }}
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
|
<Server className="w-4 h-4 text-blue-400" /> Gateway Contract
|
|
</h3>
|
|
<span className="text-xs font-medium" style={{ color: health?.routeMounted ? '#4ade80' : '#f87171' }}>
|
|
{health?.routeMounted ? 'Route mounted' : 'Route not verified'}
|
|
</span>
|
|
</div>
|
|
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
{['POST /dream-weaver', 'GET /dream-weaver/status/{job_id}', 'GET /dream-weaver/result/{job_id}'].map((endpoint) => (
|
|
<div key={endpoint} className="rounded-xl p-3" style={INNER}>
|
|
<p className="text-xs font-mono text-blue-200">{endpoint}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
}
|