Files
openmaic/OpenMAIC/components/edit/surfaces/quiz/QuizForm.tsx
2026-08-16 14:58:47 +08:00

130 lines
5.0 KiB
TypeScript

'use client';
import { Reorder } from 'motion/react';
import { useEffect, useRef, useState } from 'react';
import { ListChecks, Plus } from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { AddQuestionMenu } from './AddQuestionMenu';
import { QuestionCard } from './QuestionCard';
import {
reorderQuizQuestions,
useQuizSurfaceLifecycle,
useResolvedQuizContent,
} from './use-quiz-surface';
/**
* The quiz `SceneEditorSurface` center component — a single-column accordion
* of question cards. Self-contained: owns its expansion state, seeds/tears
* down the quiz-edit session, and dispatches every edit through the bound
* mutations in `use-quiz-surface`. Renders inside the EditShell's studio-frame
* card (full height, own scroll) and contributes no canvas-style selection.
*
* "Add question" sits after the question list, keeping this structured
* editor's primary action in the content flow instead of the slide toolbar.
*/
export function QuizForm() {
useQuizSurfaceLifecycle();
const content = useResolvedQuizContent();
const questions = content.questions;
const [expandedId, setExpandedId] = useState<string | null>(null);
// Auto-expand a question added DURING the session: when exactly one new id
// appears since the last render, open it (and collapse whatever was open).
// `prevIds` is seeded null and baselined on the first run so the questions
// already present at mount don't count as "added" (no surprise expansion).
const prevIds = useRef<string[] | null>(null);
useEffect(() => {
const ids = questions.map((q) => q.id);
if (prevIds.current === null) {
prevIds.current = ids;
return;
}
const added = ids.filter((id) => !prevIds.current!.includes(id));
if (added.length === 1) setExpandedId(added[0]);
prevIds.current = ids;
}, [questions]);
return (
<div
className="h-full w-full overflow-y-auto bg-zinc-50/40 dark:bg-zinc-950/30"
data-testid="quiz-surface"
// Faint dot grid gives the authoring canvas a subtle texture so the
// white question cards read as floating sheets rather than flat boxes.
style={{
backgroundImage: 'radial-gradient(circle, rgba(113,113,122,0.10) 1px, transparent 1px)',
backgroundSize: '22px 22px',
}}
>
<div
className="mx-auto flex max-w-2xl flex-col gap-2.5 px-5 pt-16"
// HintRail measures its rendered stack into this inherited variable.
// The normal 5rem breathing room remains when there are no hints.
style={{ paddingBottom: 'calc(var(--editor-hint-rail-height, 0px) + 5rem)' }}
>
{questions.length === 0 ? (
<EmptyState />
) : (
<Reorder.Group
axis="y"
as="ol"
values={questions.map((q) => q.id)}
onReorder={reorderQuizQuestions}
className="m-0 flex list-none flex-col gap-2.5 p-0"
>
{questions.map((q, index) => (
<QuestionCard
key={q.id}
question={q}
index={index}
expanded={expandedId === q.id}
onToggle={() => setExpandedId((id) => (id === q.id ? null : q.id))}
/>
))}
</Reorder.Group>
)}
<AddQuestionButton />
</div>
</div>
);
}
function AddQuestionButton() {
const { t } = useI18n();
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
data-testid="quiz-add-question"
className="mt-1 flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-zinc-300 bg-white/70 py-3 text-sm font-medium text-zinc-500 transition-colors hover:border-violet-300 hover:bg-violet-50/70 hover:text-violet-600 dark:border-zinc-700 dark:bg-zinc-900/60 dark:text-zinc-400 dark:hover:border-violet-500/50 dark:hover:bg-violet-500/10 dark:hover:text-violet-300"
>
<Plus className="h-4 w-4" strokeWidth={2} />
{t('edit.quiz.addQuestion')}
</button>
</PopoverTrigger>
<PopoverContent side="bottom" align="center" className="w-72 p-3">
<AddQuestionMenu />
</PopoverContent>
</Popover>
);
}
function EmptyState() {
const { t } = useI18n();
return (
<div className="mt-20 flex flex-col items-center gap-3 text-center" data-testid="quiz-empty">
<div className="relative flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-100 to-violet-50 text-violet-500 shadow-[0_8px_24px_-12px_rgba(114,46,209,0.5)] dark:from-violet-500/20 dark:to-violet-500/5 dark:text-violet-300">
<ListChecks className="h-7 w-7" strokeWidth={1.75} />
</div>
<p className="text-sm font-semibold text-zinc-600 dark:text-zinc-300">
{t('edit.quiz.empty')}
</p>
<p className="max-w-xs text-xs text-zinc-400 dark:text-zinc-500">
{t('edit.quiz.emptyHint')}
</p>
</div>
);
}