"use client" /** * NewLibraryItemForm — single-page authoring for the library. * * IA (matches the rest of the library surfaces): * * ├─ PageHeader (title + actions; parent trail is in `SiteHeader`) * │ · live question prompt (or "New question") + id subtitle * │ · primary CTA — Save question (⏎) * │ · ⋯ overflow menu (⌘⌥M) — Save as draft, inspector, discard * ├─ 2-column layout (lg+): builder scrolls; inspector rail stays fixed (split pane) * │ ┌─ Builder (left, `QuestionBuilderCard`) * │ │ · Question prompt (h1-style Textarea — type-aware) * │ │ · Answer block — varies by question type * │ │ · Explanation / rubric / model answer * │ │ · References (repeatable list) * │ └─ Inspector (right, bg-card panel) * │ · Question format (builder, above Question — SelectionTileGrid) * │ · Location, curriculum, general delivery toggles, taxonomy, tags * │ · Level / Subject / Track / Phase / Bloom / Cognitive * │ · Tags (Input + Badge list) * │ Sidebar-style collapse (⌘⌥]) — collapsed rail mimics * │ `NestedSecondaryPanelShell` icon mode. * * Composes existing primitives — `PageHeader`, `Form`/`FormField`, * `Input`, `Textarea`, `Checkbox`, `Badge`, `Button`, `Tip`, `Kbd`, * `SelectionTileGrid`, `DropdownMenu` + react-hook-form + Zod (same * stack as `new-placement-form.tsx`). * * Local helpers (`OptionRow`, `BuilderSection`, * `InspectorSection`) live inside this file — they are not new shared * primitives, so they don't need a design-system review per * `exxat-reuse-before-custom.mdc`. */ import * as React from "react" import { useNavigate } from "react-router-dom" import { useForm, useWatch, type Resolver } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { z } from "zod" import { cn } from "@/lib/utils" import { devLog } from "@/lib/dev-log" import { Form, FormControl, FormDescription, FormField, FormItem, FormMessage, } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Checkbox } from "@/components/ui/checkbox" import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Kbd, KbdGroup } from "@/components/ui/kbd" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Shortcut, } from "@/components/ui/dropdown-menu" import { Tip } from "@/components/ui/tip" import { PageHeader } from "@/components/page-header" import { NewFocusTemplate } from "@/components/templates/new-focus-template" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { SelectionTileGrid, type SelectionTileOption, } from "@/components/ui/selection-tile-grid" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" import { LibraryFolderPickerPanel } from "@/components/library-folder-picker-panel" import { ToggleSwitch } from "@/components/ui/toggle-switch" import { AskLeoButton } from "@/components/ask-leo-button" import { LeoIcon } from "@/components/ui/leo-icon" import { LibraryNewFolderSheet } from "@/components/library-new-folder-sheet" import { AUTHORING_QUESTION_TYPES, buildMockLeoQuestionDraft, LEO_QUESTION_DRAFT_DELAY_MS, AUTHORING_BLOOM_OPTIONS, AUTHORING_COG_LEVEL_OPTIONS, AUTHORING_DIFFICULTY_OPTIONS, AUTHORING_SUBJECT_AREAS, AUTHORING_DISCIPLINES, AUTHORING_PHASES, AUTHORING_DEFAULT_GENERAL_SETTINGS, AUTHORING_DEFAULT_POINTS, AUTHORING_INSPECTOR_GENERAL_TOGGLES, authoringInspectorToggleVisible, AUTHORING_STATUS_OPTIONS, AUTHORING_DEFAULT_OPTION_COUNT, AUTHORING_MIN_OPTION_COUNT, AUTHORING_MAX_OPTION_COUNT, AUTHORING_LEAD_IN_PLACEHOLDER, AUTHORING_RATIONALE_PLACEHOLDER, questionComposerHeaderTitle, suggestOptionTextsForLeadIn, createAuthoringReference, AUTHORING_REFERENCE_KIND_OPTIONS, type AuthoringReference, type AuthoringReferenceKind, type AuthoringQuestionType, } from "@/lib/library-authoring" import { DEFAULT_LIBRARY_FOLDERS, newFolderId, type LibraryFolder, type LibraryFolderColorKey, } from "@/lib/mock/library-folders" // ───────────────────────────────────────────────────────────────────────────── // Schema // ───────────────────────────────────────────────────────────────────────────── const QUESTION_TYPES = AUTHORING_QUESTION_TYPES.map(t => t.value) as [ AuthoringQuestionType, ...AuthoringQuestionType[], ] /** Tiles for the export-drawer-style "File format" pattern (radio + outline + pop). */ const QUESTION_TYPE_TILES: SelectionTileOption[] = AUTHORING_QUESTION_TYPES.map(t => ({ value: t.value, label: t.shortLabel, icon: t.icon, })) const STATUSES = AUTHORING_STATUS_OPTIONS.map(s => s.value) as [ (typeof AUTHORING_STATUS_OPTIONS)[number]["value"], ...(typeof AUTHORING_STATUS_OPTIONS)[number]["value"][], ] const optionSchema = z.object({ id: z.string(), text: z.string(), isCorrect: z.boolean(), rationale: z.string(), }) const referenceSchema = z.object({ id: z.string(), kind: z.enum(["citation", "link", "image", "document"]), citation: z.string(), url: z.string(), linkLabel: z.string(), fileName: z.string(), previewUrl: z.string(), }) const matchingPairSchema = z.object({ id: z.string(), left: z.string(), right: z.string(), }) const orderedItemSchema = z.object({ id: z.string(), text: z.string(), }) const fillBlankAnswerSchema = z.object({ id: z.string(), accepted: z.string(), }) const questionSchema = z .object({ type: z.enum(QUESTION_TYPES), status: z.enum(STATUSES), folderId: z.string().min(1, "Pick a location."), /* The lead-in IS the question (rendered as the page heading). All question types share this one field; the schema requires a real sentence so reviewers can read it on its own. */ leadIn: z.string().min(8, "Add the question prompt (at least a sentence)."), options: z.array(optionSchema), rationale: z.string(), references: z.array(referenceSchema), /* Type-specific authoring fields — populated only by the matching builder. */ numericValue: z.string(), numericTolerance: z.string(), numericUnits: z.string(), pairs: z.array(matchingPairSchema), orderedItems: z.array(orderedItemSchema), fillBlankAnswers: z.array(fillBlankAnswerSchema), difficulty: z.enum(["easy", "medium", "hard"]), subjectArea: z.string(), track: z.string(), phase: z.string(), randomizeOptions: z.boolean(), partialCredit: z.boolean(), caseSensitive: z.boolean(), negativeMarking: z.boolean(), showFeedbackWhenReviewing: z.boolean(), eligibleForRandomDraw: z.boolean(), shuffleMatchingPairs: z.boolean(), shuffleOrderingItems: z.boolean(), points: z .string() .refine(v => v.trim() === "" || /^\d+(\.\d+)?$/.test(v.trim()), { message: "Enter a positive number or leave blank.", }), bloom: z.string(), cogLevel: z.string(), tags: z.array(z.string()), }) .superRefine((data, ctx) => { const isMcq = data.type === "mcq_single" || data.type === "mcq_multiple" if (isMcq) { const filled = data.options.filter(o => o.text.trim()).length if (filled < AUTHORING_MIN_OPTION_COUNT) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["options"], message: `Provide at least ${AUTHORING_MIN_OPTION_COUNT} answer options.`, }) } if (!data.options.some(o => o.isCorrect)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["options"], message: "Mark at least one option as correct.", }) } } if (data.type === "true_false" && !data.options.some(o => o.isCorrect)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["options"], message: "Pick whether the statement is True or False.", }) } if (data.type === "short_answer" && data.rationale.trim().length < 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["rationale"], message: "Add the model answer and any acceptable variants.", }) } if (data.type === "essay" && data.rationale.trim().length < 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["rationale"], message: "Add a grading rubric so reviewers know how to score.", }) } if (data.type === "numeric" && data.numericValue.trim().length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["numericValue"], message: "Enter the correct numeric value.", }) } if (data.type === "matching") { const filled = data.pairs.filter(p => p.left.trim() && p.right.trim()).length if (filled < 2) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["pairs"], message: "Add at least two matching pairs.", }) } } if (data.type === "ordering") { const filled = data.orderedItems.filter(i => i.text.trim()).length if (filled < 2) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["orderedItems"], message: "Add at least two items to order.", }) } } if (data.type === "fill_blank") { const filled = data.fillBlankAnswers.filter(a => a.accepted.trim()).length if (filled < 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["fillBlankAnswers"], message: "Add at least one accepted answer.", }) } } data.references.forEach((ref, index) => { if (ref.kind === "citation" && ref.citation.trim().length < 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["references", index, "citation"], message: "Add a citation or remove the row.", }) } if (ref.kind === "link" && ref.url.trim().length < 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["references", index, "url"], message: "Add a URL or remove the row.", }) } if (ref.kind === "image" && ref.fileName.trim().length < 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["references", index, "fileName"], message: "Choose an image or remove the row.", }) } if (ref.kind === "document" && ref.fileName.trim().length < 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["references", index, "fileName"], message: "Choose a document or remove the row.", }) } }) }) type QuestionFormValues = z.infer // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── const OPTION_LETTERS = ["A", "B", "C", "D", "E", "F", "G", "H"] as const // Runtime ID generators — called from user actions (Add option, Add pair, etc.) which // only fire AFTER hydration, so `Math.random()` is safe here. NEVER call these from // `buildInitial*` factories below — those run during the server render too and a // random ID would mismatch the client tree on hydration. function newOptionId() { return `opt-${Math.random().toString(36).slice(2, 9)}` } function newReferenceId() { return `ref-${Math.random().toString(36).slice(2, 9)}` } function newPairId() { return `pair-${Math.random().toString(36).slice(2, 9)}` } function newOrderedId() { return `ord-${Math.random().toString(36).slice(2, 9)}` } function newBlankId() { return `blk-${Math.random().toString(36).slice(2, 9)}` } // SSR-safe defaults — deterministic, index-based IDs so the server and client render // the same `id` / `key` / DOM attributes (e.g. `Checkbox` IDs derived from `option.id`). // Hydration mismatch issue: `Math.random()` produces different values per call, so // using it in initial defaults makes every render emit a different tree. function buildInitialOptions(type: AuthoringQuestionType): QuestionFormValues["options"] { if (type === "true_false") { return [ { id: "opt-true", text: "True", isCorrect: false, rationale: "" }, { id: "opt-false", text: "False", isCorrect: false, rationale: "" }, ] } if (type === "mcq_single" || type === "mcq_multiple") { return Array.from({ length: AUTHORING_DEFAULT_OPTION_COUNT }, (_, i) => ({ id: `opt-init-${i + 1}`, text: "", isCorrect: false, rationale: "", })) } return [] } function buildInitialPairs(): QuestionFormValues["pairs"] { return Array.from({ length: 3 }, (_, i) => ({ id: `pair-init-${i + 1}`, left: "", right: "", })) } function buildInitialOrderedItems(): QuestionFormValues["orderedItems"] { return Array.from({ length: 4 }, (_, i) => ({ id: `ord-init-${i + 1}`, text: "", })) } function buildInitialFillBlankAnswers(): QuestionFormValues["fillBlankAnswers"] { return [{ id: "blk-init-1", accepted: "" }] } function folderBreadcrumb(folderId: string, folders: LibraryFolder[]): string { const f = folders.find(x => x.id === folderId) if (!f) return "" if (f.parentId == null) return f.name const parent = folders.find(x => x.id === f.parentId) return parent ? `${parent.name} / ${f.name}` : f.name } /** * Folder-aware difficulty insight — derived deterministically from the * folder id so the same folder always returns the same numbers. In a real * build this comes from analytics; for the mock it is a stable seed so * the inspector reads as if the AI had crunched historical data. */ function difficultyInsightForFolder(folder: LibraryFolder | undefined): { /** Predicted level based on the content the author is writing. */ recommendation: "easy" | "medium" | "hard" /** Contextual note shown under the meter (e.g. folder distribution). */ note: string /** Point-Biserial Index (0–1) — predicted question quality. */ pbi: number /** Average folder difficulty (0–100, same scale as the meter). */ averagePercent: number } { if (!folder) { return { recommendation: "medium", note: "Pick a location to see how your question compares to this folder.", pbi: 0.3, averagePercent: 50, } } // Deterministic hash of folder id → stable mock numbers per folder. let h = 0 for (let i = 0; i < folder.id.length; i++) h = (h * 31 + folder.id.charCodeAt(i)) >>> 0 const tilt = (h % 100) / 100 // 0..1 const mediumShare = 45 + Math.floor(tilt * 30) // 45..75 % const pbi = 0.22 + (tilt * 0.22) // 0.22..0.44 const averagePercent = 35 + Math.floor(tilt * 35) // 35..70 const recommendation: "easy" | "medium" | "hard" = averagePercent < 40 ? "easy" : averagePercent > 65 ? "hard" : "medium" return { recommendation, note: `Based on your content, this question is predicted ${recommendation}. ${mediumShare}% of items in ${folder.name} are Medium (avg PBI ${pbi.toFixed(2)}).`, pbi, averagePercent, } } // ───────────────────────────────────────────────────────────────────────────── // Local helpers (this file only — not new shared primitives) // ───────────────────────────────────────────────────────────────────────────── function InspectorToggleRow({ id, label, description, checked, onCheckedChange, }: { id: string label: string description: string checked: boolean onCheckedChange: (next: boolean) => void }) { return (

