import { useState, useRef, useEffect } from "react";
import type { BodyElement } from "../../lib/api";
import { ChartRenderer } from "../charts/ChartRenderer";
interface BodyRendererProps {
elements: BodyElement[];
isEditing?: boolean;
onUpdate?: (index: number, element: BodyElement) => void;
onReorder?: (fromIndex: number, toIndex: number) => void;
onEditingChange?: (isEditing: boolean) => void;
hasPendingRemoteUpdate?: boolean;
onOverwrite?: () => void;
}
export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder, onEditingChange, hasPendingRemoteUpdate, onOverwrite }: BodyRendererProps) {
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
if (elements.length === 0) {
return (
<div className="text-[#555] font-mono text-sm italic">
No content yet. Use the CLI below to add content.
</div>
);
}
const handleDragStart = (index: number) => (e: React.DragEvent) => {
setDraggedIndex(index);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", index.toString());
};
const handleDragOver = (index: number) => (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (draggedIndex !== null && draggedIndex !== index) {
setDragOverIndex(index);
}
};
const handleDragLeave = () => {
setDragOverIndex(null);
};
const handleDrop = (toIndex: number) => (e: React.DragEvent) => {
e.preventDefault();
const fromIndex = draggedIndex;
setDraggedIndex(null);
setDragOverIndex(null);
if (fromIndex !== null && fromIndex !== toIndex && onReorder) {
onReorder(fromIndex, toIndex);
}
};
const handleDragEnd = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
return (
<div className="space-y-1">
{elements.map((element, index) => (
<div
key={index}
className={`group flex items-start gap-2 py-1 transition-all ${
draggedIndex === index ? "opacity-50" : ""
} ${
dragOverIndex === index
? "border-t-2 border-[#75aafc] -mt-[2px] pt-[calc(0.25rem+2px)]"
: ""
}`}
onDragOver={handleDragOver(index)}
onDragLeave={handleDragLeave}
onDrop={handleDrop(index)}
>
{/* Drag handle - only show in edit mode */}
{isEditing && onReorder && (
<div
draggable
onDragStart={handleDragStart(index)}
onDragEnd={handleDragEnd}
className="flex-shrink-0 w-5 h-6 flex items-center justify-center cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity text-[#555] hover:text-[#75aafc]"
title="Drag to reorder"
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="currentColor"
>
<circle cx="3" cy="2" r="1.5" />
<circle cx="9" cy="2" r="1.5" />
<circle cx="3" cy="6" r="1.5" />
<circle cx="9" cy="6" r="1.5" />
<circle cx="3" cy="10" r="1.5" />
<circle cx="9" cy="10" r="1.5" />
</svg>
</div>
)}
<div className="flex-1">
<BodyElementRenderer
element={element}
onUpdate={isEditing && onUpdate ? (el) => onUpdate(index, el) : undefined}
onEditingChange={onEditingChange}
hasPendingRemoteUpdate={hasPendingRemoteUpdate}
onOverwrite={onOverwrite}
/>
</div>
</div>
))}
</div>
);
}
function BodyElementRenderer({
element,
onUpdate,
onEditingChange,
hasPendingRemoteUpdate,
onOverwrite,
}: {
element: BodyElement;
onUpdate?: (element: BodyElement) => void;
onEditingChange?: (isEditing: boolean) => void;
hasPendingRemoteUpdate?: boolean;
onOverwrite?: () => void;
}) {
switch (element.type) {
case "heading":
return (
<HeadingElement
level={element.level}
text={element.text}
onUpdate={
onUpdate
? (text) => onUpdate({ ...element, text })
: undefined
}
onEditingChange={onEditingChange}
hasPendingRemoteUpdate={hasPendingRemoteUpdate}
onOverwrite={onOverwrite}
/>
);
case "paragraph":
return (
<ParagraphElement
text={element.text}
onUpdate={
onUpdate
? (text) => onUpdate({ ...element, text })
: undefined
}
onEditingChange={onEditingChange}
hasPendingRemoteUpdate={hasPendingRemoteUpdate}
onOverwrite={onOverwrite}
/>
);
case "chart":
return (
<ChartElement
chartType={element.chartType}
data={element.data}
title={element.title}
config={element.config}
/>
);
case "image":
return (
<ImageElement
src={element.src}
alt={element.alt}
caption={element.caption}
/>
);
default:
return null;
}
}
function HeadingElement({
level,
text,
onUpdate,
onEditingChange,
hasPendingRemoteUpdate,
onOverwrite,
}: {
level: number;
text: string;
onUpdate?: (text: string) => void;
onEditingChange?: (isEditing: boolean) => void;
hasPendingRemoteUpdate?: boolean;
onOverwrite?: () => void;
}) {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(text);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Only update editText if not currently editing
if (!isEditing) {
setEditText(text);
}
}, [text, isEditing]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const startEditing = () => {
setIsEditing(true);
onEditingChange?.(true);
};
const stopEditing = () => {
setIsEditing(false);
onEditingChange?.(false);
};
const handleSave = () => {
// Don't auto-save if there's a pending remote update
if (hasPendingRemoteUpdate) return;
if (onUpdate && editText.trim() !== text) {
onUpdate(editText.trim());
}
stopEditing();
};
const handleOverwrite = () => {
if (onUpdate && editText.trim() !== text) {
onUpdate(editText.trim());
}
onOverwrite?.();
stopEditing();
};
const handleCancel = () => {
setEditText(text);
stopEditing();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// Disable Enter save if there's a pending remote update
if (e.key === "Enter" && !hasPendingRemoteUpdate) {
handleSave();
} else if (e.key === "Escape") {
handleCancel();
}
};
const handleBlur = () => {
// Don't auto-save on blur if there's a pending remote update
if (!hasPendingRemoteUpdate) {
handleSave();
}
};
const baseClassName = "font-mono text-[#9bc3ff]";
const sizeClasses: Record<number, string> = {
1: "text-2xl font-bold",
2: "text-xl font-bold",
3: "text-lg font-semibold",
4: "text-base font-semibold",
5: "text-sm font-semibold",
6: "text-xs font-semibold",
};
const sizeClass = sizeClasses[level] || sizeClasses[3];
if (isEditing && onUpdate) {
return (
<div>
<input
ref={inputRef}
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={`${baseClassName} ${sizeClass} w-full bg-transparent border-b border-[#3f6fb3] outline-none`}
/>
{hasPendingRemoteUpdate && (
<div className="flex items-center gap-2 mt-2">
<span className="text-yellow-500 text-xs font-mono">Remote update pending</span>
<button
onClick={handleOverwrite}
onMouseDown={(e) => e.preventDefault()}
className="px-2 py-1 font-mono text-xs text-yellow-500 border border-yellow-500/50 hover:border-yellow-500 transition-colors"
>
Overwrite
</button>
<button
onClick={handleCancel}
onMouseDown={(e) => e.preventDefault()}
className="px-2 py-1 font-mono text-xs text-[#555] hover:text-white/70 transition-colors"
>
Cancel
</button>
</div>
)}
</div>
);
}
const HeadingTag = `h${level}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
return (
<HeadingTag
className={`${baseClassName} ${sizeClass} ${onUpdate ? "cursor-text hover:bg-[rgba(117,170,252,0.05)] px-1 -mx-1 rounded" : ""}`}
onClick={() => onUpdate && startEditing()}
>
{text}
</HeadingTag>
);
}
function ParagraphElement({
text,
onUpdate,
onEditingChange,
hasPendingRemoteUpdate,
onOverwrite,
}: {
text: string;
onUpdate?: (text: string) => void;
onEditingChange?: (isEditing: boolean) => void;
hasPendingRemoteUpdate?: boolean;
onOverwrite?: () => void;
}) {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(text);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
// Only update editText if not currently editing
if (!isEditing) {
setEditText(text);
}
}, [text, isEditing]);
useEffect(() => {
if (isEditing && textareaRef.current) {
textareaRef.current.focus();
// Auto-resize textarea
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = textareaRef.current.scrollHeight + "px";
}
}, [isEditing]);
const startEditing = () => {
setIsEditing(true);
onEditingChange?.(true);
};
const stopEditing = () => {
setIsEditing(false);
onEditingChange?.(false);
};
const handleSave = () => {
// Don't auto-save if there's a pending remote update
if (hasPendingRemoteUpdate) return;
if (onUpdate && editText.trim() !== text) {
onUpdate(editText.trim());
}
stopEditing();
};
const handleOverwrite = () => {
if (onUpdate && editText.trim() !== text) {
onUpdate(editText.trim());
}
onOverwrite?.();
stopEditing();
};
const handleCancel = () => {
setEditText(text);
stopEditing();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
handleCancel();
}
// Ctrl/Cmd + Enter to save - disabled if there's a pending remote update
if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && !hasPendingRemoteUpdate) {
handleSave();
}
};
const handleBlur = () => {
// Don't auto-save on blur if there's a pending remote update
if (!hasPendingRemoteUpdate) {
handleSave();
}
};
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setEditText(e.target.value);
// Auto-resize
e.target.style.height = "auto";
e.target.style.height = e.target.scrollHeight + "px";
};
if (isEditing && onUpdate) {
return (
<div className="relative">
<textarea
ref={textareaRef}
value={editText}
onChange={handleInput}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="font-mono text-sm text-white/80 leading-relaxed w-full bg-transparent border border-[#3f6fb3] outline-none p-2 resize-none min-h-[60px]"
/>
{hasPendingRemoteUpdate ? (
<div className="flex items-center gap-2 mt-2">
<span className="text-yellow-500 text-xs font-mono">Remote update pending</span>
<button
onClick={handleOverwrite}
onMouseDown={(e) => e.preventDefault()}
className="px-2 py-1 font-mono text-xs text-yellow-500 border border-yellow-500/50 hover:border-yellow-500 transition-colors"
>
Overwrite
</button>
<button
onClick={handleCancel}
onMouseDown={(e) => e.preventDefault()}
className="px-2 py-1 font-mono text-xs text-[#555] hover:text-white/70 transition-colors"
>
Cancel
</button>
</div>
) : (
<div className="text-[10px] text-[#555] font-mono mt-1">
Ctrl+Enter to save, Esc to cancel
</div>
)}
</div>
);
}
return (
<p
className={`font-mono text-sm text-white/80 leading-relaxed ${onUpdate ? "cursor-text hover:bg-[rgba(117,170,252,0.05)] px-1 -mx-1 rounded" : ""}`}
onClick={() => onUpdate && startEditing()}
>
{text}
</p>
);
}
function ChartElement({
chartType,
data,
title,
config,
}: {
chartType: "line" | "bar" | "pie" | "area";
data: Record<string, unknown>[];
title?: string;
config?: Record<string, unknown>;
}) {
return (
<div className="border border-[#333] p-4 bg-black/30">
<ChartRenderer
chartType={chartType}
data={data}
title={title}
config={config}
/>
</div>
);
}
function ImageElement({
src,
alt,
caption,
}: {
src: string;
alt?: string;
caption?: string;
}) {
return (
<figure className="space-y-2">
<img
src={src}
alt={alt || ""}
className="max-w-full border border-[#333]"
/>
{caption && (
<figcaption className="text-[#555] font-mono text-xs italic">
{caption}
</figcaption>
)}
</figure>
);
}