Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

1524 lines
56 KiB
TypeScript

'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 '@/lib/generation/outline-type';
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<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const lastScrollTargetRef = useRef<string | null>(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<SceneOutline>) => {
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 (
<motion.div
layoutId="outline-review-surface"
transition={{ type: 'spring', stiffness: 220, damping: 28 }}
// Explicit rotate: 0 so the layout-shared morph from the tilted preview
// card interpolates rotation cleanly back to upright.
initial={{ rotate: 0 }}
animate={{ rotate: 0 }}
className={cn(
'relative overflow-hidden rounded-3xl border border-border/40',
'bg-white/85 shadow-[0_30px_80px_-30px_rgba(15,23,42,0.25)] backdrop-blur-xl',
'dark:border-white/5 dark:bg-slate-950/70 dark:shadow-[0_30px_80px_-30px_rgba(0,0,0,0.6)]',
)}
>
{/* Soft gradient wash */}
<div className="pointer-events-none absolute inset-x-0 -top-px h-px bg-gradient-to-r from-transparent via-blue-500/40 to-transparent" />
<div className="pointer-events-none absolute -top-32 left-1/2 h-64 w-[80%] -translate-x-1/2 rounded-full bg-blue-500/[0.04] blur-3xl dark:bg-blue-400/[0.08]" />
{/* Header */}
<div className="relative flex items-start gap-3 px-6 pt-6 pb-4 md:px-10 md:pt-8 md:pb-6">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground/70">
<Sparkles className="size-3 text-blue-500" />
{t('generation.outlineEditorEyebrow')}
</div>
<h2 className="text-2xl font-semibold tracking-tight md:text-[28px]">
{t('generation.outlineEditorTitle')}
</h2>
<p className="flex min-h-[1.5rem] items-center gap-2 text-sm text-muted-foreground">
{isStreaming && (
<motion.span
aria-hidden
className="inline-flex size-1.5 rounded-full bg-blue-500"
animate={{ opacity: [0.3, 1, 0.3], scale: [0.8, 1.1, 0.8] }}
transition={{ duration: 1.2, repeat: Infinity }}
/>
)}
{headerSubtitle}
</p>
</div>
{onCollapse && (
<button
type="button"
onClick={onCollapse}
disabled={isLoading}
aria-label={t('generation.collapseEditor')}
className={cn(
'mt-1 inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium',
'text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30',
'disabled:cursor-not-allowed disabled:opacity-50',
)}
>
<Minimize2 className="size-3.5" />
<span className="hidden sm:inline">{t('generation.collapseEditor')}</span>
</button>
)}
</div>
{/* Scene list */}
<div className="relative max-h-[64vh] overflow-y-auto px-3 pb-2 md:px-6">
{outlines.length === 0 ? (
<EmptyState isStreaming={isStreaming} disabled={editingDisabled} onAdd={addOutline} />
) : (
<ol className="flex flex-col py-1">
{!isStreaming && (
<InsertDivider
onClick={() => insertOutlineAt(0)}
disabled={editingDisabled}
position="edge"
/>
)}
<AnimatePresence initial={false}>
{outlines.map((outline, index) => {
const isLast = outline.id === lastOutlineId;
const isStreamingTip = isStreaming && isLast;
return (
<Fragment key={outline.id}>
<SceneRow
index={index}
outline={outline}
onUpdate={(updates) => 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 && (
<InsertDivider
onClick={() => insertOutlineAt(index + 1)}
disabled={editingDisabled}
position={isLast ? 'edge' : 'between'}
/>
)}
</Fragment>
);
})}
</AnimatePresence>
{isStreaming && <StreamingPlaceholder nextIndex={outlines.length + 1} />}
</ol>
)}
</div>
{/* Footer */}
<div className="relative flex flex-col gap-3 border-t border-border/40 bg-gradient-to-t from-background/95 to-transparent px-6 py-4 md:flex-row md:items-center md:justify-between md:px-10 md:py-5">
<label
className={cn(
'flex cursor-pointer items-center gap-2.5 text-sm text-muted-foreground transition-colors hover:text-foreground',
isLoading && 'cursor-not-allowed opacity-60',
)}
>
<Checkbox
checked={alwaysReview}
onCheckedChange={(checked) => onAlwaysReviewChange?.(checked === true)}
disabled={isLoading}
aria-label={t('generation.alwaysReviewOutlines')}
className="size-4"
/>
<span>{t('generation.alwaysReviewOutlines')}</span>
</label>
<div className="flex flex-col-reverse gap-2 md:flex-row md:items-center md:gap-2">
<Button
variant="ghost"
onClick={onBack}
disabled={isLoading}
className="rounded-full px-4 text-muted-foreground hover:text-foreground"
>
{t('generation.backToRequirements')}
</Button>
{!editingDisabled && blockingCount > 0 && (
<button
type="button"
onClick={scrollToFirstBlocking}
title={t('generation.jumpToBlankTitle')}
className="inline-flex items-center gap-2.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<span className="block h-1 w-[84px] overflow-hidden rounded-full bg-muted">
<span
className="block h-full rounded-full bg-muted-foreground/45 transition-[width]"
style={{ width: `${totalCount > 0 ? (readyCount / totalCount) * 100 : 0}%` }}
/>
</span>
{t('generation.outlinesReadyCount', { ready: readyCount, total: totalCount })}
</button>
)}
<Button
onClick={onConfirm}
disabled={isLoading || isStreaming || outlines.length === 0 || blockingCount > 0}
className="rounded-full px-6 shadow-lg shadow-blue-500/20"
>
{isLoading ? (
<>
<Loader2 className="size-4 animate-spin" />
{t('generation.generatingInProgress')}
</>
) : isStreaming ? (
<>
<Loader2 className="size-4 animate-spin" />
{t('generation.outlineEditorWaitingConfirm')}
</>
) : (
<>
<Check className="size-4" />
{t('generation.confirmAndGenerateCourse')}
</>
)}
</Button>
</div>
</div>
</motion.div>
);
}
// ────────────────────────────────────────────────────────────────────────────────
// Scene row — Notion-style inline-editable card
// ────────────────────────────────────────────────────────────────────────────────
interface SceneRowProps {
index: number;
outline: SceneOutline;
onUpdate: (updates: Partial<SceneOutline>) => 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<HTMLTextAreaElement>(null);
const descRef = useRef<HTMLTextAreaElement>(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<HTMLInputElement>) => {
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 (
<motion.li
id={`outline-scene-${outline.id}`}
layout
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8, scale: 0.98 }}
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
onDragOver={(event) => {
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',
)}
>
<div className="flex items-start gap-2.5">
{/* Left rail: drag handle + number, baseline-aligned with title */}
<div className="flex shrink-0 items-center gap-0.5 pt-1">
<button
type="button"
draggable={!disabled}
title={t('generation.dragSceneHint')}
aria-label={t('generation.dragSceneHint')}
aria-keyshortcuts="Control+ArrowUp Control+ArrowDown Meta+ArrowUp Meta+ArrowDown"
onDragStart={(event) => {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', outline.id);
onDragStart();
}}
onDragEnd={onDragEnd}
onKeyDown={(event) => {
if (disabled) return;
// Keyboard reorder: Cmd/Ctrl + ArrowUp / ArrowDown. Plain arrows
// are reserved for browser text-cursor navigation when focus
// shifts between fields.
if (!(event.ctrlKey || event.metaKey)) return;
if (event.key === 'ArrowUp' && canMoveUp) {
event.preventDefault();
onMoveUp();
} else if (event.key === 'ArrowDown' && canMoveDown) {
event.preventDefault();
onMoveDown();
}
}}
disabled={disabled}
className={cn(
'flex size-7 shrink-0 cursor-grab items-center justify-center rounded-md',
'text-muted-foreground/45 transition-all',
'hover:bg-muted hover:text-foreground/80',
'group-hover/scene:text-muted-foreground/70',
'active:cursor-grabbing',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30',
disabled && 'pointer-events-none opacity-30',
)}
>
<GripVertical className="size-4" aria-hidden />
</button>
<span
className={cn(
'relative flex size-7 items-center justify-center rounded-full text-xs font-semibold tabular-nums transition-colors',
'bg-muted/60 text-muted-foreground',
'group-hover/scene:bg-muted',
)}
>
{index + 1}
{isStreamingTip && (
<motion.span
aria-hidden
className={cn('absolute -right-0.5 -top-0.5 size-2 rounded-full', theme.dot)}
animate={{ scale: [0.8, 1.2, 0.8], opacity: [0.6, 1, 0.6] }}
transition={{ duration: 1.2, repeat: Infinity }}
/>
)}
</span>
</div>
{/* Body */}
<div className="min-w-0 flex-1 space-y-1.5">
{/* Title row */}
<div className="flex items-start justify-between gap-2">
{!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.
<span aria-hidden className="mt-[11px] h-2 w-2 shrink-0 rounded-full bg-amber-400" />
)}
<textarea
ref={titleRef}
value={outline.title}
onChange={(event) => onUpdate({ title: event.target.value })}
placeholder={t('generation.sceneTitlePlaceholder')}
disabled={disabled}
rows={1}
spellCheck={false}
className={cn(
'flex-1 resize-none border-none bg-transparent p-0 text-base font-semibold leading-7 tracking-tight',
'placeholder:font-normal placeholder:text-muted-foreground/40',
'focus:outline-none focus:ring-0 md:text-lg',
disabled && 'cursor-default',
)}
/>
<div className="flex shrink-0 items-center gap-1.5 pt-0.5">
{/* Cascading control: type-specific config (left) joined to the type selector (right) */}
<div className="inline-flex items-center overflow-hidden rounded-full">
{!disabled && outline.type === 'quiz' && (
<QuizConfigDisclosure outline={outline} onUpdate={onUpdate} theme={theme} />
)}
{!disabled && outline.type === 'interactive' && (
<InteractiveConfigDisclosure
outline={outline}
onUpdate={onUpdate}
theme={theme}
/>
)}
{!disabled && outline.type === 'pbl' && (
<PblConfigDisclosure outline={outline} onUpdate={onUpdate} theme={theme} />
)}
<TypePill
type={outline.type}
onChange={(type) => onReplace(changeOutlineType(outline, type))}
disabled={disabled}
label={sceneTypeLabel(outline.type)}
theme={theme}
connected={!disabled && outline.type !== 'slide'}
/>
</div>
{!disabled && <DeleteSceneButton onConfirm={onRemove} />}
</div>
</div>
{/* Description */}
<textarea
ref={descRef}
value={outline.description}
onChange={(event) => onUpdate({ description: event.target.value })}
placeholder={t('generation.sceneDescriptionPlaceholder')}
disabled={disabled}
rows={1}
className={cn(
'block w-full resize-none border-none bg-transparent p-0 text-sm leading-relaxed text-muted-foreground',
'placeholder:text-muted-foreground/40',
'focus:outline-none focus:ring-0 focus:text-foreground/90',
disabled && 'cursor-default',
)}
/>
{/* Key points */}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<AnimatePresence initial={false}>
{(outline.keyPoints ?? []).filter(Boolean).map((point, idx) => (
<motion.span
key={`${outline.id}-kp-${idx}-${point}`}
layout
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
transition={{ duration: 0.15 }}
className={cn(
'group/chip inline-flex max-w-[18rem] items-center gap-1 rounded-full px-2.5 py-1 text-xs',
'bg-muted/70 text-foreground/80',
)}
>
<span className="whitespace-normal break-words" title={point}>
{point}
</span>
{!disabled && (
<button
type="button"
onClick={() => removeKeyPoint(idx)}
aria-label={t('generation.removeKeyPoint')}
className="ml-0.5 inline-flex size-3.5 shrink-0 items-center justify-center rounded-full text-muted-foreground/0 transition-colors hover:bg-muted-foreground/20 hover:text-muted-foreground group-hover/chip:text-muted-foreground/70"
>
<X className="size-2.5" />
</button>
)}
</motion.span>
))}
</AnimatePresence>
{!disabled && (
<KeyPointInput
value={keyPointDraft}
onChange={setKeyPointDraft}
onKeyDown={handleKeyPointKeyDown}
placeholder={t('generation.addKeyPoint')}
/>
)}
</div>
</div>
</div>
</motion.li>
);
}
// ────────────────────────────────────────────────────────────────────────────────
// Subcomponents
// ────────────────────────────────────────────────────────────────────────────────
function EmptyState({
isStreaming,
disabled,
onAdd,
}: {
isStreaming: boolean;
disabled: boolean;
onAdd: () => void;
}) {
const { t } = useI18n();
if (isStreaming) {
return (
<div className="flex min-h-[200px] flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
className="text-blue-500/70"
>
<Loader2 className="size-6" />
</motion.div>
<p className="text-sm text-muted-foreground">
{t('generation.outlineEditorStreamingWaiting')}
</p>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center gap-4 px-6 py-12 text-center">
<p className="text-sm text-muted-foreground">{t('generation.noOutlines')}</p>
<Button variant="outline" onClick={onAdd} disabled={disabled} className="rounded-full">
<Plus className="size-4" />
{t('generation.addFirstScene')}
</Button>
</div>
);
}
function TypePill({
type,
onChange,
disabled,
label,
theme,
connected = false,
}: {
type: SceneType;
onChange: (type: SceneType) => void;
disabled: boolean;
label: string;
theme: (typeof TYPE_THEME)[SceneType];
/** When part of a cascading group, drop own rounding so the wrapper clips it. */
connected?: boolean;
}) {
const { t } = useI18n();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={disabled}>
<button
type="button"
className={cn(
'inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider transition-colors',
connected ? 'rounded-none' : 'rounded-full',
theme.chip,
!disabled && theme.chipHover,
disabled && 'cursor-default',
)}
>
{label}
{!disabled && <ChevronDown className="size-3 opacity-70" />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[140px]">
{SCENE_TYPES.map((option) => {
const optionTheme = TYPE_THEME[option];
return (
<DropdownMenuItem
key={option}
onClick={() => onChange(option)}
className="flex items-center justify-between gap-2"
>
<span className="flex items-center gap-2">
<span className={cn('size-2 rounded-full', optionTheme.accent)} />
{t(`generation.sceneType${capitalize(option)}`)}
</span>
{option === type && <Check className="size-3.5 text-muted-foreground" />}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
function DeleteSceneButton({ onConfirm }: { onConfirm: () => void }) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={t('generation.deleteScene')}
className={cn(
'inline-flex size-7 items-center justify-center rounded-full text-muted-foreground/40 transition-all',
'hover:bg-destructive/10 hover:text-destructive',
'opacity-0 group-hover/scene:opacity-100',
'data-[state=open]:opacity-100 data-[state=open]:bg-destructive/10 data-[state=open]:text-destructive',
'focus-visible:opacity-100 focus-visible:outline-none',
)}
>
<Trash2 className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={4} className="w-56 p-3">
<p className="text-sm font-medium">{t('generation.deleteSceneConfirm')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('generation.deleteSceneConfirmDesc')}
</p>
<div className="mt-3 flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setOpen(false)}
className="h-8"
>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
onConfirm();
setOpen(false);
}}
className="h-8"
>
{t('generation.deleteSceneConfirmAction')}
</Button>
</div>
</PopoverContent>
</Popover>
);
}
function StreamingPlaceholder({ nextIndex }: { nextIndex: number }) {
const { t } = useI18n();
return (
<motion.li
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ type: 'spring', stiffness: 360, damping: 32 }}
aria-live="polite"
aria-label={t('generation.outlineEditorStreamingWaiting')}
className="relative flex items-start gap-2.5 px-3 py-3.5 md:px-4"
>
{/* Left rail: spacer for grip column + spinner where the number badge would be */}
<div className="flex shrink-0 items-center gap-0.5 pt-1">
<span className="size-7" aria-hidden />
<span className="flex size-7 items-center justify-center rounded-full bg-blue-500/10 text-blue-500">
<Loader2 className="size-3.5 animate-spin" />
</span>
</div>
{/* Body: pulsing skeleton lines that mirror title + description heights */}
<div className="min-w-0 flex-1 space-y-2 pt-1.5">
<motion.div
aria-hidden
animate={{ opacity: [0.35, 0.7, 0.35] }}
transition={{ duration: 1.4, repeat: Infinity }}
className="h-4 w-3/5 rounded-md bg-muted/50"
/>
<motion.div
aria-hidden
animate={{ opacity: [0.35, 0.7, 0.35] }}
transition={{ duration: 1.4, repeat: Infinity, delay: 0.2 }}
className="h-3 w-2/5 rounded bg-muted/40"
/>
</div>
{/* Hidden but exposed to screen readers */}
<span className="sr-only">
{t('generation.outlineEditorStreamingProgress', { count: nextIndex - 1 })}
</span>
</motion.li>
);
}
function InsertDivider({
onClick,
disabled,
position = 'between',
}: {
onClick: () => void;
disabled: boolean;
/** Edge dividers (before first / after last) keep a faint hint to invite adding. */
position?: 'between' | 'edge';
}) {
const { t } = useI18n();
const isEdge = position === 'edge';
return (
<li
role="presentation"
className="relative z-10 flex h-7 items-center justify-center px-3 md:px-4"
>
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={t('generation.insertSceneHere')}
className={cn(
'group/insert relative flex h-full w-full items-center justify-center transition-opacity',
// Edges show a barely-there hint by default; hover/focus brings it to full
isEdge
? 'opacity-25 hover:opacity-100 focus-visible:opacity-100'
: 'opacity-0 hover:opacity-100 focus-visible:opacity-100',
'focus-visible:outline-none',
disabled && 'pointer-events-none opacity-20',
)}
>
<span
aria-hidden
className={cn(
'absolute top-1/2 h-px -translate-y-1/2 transition-colors',
// Edge: short, neutral line; between: longer, blue line
isEdge
? 'inset-x-16 bg-muted-foreground/40 group-hover/insert:bg-blue-500/60'
: 'inset-x-8 bg-blue-400/30 group-hover/insert:bg-blue-500/60',
)}
/>
<span
className={cn(
'relative flex items-center justify-center rounded-full text-white transition-all',
// Edge: smaller, neutral, no shadow until hover; between: full blue badge
isEdge
? 'size-4 bg-muted-foreground/60 group-hover/insert:size-5 group-hover/insert:bg-blue-500 group-hover/insert:shadow-md group-hover/insert:shadow-blue-500/30'
: 'size-5 bg-blue-500 shadow-md shadow-blue-500/30 group-hover/insert:scale-110',
)}
>
<Plus className={cn('transition-all', isEdge ? 'size-2.5' : 'size-3')} />
</span>
</button>
</li>
);
}
function KeyPointInput({
value,
onChange,
onKeyDown,
placeholder,
}: {
value: string;
onChange: (v: string) => void;
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
placeholder: string;
}) {
const ref = useRef<HTMLInputElement>(null);
const [width, setWidth] = useState(120);
useEffect(() => {
setWidth(Math.max(100, Math.min(280, value.length * 8 + 40)));
}, [value]);
// Note: intentionally no onBlur commit. Committing on blur surprises users
// who type a partial value then click away — that text becomes a chip they
// didn't ask for. Only Enter / comma should commit (handled by onKeyDown).
return (
<div className="inline-flex items-center gap-1">
<input
ref={ref}
type="text"
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
style={{ width }}
className={cn(
'inline-block rounded-full bg-transparent px-2.5 py-1 text-xs',
'text-foreground placeholder:text-muted-foreground/50',
'border border-dashed border-transparent transition-colors',
'hover:border-muted-foreground/20 focus:border-blue-400/50 focus:bg-blue-500/[0.03]',
'focus:outline-none focus:ring-0',
)}
/>
</div>
);
}
// Left segment of the cascading type control: a themed pill chunk that joins the
// TypePill on its right via a hairline divider (the wrapper clips the corners).
function cascadeSegmentClass(theme: (typeof TYPE_THEME)[SceneType]) {
return cn(
'inline-flex items-center gap-1 border-r border-black/[0.07] px-2.5 py-1 text-[11px] font-medium uppercase tracking-wider transition-colors dark:border-white/10',
theme.chip,
theme.chipHover,
);
}
function QuizConfigDisclosure({
outline,
onUpdate,
theme,
}: {
outline: SceneOutline;
onUpdate: (updates: Partial<SceneOutline>) => void;
theme: (typeof TYPE_THEME)[SceneType];
}) {
const { t } = useI18n();
const config = outline.quizConfig ?? {
questionCount: 3,
difficulty: 'medium' as const,
questionTypes: ['single' as const],
};
const updateConfig = (updates: Partial<typeof config>) => {
onUpdate({
quizConfig: {
questionCount: config.questionCount ?? 3,
difficulty: config.difficulty ?? 'medium',
questionTypes: config.questionTypes ?? ['single'],
...updates,
},
});
};
return (
<Popover>
<PopoverTrigger asChild>
<button type="button" className={cascadeSegmentClass(theme)}>
<span className="max-w-[8rem] truncate">
{t('generation.quizConfigSummary', { count: config.questionCount ?? 3 })}
</span>
<ChevronDown className="size-3 opacity-70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-64 space-y-2.5 p-3">
{/* Count: label left, stepper right */}
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium text-muted-foreground">
{t('generation.quizQuestionCount')}
</span>
<Stepper
value={config.questionCount ?? 3}
min={1}
max={10}
onChange={(next) => updateConfig({ questionCount: next })}
/>
</div>
{/* Difficulty: label left, segmented right */}
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium text-muted-foreground">
{t('generation.quizDifficulty')}
</span>
<SegmentedControl
value={config.difficulty ?? 'medium'}
onChange={(value) => updateConfig({ difficulty: value as 'easy' | 'medium' | 'hard' })}
options={[
{ value: 'easy', label: t('generation.quizDifficultyEasy') },
{ value: 'medium', label: t('generation.quizDifficultyMedium') },
{ value: 'hard', label: t('generation.quizDifficultyHard') },
]}
/>
</div>
{/* Type: label above, multi-select pills below */}
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
{t('generation.quizType')}
</span>
<div className="flex gap-1">
{(
[
['single', 'generation.quizTypeSingle'],
['multiple', 'generation.quizTypeMultiple'],
['text', 'generation.quizTypeText'],
] as const
).map(([type, labelKey]) => {
const current = config.questionTypes ?? ['single'];
const selected = current.includes(type);
const isOnlySelected = selected && current.length === 1;
return (
<button
key={type}
type="button"
disabled={isOnlySelected}
aria-pressed={selected}
onClick={() => {
const next = selected
? current.filter((t) => t !== type)
: Array.from(new Set([...current, type]));
if (next.length === 0) return;
updateConfig({ questionTypes: next });
}}
className={cn(
'flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition-all',
'border',
selected
? 'border-purple-500/40 bg-purple-500/10 text-purple-700 dark:text-purple-200'
: 'border-border/40 bg-transparent text-muted-foreground hover:border-border hover:bg-muted/60 hover:text-foreground',
isOnlySelected && 'cursor-not-allowed opacity-90',
)}
>
{t(labelKey)}
</button>
);
})}
</div>
</div>
</PopoverContent>
</Popover>
);
}
const WIDGET_KINDS: ReadonlyArray<readonly [WidgetType, string]> = [
['simulation', 'generation.widgetSimulation'],
['diagram', 'generation.widgetDiagram'],
['code', 'generation.widgetCode'],
['game', 'generation.widgetGame'],
['visualization3d', 'generation.widgetVisualization3d'],
];
function InteractiveConfigDisclosure({
outline,
onUpdate,
theme,
}: {
outline: SceneOutline;
onUpdate: (updates: Partial<SceneOutline>) => void;
theme: (typeof TYPE_THEME)[SceneType];
}) {
const { t } = useI18n();
const widgetType = outline.widgetType ?? 'simulation';
const concept = outline.widgetOutline?.concept ?? '';
// A gated procedural-skill outline isn't manually selectable, but it can reach
// here via preservation; surface it as the current (selected) kind so it isn't
// mislabeled, and never clobber its task-engine fields on a same-kind re-select.
const kinds: ReadonlyArray<readonly [WidgetType, string]> =
widgetType === 'procedural-skill'
? [['procedural-skill', 'generation.widgetProceduralSkill'], ...WIDGET_KINDS]
: WIDGET_KINDS;
const setWidgetType = (next: WidgetType) => {
if (next === widgetType) return; // same kind — keep the existing widgetOutline as-is
// Reset to the shared field only — keeping the previous kind's type-specific
// widgetOutline fields (language/gameType/diagramType/…) would leak stale config.
onUpdate({ widgetType: next, widgetOutline: { concept } });
};
const setConcept = (next: string) => {
onUpdate({ widgetType, widgetOutline: { ...outline.widgetOutline, concept: next } });
};
const currentLabel =
kinds.find(([value]) => value === widgetType)?.[1] ?? 'generation.widgetSimulation';
return (
<Popover>
<PopoverTrigger asChild>
<button type="button" className={cascadeSegmentClass(theme)}>
<span className="max-w-[8rem] truncate">{t(currentLabel)}</span>
<ChevronDown className="size-3 opacity-70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-64 space-y-2.5 p-3">
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
{t('generation.interactiveWidgetKind')}
</span>
<div className="flex flex-wrap gap-1">
{kinds.map(([value, labelKey]) => {
const selected = value === widgetType;
return (
<button
key={value}
type="button"
aria-pressed={selected}
onClick={() => setWidgetType(value)}
className={cn(
'rounded-md border px-2 py-1.5 text-xs font-medium transition-all',
selected
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
: 'border-border/40 bg-transparent text-muted-foreground hover:border-border hover:bg-muted/60 hover:text-foreground',
)}
>
{t(labelKey)}
</button>
);
})}
</div>
</div>
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
{t('generation.interactiveConcept')}
</span>
<input
type="text"
value={concept}
onChange={(event) => setConcept(event.target.value)}
placeholder={t('generation.interactiveConceptPlaceholder')}
className={cn(
'w-full rounded-md border border-border/50 bg-background px-2 py-1.5 text-xs',
'focus:border-emerald-400/50 focus:outline-none focus:ring-0',
)}
/>
</div>
</PopoverContent>
</Popover>
);
}
function PblConfigDisclosure({
outline,
onUpdate,
theme,
}: {
outline: SceneOutline;
onUpdate: (updates: Partial<SceneOutline>) => void;
theme: (typeof TYPE_THEME)[SceneType];
}) {
const { t } = useI18n();
const [skillDraft, setSkillDraft] = useState('');
const projectTopic = outline.pblConfig?.projectTopic ?? '';
const projectDescription = outline.pblConfig?.projectDescription ?? '';
const skills = outline.pblConfig?.targetSkills ?? [];
const scenarioRoleplay = outline.pblConfig?.scenarioRoleplay === true;
const scenarioBrief = outline.pblConfig?.scenarioBrief ?? '';
const baseConfig = (): NonNullable<SceneOutline['pblConfig']> => ({
...outline.pblConfig,
projectTopic,
projectDescription,
targetSkills: skills,
});
const updateConfig = (updates: Partial<NonNullable<SceneOutline['pblConfig']>>) => {
const nextConfig = { ...baseConfig(), ...updates };
if (nextConfig.scenarioRoleplay !== true) {
delete nextConfig.scenarioRoleplay;
delete nextConfig.scenarioBrief;
}
onUpdate({
pblConfig: nextConfig,
});
};
const updateSubtype = (nextScenarioRoleplay: boolean) => {
const nextConfig = baseConfig();
if (nextScenarioRoleplay) {
nextConfig.scenarioRoleplay = true;
nextConfig.scenarioBrief =
scenarioBrief.trim() || projectDescription || outline.description || projectTopic;
} else {
delete nextConfig.scenarioRoleplay;
delete nextConfig.scenarioBrief;
}
onUpdate({ pblConfig: nextConfig });
};
const addSkill = () => {
const value = skillDraft.trim();
if (!value || skills.includes(value)) {
setSkillDraft('');
return;
}
updateConfig({ targetSkills: [...skills, value] });
setSkillDraft('');
};
const removeSkill = (skill: string) => {
updateConfig({ targetSkills: skills.filter((s) => s !== skill) });
};
return (
<Popover>
<PopoverTrigger asChild>
<button type="button" className={cascadeSegmentClass(theme)}>
<span className="max-w-[8rem] truncate">
{scenarioRoleplay
? t('generation.pblSubtypeScenario')
: t('generation.pblSubtypeProject')}
</span>
<ChevronDown className="size-3 opacity-70" />
</button>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-72 space-y-2.5 p-3">
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
{t('generation.pblSubtype')}
</span>
<div className="grid grid-cols-2 overflow-hidden rounded-md border border-border/50 bg-muted/30 p-0.5">
{[
{ value: false, label: t('generation.pblSubtypeProject') },
{ value: true, label: t('generation.pblSubtypeScenario') },
].map((option) => {
const active = scenarioRoleplay === option.value;
return (
<button
key={String(option.value)}
type="button"
aria-pressed={active}
onClick={() => updateSubtype(option.value)}
className={cn(
'rounded px-2 py-1 text-xs font-medium transition-colors',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{option.label}
</button>
);
})}
</div>
</div>
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
{t('generation.pblProjectTopic')}
</span>
<input
type="text"
value={projectTopic}
onChange={(event) => updateConfig({ projectTopic: event.target.value })}
placeholder={t('generation.pblProjectTopicPlaceholder')}
className={cn(
'w-full rounded-md border border-border/50 bg-background px-2 py-1.5 text-xs',
'focus:border-amber-400/50 focus:outline-none focus:ring-0',
)}
/>
</div>
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
{t('generation.pblProjectDescription')}
</span>
<textarea
value={projectDescription}
onChange={(event) => updateConfig({ projectDescription: event.target.value })}
placeholder={t('generation.pblProjectDescriptionPlaceholder')}
rows={2}
className={cn(
'w-full resize-none rounded-md border border-border/50 bg-background px-2 py-1.5 text-xs',
'focus:border-amber-400/50 focus:outline-none focus:ring-0',
)}
/>
</div>
{scenarioRoleplay && (
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
{t('generation.pblScenarioBrief')}
</span>
<textarea
value={scenarioBrief}
onChange={(event) => updateConfig({ scenarioBrief: event.target.value })}
placeholder={t('generation.pblScenarioBriefPlaceholder')}
rows={2}
className={cn(
'w-full resize-none rounded-md border border-border/50 bg-background px-2 py-1.5 text-xs',
'focus:border-amber-400/50 focus:outline-none focus:ring-0',
)}
/>
</div>
)}
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
{t('generation.pblTargetSkills')}
</span>
{skills.length > 0 && (
<div className="flex flex-wrap gap-1">
{skills.map((skill) => (
<span
key={skill}
className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs text-amber-700 dark:text-amber-200"
>
{skill}
<button
type="button"
onClick={() => removeSkill(skill)}
aria-label={t('generation.removeSkill')}
className="inline-flex size-3 items-center justify-center rounded-full hover:bg-amber-500/20"
>
<X className="size-2.5" />
</button>
</span>
))}
</div>
)}
<input
type="text"
value={skillDraft}
onChange={(event) => setSkillDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addSkill();
}
}}
onBlur={addSkill}
placeholder={t('generation.pblAddSkill')}
className={cn(
'w-full rounded-md border border-dashed border-border/50 bg-background px-2 py-1.5 text-xs',
'focus:border-amber-400/50 focus:outline-none focus:ring-0',
)}
/>
</div>
</PopoverContent>
</Popover>
);
}
function Stepper({
value,
min,
max,
onChange,
}: {
value: number;
min: number;
max: number;
onChange: (next: number) => void;
}) {
const dec = () => onChange(Math.max(min, value - 1));
const inc = () => onChange(Math.min(max, value + 1));
return (
<div className="inline-flex items-center overflow-hidden rounded-md border border-border/50 bg-background">
<button
type="button"
onClick={dec}
disabled={value <= min}
aria-label="Decrease"
className="flex size-7 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
>
<Minus className="size-3.5" />
</button>
<span className="w-8 text-center text-sm font-semibold tabular-nums">{value}</span>
<button
type="button"
onClick={inc}
disabled={value >= max}
aria-label="Increase"
className="flex size-7 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
>
<Plus className="size-3.5" />
</button>
</div>
);
}
function SegmentedControl({
value,
onChange,
options,
}: {
value: string;
onChange: (value: string) => void;
options: Array<{ value: string; label: string }>;
}) {
return (
<div className="inline-flex rounded-md border border-border/50 bg-background p-0.5">
{options.map((opt) => {
const selected = opt.value === value;
return (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
aria-pressed={selected}
className={cn(
'rounded px-2 py-0.5 text-xs font-medium transition-colors',
selected
? 'bg-foreground text-background'
: 'text-muted-foreground hover:text-foreground',
)}
>
{opt.label}
</button>
);
})}
</div>
);
}
// ────────────────────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────────────────────
function useAutoResize(ref: React.RefObject<HTMLTextAreaElement | null>, value: string) {
useEffect(() => {
const node = ref.current;
if (!node) return;
// Defer measurement+write to a frame so a burst of edits doesn't thrash
// layout (read scrollHeight ≡ forced reflow). Cancel any prior frame so
// we only run once per render.
const frame = requestAnimationFrame(() => {
node.style.height = 'auto';
node.style.height = `${node.scrollHeight}px`;
});
return () => cancelAnimationFrame(frame);
}, [ref, value]);
}
function capitalize(input: string): string {
return input.charAt(0).toUpperCase() + input.slice(1);
}