'use client'; import { Fragment, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import { Check, ChevronDown, GripVertical, Loader2, Minimize2, Minus, Plus, Sparkles, Trash2, X, } from 'lucide-react'; import { nanoid } from 'nanoid'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { useI18n } from '@/lib/hooks/use-i18n'; import { cn } from '@/lib/utils'; import type { SceneOutline } from '@/lib/types/generation'; import type { WidgetType } from '@/lib/types/widgets'; import { changeOutlineType } from '@openmaic/generation'; import { countBlockingOutlines, validateOutline } from '@/lib/edit/content-validation'; type SceneType = SceneOutline['type']; interface OutlinesEditorProps { outlines: SceneOutline[]; onChange: (outlines: SceneOutline[]) => void; onConfirm: () => void; onBack: () => void; alwaysReview?: boolean; onAlwaysReviewChange?: (enabled: boolean) => void; isLoading?: boolean; /** SSE is still pumping outlines into this editor — render read-only. */ isStreaming?: boolean; /** Collapse the editor back to the preview surface (small streaming card / outline-ready). */ onCollapse?: () => void; } const SCENE_TYPES: SceneType[] = ['slide', 'quiz', 'interactive', 'pbl']; const TYPE_THEME: Record< SceneType, { chip: string; chipHover: string; accent: string; dot: string; } > = { slide: { chip: 'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-300', chipHover: 'hover:bg-blue-100/80 dark:hover:bg-blue-500/15', accent: 'bg-blue-500', dot: 'bg-blue-400', }, quiz: { chip: 'bg-purple-50 text-purple-600 dark:bg-purple-500/10 dark:text-purple-300', chipHover: 'hover:bg-purple-100/80 dark:hover:bg-purple-500/15', accent: 'bg-purple-500', dot: 'bg-purple-400', }, interactive: { chip: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-300', chipHover: 'hover:bg-emerald-100/80 dark:hover:bg-emerald-500/15', accent: 'bg-emerald-500', dot: 'bg-emerald-400', }, pbl: { chip: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-300', chipHover: 'hover:bg-amber-100/80 dark:hover:bg-amber-500/15', accent: 'bg-amber-500', dot: 'bg-amber-400', }, }; function normalizeOrder(outlines: SceneOutline[]): SceneOutline[] { return outlines.map((outline, index) => ({ ...outline, order: index + 1, })); } function useSceneTypeLabel() { const { t } = useI18n(); return (type: SceneType) => { switch (type) { case 'quiz': return t('generation.sceneTypeQuiz'); case 'interactive': return t('generation.sceneTypeInteractive'); case 'pbl': return t('generation.sceneTypePbl'); case 'slide': default: return t('generation.sceneTypeSlide'); } }; } export function OutlinesEditor({ outlines, onChange, onConfirm, onBack, alwaysReview = false, onAlwaysReviewChange, isLoading = false, isStreaming = false, onCollapse, }: OutlinesEditorProps) { const { t } = useI18n(); const sceneTypeLabel = useSceneTypeLabel(); const [draggingId, setDraggingId] = useState(null); const [dragOverId, setDragOverId] = useState(null); const lastScrollTargetRef = useRef(null); const editingDisabled = isLoading || isStreaming; const lastOutlineId = outlines.length > 0 ? outlines[outlines.length - 1].id : null; // Generation gate: an outline with a blank title is meaningless to generate, // so block "Confirm & generate" until every section has a title. A neutral // "N / M ready" counter by the button explains the gate and jumps to the // first offending section. const blockingCount = countBlockingOutlines(outlines); const totalCount = outlines.length; const readyCount = totalCount - blockingCount; const firstBlockingId = blockingCount > 0 ? outlines.find((o) => validateOutline(o).length > 0)?.id : undefined; const scrollToFirstBlocking = () => { if (!firstBlockingId) return; const node = document.getElementById(`outline-scene-${firstBlockingId}`); node?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }; // Auto-scroll to the latest streamed scene so streaming feels alive. useEffect(() => { if (!isStreaming || !lastOutlineId) return; if (lastScrollTargetRef.current === lastOutlineId) return; lastScrollTargetRef.current = lastOutlineId; const node = document.getElementById(`outline-scene-${lastOutlineId}`); if (node) { node.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, [isStreaming, lastOutlineId]); const addOutline = () => { if (editingDisabled) return; const newOutline: SceneOutline = { id: nanoid(8), type: 'slide', title: '', description: '', keyPoints: [], order: outlines.length + 1, }; onChange(normalizeOrder([...outlines, newOutline])); }; const updateOutline = (index: number, updates: Partial) => { const next = [...outlines]; next[index] = { ...next[index], ...updates }; onChange(normalizeOrder(next)); }; // Replace the whole outline object (not a partial merge) — used when changing // type, so stale per-type config from the previous type is dropped instead of // lingering and being persisted. const replaceOutline = (index: number, outline: SceneOutline) => { const next = [...outlines]; next[index] = outline; onChange(normalizeOrder(next)); }; const removeOutline = (index: number) => { if (editingDisabled) return; onChange(normalizeOrder(outlines.filter((_, i) => i !== index))); }; const insertOutlineAt = (atIndex: number) => { if (editingDisabled) return; const newOutline: SceneOutline = { id: nanoid(8), type: 'slide', title: '', description: '', keyPoints: [], order: atIndex + 1, }; const next = [...outlines]; next.splice(atIndex, 0, newOutline); onChange(normalizeOrder(next)); }; const moveOutline = (index: number, direction: 'up' | 'down') => { if (editingDisabled) return; const targetIndex = direction === 'up' ? index - 1 : index + 1; if (targetIndex < 0 || targetIndex >= outlines.length) return; const next = [...outlines]; [next[index], next[targetIndex]] = [next[targetIndex], next[index]]; onChange(normalizeOrder(next)); }; const reorderOutline = (fromIndex: number, toIndex: number) => { if (editingDisabled) return; if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return; const next = [...outlines]; const [item] = next.splice(fromIndex, 1); next.splice(toIndex, 0, item); onChange(normalizeOrder(next)); }; const headerSubtitle = useMemo(() => { if (isStreaming) { return outlines.length > 0 ? t('generation.outlineEditorStreamingProgress', { count: outlines.length }) : t('generation.outlineEditorStreamingWaiting'); } return t('generation.outlineEditorSummary', { count: outlines.length }); }, [isStreaming, outlines.length, t]); return ( {/* Soft gradient wash */}
{/* Header */}
{t('generation.outlineEditorEyebrow')}

{t('generation.outlineEditorTitle')}

{isStreaming && ( )} {headerSubtitle}

{onCollapse && ( )}
{/* Scene list */}
{outlines.length === 0 ? ( ) : (
    {!isStreaming && ( insertOutlineAt(0)} disabled={editingDisabled} position="edge" /> )} {outlines.map((outline, index) => { const isLast = outline.id === lastOutlineId; const isStreamingTip = isStreaming && isLast; return ( updateOutline(index, updates)} onReplace={(next) => replaceOutline(index, next)} onRemove={() => removeOutline(index)} onMoveUp={() => moveOutline(index, 'up')} onMoveDown={() => moveOutline(index, 'down')} canMoveUp={index > 0} canMoveDown={index < outlines.length - 1} sceneTypeLabel={sceneTypeLabel} disabled={editingDisabled} isStreamingTip={isStreamingTip} isDragging={draggingId === outline.id} isDragTarget={dragOverId === outline.id && draggingId !== outline.id} onDragStart={() => setDraggingId(outline.id)} onDragEnd={() => { setDraggingId(null); setDragOverId(null); }} onDragEnter={() => { if (draggingId && draggingId !== outline.id) { setDragOverId(outline.id); } }} onDrop={(sourceId) => { const fromIndex = outlines.findIndex((item) => item.id === sourceId); if (fromIndex >= 0) reorderOutline(fromIndex, index); setDraggingId(null); setDragOverId(null); }} /> {!isStreaming && ( insertOutlineAt(index + 1)} disabled={editingDisabled} position={isLast ? 'edge' : 'between'} /> )} ); })} {isStreaming && }
)}
{/* Footer */}
{!editingDisabled && blockingCount > 0 && ( )}
); } // ──────────────────────────────────────────────────────────────────────────────── // Scene row — Notion-style inline-editable card // ──────────────────────────────────────────────────────────────────────────────── interface SceneRowProps { index: number; outline: SceneOutline; onUpdate: (updates: Partial) => void; onReplace: (outline: SceneOutline) => void; onRemove: () => void; onMoveUp: () => void; onMoveDown: () => void; canMoveUp: boolean; canMoveDown: boolean; sceneTypeLabel: (type: SceneType) => string; disabled: boolean; isStreamingTip: boolean; isDragging: boolean; isDragTarget: boolean; onDragStart: () => void; onDragEnd: () => void; onDragEnter: () => void; onDrop: (sourceId: string) => void; } function SceneRow({ index, outline, onUpdate, onReplace, onRemove, onMoveUp, onMoveDown, canMoveUp, canMoveDown, sceneTypeLabel, disabled, isStreamingTip, isDragging, isDragTarget, onDragStart, onDragEnd, onDragEnter, onDrop, }: SceneRowProps) { const { t } = useI18n(); const theme = TYPE_THEME[outline.type] ?? TYPE_THEME.slide; const [keyPointDraft, setKeyPointDraft] = useState(''); const titleRef = useRef(null); const descRef = useRef(null); // Auto-resize textareas to content for the typography-first feel. useAutoResize(titleRef, outline.title); useAutoResize(descRef, outline.description); const addKeyPoint = (raw: string) => { const trimmed = raw.trim(); if (!trimmed) return; const next = [...(outline.keyPoints ?? []), trimmed]; onUpdate({ keyPoints: next }); setKeyPointDraft(''); }; const removeKeyPoint = (idx: number) => { const next = (outline.keyPoints ?? []).filter((_, i) => i !== idx); onUpdate({ keyPoints: next }); }; const handleKeyPointKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ',') { event.preventDefault(); addKeyPoint(keyPointDraft); } else if ( event.key === 'Backspace' && !keyPointDraft && (outline.keyPoints?.length ?? 0) > 0 ) { removeKeyPoint((outline.keyPoints?.length ?? 0) - 1); } }; return ( { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; }} onDragEnter={onDragEnter} onDrop={(event) => { event.preventDefault(); const sourceId = event.dataTransfer.getData('text/plain'); if (sourceId) onDrop(sourceId); }} className={cn( 'group/scene relative rounded-2xl px-3 py-3.5 transition-colors md:px-4', 'hover:bg-slate-50/60 dark:hover:bg-slate-800/30', 'focus-within:bg-slate-50/80 dark:focus-within:bg-slate-800/40', isDragging && 'opacity-40', isDragTarget && 'bg-blue-500/[0.04] ring-1 ring-blue-400/40', )} >
{/* Left rail: drag handle + number, baseline-aligned with title */}
{index + 1} {isStreamingTip && ( )}
{/* Body */}
{/* Title row */}
{!disabled && !outline.title.trim() && ( // Incomplete marker — a soft amber dot before a blank title. A // blank title blocks generation; the gate below counts how many. )}