{description}

) } function InspectorSection({ title, htmlFor, children, description, }: { title: string htmlFor?: string children: React.ReactNode description?: React.ReactNode }) { return (
{children} {description ? (

{description}

) : null}
) } /** Shared composer column card — fixed chrome, scrollable body (builder + inspector). */ function ComposerPanelCard({ title, headerActions, bodyClassName, children, }: { title?: string headerActions?: React.ReactNode bodyClassName?: string children: React.ReactNode }) { return (
{title ? (

{title}

{headerActions}
) : null}
{children}
) } /** Builder canvas — format, stem, type-specific blocks, rationale, references. */ function QuestionBuilderCard({ children }: { children: React.ReactNode }) { return {children} } function BuilderSection({ title, required, hint, headerActions, children, }: { title: string required?: boolean hint?: React.ReactNode headerActions?: React.ReactNode children: React.ReactNode }) { return (

{title} {required ? ( ) : null}

{headerActions}
{hint ? ( {hint} ) : null}
{children}
) } // ─── Folder picker (Popover + Library secondary tree) ─────────────────────── // // Compact selected tile — same visual rhythm as the collapsed // "Question format" card. Popover uses `LibraryFolderPickerPanel` (search + // folder tree aligned with the Library secondary panel). const FOLDER_TINT_BG: Record = { brand: "bg-brand-tint text-brand-deep dark:bg-brand-tint-light dark:text-foreground", success: "bg-[var(--icon-disc-chart-2-bg)] text-[var(--icon-disc-chart-2-fg)]", warning: "bg-[var(--icon-disc-chart-4-bg)] text-[var(--icon-disc-chart-4-fg)]", destructive: "bg-destructive/15 text-destructive", muted: "bg-muted text-muted-foreground", chart1: "bg-[color-mix(in_oklch,var(--color-chart-1)_15%,transparent)] text-[var(--color-chart-1)]", chart2: "bg-[var(--icon-disc-chart-2-bg)] text-[var(--icon-disc-chart-2-fg)]", chart3: "bg-[color-mix(in_oklch,var(--color-chart-3)_15%,transparent)] text-[var(--color-chart-3)]", } interface FolderPickerControlProps { folders: LibraryFolder[] value: string onChange: (id: string) => void open: boolean onOpenChange: (open: boolean) => void onRequestNewFolder: () => void } function FolderPickerControl({ folders, value, onChange, open, onOpenChange, onRequestNewFolder, }: FolderPickerControlProps) { const selected = folders.find(f => f.id === value) const tint = selected ? FOLDER_TINT_BG[selected.colorKey] : FOLDER_TINT_BG.muted return ( { onChange(id) onOpenChange(false) }} onRequestNewFolder={onRequestNewFolder} /> ) } // ─── Difficulty meter (predicted from content + PBI + folder note) ─────────── // // AI analyses the question content (stem length, option count, Bloom's level, // vocabulary complexity) to **predict** how difficult examinees will find this // item. The author can override by flipping to Manual mode. interface DifficultyMeterProps { value: "easy" | "medium" | "hard" onChange: (next: "easy" | "medium" | "hard") => void mode: "auto" | "manual" onModeChange: (next: "auto" | "manual") => void insight: ReturnType } function DifficultyMeter({ value, onChange, mode, onModeChange, insight, }: DifficultyMeterProps) { const pbiPercent = Math.min(100, Math.max(0, insight.pbi * 100)) const pbiTone = insight.pbi >= 0.3 ? "bg-brand" : insight.pbi >= 0.2 ? "bg-[var(--brand-color-light)]" : "bg-destructive" const levelLabel = value === "easy" ? "Low" : value === "hard" ? "High" : "Normal" return (
{/* Level heading + AI / Manual toggle */}

{levelLabel}