'use client';
import { useMemo, useState } from 'react';
import classNames from 'classnames';
import { Plus, Trash2, X, SquareDashed } from 'lucide-react';
import { boxColor, boxColorAlpha } from './BoundingBoxOverlay';
// Detects an Ideogram structured caption (the distinctive marker is the
// compositional_deconstruction block). Used by the viewer to decide whether to
// show this form editor instead of a plain textarea.
export function isIdeogramCaption(text: string): boolean {
const t = text.trim();
if (!t.startsWith('{')) return false;
try {
const d = JSON.parse(t);
return !!d && typeof d === 'object' && typeof d.compositional_deconstruction === 'object';
} catch {
return false;
}
}
// Normalize an arbitrary color string to a #rrggbb value usable by .
function toHex6(c: string): string {
const s = (c || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(s)) return s;
if (/^#[0-9a-fA-F]{3}$/.test(s)) {
return (
'#' +
s
.slice(1)
.split('')
.map(ch => ch + ch)
.join('')
);
}
return '#000000';
}
const clamp1000 = (n: number) => Math.max(0, Math.min(1000, Math.round(isNaN(n) ? 0 : n)));
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
);
}
function TextField({
label,
value,
onChange,
placeholder,
}: {
label?: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) {
return (
{label && {label} }
onChange(e.target.value)}
className="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-100 outline-none focus:border-blue-500"
/>
);
}
function TextAreaField({
label,
value,
onChange,
rows = 3,
placeholder,
}: {
label?: string;
value: string;
onChange: (v: string) => void;
rows?: number;
placeholder?: string;
}) {
return (
{label && {label} }
);
}
function ColorPalette({ colors, max, onChange }: { colors: string[]; max: number; onChange: (c: string[]) => void }) {
const setAt = (i: number, v: string) => onChange(colors.map((c, idx) => (idx === i ? v : c)));
const removeAt = (i: number) => onChange(colors.filter((_, idx) => idx !== i));
return (
);
}
interface Props {
caption: string;
onChange: (next: string) => void;
selectedIndex: number | null;
onSelectIndex: (i: number | null) => void;
isDrawing: boolean;
onToggleDrawing: () => void;
onSave: () => void;
isDirty: boolean;
}
export default function IdeogramCaptionSidebar({
caption,
onChange,
selectedIndex,
onSelectIndex,
isDrawing,
onToggleDrawing,
onSave,
isDirty,
}: Props) {
const [showRaw, setShowRaw] = useState(false);
const data = useMemo(() => {
try {
return JSON.parse(caption);
} catch {
return null;
}
}, [caption]);
// Parse fresh from the current caption, mutate, and emit pretty JSON so the
// string remains the single source of truth (shared with the image editor).
const update = (mutator: (d: any) => void) => {
let d: any;
try {
d = JSON.parse(caption);
} catch {
return;
}
mutator(d);
onChange(JSON.stringify(d, null, 2));
};
if (!data) {
return (
Caption is not valid JSON, so the form editor is unavailable. Fix it in the raw text below.
);
}
const style = data.style_description || {};
const decon = data.compositional_deconstruction || {};
const elements: any[] = Array.isArray(decon.elements) ? decon.elements : [];
const setStyle = (key: string, value: string) =>
update(d => {
d.style_description = { ...(d.style_description || {}), [key]: value };
});
const setStylePalette = (cols: string[]) =>
update(d => {
const sd = { ...(d.style_description || {}) };
if (cols.length) sd.color_palette = cols;
else delete sd.color_palette;
d.style_description = sd;
});
const setElement = (i: number, mutator: (el: any) => void) =>
update(d => {
const els = d?.compositional_deconstruction?.elements;
if (Array.isArray(els) && els[i]) mutator(els[i]);
});
const addElement = () => {
update(d => {
if (!d.compositional_deconstruction || typeof d.compositional_deconstruction !== 'object') {
d.compositional_deconstruction = {};
}
if (!Array.isArray(d.compositional_deconstruction.elements)) {
d.compositional_deconstruction.elements = [];
}
d.compositional_deconstruction.elements.push({ type: 'obj', desc: '' });
});
onSelectIndex(elements.length);
};
const removeElement = (i: number) => {
update(d => {
d?.compositional_deconstruction?.elements?.splice(i, 1);
});
onSelectIndex(null);
};
return (
{/* Header — stays pinned while the form scrolls */}
Ideogram Caption
{isDirty && (
)}
{isDirty ? 'Save' : 'Saved'}
update(d => (d.high_level_description = v))}
rows={3}
placeholder="One-sentence summary of the image..."
/>
setStyle('aesthetics', v)} />
setStyle('lighting', v)} />
setStyle('photo', v)} />
setStyle('medium', v)}
placeholder="Photograph."
/>
Color palette (max 16)
update(d => {
d.compositional_deconstruction = { ...(d.compositional_deconstruction || {}), background: v };
})
}
rows={4}
placeholder="The scene shell: walls, floor, sky, ambient light..."
/>
Add element
{isDrawing ? 'Drawing… (esc to cancel)' : 'Draw box'}
{elements.map((el, i) => {
const isText = el.type === 'text';
const selected = selectedIndex === i;
const bbox = Array.isArray(el.bbox) && el.bbox.length === 4 ? el.bbox : null;
const palette = Array.isArray(el.color_palette) ? el.color_palette : [];
return (
onSelectIndex(selected ? null : i)}
className={classNames(
'flex items-center gap-1.5 text-[11px] font-semibold px-2.5 py-1 rounded-md transition-colors',
{
'bg-blue-600 text-white': selected,
'bg-gray-800 text-gray-400 hover:text-gray-200': !selected,
},
)}
title="Select (highlights its box on the image)"
>
#{i + 1}
setElement(i, e => {
e.type = 'obj';
delete e.text;
})
}
className={classNames('text-[11px] px-3 py-1 transition-colors', {
'bg-cyan-600 text-white': !isText,
'text-gray-300 hover:bg-gray-800': isText,
})}
>
obj
setElement(i, e => {
e.type = 'text';
if (e.text == null) e.text = '';
})
}
className={classNames('text-[11px] px-3 py-1 border-l border-gray-700 transition-colors', {
'bg-amber-600 text-white': isText,
'text-gray-300 hover:bg-gray-800': !isText,
})}
>
text
removeElement(i)}
className="ml-auto text-gray-500 hover:text-rose-400 transition-colors"
title="Delete element"
>
{isText && (
setElement(i, e => (e.text = v))}
rows={2}
/>
)}
setElement(i, e => (e.desc = v))}
rows={3}
/>
Bounding box {bbox ? '' : '(none)'}
{bbox ? (
setElement(i, e => delete e.bbox)}
className="text-[10px] text-rose-400 hover:text-rose-300"
>
remove
) : (
setElement(i, e => (e.bbox = [250, 250, 750, 750]))}
className="text-[10px] text-blue-400 hover:text-blue-300"
>
+ add box
)}
{bbox && (
{['y1', 'x1', 'y2', 'x2'].map((lbl, k) => (
{lbl}
setElement(i, el2 => {
const bb = [...el2.bbox];
bb[k] = clamp1000(parseInt(e.target.value, 10));
el2.bbox = bb;
})
}
className="bg-gray-800 border border-gray-700 rounded px-1 py-0.5 text-[11px] text-gray-100 outline-none focus:border-blue-500"
/>
))}
)}
Colors (max 5)
setElement(i, e => {
if (cols.length) e.color_palette = cols;
else delete e.color_palette;
})
}
/>
);
})}
{/* Advanced raw-JSON escape hatch */}
setShowRaw(s => !s)}
className="text-[11px] text-gray-500 hover:text-gray-300"
>
{showRaw ? '▾ Hide raw JSON' : '▸ Raw JSON'}
{showRaw && (
);
}