@exxatdesignux/ui 0.2.16 → 0.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +148 -3
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
- package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +14 -0
- package/package.json +3 -3
- package/src/components/ui/banner.tsx +2 -0
- package/src/components/ui/chart.tsx +57 -2
- package/src/components/ui/sidebar.tsx +1 -0
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +18 -15
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/question-bank/layout.tsx +18 -5
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/globals.css +108 -1
- package/template/app/layout.tsx +41 -5
- package/template/components/app-sidebar.tsx +68 -34
- package/template/components/ask-leo-sidebar.tsx +0 -2
- package/template/components/brand-color-picker.tsx +344 -0
- package/template/components/compliance-list-view.tsx +33 -51
- package/template/components/compliance-table.tsx +24 -0
- package/template/components/data-table/index.tsx +68 -24
- package/template/components/data-table/pagination.tsx +0 -1
- package/template/components/data-table/types.ts +4 -1
- package/template/components/data-table/use-table-state.ts +243 -94
- package/template/components/data-views/data-row-list.tsx +183 -0
- package/template/components/data-views/index.ts +7 -3
- package/template/components/data-views/os-folder-glyph.tsx +8 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +172 -317
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +74 -46
- package/template/components/new-placement-form.tsx +4 -2
- package/template/components/new-question-composer.tsx +2208 -0
- package/template/components/page-breadcrumb-trail.tsx +131 -0
- package/template/components/page-header.tsx +2 -1
- package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
- package/template/components/placement-detail.tsx +1 -1
- package/template/components/placements-board-view.tsx +1 -1
- package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
- package/template/components/placements-list-view.tsx +18 -132
- package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
- package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
- package/template/components/placements-table-columns.tsx +2 -2
- package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
- package/template/components/product-switcher.tsx +26 -8
- package/template/components/product-wordmark.tsx +285 -0
- package/template/components/question-bank-client.tsx +20 -2
- package/template/components/question-bank-hub-client.tsx +108 -115
- package/template/components/question-bank-list-view.tsx +30 -54
- package/template/components/question-bank-new-folder-sheet.tsx +1 -1
- package/template/components/question-bank-secondary-nav.tsx +0 -3
- package/template/components/question-bank-table.tsx +30 -5
- package/template/components/rotations-empty-state.tsx +3 -0
- package/template/components/secondary-panel.tsx +23 -3
- package/template/components/settings-appearance-card.tsx +584 -141
- package/template/components/site-header.tsx +36 -31
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +24 -0
- package/template/components/table-properties/drawer.tsx +1 -1
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +29 -3
- package/template/components/templates/nested-secondary-panel-shell.tsx +8 -2
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +51 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- package/template/eslint.config.mjs +18 -0
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/data-list-persistence.ts +57 -257
- package/template/lib/dev-log.test.ts +6 -5
- package/template/lib/exxat-palette.json +1462 -0
- package/template/lib/exxat-palette.ts +136 -0
- package/template/lib/list-page-table-properties.ts +1 -1
- package/template/lib/list-status-badges.ts +1 -1
- package/template/lib/mailto.ts +29 -0
- package/template/lib/placement-board-card-layout.ts +1 -1
- package/template/lib/product-brand.ts +268 -0
- package/template/lib/question-bank-authoring.ts +308 -0
- package/template/lib/question-bank-nav.ts +44 -0
- package/template/lib/raf-throttle.ts +45 -0
- package/template/lib/table-state-lifecycle.ts +474 -0
- package/template/next.config.mjs +156 -0
- package/template/package.json +3 -3
- package/template/stores/app-store.ts +46 -1
|
@@ -0,0 +1,2208 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NewQuestionComposer — single-page authoring for the question bank.
|
|
5
|
+
*
|
|
6
|
+
* IA (matches the rest of the question bank surfaces):
|
|
7
|
+
*
|
|
8
|
+
* ├─ PageHeader (title + actions; parent trail is in `SiteHeader`)
|
|
9
|
+
* │ · "New question" + "V1 · Last updated …" subtitle
|
|
10
|
+
* │ · single primary CTA — Save as draft (⏎)
|
|
11
|
+
* │ · ⋯ overflow menu (⌘⌥M) for inspector toggle + discard
|
|
12
|
+
* ├─ 2-column layout (lg+): page scrolls as one; inspector card `sticky` on lg+
|
|
13
|
+
* │ ┌─ Builder (left, no card chrome)
|
|
14
|
+
* │ │ · Question prompt (h1-style Textarea — type-aware)
|
|
15
|
+
* │ │ · Answer block — varies by question type
|
|
16
|
+
* │ │ · Explanation / rubric / model answer
|
|
17
|
+
* │ │ · References (repeatable list)
|
|
18
|
+
* │ └─ Inspector (right, bg-card panel)
|
|
19
|
+
* │ · Question format (SelectionTileGrid → compact)
|
|
20
|
+
* │ · Location (folder SelectionTileGrid)
|
|
21
|
+
* │ · Difficulty / Bloom / NBME (chips)
|
|
22
|
+
* │ · Tags (Input + Badge list)
|
|
23
|
+
* │ Sidebar-style collapse (⌘⌥]) — collapsed rail mimics
|
|
24
|
+
* │ `NestedSecondaryPanelShell` icon mode.
|
|
25
|
+
*
|
|
26
|
+
* Composes existing primitives — `PageHeader`, `Form`/`FormField`,
|
|
27
|
+
* `Input`, `Textarea`, `Checkbox`, `Badge`, `Button`, `Tip`, `Kbd`,
|
|
28
|
+
* `SelectionTileGrid`, `DropdownMenu` + react-hook-form + Zod (same
|
|
29
|
+
* stack as `new-placement-form.tsx`).
|
|
30
|
+
*
|
|
31
|
+
* Local helpers (`OptionRow`, `BuilderSection`,
|
|
32
|
+
* `InspectorSection`) live inside this file — they are not new shared
|
|
33
|
+
* primitives, so they don't need a design-system review per
|
|
34
|
+
* `exxat-reuse-before-custom.mdc`.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import * as React from "react"
|
|
38
|
+
import { useRouter } from "next/navigation"
|
|
39
|
+
import { useForm, useWatch, type Resolver } from "react-hook-form"
|
|
40
|
+
import { zodResolver } from "@hookform/resolvers/zod"
|
|
41
|
+
import { z } from "zod"
|
|
42
|
+
|
|
43
|
+
import { cn } from "@/lib/utils"
|
|
44
|
+
import { devLog } from "@/lib/dev-log"
|
|
45
|
+
|
|
46
|
+
import {
|
|
47
|
+
Form,
|
|
48
|
+
FormControl,
|
|
49
|
+
FormDescription,
|
|
50
|
+
FormField,
|
|
51
|
+
FormItem,
|
|
52
|
+
FormMessage,
|
|
53
|
+
} from "@/components/ui/form"
|
|
54
|
+
import { Input } from "@/components/ui/input"
|
|
55
|
+
import { Textarea } from "@/components/ui/textarea"
|
|
56
|
+
import { Button } from "@/components/ui/button"
|
|
57
|
+
import { Badge } from "@/components/ui/badge"
|
|
58
|
+
import { Checkbox } from "@/components/ui/checkbox"
|
|
59
|
+
import { Label } from "@/components/ui/label"
|
|
60
|
+
import { Kbd, KbdGroup } from "@/components/ui/kbd"
|
|
61
|
+
import {
|
|
62
|
+
DropdownMenu,
|
|
63
|
+
DropdownMenuContent,
|
|
64
|
+
DropdownMenuItem,
|
|
65
|
+
DropdownMenuSeparator,
|
|
66
|
+
DropdownMenuTrigger,
|
|
67
|
+
Shortcut,
|
|
68
|
+
} from "@/components/ui/dropdown-menu"
|
|
69
|
+
import { Tip } from "@/components/ui/tip"
|
|
70
|
+
import { PageHeader } from "@/components/page-header"
|
|
71
|
+
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
|
72
|
+
import {
|
|
73
|
+
SelectionTileGrid,
|
|
74
|
+
type SelectionTileOption,
|
|
75
|
+
} from "@/components/ui/selection-tile-grid"
|
|
76
|
+
import {
|
|
77
|
+
Popover,
|
|
78
|
+
PopoverContent,
|
|
79
|
+
PopoverTrigger,
|
|
80
|
+
} from "@/components/ui/popover"
|
|
81
|
+
import {
|
|
82
|
+
Command,
|
|
83
|
+
CommandEmpty,
|
|
84
|
+
CommandGroup,
|
|
85
|
+
CommandInput,
|
|
86
|
+
CommandItem,
|
|
87
|
+
CommandList,
|
|
88
|
+
CommandSeparator,
|
|
89
|
+
} from "@/components/ui/command"
|
|
90
|
+
import { QuestionBankNewFolderSheet } from "@/components/question-bank-new-folder-sheet"
|
|
91
|
+
|
|
92
|
+
import {
|
|
93
|
+
AUTHORING_QUESTION_TYPES,
|
|
94
|
+
AUTHORING_BLOOM_OPTIONS,
|
|
95
|
+
AUTHORING_COG_LEVEL_OPTIONS,
|
|
96
|
+
AUTHORING_DIFFICULTY_OPTIONS,
|
|
97
|
+
AUTHORING_STATUS_OPTIONS,
|
|
98
|
+
AUTHORING_DEFAULT_OPTION_COUNT,
|
|
99
|
+
AUTHORING_MIN_OPTION_COUNT,
|
|
100
|
+
AUTHORING_MAX_OPTION_COUNT,
|
|
101
|
+
AUTHORING_LEAD_IN_PLACEHOLDER,
|
|
102
|
+
AUTHORING_RATIONALE_PLACEHOLDER,
|
|
103
|
+
authoringQuestionType,
|
|
104
|
+
type AuthoringQuestionType,
|
|
105
|
+
} from "@/lib/question-bank-authoring"
|
|
106
|
+
import {
|
|
107
|
+
DEFAULT_QUESTION_BANK_FOLDERS,
|
|
108
|
+
newFolderId,
|
|
109
|
+
type QuestionBankFolder,
|
|
110
|
+
type QuestionBankFolderColorKey,
|
|
111
|
+
} from "@/lib/mock/question-bank-folders"
|
|
112
|
+
|
|
113
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
114
|
+
// Schema
|
|
115
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
const QUESTION_TYPES = AUTHORING_QUESTION_TYPES.map(t => t.value) as [
|
|
118
|
+
AuthoringQuestionType,
|
|
119
|
+
...AuthoringQuestionType[],
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
/** Tiles for the export-drawer-style "File format" pattern (radio + outline + pop). */
|
|
123
|
+
const QUESTION_TYPE_TILES: SelectionTileOption<AuthoringQuestionType>[] =
|
|
124
|
+
AUTHORING_QUESTION_TYPES.map(t => ({
|
|
125
|
+
value: t.value,
|
|
126
|
+
label: t.shortLabel,
|
|
127
|
+
icon: t.icon,
|
|
128
|
+
}))
|
|
129
|
+
const STATUSES = AUTHORING_STATUS_OPTIONS.map(s => s.value) as [
|
|
130
|
+
(typeof AUTHORING_STATUS_OPTIONS)[number]["value"],
|
|
131
|
+
...(typeof AUTHORING_STATUS_OPTIONS)[number]["value"][],
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
const optionSchema = z.object({
|
|
135
|
+
id: z.string(),
|
|
136
|
+
text: z.string(),
|
|
137
|
+
isCorrect: z.boolean(),
|
|
138
|
+
rationale: z.string(),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const referenceSchema = z.object({
|
|
142
|
+
id: z.string(),
|
|
143
|
+
citation: z.string().min(1, "Add a citation or remove the row."),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const matchingPairSchema = z.object({
|
|
147
|
+
id: z.string(),
|
|
148
|
+
left: z.string(),
|
|
149
|
+
right: z.string(),
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const orderedItemSchema = z.object({
|
|
153
|
+
id: z.string(),
|
|
154
|
+
text: z.string(),
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const fillBlankAnswerSchema = z.object({
|
|
158
|
+
id: z.string(),
|
|
159
|
+
accepted: z.string(),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const questionSchema = z
|
|
163
|
+
.object({
|
|
164
|
+
type: z.enum(QUESTION_TYPES),
|
|
165
|
+
status: z.enum(STATUSES),
|
|
166
|
+
folderId: z.string().min(1, "Pick a location."),
|
|
167
|
+
/* The lead-in IS the question (rendered as the page heading). All
|
|
168
|
+
question types share this one field; the schema requires a real
|
|
169
|
+
sentence so reviewers can read it on its own. */
|
|
170
|
+
leadIn: z.string().min(8, "Add the question prompt (at least a sentence)."),
|
|
171
|
+
options: z.array(optionSchema),
|
|
172
|
+
rationale: z.string(),
|
|
173
|
+
references: z.array(referenceSchema),
|
|
174
|
+
/* Type-specific authoring fields — populated only by the matching builder. */
|
|
175
|
+
numericValue: z.string(),
|
|
176
|
+
numericTolerance: z.string(),
|
|
177
|
+
numericUnits: z.string(),
|
|
178
|
+
pairs: z.array(matchingPairSchema),
|
|
179
|
+
orderedItems: z.array(orderedItemSchema),
|
|
180
|
+
fillBlankAnswers: z.array(fillBlankAnswerSchema),
|
|
181
|
+
difficulty: z.enum(["easy", "medium", "hard"]),
|
|
182
|
+
bloom: z.string(),
|
|
183
|
+
cogLevel: z.string(),
|
|
184
|
+
tags: z.array(z.string()),
|
|
185
|
+
})
|
|
186
|
+
.superRefine((data, ctx) => {
|
|
187
|
+
const isMcq = data.type === "mcq_single" || data.type === "mcq_multiple"
|
|
188
|
+
if (isMcq) {
|
|
189
|
+
const filled = data.options.filter(o => o.text.trim()).length
|
|
190
|
+
if (filled < AUTHORING_MIN_OPTION_COUNT) {
|
|
191
|
+
ctx.addIssue({
|
|
192
|
+
code: z.ZodIssueCode.custom,
|
|
193
|
+
path: ["options"],
|
|
194
|
+
message: `Provide at least ${AUTHORING_MIN_OPTION_COUNT} answer options.`,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
if (!data.options.some(o => o.isCorrect)) {
|
|
198
|
+
ctx.addIssue({
|
|
199
|
+
code: z.ZodIssueCode.custom,
|
|
200
|
+
path: ["options"],
|
|
201
|
+
message: "Mark at least one option as correct.",
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (data.type === "true_false" && !data.options.some(o => o.isCorrect)) {
|
|
206
|
+
ctx.addIssue({
|
|
207
|
+
code: z.ZodIssueCode.custom,
|
|
208
|
+
path: ["options"],
|
|
209
|
+
message: "Pick whether the statement is True or False.",
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
if (data.type === "short_answer" && data.rationale.trim().length < 1) {
|
|
213
|
+
ctx.addIssue({
|
|
214
|
+
code: z.ZodIssueCode.custom,
|
|
215
|
+
path: ["rationale"],
|
|
216
|
+
message: "Add the model answer and any acceptable variants.",
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
if (data.type === "essay" && data.rationale.trim().length < 1) {
|
|
220
|
+
ctx.addIssue({
|
|
221
|
+
code: z.ZodIssueCode.custom,
|
|
222
|
+
path: ["rationale"],
|
|
223
|
+
message: "Add a grading rubric so reviewers know how to score.",
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
if (data.type === "numeric" && data.numericValue.trim().length === 0) {
|
|
227
|
+
ctx.addIssue({
|
|
228
|
+
code: z.ZodIssueCode.custom,
|
|
229
|
+
path: ["numericValue"],
|
|
230
|
+
message: "Enter the correct numeric value.",
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
if (data.type === "matching") {
|
|
234
|
+
const filled = data.pairs.filter(p => p.left.trim() && p.right.trim()).length
|
|
235
|
+
if (filled < 2) {
|
|
236
|
+
ctx.addIssue({
|
|
237
|
+
code: z.ZodIssueCode.custom,
|
|
238
|
+
path: ["pairs"],
|
|
239
|
+
message: "Add at least two matching pairs.",
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (data.type === "ordering") {
|
|
244
|
+
const filled = data.orderedItems.filter(i => i.text.trim()).length
|
|
245
|
+
if (filled < 2) {
|
|
246
|
+
ctx.addIssue({
|
|
247
|
+
code: z.ZodIssueCode.custom,
|
|
248
|
+
path: ["orderedItems"],
|
|
249
|
+
message: "Add at least two items to order.",
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (data.type === "fill_blank") {
|
|
254
|
+
const filled = data.fillBlankAnswers.filter(a => a.accepted.trim()).length
|
|
255
|
+
if (filled < 1) {
|
|
256
|
+
ctx.addIssue({
|
|
257
|
+
code: z.ZodIssueCode.custom,
|
|
258
|
+
path: ["fillBlankAnswers"],
|
|
259
|
+
message: "Add at least one accepted answer.",
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
type QuestionFormValues = z.infer<typeof questionSchema>
|
|
266
|
+
|
|
267
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
268
|
+
// Helpers
|
|
269
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
const OPTION_LETTERS = ["A", "B", "C", "D", "E", "F", "G", "H"] as const
|
|
272
|
+
|
|
273
|
+
function newOptionId() {
|
|
274
|
+
return `opt-${Math.random().toString(36).slice(2, 9)}`
|
|
275
|
+
}
|
|
276
|
+
function newReferenceId() {
|
|
277
|
+
return `ref-${Math.random().toString(36).slice(2, 9)}`
|
|
278
|
+
}
|
|
279
|
+
function newPairId() {
|
|
280
|
+
return `pair-${Math.random().toString(36).slice(2, 9)}`
|
|
281
|
+
}
|
|
282
|
+
function newOrderedId() {
|
|
283
|
+
return `ord-${Math.random().toString(36).slice(2, 9)}`
|
|
284
|
+
}
|
|
285
|
+
function newBlankId() {
|
|
286
|
+
return `blk-${Math.random().toString(36).slice(2, 9)}`
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function buildInitialOptions(type: AuthoringQuestionType): QuestionFormValues["options"] {
|
|
290
|
+
if (type === "true_false") {
|
|
291
|
+
return [
|
|
292
|
+
{ id: newOptionId(), text: "True", isCorrect: false, rationale: "" },
|
|
293
|
+
{ id: newOptionId(), text: "False", isCorrect: false, rationale: "" },
|
|
294
|
+
]
|
|
295
|
+
}
|
|
296
|
+
if (type === "mcq_single" || type === "mcq_multiple") {
|
|
297
|
+
return Array.from({ length: AUTHORING_DEFAULT_OPTION_COUNT }, () => ({
|
|
298
|
+
id: newOptionId(),
|
|
299
|
+
text: "",
|
|
300
|
+
isCorrect: false,
|
|
301
|
+
rationale: "",
|
|
302
|
+
}))
|
|
303
|
+
}
|
|
304
|
+
return []
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function buildInitialPairs(): QuestionFormValues["pairs"] {
|
|
308
|
+
return Array.from({ length: 3 }, () => ({ id: newPairId(), left: "", right: "" }))
|
|
309
|
+
}
|
|
310
|
+
function buildInitialOrderedItems(): QuestionFormValues["orderedItems"] {
|
|
311
|
+
return Array.from({ length: 4 }, () => ({ id: newOrderedId(), text: "" }))
|
|
312
|
+
}
|
|
313
|
+
function buildInitialFillBlankAnswers(): QuestionFormValues["fillBlankAnswers"] {
|
|
314
|
+
return [{ id: newBlankId(), accepted: "" }]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function folderBreadcrumb(folderId: string, folders: QuestionBankFolder[]): string {
|
|
318
|
+
const f = folders.find(x => x.id === folderId)
|
|
319
|
+
if (!f) return ""
|
|
320
|
+
if (f.parentId == null) return f.name
|
|
321
|
+
const parent = folders.find(x => x.id === f.parentId)
|
|
322
|
+
return parent ? `${parent.name} / ${f.name}` : f.name
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** 0–100 percentage of the difficulty meter for the given level. */
|
|
326
|
+
function difficultyToPercent(value: "easy" | "medium" | "hard"): number {
|
|
327
|
+
if (value === "easy") return 18
|
|
328
|
+
if (value === "hard") return 82
|
|
329
|
+
return 50
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Folder-aware difficulty insight — derived deterministically from the
|
|
334
|
+
* folder id so the same folder always returns the same numbers. In a real
|
|
335
|
+
* build this comes from analytics; for the mock it is a stable seed so
|
|
336
|
+
* the inspector reads as if the AI had crunched historical data.
|
|
337
|
+
*/
|
|
338
|
+
function difficultyInsightForFolder(folder: QuestionBankFolder | undefined): {
|
|
339
|
+
/** Predicted level based on the content the author is writing. */
|
|
340
|
+
recommendation: "easy" | "medium" | "hard"
|
|
341
|
+
/** Contextual note shown under the meter (e.g. folder distribution). */
|
|
342
|
+
note: string
|
|
343
|
+
/** Point-Biserial Index (0–1) — predicted question quality. */
|
|
344
|
+
pbi: number
|
|
345
|
+
/** Average folder difficulty (0–100, same scale as the meter). */
|
|
346
|
+
averagePercent: number
|
|
347
|
+
} {
|
|
348
|
+
if (!folder) {
|
|
349
|
+
return {
|
|
350
|
+
recommendation: "medium",
|
|
351
|
+
note: "Pick a location to see how your question compares to this folder.",
|
|
352
|
+
pbi: 0.3,
|
|
353
|
+
averagePercent: 50,
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Deterministic hash of folder id → stable mock numbers per folder.
|
|
357
|
+
let h = 0
|
|
358
|
+
for (let i = 0; i < folder.id.length; i++) h = (h * 31 + folder.id.charCodeAt(i)) >>> 0
|
|
359
|
+
const tilt = (h % 100) / 100 // 0..1
|
|
360
|
+
const mediumShare = 45 + Math.floor(tilt * 30) // 45..75 %
|
|
361
|
+
const pbi = 0.22 + (tilt * 0.22) // 0.22..0.44
|
|
362
|
+
const averagePercent = 35 + Math.floor(tilt * 35) // 35..70
|
|
363
|
+
const recommendation: "easy" | "medium" | "hard" =
|
|
364
|
+
averagePercent < 40 ? "easy" : averagePercent > 65 ? "hard" : "medium"
|
|
365
|
+
return {
|
|
366
|
+
recommendation,
|
|
367
|
+
note: `Based on your content, this question is predicted ${recommendation}. ${mediumShare}% of items in ${folder.name} are Medium (avg PBI ${pbi.toFixed(2)}).`,
|
|
368
|
+
pbi,
|
|
369
|
+
averagePercent,
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
374
|
+
// Local helpers (this file only — not new shared primitives)
|
|
375
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
function InspectorSection({
|
|
379
|
+
title,
|
|
380
|
+
htmlFor,
|
|
381
|
+
children,
|
|
382
|
+
description,
|
|
383
|
+
}: {
|
|
384
|
+
title: string
|
|
385
|
+
htmlFor?: string
|
|
386
|
+
children: React.ReactNode
|
|
387
|
+
description?: React.ReactNode
|
|
388
|
+
}) {
|
|
389
|
+
return (
|
|
390
|
+
<section className="flex flex-col gap-2">
|
|
391
|
+
<Label
|
|
392
|
+
htmlFor={htmlFor}
|
|
393
|
+
className="text-xs font-medium text-muted-foreground"
|
|
394
|
+
>
|
|
395
|
+
{title}
|
|
396
|
+
</Label>
|
|
397
|
+
{children}
|
|
398
|
+
{description ? (
|
|
399
|
+
<p className="text-[11px] leading-snug text-muted-foreground">{description}</p>
|
|
400
|
+
) : null}
|
|
401
|
+
</section>
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function BuilderSection({
|
|
406
|
+
title,
|
|
407
|
+
required,
|
|
408
|
+
hint,
|
|
409
|
+
children,
|
|
410
|
+
}: {
|
|
411
|
+
title: string
|
|
412
|
+
required?: boolean
|
|
413
|
+
hint?: React.ReactNode
|
|
414
|
+
children: React.ReactNode
|
|
415
|
+
}) {
|
|
416
|
+
return (
|
|
417
|
+
<section className="flex flex-col gap-2">
|
|
418
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
419
|
+
<h2 className="text-sm font-semibold text-foreground">
|
|
420
|
+
{title}
|
|
421
|
+
{required ? (
|
|
422
|
+
<span className="ml-1 text-destructive" aria-hidden="true">
|
|
423
|
+
*
|
|
424
|
+
</span>
|
|
425
|
+
) : null}
|
|
426
|
+
</h2>
|
|
427
|
+
{hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
|
|
428
|
+
</div>
|
|
429
|
+
{children}
|
|
430
|
+
</section>
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ─── Folder picker (Popover + Command) ──────────────────────────────────────
|
|
435
|
+
//
|
|
436
|
+
// Compact selected tile — same visual rhythm as the collapsed
|
|
437
|
+
// "Question format" card. Clicking the tile opens a Command popover with
|
|
438
|
+
// search, full folder list, and an "Add new folder" footer that bridges to
|
|
439
|
+
// `QuestionBankNewFolderSheet`. Visuals reuse the folder-color tokens from
|
|
440
|
+
// `lib/mock/question-bank-folders.ts` so the inspector reads the same as
|
|
441
|
+
// the rest of the question bank.
|
|
442
|
+
|
|
443
|
+
const FOLDER_TINT_BG: Record<QuestionBankFolderColorKey, string> = {
|
|
444
|
+
brand: "bg-[var(--icon-disc-brand-bg)] text-[var(--icon-disc-brand-fg)]",
|
|
445
|
+
success: "bg-[var(--icon-disc-chart-2-bg)] text-[var(--icon-disc-chart-2-fg)]",
|
|
446
|
+
warning: "bg-[var(--icon-disc-chart-4-bg)] text-[var(--icon-disc-chart-4-fg)]",
|
|
447
|
+
destructive: "bg-destructive/15 text-destructive",
|
|
448
|
+
muted: "bg-muted text-muted-foreground",
|
|
449
|
+
chart1: "bg-[color-mix(in_oklch,var(--color-chart-1)_15%,transparent)] text-[var(--color-chart-1)]",
|
|
450
|
+
chart2: "bg-[var(--icon-disc-chart-2-bg)] text-[var(--icon-disc-chart-2-fg)]",
|
|
451
|
+
chart3: "bg-[color-mix(in_oklch,var(--color-chart-3)_15%,transparent)] text-[var(--color-chart-3)]",
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
interface FolderPickerControlProps {
|
|
455
|
+
folders: QuestionBankFolder[]
|
|
456
|
+
value: string
|
|
457
|
+
onChange: (id: string) => void
|
|
458
|
+
open: boolean
|
|
459
|
+
onOpenChange: (open: boolean) => void
|
|
460
|
+
onRequestNewFolder: () => void
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Build a tree-ordered array: parents first, then their children
|
|
464
|
+
indented beneath. Each entry carries a `depth` for left-padding. */
|
|
465
|
+
function buildFolderTree(
|
|
466
|
+
folders: QuestionBankFolder[],
|
|
467
|
+
): Array<{ folder: QuestionBankFolder; depth: number }> {
|
|
468
|
+
const roots = folders.filter(f => f.parentId == null)
|
|
469
|
+
const childrenOf = (parentId: string) =>
|
|
470
|
+
folders.filter(f => f.parentId === parentId)
|
|
471
|
+
|
|
472
|
+
const out: Array<{ folder: QuestionBankFolder; depth: number }> = []
|
|
473
|
+
function walk(parent: QuestionBankFolder, depth: number) {
|
|
474
|
+
out.push({ folder: parent, depth })
|
|
475
|
+
for (const child of childrenOf(parent.id)) {
|
|
476
|
+
walk(child, depth + 1)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
for (const root of roots) walk(root, 0)
|
|
480
|
+
return out
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function FolderPickerControl({
|
|
484
|
+
folders,
|
|
485
|
+
value,
|
|
486
|
+
onChange,
|
|
487
|
+
open,
|
|
488
|
+
onOpenChange,
|
|
489
|
+
onRequestNewFolder,
|
|
490
|
+
}: FolderPickerControlProps) {
|
|
491
|
+
const selected = folders.find(f => f.id === value)
|
|
492
|
+
const tint = selected ? FOLDER_TINT_BG[selected.colorKey] : FOLDER_TINT_BG.muted
|
|
493
|
+
const tree = React.useMemo(() => buildFolderTree(folders), [folders])
|
|
494
|
+
|
|
495
|
+
return (
|
|
496
|
+
<Popover open={open} onOpenChange={onOpenChange}>
|
|
497
|
+
<PopoverTrigger asChild>
|
|
498
|
+
<button
|
|
499
|
+
type="button"
|
|
500
|
+
aria-haspopup="listbox"
|
|
501
|
+
aria-expanded={open}
|
|
502
|
+
aria-label={
|
|
503
|
+
selected
|
|
504
|
+
? `Change location — currently ${folderBreadcrumb(selected.id, folders)}`
|
|
505
|
+
: "Pick a location"
|
|
506
|
+
}
|
|
507
|
+
className={cn(
|
|
508
|
+
"group flex w-full items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 text-left transition-colors",
|
|
509
|
+
"hover:border-foreground/30 hover:bg-muted/40",
|
|
510
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
511
|
+
)}
|
|
512
|
+
>
|
|
513
|
+
<span
|
|
514
|
+
className={cn(
|
|
515
|
+
"inline-flex size-9 shrink-0 items-center justify-center rounded-md",
|
|
516
|
+
tint,
|
|
517
|
+
)}
|
|
518
|
+
aria-hidden="true"
|
|
519
|
+
>
|
|
520
|
+
<i className={cn("fa-light text-base", selected?.icon ?? "fa-folder")} />
|
|
521
|
+
</span>
|
|
522
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
523
|
+
<span className="truncate text-sm font-medium text-foreground">
|
|
524
|
+
{selected ? selected.name : "Pick a location"}
|
|
525
|
+
</span>
|
|
526
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
527
|
+
{selected
|
|
528
|
+
? selected.parentId
|
|
529
|
+
? `in ${folders.find(p => p.id === selected.parentId)?.name}`
|
|
530
|
+
: "Top-level folder"
|
|
531
|
+
: "Required"}
|
|
532
|
+
</span>
|
|
533
|
+
</span>
|
|
534
|
+
<span
|
|
535
|
+
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors group-hover:bg-muted group-hover:text-foreground"
|
|
536
|
+
aria-hidden="true"
|
|
537
|
+
>
|
|
538
|
+
<i className="fa-light fa-chevron-down text-xs" />
|
|
539
|
+
</span>
|
|
540
|
+
</button>
|
|
541
|
+
</PopoverTrigger>
|
|
542
|
+
<PopoverContent
|
|
543
|
+
align="start"
|
|
544
|
+
sideOffset={6}
|
|
545
|
+
className="w-[var(--radix-popover-trigger-width)] min-w-[260px] p-0"
|
|
546
|
+
>
|
|
547
|
+
<Command>
|
|
548
|
+
<CommandInput placeholder="Search folders…" />
|
|
549
|
+
<CommandList>
|
|
550
|
+
<CommandEmpty>No folders match that search.</CommandEmpty>
|
|
551
|
+
<CommandGroup heading="Folders">
|
|
552
|
+
{tree.map(({ folder: f, depth }) => {
|
|
553
|
+
const itemTint = FOLDER_TINT_BG[f.colorKey]
|
|
554
|
+
return (
|
|
555
|
+
<CommandItem
|
|
556
|
+
key={f.id}
|
|
557
|
+
value={f.name}
|
|
558
|
+
data-checked={f.id === value}
|
|
559
|
+
onSelect={() => {
|
|
560
|
+
onChange(f.id)
|
|
561
|
+
onOpenChange(false)
|
|
562
|
+
}}
|
|
563
|
+
style={{ paddingLeft: `${8 + depth * 20}px` }}
|
|
564
|
+
>
|
|
565
|
+
{depth > 0 ? (
|
|
566
|
+
<span
|
|
567
|
+
className="text-muted-foreground/40"
|
|
568
|
+
aria-hidden="true"
|
|
569
|
+
>
|
|
570
|
+
<i className="fa-light fa-turn-down-right text-[11px]" />
|
|
571
|
+
</span>
|
|
572
|
+
) : null}
|
|
573
|
+
<span
|
|
574
|
+
className={cn(
|
|
575
|
+
"inline-flex size-7 shrink-0 items-center justify-center rounded-md",
|
|
576
|
+
itemTint,
|
|
577
|
+
)}
|
|
578
|
+
aria-hidden="true"
|
|
579
|
+
>
|
|
580
|
+
<i className={cn("fa-light text-sm", f.icon)} />
|
|
581
|
+
</span>
|
|
582
|
+
<span className="truncate text-sm text-foreground">
|
|
583
|
+
{f.name}
|
|
584
|
+
</span>
|
|
585
|
+
</CommandItem>
|
|
586
|
+
)
|
|
587
|
+
})}
|
|
588
|
+
</CommandGroup>
|
|
589
|
+
<CommandSeparator />
|
|
590
|
+
<CommandGroup>
|
|
591
|
+
<CommandItem
|
|
592
|
+
value="__new-folder__"
|
|
593
|
+
onSelect={onRequestNewFolder}
|
|
594
|
+
className="text-foreground"
|
|
595
|
+
>
|
|
596
|
+
<span
|
|
597
|
+
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md bg-[var(--icon-disc-brand-bg)] text-[var(--icon-disc-brand-fg)]"
|
|
598
|
+
aria-hidden="true"
|
|
599
|
+
>
|
|
600
|
+
<i className="fa-light fa-folder-plus text-sm" />
|
|
601
|
+
</span>
|
|
602
|
+
<span className="text-sm font-medium">New folder…</span>
|
|
603
|
+
</CommandItem>
|
|
604
|
+
</CommandGroup>
|
|
605
|
+
</CommandList>
|
|
606
|
+
</Command>
|
|
607
|
+
</PopoverContent>
|
|
608
|
+
</Popover>
|
|
609
|
+
)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ─── Difficulty meter (predicted from content + PBI + folder note) ───────────
|
|
613
|
+
//
|
|
614
|
+
// AI analyses the question content (stem length, option count, Bloom's level,
|
|
615
|
+
// vocabulary complexity) to **predict** how difficult examinees will find this
|
|
616
|
+
// item. The author can override by flipping to Manual mode.
|
|
617
|
+
|
|
618
|
+
interface DifficultyMeterProps {
|
|
619
|
+
value: "easy" | "medium" | "hard"
|
|
620
|
+
onChange: (next: "easy" | "medium" | "hard") => void
|
|
621
|
+
mode: "auto" | "manual"
|
|
622
|
+
onModeChange: (next: "auto" | "manual") => void
|
|
623
|
+
insight: ReturnType<typeof difficultyInsightForFolder>
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function DifficultyMeter({
|
|
627
|
+
value,
|
|
628
|
+
onChange,
|
|
629
|
+
mode,
|
|
630
|
+
onModeChange,
|
|
631
|
+
insight,
|
|
632
|
+
}: DifficultyMeterProps) {
|
|
633
|
+
const pbiPercent = Math.min(100, Math.max(0, insight.pbi * 100))
|
|
634
|
+
const pbiTone =
|
|
635
|
+
insight.pbi >= 0.3
|
|
636
|
+
? "bg-[var(--color-chart-2)]"
|
|
637
|
+
: insight.pbi >= 0.2
|
|
638
|
+
? "bg-[var(--color-chart-4)]"
|
|
639
|
+
: "bg-destructive"
|
|
640
|
+
|
|
641
|
+
const levelLabel =
|
|
642
|
+
value === "easy" ? "Easy" : value === "hard" ? "Hard" : "Medium"
|
|
643
|
+
|
|
644
|
+
return (
|
|
645
|
+
<section className="flex flex-col gap-3">
|
|
646
|
+
<Label className="text-xs font-medium text-muted-foreground">
|
|
647
|
+
Predicted difficulty
|
|
648
|
+
</Label>
|
|
649
|
+
|
|
650
|
+
<div className="flex flex-col gap-3 rounded-lg border border-border bg-muted/20 p-3">
|
|
651
|
+
{/* Level heading + AI / Manual toggle */}
|
|
652
|
+
<div className="flex items-center justify-between gap-2">
|
|
653
|
+
<h3 className="text-base font-semibold text-foreground">
|
|
654
|
+
{levelLabel}
|
|
655
|
+
</h3>
|
|
656
|
+
<Tip
|
|
657
|
+
side="top"
|
|
658
|
+
label={
|
|
659
|
+
mode === "auto"
|
|
660
|
+
? "Override the predicted difficulty"
|
|
661
|
+
: "Let AI predict difficulty from your content"
|
|
662
|
+
}
|
|
663
|
+
>
|
|
664
|
+
<Button
|
|
665
|
+
type="button"
|
|
666
|
+
variant="ghost"
|
|
667
|
+
size="sm"
|
|
668
|
+
className="h-6 gap-1 px-2 text-[11px] font-medium"
|
|
669
|
+
onClick={() => onModeChange(mode === "auto" ? "manual" : "auto")}
|
|
670
|
+
aria-pressed={mode === "manual"}
|
|
671
|
+
>
|
|
672
|
+
{mode === "auto" ? (
|
|
673
|
+
<>
|
|
674
|
+
<i className="fa-light fa-sparkles text-[10px]" aria-hidden="true" />
|
|
675
|
+
Predicted
|
|
676
|
+
</>
|
|
677
|
+
) : (
|
|
678
|
+
<>
|
|
679
|
+
<i className="fa-light fa-hand-pointer text-[10px]" aria-hidden="true" />
|
|
680
|
+
Manual
|
|
681
|
+
</>
|
|
682
|
+
)}
|
|
683
|
+
</Button>
|
|
684
|
+
</Tip>
|
|
685
|
+
</div>
|
|
686
|
+
|
|
687
|
+
{/* Manual chips — only shown when the author has overridden. */}
|
|
688
|
+
{mode === "manual" ? (
|
|
689
|
+
<ToggleGroup
|
|
690
|
+
type="single"
|
|
691
|
+
variant="outline"
|
|
692
|
+
size="sm"
|
|
693
|
+
spacing={1}
|
|
694
|
+
value={value}
|
|
695
|
+
onValueChange={v => { if (v) onChange(v as "easy" | "medium" | "hard") }}
|
|
696
|
+
className="flex-wrap"
|
|
697
|
+
>
|
|
698
|
+
{AUTHORING_DIFFICULTY_OPTIONS.map(d => (
|
|
699
|
+
<ToggleGroupItem
|
|
700
|
+
key={d.value}
|
|
701
|
+
value={d.value}
|
|
702
|
+
title={d.description}
|
|
703
|
+
className="rounded-full px-3"
|
|
704
|
+
>
|
|
705
|
+
{d.label}
|
|
706
|
+
</ToggleGroupItem>
|
|
707
|
+
))}
|
|
708
|
+
</ToggleGroup>
|
|
709
|
+
) : null}
|
|
710
|
+
|
|
711
|
+
{/* PBI score bar — the only progress indicator, so the section
|
|
712
|
+
reads as a single unified card instead of two separate ones. */}
|
|
713
|
+
<div className="flex flex-col gap-1">
|
|
714
|
+
<div className="flex items-center justify-between">
|
|
715
|
+
<Tip
|
|
716
|
+
side="top"
|
|
717
|
+
label="Point-Biserial Index — correlation between getting this question right and total score. Above 0.30 is good; below 0.20 suggests the item needs review."
|
|
718
|
+
>
|
|
719
|
+
<span
|
|
720
|
+
tabIndex={0}
|
|
721
|
+
role="img"
|
|
722
|
+
aria-label="PBI score — Point-Biserial Index, a measure of question quality"
|
|
723
|
+
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground"
|
|
724
|
+
>
|
|
725
|
+
PBI score
|
|
726
|
+
<i
|
|
727
|
+
className="fa-light fa-circle-info text-[10px] text-muted-foreground/70"
|
|
728
|
+
aria-hidden="true"
|
|
729
|
+
/>
|
|
730
|
+
</span>
|
|
731
|
+
</Tip>
|
|
732
|
+
<span className="font-mono tabular-nums text-xs font-medium text-foreground">
|
|
733
|
+
{insight.pbi.toFixed(2)}
|
|
734
|
+
</span>
|
|
735
|
+
</div>
|
|
736
|
+
<div className="h-1.5 rounded-full bg-muted">
|
|
737
|
+
<div
|
|
738
|
+
className={cn("h-full rounded-full transition-[width] duration-200", pbiTone)}
|
|
739
|
+
style={{ width: `${pbiPercent}%` }}
|
|
740
|
+
/>
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
|
|
744
|
+
{/* Folder note */}
|
|
745
|
+
<p className="flex items-start gap-1.5 text-[11px] leading-snug text-muted-foreground">
|
|
746
|
+
<i
|
|
747
|
+
className="fa-light fa-circle-info mt-0.5 text-[10px]"
|
|
748
|
+
aria-hidden="true"
|
|
749
|
+
/>
|
|
750
|
+
<span>{insight.note}</span>
|
|
751
|
+
</p>
|
|
752
|
+
</div>
|
|
753
|
+
</section>
|
|
754
|
+
)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
758
|
+
// Composer
|
|
759
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
760
|
+
|
|
761
|
+
interface NewQuestionComposerProps {
|
|
762
|
+
/** Assigned on the server — keeps SSR and hydration in sync. */
|
|
763
|
+
draftQuestionId: string
|
|
764
|
+
defaultFolderId?: string
|
|
765
|
+
/** Where to send the user when they cancel or save. */
|
|
766
|
+
backHref: string
|
|
767
|
+
folders?: QuestionBankFolder[]
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export function NewQuestionComposer({
|
|
771
|
+
draftQuestionId,
|
|
772
|
+
defaultFolderId,
|
|
773
|
+
backHref,
|
|
774
|
+
folders = DEFAULT_QUESTION_BANK_FOLDERS,
|
|
775
|
+
}: NewQuestionComposerProps) {
|
|
776
|
+
const router = useRouter()
|
|
777
|
+
const [submitting, setSubmitting] = React.useState(false)
|
|
778
|
+
const [tagDraft, setTagDraft] = React.useState("")
|
|
779
|
+
const [inspectorOpen, setInspectorOpen] = React.useState(true)
|
|
780
|
+
const [moreOpen, setMoreOpen] = React.useState(false)
|
|
781
|
+
/** Question-type chooser visibility — collapses into a single
|
|
782
|
+
"selected type" tile once the author picks the first time so the
|
|
783
|
+
inspector stays compact for the rest of the authoring flow. */
|
|
784
|
+
const [typeChooserOpen, setTypeChooserOpen] = React.useState(true)
|
|
785
|
+
/** Local folder list — extended in-place when the author adds one
|
|
786
|
+
from the location picker so the new entry is selectable without
|
|
787
|
+
a page navigation. */
|
|
788
|
+
const [localFolders, setLocalFolders] = React.useState<QuestionBankFolder[]>(folders)
|
|
789
|
+
React.useEffect(() => {
|
|
790
|
+
setLocalFolders(prev =>
|
|
791
|
+
prev.length === folders.length && prev.every((f, i) => f.id === folders[i]?.id)
|
|
792
|
+
? prev
|
|
793
|
+
: folders,
|
|
794
|
+
)
|
|
795
|
+
}, [folders])
|
|
796
|
+
const [folderPickerOpen, setFolderPickerOpen] = React.useState(false)
|
|
797
|
+
const [newFolderOpen, setNewFolderOpen] = React.useState(false)
|
|
798
|
+
/** "auto" → the meter follows the AI recommendation derived from the
|
|
799
|
+
selected folder's history; "manual" → the author has overridden
|
|
800
|
+
the level via the chip row. */
|
|
801
|
+
const [difficultyMode, setDifficultyMode] = React.useState<"auto" | "manual">("auto")
|
|
802
|
+
|
|
803
|
+
const initialFolderId =
|
|
804
|
+
defaultFolderId && folders.some(f => f.id === defaultFolderId)
|
|
805
|
+
? defaultFolderId
|
|
806
|
+
: folders.find(f => f.id === "fld-favorites")?.id ?? folders[0]?.id ?? "fld-favorites"
|
|
807
|
+
|
|
808
|
+
const form = useForm<QuestionFormValues>({
|
|
809
|
+
resolver: zodResolver(questionSchema) as Resolver<QuestionFormValues>,
|
|
810
|
+
mode: "onTouched",
|
|
811
|
+
defaultValues: {
|
|
812
|
+
type: "mcq_single",
|
|
813
|
+
status: "draft",
|
|
814
|
+
folderId: initialFolderId,
|
|
815
|
+
leadIn: "",
|
|
816
|
+
options: buildInitialOptions("mcq_single"),
|
|
817
|
+
rationale: "",
|
|
818
|
+
references: [],
|
|
819
|
+
numericValue: "",
|
|
820
|
+
numericTolerance: "",
|
|
821
|
+
numericUnits: "",
|
|
822
|
+
pairs: buildInitialPairs(),
|
|
823
|
+
orderedItems: buildInitialOrderedItems(),
|
|
824
|
+
fillBlankAnswers: buildInitialFillBlankAnswers(),
|
|
825
|
+
difficulty: "medium",
|
|
826
|
+
bloom: "",
|
|
827
|
+
cogLevel: "",
|
|
828
|
+
tags: [],
|
|
829
|
+
},
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
// `useWatch` is React-Compiler-safe; `form.watch()` is not. Same fix as
|
|
833
|
+
// `new-placement-form.tsx`.
|
|
834
|
+
const watchedType = useWatch({ control: form.control, name: "type" })
|
|
835
|
+
const watchedOptions = useWatch({ control: form.control, name: "options" })
|
|
836
|
+
const watchedPairs = useWatch({ control: form.control, name: "pairs" })
|
|
837
|
+
const watchedOrderedItems = useWatch({ control: form.control, name: "orderedItems" })
|
|
838
|
+
const watchedFillBlankAnswers = useWatch({
|
|
839
|
+
control: form.control,
|
|
840
|
+
name: "fillBlankAnswers",
|
|
841
|
+
})
|
|
842
|
+
const watchedTags = useWatch({ control: form.control, name: "tags" })
|
|
843
|
+
const watchedFolderId = useWatch({ control: form.control, name: "folderId" })
|
|
844
|
+
const watchedDifficulty = useWatch({ control: form.control, name: "difficulty" })
|
|
845
|
+
|
|
846
|
+
const difficultyInsight = React.useMemo(
|
|
847
|
+
() => difficultyInsightForFolder(localFolders.find(f => f.id === watchedFolderId)),
|
|
848
|
+
[localFolders, watchedFolderId],
|
|
849
|
+
)
|
|
850
|
+
// When the AI is in charge, mirror its recommendation into the form.
|
|
851
|
+
// Avoids race conditions where the meter shows one value but submit
|
|
852
|
+
// ships another.
|
|
853
|
+
React.useEffect(() => {
|
|
854
|
+
if (difficultyMode === "auto" && watchedDifficulty !== difficultyInsight.recommendation) {
|
|
855
|
+
form.setValue("difficulty", difficultyInsight.recommendation, {
|
|
856
|
+
shouldDirty: false,
|
|
857
|
+
shouldValidate: false,
|
|
858
|
+
})
|
|
859
|
+
}
|
|
860
|
+
}, [difficultyMode, difficultyInsight.recommendation, watchedDifficulty, form])
|
|
861
|
+
|
|
862
|
+
const activeType = authoringQuestionType(watchedType)
|
|
863
|
+
const isMulti = watchedType === "mcq_multiple"
|
|
864
|
+
const isMcq = watchedType === "mcq_single" || watchedType === "mcq_multiple"
|
|
865
|
+
const isTrueFalse = watchedType === "true_false"
|
|
866
|
+
const isShortAnswer = watchedType === "short_answer"
|
|
867
|
+
const isEssay = watchedType === "essay"
|
|
868
|
+
const isNumeric = watchedType === "numeric"
|
|
869
|
+
const isFillBlank = watchedType === "fill_blank"
|
|
870
|
+
const isMatching = watchedType === "matching"
|
|
871
|
+
const isOrdering = watchedType === "ordering"
|
|
872
|
+
const isHotspot = watchedType === "hotspot"
|
|
873
|
+
const showOptionsBlock = isMcq || isTrueFalse
|
|
874
|
+
const correctCount = watchedOptions.filter(o => o.isCorrect).length
|
|
875
|
+
|
|
876
|
+
function changeType(next: AuthoringQuestionType) {
|
|
877
|
+
const prev = form.getValues("type")
|
|
878
|
+
// Always collapse the chooser after an interaction — even when the
|
|
879
|
+
// author re-clicks the already-selected tile to confirm their choice.
|
|
880
|
+
setTypeChooserOpen(false)
|
|
881
|
+
if (prev === next) return
|
|
882
|
+
form.setValue("type", next, { shouldValidate: false })
|
|
883
|
+
|
|
884
|
+
// mcq_single ↔ mcq_multiple preserves the typed options the author has
|
|
885
|
+
// already drafted; any other transition rebuilds the options block.
|
|
886
|
+
const mcqFamily = new Set(["mcq_single", "mcq_multiple"])
|
|
887
|
+
if (mcqFamily.has(prev) && mcqFamily.has(next)) {
|
|
888
|
+
if (next !== "mcq_multiple") {
|
|
889
|
+
const opts = form.getValues("options")
|
|
890
|
+
let firstCorrect = true
|
|
891
|
+
form.setValue(
|
|
892
|
+
"options",
|
|
893
|
+
opts.map(o => {
|
|
894
|
+
if (!o.isCorrect) return o
|
|
895
|
+
if (firstCorrect) {
|
|
896
|
+
firstCorrect = false
|
|
897
|
+
return o
|
|
898
|
+
}
|
|
899
|
+
return { ...o, isCorrect: false }
|
|
900
|
+
}),
|
|
901
|
+
{ shouldValidate: false },
|
|
902
|
+
)
|
|
903
|
+
}
|
|
904
|
+
return
|
|
905
|
+
}
|
|
906
|
+
form.setValue("options", buildInitialOptions(next), { shouldValidate: false })
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function patchOption(id: string, patch: Partial<QuestionFormValues["options"][number]>) {
|
|
910
|
+
form.setValue(
|
|
911
|
+
"options",
|
|
912
|
+
form.getValues("options").map(o => (o.id === id ? { ...o, ...patch } : o)),
|
|
913
|
+
{ shouldDirty: true },
|
|
914
|
+
)
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function toggleCorrect(id: string) {
|
|
918
|
+
const opts = form.getValues("options")
|
|
919
|
+
const next = opts.map(o => {
|
|
920
|
+
if (o.id === id) return { ...o, isCorrect: !o.isCorrect }
|
|
921
|
+
if (!isMulti) return { ...o, isCorrect: false }
|
|
922
|
+
return o
|
|
923
|
+
})
|
|
924
|
+
form.setValue("options", next, { shouldValidate: false, shouldDirty: true })
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function addOption() {
|
|
928
|
+
const opts = form.getValues("options")
|
|
929
|
+
if (opts.length >= AUTHORING_MAX_OPTION_COUNT) return
|
|
930
|
+
form.setValue(
|
|
931
|
+
"options",
|
|
932
|
+
[...opts, { id: newOptionId(), text: "", isCorrect: false, rationale: "" }],
|
|
933
|
+
{ shouldDirty: true },
|
|
934
|
+
)
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function removeOption(id: string) {
|
|
938
|
+
const opts = form.getValues("options")
|
|
939
|
+
if (opts.length <= AUTHORING_MIN_OPTION_COUNT) return
|
|
940
|
+
form.setValue(
|
|
941
|
+
"options",
|
|
942
|
+
opts.filter(o => o.id !== id),
|
|
943
|
+
{ shouldDirty: true },
|
|
944
|
+
)
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function addReference() {
|
|
948
|
+
const refs = form.getValues("references")
|
|
949
|
+
form.setValue("references", [...refs, { id: newReferenceId(), citation: "" }], {
|
|
950
|
+
shouldDirty: true,
|
|
951
|
+
})
|
|
952
|
+
}
|
|
953
|
+
function removeReference(id: string) {
|
|
954
|
+
form.setValue(
|
|
955
|
+
"references",
|
|
956
|
+
form.getValues("references").filter(r => r.id !== id),
|
|
957
|
+
{ shouldDirty: true },
|
|
958
|
+
)
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function patchPair(id: string, patch: Partial<QuestionFormValues["pairs"][number]>) {
|
|
962
|
+
form.setValue(
|
|
963
|
+
"pairs",
|
|
964
|
+
form.getValues("pairs").map(p => (p.id === id ? { ...p, ...patch } : p)),
|
|
965
|
+
{ shouldDirty: true },
|
|
966
|
+
)
|
|
967
|
+
}
|
|
968
|
+
function addPair() {
|
|
969
|
+
form.setValue(
|
|
970
|
+
"pairs",
|
|
971
|
+
[...form.getValues("pairs"), { id: newPairId(), left: "", right: "" }],
|
|
972
|
+
{ shouldDirty: true },
|
|
973
|
+
)
|
|
974
|
+
}
|
|
975
|
+
function removePair(id: string) {
|
|
976
|
+
const pairs = form.getValues("pairs")
|
|
977
|
+
if (pairs.length <= 2) return
|
|
978
|
+
form.setValue(
|
|
979
|
+
"pairs",
|
|
980
|
+
pairs.filter(p => p.id !== id),
|
|
981
|
+
{ shouldDirty: true },
|
|
982
|
+
)
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function patchOrdered(id: string, text: string) {
|
|
986
|
+
form.setValue(
|
|
987
|
+
"orderedItems",
|
|
988
|
+
form.getValues("orderedItems").map(i => (i.id === id ? { ...i, text } : i)),
|
|
989
|
+
{ shouldDirty: true },
|
|
990
|
+
)
|
|
991
|
+
}
|
|
992
|
+
function addOrdered() {
|
|
993
|
+
form.setValue(
|
|
994
|
+
"orderedItems",
|
|
995
|
+
[...form.getValues("orderedItems"), { id: newOrderedId(), text: "" }],
|
|
996
|
+
{ shouldDirty: true },
|
|
997
|
+
)
|
|
998
|
+
}
|
|
999
|
+
function removeOrdered(id: string) {
|
|
1000
|
+
const items = form.getValues("orderedItems")
|
|
1001
|
+
if (items.length <= 2) return
|
|
1002
|
+
form.setValue(
|
|
1003
|
+
"orderedItems",
|
|
1004
|
+
items.filter(i => i.id !== id),
|
|
1005
|
+
{ shouldDirty: true },
|
|
1006
|
+
)
|
|
1007
|
+
}
|
|
1008
|
+
function moveOrdered(id: string, delta: -1 | 1) {
|
|
1009
|
+
const items = form.getValues("orderedItems")
|
|
1010
|
+
const idx = items.findIndex(i => i.id === id)
|
|
1011
|
+
if (idx < 0) return
|
|
1012
|
+
const target = idx + delta
|
|
1013
|
+
if (target < 0 || target >= items.length) return
|
|
1014
|
+
const next = items.slice()
|
|
1015
|
+
const [moved] = next.splice(idx, 1)
|
|
1016
|
+
next.splice(target, 0, moved)
|
|
1017
|
+
form.setValue("orderedItems", next, { shouldDirty: true })
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function patchFillBlank(id: string, accepted: string) {
|
|
1021
|
+
form.setValue(
|
|
1022
|
+
"fillBlankAnswers",
|
|
1023
|
+
form.getValues("fillBlankAnswers").map(a => (a.id === id ? { ...a, accepted } : a)),
|
|
1024
|
+
{ shouldDirty: true },
|
|
1025
|
+
)
|
|
1026
|
+
}
|
|
1027
|
+
function addFillBlank() {
|
|
1028
|
+
form.setValue(
|
|
1029
|
+
"fillBlankAnswers",
|
|
1030
|
+
[...form.getValues("fillBlankAnswers"), { id: newBlankId(), accepted: "" }],
|
|
1031
|
+
{ shouldDirty: true },
|
|
1032
|
+
)
|
|
1033
|
+
}
|
|
1034
|
+
function removeFillBlank(id: string) {
|
|
1035
|
+
const items = form.getValues("fillBlankAnswers")
|
|
1036
|
+
if (items.length <= 1) return
|
|
1037
|
+
form.setValue(
|
|
1038
|
+
"fillBlankAnswers",
|
|
1039
|
+
items.filter(a => a.id !== id),
|
|
1040
|
+
{ shouldDirty: true },
|
|
1041
|
+
)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function commitTag() {
|
|
1045
|
+
const t = tagDraft.trim()
|
|
1046
|
+
if (!t) return
|
|
1047
|
+
const tags = form.getValues("tags")
|
|
1048
|
+
if (!tags.includes(t)) {
|
|
1049
|
+
form.setValue("tags", [...tags, t], { shouldDirty: true })
|
|
1050
|
+
}
|
|
1051
|
+
setTagDraft("")
|
|
1052
|
+
}
|
|
1053
|
+
function removeTag(t: string) {
|
|
1054
|
+
form.setValue(
|
|
1055
|
+
"tags",
|
|
1056
|
+
form.getValues("tags").filter(x => x !== t),
|
|
1057
|
+
{ shouldDirty: true },
|
|
1058
|
+
)
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** Two save actions — Save Question (primary, full validation, status
|
|
1062
|
+
moves to In review) and Save as draft (secondary, skips validation,
|
|
1063
|
+
status stays Draft). Both route back to the parent hub on success;
|
|
1064
|
+
no toasts per `exxat-no-toast.mdc`. */
|
|
1065
|
+
async function persist(values: QuestionFormValues, mode: "publish" | "draft") {
|
|
1066
|
+
setSubmitting(true)
|
|
1067
|
+
await new Promise(r => setTimeout(r, 700))
|
|
1068
|
+
devLog(`Question ${mode === "publish" ? "saved" : "drafted"} (${draftQuestionId}):`, values)
|
|
1069
|
+
setSubmitting(false)
|
|
1070
|
+
router.push(backHref)
|
|
1071
|
+
}
|
|
1072
|
+
function handleSaveQuestion() {
|
|
1073
|
+
void form.handleSubmit(values => persist({ ...values, status: "in_review" }, "publish"))()
|
|
1074
|
+
}
|
|
1075
|
+
function handleSaveAsDraft() {
|
|
1076
|
+
if (submitting) return
|
|
1077
|
+
const values = { ...form.getValues(), status: "draft" as const }
|
|
1078
|
+
void persist(values, "draft")
|
|
1079
|
+
}
|
|
1080
|
+
function handleCancel() {
|
|
1081
|
+
router.push(backHref)
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// PageHeader subtitle — question id + version + last-updated stamp.
|
|
1085
|
+
const headerSubtitle = (
|
|
1086
|
+
<>
|
|
1087
|
+
<span className="font-mono tabular-nums">{draftQuestionId}</span>
|
|
1088
|
+
{" · V1 · Last updated just now"}
|
|
1089
|
+
</>
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
return (
|
|
1093
|
+
<Form {...form}>
|
|
1094
|
+
{/* Global shortcuts — bound while the composer is mounted. The
|
|
1095
|
+
`useShortcut` hook skips inputs/textarea/contenteditable so
|
|
1096
|
+
Enter still types newlines inside the stem; Save fires only
|
|
1097
|
+
when focus is on chrome. */}
|
|
1098
|
+
<Shortcut keys="Escape" disabled={submitting} onInvoke={handleCancel} />
|
|
1099
|
+
<Shortcut keys="Enter" disabled={submitting} onInvoke={handleSaveQuestion} />
|
|
1100
|
+
<Shortcut
|
|
1101
|
+
keys="⌘⌥M"
|
|
1102
|
+
disabled={submitting}
|
|
1103
|
+
onInvoke={() => setMoreOpen(o => !o)}
|
|
1104
|
+
/>
|
|
1105
|
+
<Shortcut
|
|
1106
|
+
keys="⌘⌥]"
|
|
1107
|
+
disabled={submitting}
|
|
1108
|
+
onInvoke={() => setInspectorOpen(o => !o)}
|
|
1109
|
+
/>
|
|
1110
|
+
|
|
1111
|
+
<form
|
|
1112
|
+
onSubmit={form.handleSubmit(values => persist({ ...values, status: "in_review" }, "publish"))}
|
|
1113
|
+
noValidate
|
|
1114
|
+
aria-label="New question form"
|
|
1115
|
+
className="flex flex-col gap-6"
|
|
1116
|
+
>
|
|
1117
|
+
<PageHeader
|
|
1118
|
+
title="New question"
|
|
1119
|
+
subtitle={headerSubtitle}
|
|
1120
|
+
actions={
|
|
1121
|
+
<>
|
|
1122
|
+
{/* Save as draft — icon-only button (leftmost). */}
|
|
1123
|
+
<Tip side="bottom" label="Save as draft">
|
|
1124
|
+
<Button
|
|
1125
|
+
type="button"
|
|
1126
|
+
size="lg"
|
|
1127
|
+
variant="outline"
|
|
1128
|
+
className="aspect-square px-0"
|
|
1129
|
+
disabled={submitting}
|
|
1130
|
+
onClick={handleSaveAsDraft}
|
|
1131
|
+
aria-label="Save as draft"
|
|
1132
|
+
>
|
|
1133
|
+
<i className="fa-light fa-file-pen text-base" aria-hidden="true" />
|
|
1134
|
+
</Button>
|
|
1135
|
+
</Tip>
|
|
1136
|
+
|
|
1137
|
+
{/* Primary — Save Question. Full validation; the
|
|
1138
|
+
question moves to In review and the user is
|
|
1139
|
+
returned to the parent hub. */}
|
|
1140
|
+
<Button
|
|
1141
|
+
type="button"
|
|
1142
|
+
size="lg"
|
|
1143
|
+
disabled={submitting}
|
|
1144
|
+
aria-busy={submitting}
|
|
1145
|
+
onClick={handleSaveQuestion}
|
|
1146
|
+
>
|
|
1147
|
+
{submitting ? (
|
|
1148
|
+
<>
|
|
1149
|
+
<i
|
|
1150
|
+
className="fa-light fa-spinner-third fa-spin text-[13px]"
|
|
1151
|
+
aria-hidden="true"
|
|
1152
|
+
/>
|
|
1153
|
+
Saving…
|
|
1154
|
+
</>
|
|
1155
|
+
) : (
|
|
1156
|
+
<>
|
|
1157
|
+
Save question
|
|
1158
|
+
<KbdGroup className="ml-1.5">
|
|
1159
|
+
<Kbd variant="bare">⏎</Kbd>
|
|
1160
|
+
</KbdGroup>
|
|
1161
|
+
</>
|
|
1162
|
+
)}
|
|
1163
|
+
</Button>
|
|
1164
|
+
|
|
1165
|
+
{/* More — overflow menu (⌘⌥M). */}
|
|
1166
|
+
<DropdownMenu open={moreOpen} onOpenChange={setMoreOpen}>
|
|
1167
|
+
<Tip side="bottom" label="More actions">
|
|
1168
|
+
<DropdownMenuTrigger asChild>
|
|
1169
|
+
<Button
|
|
1170
|
+
type="button"
|
|
1171
|
+
size="lg"
|
|
1172
|
+
variant="outline"
|
|
1173
|
+
className="aspect-square px-0"
|
|
1174
|
+
aria-label="More actions"
|
|
1175
|
+
>
|
|
1176
|
+
<i
|
|
1177
|
+
className="fa-light fa-ellipsis text-base"
|
|
1178
|
+
aria-hidden="true"
|
|
1179
|
+
/>
|
|
1180
|
+
</Button>
|
|
1181
|
+
</DropdownMenuTrigger>
|
|
1182
|
+
</Tip>
|
|
1183
|
+
<DropdownMenuContent align="end">
|
|
1184
|
+
<DropdownMenuItem
|
|
1185
|
+
shortcut="⌘⌥]"
|
|
1186
|
+
onSelect={() => {
|
|
1187
|
+
window.setTimeout(
|
|
1188
|
+
() => setInspectorOpen(o => !o),
|
|
1189
|
+
0,
|
|
1190
|
+
)
|
|
1191
|
+
}}
|
|
1192
|
+
>
|
|
1193
|
+
<i
|
|
1194
|
+
className={cn(
|
|
1195
|
+
"fa-light",
|
|
1196
|
+
inspectorOpen ? "fa-sidebar-flip" : "fa-sidebar",
|
|
1197
|
+
)}
|
|
1198
|
+
aria-hidden="true"
|
|
1199
|
+
/>
|
|
1200
|
+
{inspectorOpen ? "Hide inspector" : "Show inspector"}
|
|
1201
|
+
</DropdownMenuItem>
|
|
1202
|
+
<DropdownMenuSeparator />
|
|
1203
|
+
<DropdownMenuItem
|
|
1204
|
+
shortcut="Esc"
|
|
1205
|
+
onSelect={() => {
|
|
1206
|
+
window.setTimeout(() => handleCancel(), 0)
|
|
1207
|
+
}}
|
|
1208
|
+
variant="destructive"
|
|
1209
|
+
>
|
|
1210
|
+
<i className="fa-light fa-trash-can" aria-hidden="true" />
|
|
1211
|
+
Discard draft
|
|
1212
|
+
</DropdownMenuItem>
|
|
1213
|
+
</DropdownMenuContent>
|
|
1214
|
+
</DropdownMenu>
|
|
1215
|
+
</>
|
|
1216
|
+
}
|
|
1217
|
+
className="px-0 lg:px-0"
|
|
1218
|
+
/>
|
|
1219
|
+
|
|
1220
|
+
{/* ── 2-column body — scrolls with the page; inspector sticky (lg+) */}
|
|
1221
|
+
<div
|
|
1222
|
+
className={cn(
|
|
1223
|
+
"flex flex-col gap-8",
|
|
1224
|
+
"lg:flex-row lg:items-start",
|
|
1225
|
+
inspectorOpen ? "lg:gap-x-10" : "lg:gap-x-4",
|
|
1226
|
+
)}
|
|
1227
|
+
>
|
|
1228
|
+
{/* ── Builder (no Card chrome) ─────────────────────────────── */}
|
|
1229
|
+
<div className="flex min-w-0 flex-1 flex-col gap-7 lg:pr-2">
|
|
1230
|
+
{/* Question prompt — a real bordered field, not an
|
|
1231
|
+
inline-editable headline. Larger heading-weight font keeps
|
|
1232
|
+
the question front-and-centre, but the visible border /
|
|
1233
|
+
padding tell the author "this is the field you type in"
|
|
1234
|
+
instead of suggesting a click-to-edit document. */}
|
|
1235
|
+
<FormField
|
|
1236
|
+
control={form.control}
|
|
1237
|
+
name="leadIn"
|
|
1238
|
+
render={({ field }) => (
|
|
1239
|
+
<FormItem className="gap-1.5">
|
|
1240
|
+
<Label
|
|
1241
|
+
htmlFor="qb-lead-in"
|
|
1242
|
+
className="text-xs font-medium text-muted-foreground"
|
|
1243
|
+
>
|
|
1244
|
+
Question
|
|
1245
|
+
</Label>
|
|
1246
|
+
<FormControl>
|
|
1247
|
+
<Textarea
|
|
1248
|
+
{...field}
|
|
1249
|
+
id="qb-lead-in"
|
|
1250
|
+
placeholder={
|
|
1251
|
+
isFillBlank
|
|
1252
|
+
? "Type your fill-in-the-blank question here. Use {{1}}, {{2}}, … to mark the blanks."
|
|
1253
|
+
: isMatching
|
|
1254
|
+
? "Match each item on the left to its match on the right."
|
|
1255
|
+
: isOrdering
|
|
1256
|
+
? "Place the following steps in the correct order."
|
|
1257
|
+
: isHotspot
|
|
1258
|
+
? "Click the correct region of the image below."
|
|
1259
|
+
: isNumeric
|
|
1260
|
+
? "What is the calculated value? (Include units in the answer field.)"
|
|
1261
|
+
: AUTHORING_LEAD_IN_PLACEHOLDER
|
|
1262
|
+
}
|
|
1263
|
+
rows={3}
|
|
1264
|
+
aria-required="true"
|
|
1265
|
+
className={cn(
|
|
1266
|
+
"min-h-[5rem] resize-y leading-snug",
|
|
1267
|
+
"text-lg font-semibold tracking-tight md:text-xl",
|
|
1268
|
+
"placeholder:font-medium placeholder:text-muted-foreground",
|
|
1269
|
+
)}
|
|
1270
|
+
/>
|
|
1271
|
+
</FormControl>
|
|
1272
|
+
<FormMessage />
|
|
1273
|
+
</FormItem>
|
|
1274
|
+
)}
|
|
1275
|
+
/>
|
|
1276
|
+
|
|
1277
|
+
{/* MCQ family + True/False — bordered OptionRows. */}
|
|
1278
|
+
{showOptionsBlock ? (
|
|
1279
|
+
<FormField
|
|
1280
|
+
control={form.control}
|
|
1281
|
+
name="options"
|
|
1282
|
+
render={() => (
|
|
1283
|
+
<FormItem>
|
|
1284
|
+
<BuilderSection
|
|
1285
|
+
title="Answer choices"
|
|
1286
|
+
hint={`${watchedOptions.length} options · ${correctCount} correct`}
|
|
1287
|
+
>
|
|
1288
|
+
<p className="text-xs text-muted-foreground">
|
|
1289
|
+
{isMulti
|
|
1290
|
+
? "NCLEX-style — mark every correct response."
|
|
1291
|
+
: isTrueFalse
|
|
1292
|
+
? "Mark whether the statement is True or False."
|
|
1293
|
+
: "Mark the single best answer. Distractors should be plausible — same length and grammar as the correct option."}
|
|
1294
|
+
</p>
|
|
1295
|
+
<div className="space-y-2">
|
|
1296
|
+
{watchedOptions.map((opt, idx) => (
|
|
1297
|
+
<OptionRow
|
|
1298
|
+
key={opt.id}
|
|
1299
|
+
letter={OPTION_LETTERS[idx] ?? `${idx + 1}`}
|
|
1300
|
+
option={opt}
|
|
1301
|
+
locked={isTrueFalse}
|
|
1302
|
+
canRemove={
|
|
1303
|
+
!isTrueFalse &&
|
|
1304
|
+
watchedOptions.length > AUTHORING_MIN_OPTION_COUNT
|
|
1305
|
+
}
|
|
1306
|
+
onTextChange={t => patchOption(opt.id, { text: t })}
|
|
1307
|
+
onToggleCorrect={() => toggleCorrect(opt.id)}
|
|
1308
|
+
onRationaleChange={r => patchOption(opt.id, { rationale: r })}
|
|
1309
|
+
onRemove={() => removeOption(opt.id)}
|
|
1310
|
+
/>
|
|
1311
|
+
))}
|
|
1312
|
+
</div>
|
|
1313
|
+
{!isTrueFalse ? (
|
|
1314
|
+
<Button
|
|
1315
|
+
type="button"
|
|
1316
|
+
variant="outline"
|
|
1317
|
+
size="sm"
|
|
1318
|
+
onClick={addOption}
|
|
1319
|
+
disabled={watchedOptions.length >= AUTHORING_MAX_OPTION_COUNT}
|
|
1320
|
+
className="self-start"
|
|
1321
|
+
>
|
|
1322
|
+
<i className="fa-light fa-plus" aria-hidden="true" />
|
|
1323
|
+
Add option
|
|
1324
|
+
</Button>
|
|
1325
|
+
) : null}
|
|
1326
|
+
<FormMessage />
|
|
1327
|
+
</BuilderSection>
|
|
1328
|
+
</FormItem>
|
|
1329
|
+
)}
|
|
1330
|
+
/>
|
|
1331
|
+
) : null}
|
|
1332
|
+
|
|
1333
|
+
{/* Numeric — value, tolerance, units. */}
|
|
1334
|
+
{isNumeric ? (
|
|
1335
|
+
<BuilderSection
|
|
1336
|
+
title="Correct value"
|
|
1337
|
+
required
|
|
1338
|
+
hint="Auto-graded on submit — accept ± tolerance band"
|
|
1339
|
+
>
|
|
1340
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)]">
|
|
1341
|
+
<FormField
|
|
1342
|
+
control={form.control}
|
|
1343
|
+
name="numericValue"
|
|
1344
|
+
render={({ field }) => (
|
|
1345
|
+
<FormItem className="gap-1.5">
|
|
1346
|
+
<Label htmlFor="qb-numeric-value" className="text-xs text-muted-foreground">
|
|
1347
|
+
Answer
|
|
1348
|
+
</Label>
|
|
1349
|
+
<FormControl>
|
|
1350
|
+
<Input
|
|
1351
|
+
{...field}
|
|
1352
|
+
id="qb-numeric-value"
|
|
1353
|
+
inputMode="decimal"
|
|
1354
|
+
placeholder="e.g. 12"
|
|
1355
|
+
/>
|
|
1356
|
+
</FormControl>
|
|
1357
|
+
<FormMessage />
|
|
1358
|
+
</FormItem>
|
|
1359
|
+
)}
|
|
1360
|
+
/>
|
|
1361
|
+
<FormField
|
|
1362
|
+
control={form.control}
|
|
1363
|
+
name="numericTolerance"
|
|
1364
|
+
render={({ field }) => (
|
|
1365
|
+
<FormItem className="gap-1.5">
|
|
1366
|
+
<Label htmlFor="qb-numeric-tol" className="text-xs text-muted-foreground">
|
|
1367
|
+
Tolerance ±
|
|
1368
|
+
</Label>
|
|
1369
|
+
<FormControl>
|
|
1370
|
+
<Input
|
|
1371
|
+
{...field}
|
|
1372
|
+
id="qb-numeric-tol"
|
|
1373
|
+
inputMode="decimal"
|
|
1374
|
+
placeholder="e.g. 0.5"
|
|
1375
|
+
/>
|
|
1376
|
+
</FormControl>
|
|
1377
|
+
<FormDescription className="text-[11px]">
|
|
1378
|
+
Absolute, in the same unit
|
|
1379
|
+
</FormDescription>
|
|
1380
|
+
</FormItem>
|
|
1381
|
+
)}
|
|
1382
|
+
/>
|
|
1383
|
+
<FormField
|
|
1384
|
+
control={form.control}
|
|
1385
|
+
name="numericUnits"
|
|
1386
|
+
render={({ field }) => (
|
|
1387
|
+
<FormItem className="gap-1.5">
|
|
1388
|
+
<Label htmlFor="qb-numeric-unit" className="text-xs text-muted-foreground">
|
|
1389
|
+
Units
|
|
1390
|
+
</Label>
|
|
1391
|
+
<FormControl>
|
|
1392
|
+
<Input
|
|
1393
|
+
{...field}
|
|
1394
|
+
id="qb-numeric-unit"
|
|
1395
|
+
placeholder="e.g. mEq/L"
|
|
1396
|
+
/>
|
|
1397
|
+
</FormControl>
|
|
1398
|
+
</FormItem>
|
|
1399
|
+
)}
|
|
1400
|
+
/>
|
|
1401
|
+
</div>
|
|
1402
|
+
</BuilderSection>
|
|
1403
|
+
) : null}
|
|
1404
|
+
|
|
1405
|
+
{/* Fill in the blank — accepted answers per blank. */}
|
|
1406
|
+
{isFillBlank ? (
|
|
1407
|
+
<BuilderSection
|
|
1408
|
+
title="Accepted answers"
|
|
1409
|
+
required
|
|
1410
|
+
hint={`${watchedFillBlankAnswers.length} blank${watchedFillBlankAnswers.length === 1 ? "" : "s"}`}
|
|
1411
|
+
>
|
|
1412
|
+
<p className="text-xs text-muted-foreground">
|
|
1413
|
+
One row per blank — separate accepted variants with a comma. Numbered
|
|
1414
|
+
to match the <code className="rounded bg-muted px-1 py-0.5 text-[11px]">{`{{1}}`}</code>{" "}
|
|
1415
|
+
markers in the lead-in.
|
|
1416
|
+
</p>
|
|
1417
|
+
<div className="space-y-2">
|
|
1418
|
+
{watchedFillBlankAnswers.map((ans, idx) => (
|
|
1419
|
+
<div key={ans.id} className="flex items-center gap-2">
|
|
1420
|
+
<span
|
|
1421
|
+
className="inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-medium text-muted-foreground tabular-nums"
|
|
1422
|
+
aria-hidden="true"
|
|
1423
|
+
>
|
|
1424
|
+
{idx + 1}
|
|
1425
|
+
</span>
|
|
1426
|
+
<Input
|
|
1427
|
+
value={ans.accepted}
|
|
1428
|
+
onChange={e => patchFillBlank(ans.id, e.target.value)}
|
|
1429
|
+
placeholder="e.g. STEMI, ST-elevation MI"
|
|
1430
|
+
aria-label={`Blank ${idx + 1} accepted answers`}
|
|
1431
|
+
/>
|
|
1432
|
+
{watchedFillBlankAnswers.length > 1 ? (
|
|
1433
|
+
<Tip side="top" label={`Remove blank ${idx + 1}`}>
|
|
1434
|
+
<Button
|
|
1435
|
+
type="button"
|
|
1436
|
+
variant="ghost"
|
|
1437
|
+
size="icon-sm"
|
|
1438
|
+
onClick={() => removeFillBlank(ans.id)}
|
|
1439
|
+
aria-label={`Remove blank ${idx + 1}`}
|
|
1440
|
+
>
|
|
1441
|
+
<i className="fa-light fa-xmark" aria-hidden="true" />
|
|
1442
|
+
</Button>
|
|
1443
|
+
</Tip>
|
|
1444
|
+
) : null}
|
|
1445
|
+
</div>
|
|
1446
|
+
))}
|
|
1447
|
+
</div>
|
|
1448
|
+
<Button
|
|
1449
|
+
type="button"
|
|
1450
|
+
variant="outline"
|
|
1451
|
+
size="sm"
|
|
1452
|
+
onClick={addFillBlank}
|
|
1453
|
+
className="self-start"
|
|
1454
|
+
>
|
|
1455
|
+
<i className="fa-light fa-plus" aria-hidden="true" />
|
|
1456
|
+
Add blank
|
|
1457
|
+
</Button>
|
|
1458
|
+
<FormMessage />
|
|
1459
|
+
</BuilderSection>
|
|
1460
|
+
) : null}
|
|
1461
|
+
|
|
1462
|
+
{/* Matching — pair editor. */}
|
|
1463
|
+
{isMatching ? (
|
|
1464
|
+
<BuilderSection
|
|
1465
|
+
title="Pairs"
|
|
1466
|
+
required
|
|
1467
|
+
hint={`${watchedPairs.length} pair${watchedPairs.length === 1 ? "" : "s"} · learners drag right side to match left`}
|
|
1468
|
+
>
|
|
1469
|
+
<div className="space-y-2">
|
|
1470
|
+
{watchedPairs.map((p, idx) => (
|
|
1471
|
+
<div
|
|
1472
|
+
key={p.id}
|
|
1473
|
+
className="grid grid-cols-1 items-center gap-2 sm:grid-cols-[24px_minmax(0,1fr)_24px_minmax(0,1fr)_auto]"
|
|
1474
|
+
>
|
|
1475
|
+
<span
|
|
1476
|
+
className="inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-medium text-muted-foreground tabular-nums"
|
|
1477
|
+
aria-hidden="true"
|
|
1478
|
+
>
|
|
1479
|
+
{idx + 1}
|
|
1480
|
+
</span>
|
|
1481
|
+
<Input
|
|
1482
|
+
value={p.left}
|
|
1483
|
+
onChange={e => patchPair(p.id, { left: e.target.value })}
|
|
1484
|
+
placeholder="Prompt — e.g. β-blocker"
|
|
1485
|
+
aria-label={`Pair ${idx + 1} prompt`}
|
|
1486
|
+
/>
|
|
1487
|
+
<i
|
|
1488
|
+
className="fa-light fa-arrow-right hidden text-muted-foreground/60 sm:block"
|
|
1489
|
+
aria-hidden="true"
|
|
1490
|
+
/>
|
|
1491
|
+
<Input
|
|
1492
|
+
value={p.right}
|
|
1493
|
+
onChange={e => patchPair(p.id, { right: e.target.value })}
|
|
1494
|
+
placeholder="Match — e.g. blocks β1 receptors"
|
|
1495
|
+
aria-label={`Pair ${idx + 1} match`}
|
|
1496
|
+
/>
|
|
1497
|
+
{watchedPairs.length > 2 ? (
|
|
1498
|
+
<Tip side="top" label={`Remove pair ${idx + 1}`}>
|
|
1499
|
+
<Button
|
|
1500
|
+
type="button"
|
|
1501
|
+
variant="ghost"
|
|
1502
|
+
size="icon-sm"
|
|
1503
|
+
onClick={() => removePair(p.id)}
|
|
1504
|
+
aria-label={`Remove pair ${idx + 1}`}
|
|
1505
|
+
>
|
|
1506
|
+
<i className="fa-light fa-xmark" aria-hidden="true" />
|
|
1507
|
+
</Button>
|
|
1508
|
+
</Tip>
|
|
1509
|
+
) : null}
|
|
1510
|
+
</div>
|
|
1511
|
+
))}
|
|
1512
|
+
</div>
|
|
1513
|
+
<Button
|
|
1514
|
+
type="button"
|
|
1515
|
+
variant="outline"
|
|
1516
|
+
size="sm"
|
|
1517
|
+
onClick={addPair}
|
|
1518
|
+
className="self-start"
|
|
1519
|
+
>
|
|
1520
|
+
<i className="fa-light fa-plus" aria-hidden="true" />
|
|
1521
|
+
Add pair
|
|
1522
|
+
</Button>
|
|
1523
|
+
<FormMessage />
|
|
1524
|
+
</BuilderSection>
|
|
1525
|
+
) : null}
|
|
1526
|
+
|
|
1527
|
+
{/* Ordering — ordered list editor with up/down moves. */}
|
|
1528
|
+
{isOrdering ? (
|
|
1529
|
+
<BuilderSection
|
|
1530
|
+
title="Items in correct order"
|
|
1531
|
+
required
|
|
1532
|
+
hint={`${watchedOrderedItems.length} step${watchedOrderedItems.length === 1 ? "" : "s"} · the order shown here is the answer key`}
|
|
1533
|
+
>
|
|
1534
|
+
<div className="space-y-2">
|
|
1535
|
+
{watchedOrderedItems.map((item, idx) => (
|
|
1536
|
+
<div
|
|
1537
|
+
key={item.id}
|
|
1538
|
+
className="flex items-center gap-2 rounded-lg border border-border px-2.5 py-2"
|
|
1539
|
+
>
|
|
1540
|
+
<span
|
|
1541
|
+
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md bg-[var(--icon-disc-brand-bg)] text-xs font-semibold text-[var(--icon-disc-brand-fg)] tabular-nums"
|
|
1542
|
+
aria-hidden="true"
|
|
1543
|
+
>
|
|
1544
|
+
{idx + 1}
|
|
1545
|
+
</span>
|
|
1546
|
+
<Input
|
|
1547
|
+
value={item.text}
|
|
1548
|
+
onChange={e => patchOrdered(item.id, e.target.value)}
|
|
1549
|
+
placeholder={`Step ${idx + 1} — e.g. Check responsiveness`}
|
|
1550
|
+
aria-label={`Step ${idx + 1}`}
|
|
1551
|
+
className="border-0 bg-transparent shadow-none focus-visible:ring-0"
|
|
1552
|
+
/>
|
|
1553
|
+
<div className="flex shrink-0 items-center gap-0.5">
|
|
1554
|
+
<Tip side="top" label="Move up">
|
|
1555
|
+
<Button
|
|
1556
|
+
type="button"
|
|
1557
|
+
variant="ghost"
|
|
1558
|
+
size="icon-sm"
|
|
1559
|
+
disabled={idx === 0}
|
|
1560
|
+
onClick={() => moveOrdered(item.id, -1)}
|
|
1561
|
+
aria-label={`Move step ${idx + 1} up`}
|
|
1562
|
+
>
|
|
1563
|
+
<i className="fa-light fa-arrow-up" aria-hidden="true" />
|
|
1564
|
+
</Button>
|
|
1565
|
+
</Tip>
|
|
1566
|
+
<Tip side="top" label="Move down">
|
|
1567
|
+
<Button
|
|
1568
|
+
type="button"
|
|
1569
|
+
variant="ghost"
|
|
1570
|
+
size="icon-sm"
|
|
1571
|
+
disabled={idx === watchedOrderedItems.length - 1}
|
|
1572
|
+
onClick={() => moveOrdered(item.id, 1)}
|
|
1573
|
+
aria-label={`Move step ${idx + 1} down`}
|
|
1574
|
+
>
|
|
1575
|
+
<i className="fa-light fa-arrow-down" aria-hidden="true" />
|
|
1576
|
+
</Button>
|
|
1577
|
+
</Tip>
|
|
1578
|
+
{watchedOrderedItems.length > 2 ? (
|
|
1579
|
+
<Tip side="top" label="Remove step">
|
|
1580
|
+
<Button
|
|
1581
|
+
type="button"
|
|
1582
|
+
variant="ghost"
|
|
1583
|
+
size="icon-sm"
|
|
1584
|
+
onClick={() => removeOrdered(item.id)}
|
|
1585
|
+
aria-label={`Remove step ${idx + 1}`}
|
|
1586
|
+
>
|
|
1587
|
+
<i className="fa-light fa-xmark" aria-hidden="true" />
|
|
1588
|
+
</Button>
|
|
1589
|
+
</Tip>
|
|
1590
|
+
) : null}
|
|
1591
|
+
</div>
|
|
1592
|
+
</div>
|
|
1593
|
+
))}
|
|
1594
|
+
</div>
|
|
1595
|
+
<Button
|
|
1596
|
+
type="button"
|
|
1597
|
+
variant="outline"
|
|
1598
|
+
size="sm"
|
|
1599
|
+
onClick={addOrdered}
|
|
1600
|
+
className="self-start"
|
|
1601
|
+
>
|
|
1602
|
+
<i className="fa-light fa-plus" aria-hidden="true" />
|
|
1603
|
+
Add step
|
|
1604
|
+
</Button>
|
|
1605
|
+
<FormMessage />
|
|
1606
|
+
</BuilderSection>
|
|
1607
|
+
) : null}
|
|
1608
|
+
|
|
1609
|
+
{/* Hotspot — placeholder image picker (full region drawing
|
|
1610
|
+
arrives with the asset pipeline; this is a clean empty state). */}
|
|
1611
|
+
{isHotspot ? (
|
|
1612
|
+
<BuilderSection title="Reference image" hint="Upload + draw correct regions">
|
|
1613
|
+
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border bg-muted/20 px-6 py-10 text-center">
|
|
1614
|
+
<div
|
|
1615
|
+
className="flex size-12 items-center justify-center rounded-full bg-background text-muted-foreground"
|
|
1616
|
+
aria-hidden="true"
|
|
1617
|
+
>
|
|
1618
|
+
<i className="fa-light fa-bullseye-pointer text-lg" />
|
|
1619
|
+
</div>
|
|
1620
|
+
<div className="max-w-md">
|
|
1621
|
+
<p className="text-sm font-medium text-foreground">
|
|
1622
|
+
Image hotspot authoring
|
|
1623
|
+
</p>
|
|
1624
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
1625
|
+
Upload an anatomy diagram, x-ray, or ECG, then draw the correct
|
|
1626
|
+
region(s). Image upload + region drawing tools arrive in the
|
|
1627
|
+
next phase — for now, describe the expected target in the
|
|
1628
|
+
explanation below.
|
|
1629
|
+
</p>
|
|
1630
|
+
</div>
|
|
1631
|
+
<Button type="button" variant="outline" size="sm" disabled>
|
|
1632
|
+
<i className="fa-light fa-arrow-up-from-bracket" aria-hidden="true" />
|
|
1633
|
+
Upload image (coming soon)
|
|
1634
|
+
</Button>
|
|
1635
|
+
</div>
|
|
1636
|
+
</BuilderSection>
|
|
1637
|
+
) : null}
|
|
1638
|
+
|
|
1639
|
+
<FormField
|
|
1640
|
+
control={form.control}
|
|
1641
|
+
name="rationale"
|
|
1642
|
+
render={({ field }) => (
|
|
1643
|
+
<FormItem>
|
|
1644
|
+
<BuilderSection
|
|
1645
|
+
title={
|
|
1646
|
+
isShortAnswer
|
|
1647
|
+
? "Model answer"
|
|
1648
|
+
: isEssay
|
|
1649
|
+
? "Grading rubric"
|
|
1650
|
+
: "Explanation & rationale"
|
|
1651
|
+
}
|
|
1652
|
+
required={isShortAnswer || isEssay}
|
|
1653
|
+
hint={
|
|
1654
|
+
isShortAnswer
|
|
1655
|
+
? "Canonical answer + accepted variants"
|
|
1656
|
+
: isEssay
|
|
1657
|
+
? "Criteria reviewers use to score"
|
|
1658
|
+
: "Cite mechanism, guideline, or reference"
|
|
1659
|
+
}
|
|
1660
|
+
>
|
|
1661
|
+
<FormControl>
|
|
1662
|
+
<Textarea
|
|
1663
|
+
{...field}
|
|
1664
|
+
placeholder={
|
|
1665
|
+
isShortAnswer
|
|
1666
|
+
? "e.g. 50 mg/dL\nAccepted: 50, 50.0, fifty milligrams per deciliter"
|
|
1667
|
+
: isEssay
|
|
1668
|
+
? "Full marks (4): identifies DKA, orders ABG + serum ketones, starts isotonic fluids, replaces K+ before insulin.\nPartial (2-3): identifies DKA but misses K+ replacement.\nMinimal (0-1): misses DKA diagnosis."
|
|
1669
|
+
: AUTHORING_RATIONALE_PLACEHOLDER
|
|
1670
|
+
}
|
|
1671
|
+
rows={isEssay ? 7 : 5}
|
|
1672
|
+
className={cn(
|
|
1673
|
+
"resize-y leading-relaxed",
|
|
1674
|
+
isEssay ? "min-h-[160px]" : "min-h-[120px]",
|
|
1675
|
+
)}
|
|
1676
|
+
/>
|
|
1677
|
+
</FormControl>
|
|
1678
|
+
<FormMessage />
|
|
1679
|
+
</BuilderSection>
|
|
1680
|
+
</FormItem>
|
|
1681
|
+
)}
|
|
1682
|
+
/>
|
|
1683
|
+
|
|
1684
|
+
{!isShortAnswer && !isEssay ? (
|
|
1685
|
+
<FormField
|
|
1686
|
+
control={form.control}
|
|
1687
|
+
name="references"
|
|
1688
|
+
render={({ field }) => (
|
|
1689
|
+
<FormItem>
|
|
1690
|
+
<BuilderSection
|
|
1691
|
+
title="References"
|
|
1692
|
+
hint={
|
|
1693
|
+
field.value.length === 0
|
|
1694
|
+
? "Optional"
|
|
1695
|
+
: `${field.value.length} cited`
|
|
1696
|
+
}
|
|
1697
|
+
>
|
|
1698
|
+
{field.value.length === 0 ? (
|
|
1699
|
+
<FormDescription>
|
|
1700
|
+
Add Harrison's, UpToDate, AHA / ATS guidelines, or
|
|
1701
|
+
a journal article a reviewer can verify against.
|
|
1702
|
+
</FormDescription>
|
|
1703
|
+
) : (
|
|
1704
|
+
<div className="space-y-2">
|
|
1705
|
+
{field.value.map((r, idx) => (
|
|
1706
|
+
<div key={r.id} className="flex items-center gap-2">
|
|
1707
|
+
<span
|
|
1708
|
+
className="inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-medium text-muted-foreground"
|
|
1709
|
+
aria-hidden="true"
|
|
1710
|
+
>
|
|
1711
|
+
{idx + 1}
|
|
1712
|
+
</span>
|
|
1713
|
+
<Input
|
|
1714
|
+
value={r.citation}
|
|
1715
|
+
onChange={e =>
|
|
1716
|
+
form.setValue(
|
|
1717
|
+
"references",
|
|
1718
|
+
field.value.map(x =>
|
|
1719
|
+
x.id === r.id
|
|
1720
|
+
? { ...x, citation: e.target.value }
|
|
1721
|
+
: x,
|
|
1722
|
+
),
|
|
1723
|
+
{ shouldDirty: true },
|
|
1724
|
+
)
|
|
1725
|
+
}
|
|
1726
|
+
placeholder='e.g. Harrison's Internal Medicine, 21st ed., ch. 269'
|
|
1727
|
+
aria-label={`Reference ${idx + 1} citation`}
|
|
1728
|
+
/>
|
|
1729
|
+
<Tip side="top" label="Remove reference">
|
|
1730
|
+
<Button
|
|
1731
|
+
type="button"
|
|
1732
|
+
variant="ghost"
|
|
1733
|
+
size="icon-sm"
|
|
1734
|
+
onClick={() => removeReference(r.id)}
|
|
1735
|
+
aria-label={`Remove reference ${idx + 1}`}
|
|
1736
|
+
>
|
|
1737
|
+
<i className="fa-light fa-xmark" aria-hidden="true" />
|
|
1738
|
+
</Button>
|
|
1739
|
+
</Tip>
|
|
1740
|
+
</div>
|
|
1741
|
+
))}
|
|
1742
|
+
</div>
|
|
1743
|
+
)}
|
|
1744
|
+
<Button
|
|
1745
|
+
type="button"
|
|
1746
|
+
variant="outline"
|
|
1747
|
+
size="sm"
|
|
1748
|
+
onClick={addReference}
|
|
1749
|
+
className="self-start"
|
|
1750
|
+
>
|
|
1751
|
+
<i className="fa-light fa-plus" aria-hidden="true" />
|
|
1752
|
+
Add reference
|
|
1753
|
+
</Button>
|
|
1754
|
+
<FormMessage />
|
|
1755
|
+
</BuilderSection>
|
|
1756
|
+
</FormItem>
|
|
1757
|
+
)}
|
|
1758
|
+
/>
|
|
1759
|
+
) : null}
|
|
1760
|
+
</div>
|
|
1761
|
+
|
|
1762
|
+
{/* ── Inspector (right rail) — sticky while the page scrolls (lg+) */}
|
|
1763
|
+
<aside
|
|
1764
|
+
aria-label="Question inspector"
|
|
1765
|
+
className={cn(
|
|
1766
|
+
"shrink-0",
|
|
1767
|
+
"lg:pl-2",
|
|
1768
|
+
inspectorOpen ? "lg:w-[320px]" : "lg:w-14",
|
|
1769
|
+
)}
|
|
1770
|
+
>
|
|
1771
|
+
{!inspectorOpen ? (
|
|
1772
|
+
<div
|
|
1773
|
+
className={cn(
|
|
1774
|
+
"flex w-12 flex-col items-center gap-1 rounded-xl bg-[var(--secondary-panel-bg)] px-1.5 py-2 ring-1 ring-border shadow-sm",
|
|
1775
|
+
"lg:sticky lg:top-4 lg:z-10",
|
|
1776
|
+
)}
|
|
1777
|
+
>
|
|
1778
|
+
<Tip side="left" label="Show inspector">
|
|
1779
|
+
<button
|
|
1780
|
+
type="button"
|
|
1781
|
+
onClick={() => setInspectorOpen(true)}
|
|
1782
|
+
aria-label="Show inspector"
|
|
1783
|
+
aria-expanded={false}
|
|
1784
|
+
className={cn(
|
|
1785
|
+
"flex size-9 shrink-0 items-center justify-center rounded-md text-sidebar-foreground transition-colors",
|
|
1786
|
+
"hover:bg-sidebar-accent/50",
|
|
1787
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
|
1788
|
+
)}
|
|
1789
|
+
>
|
|
1790
|
+
<i
|
|
1791
|
+
className="fa-light fa-arrow-left-to-line text-[15px] leading-none"
|
|
1792
|
+
aria-hidden="true"
|
|
1793
|
+
/>
|
|
1794
|
+
</button>
|
|
1795
|
+
</Tip>
|
|
1796
|
+
</div>
|
|
1797
|
+
) : (
|
|
1798
|
+
<div
|
|
1799
|
+
className={cn(
|
|
1800
|
+
"flex flex-col gap-6 rounded-xl border border-border bg-card p-4",
|
|
1801
|
+
"lg:sticky lg:top-4 lg:z-10",
|
|
1802
|
+
"lg:max-h-[calc(100dvh-var(--header-height)-2rem)] lg:overflow-y-auto lg:overscroll-y-contain",
|
|
1803
|
+
)}
|
|
1804
|
+
>
|
|
1805
|
+
{/* Inspector header — title + collapse control. */}
|
|
1806
|
+
<div className="flex items-center justify-between">
|
|
1807
|
+
<p className="text-xs font-medium text-muted-foreground">
|
|
1808
|
+
Inspector
|
|
1809
|
+
</p>
|
|
1810
|
+
<Tip side="bottom" label="Hide inspector">
|
|
1811
|
+
<Button
|
|
1812
|
+
type="button"
|
|
1813
|
+
variant="ghost"
|
|
1814
|
+
size="icon-sm"
|
|
1815
|
+
onClick={() => setInspectorOpen(false)}
|
|
1816
|
+
aria-label="Hide inspector"
|
|
1817
|
+
aria-expanded={true}
|
|
1818
|
+
>
|
|
1819
|
+
<i
|
|
1820
|
+
className="fa-light fa-arrow-right-to-line"
|
|
1821
|
+
aria-hidden="true"
|
|
1822
|
+
/>
|
|
1823
|
+
</Button>
|
|
1824
|
+
</Tip>
|
|
1825
|
+
</div>
|
|
1826
|
+
|
|
1827
|
+
{/* Question format — first-time landing shows the full
|
|
1828
|
+
`SelectionTileGrid` (matches the "File format" pattern
|
|
1829
|
+
in `ExportDrawer`). After the author picks once it
|
|
1830
|
+
collapses into a single selected-type tile with a
|
|
1831
|
+
"Change" affordance that re-opens the grid. */}
|
|
1832
|
+
<FormField
|
|
1833
|
+
control={form.control}
|
|
1834
|
+
name="type"
|
|
1835
|
+
render={({ field }) => (
|
|
1836
|
+
<FormItem>
|
|
1837
|
+
<FormControl>
|
|
1838
|
+
{typeChooserOpen ? (
|
|
1839
|
+
<SelectionTileGrid
|
|
1840
|
+
sectionLabel="Question format"
|
|
1841
|
+
options={QUESTION_TYPE_TILES}
|
|
1842
|
+
columns={2}
|
|
1843
|
+
value={field.value}
|
|
1844
|
+
onValueChange={v => changeType(v)}
|
|
1845
|
+
interaction="radio"
|
|
1846
|
+
idPrefix="qb-format"
|
|
1847
|
+
itemVariant="outline"
|
|
1848
|
+
itemMotion="pop"
|
|
1849
|
+
/>
|
|
1850
|
+
) : (
|
|
1851
|
+
<div className="flex flex-col gap-2">
|
|
1852
|
+
<Label
|
|
1853
|
+
className="text-xs font-medium text-muted-foreground"
|
|
1854
|
+
>
|
|
1855
|
+
Question format
|
|
1856
|
+
</Label>
|
|
1857
|
+
<button
|
|
1858
|
+
type="button"
|
|
1859
|
+
onClick={() => setTypeChooserOpen(true)}
|
|
1860
|
+
aria-label={`Change question format — currently ${activeType.label}`}
|
|
1861
|
+
className={cn(
|
|
1862
|
+
"group flex items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 text-left transition-colors",
|
|
1863
|
+
"hover:border-foreground/30 hover:bg-muted/40",
|
|
1864
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
1865
|
+
)}
|
|
1866
|
+
>
|
|
1867
|
+
<span
|
|
1868
|
+
className="inline-flex size-9 shrink-0 items-center justify-center rounded-md bg-[var(--icon-disc-brand-bg)] text-[var(--icon-disc-brand-fg)]"
|
|
1869
|
+
aria-hidden="true"
|
|
1870
|
+
>
|
|
1871
|
+
<i className={cn("fa-light text-base", activeType.icon)} />
|
|
1872
|
+
</span>
|
|
1873
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
1874
|
+
<span className="truncate text-sm font-medium text-foreground">
|
|
1875
|
+
{activeType.label}
|
|
1876
|
+
</span>
|
|
1877
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
1878
|
+
{activeType.tileSummary ?? activeType.description}
|
|
1879
|
+
</span>
|
|
1880
|
+
</span>
|
|
1881
|
+
<span
|
|
1882
|
+
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors group-hover:bg-muted group-hover:text-foreground"
|
|
1883
|
+
aria-hidden="true"
|
|
1884
|
+
>
|
|
1885
|
+
<i className="fa-light fa-pen-to-square text-xs" />
|
|
1886
|
+
</span>
|
|
1887
|
+
</button>
|
|
1888
|
+
</div>
|
|
1889
|
+
)}
|
|
1890
|
+
</FormControl>
|
|
1891
|
+
{typeChooserOpen ? (
|
|
1892
|
+
<FormDescription>{activeType.description}</FormDescription>
|
|
1893
|
+
) : null}
|
|
1894
|
+
<FormMessage />
|
|
1895
|
+
</FormItem>
|
|
1896
|
+
)}
|
|
1897
|
+
/>
|
|
1898
|
+
|
|
1899
|
+
{/* Location — compact selected tile (mirrors the
|
|
1900
|
+
collapsed question-format card). Click opens a Command
|
|
1901
|
+
popover with search and an "Add new folder" footer
|
|
1902
|
+
that bridges into `QuestionBankNewFolderSheet`. */}
|
|
1903
|
+
<FormField
|
|
1904
|
+
control={form.control}
|
|
1905
|
+
name="folderId"
|
|
1906
|
+
render={({ field }) => (
|
|
1907
|
+
<FormItem>
|
|
1908
|
+
<Label
|
|
1909
|
+
className="text-xs font-medium text-muted-foreground"
|
|
1910
|
+
>
|
|
1911
|
+
Location
|
|
1912
|
+
</Label>
|
|
1913
|
+
<FormControl>
|
|
1914
|
+
<FolderPickerControl
|
|
1915
|
+
folders={localFolders}
|
|
1916
|
+
value={field.value}
|
|
1917
|
+
onChange={v => field.onChange(v)}
|
|
1918
|
+
open={folderPickerOpen}
|
|
1919
|
+
onOpenChange={setFolderPickerOpen}
|
|
1920
|
+
onRequestNewFolder={() => {
|
|
1921
|
+
setFolderPickerOpen(false)
|
|
1922
|
+
setNewFolderOpen(true)
|
|
1923
|
+
}}
|
|
1924
|
+
/>
|
|
1925
|
+
</FormControl>
|
|
1926
|
+
<FormMessage />
|
|
1927
|
+
</FormItem>
|
|
1928
|
+
)}
|
|
1929
|
+
/>
|
|
1930
|
+
|
|
1931
|
+
{/* Difficulty — meter + AI estimate + PBI + folder note.
|
|
1932
|
+
Defaults to AI mode (the meter follows the folder
|
|
1933
|
+
recommendation); "Override" flips to manual chips for
|
|
1934
|
+
authors who want to lock the level themselves. */}
|
|
1935
|
+
<FormField
|
|
1936
|
+
control={form.control}
|
|
1937
|
+
name="difficulty"
|
|
1938
|
+
render={({ field }) => (
|
|
1939
|
+
<DifficultyMeter
|
|
1940
|
+
value={field.value}
|
|
1941
|
+
onChange={v => field.onChange(v)}
|
|
1942
|
+
mode={difficultyMode}
|
|
1943
|
+
onModeChange={setDifficultyMode}
|
|
1944
|
+
insight={difficultyInsight}
|
|
1945
|
+
/>
|
|
1946
|
+
)}
|
|
1947
|
+
/>
|
|
1948
|
+
|
|
1949
|
+
<FormField
|
|
1950
|
+
control={form.control}
|
|
1951
|
+
name="bloom"
|
|
1952
|
+
render={({ field }) => (
|
|
1953
|
+
<InspectorSection title="Bloom's taxonomy">
|
|
1954
|
+
<ToggleGroup
|
|
1955
|
+
type="single"
|
|
1956
|
+
variant="outline"
|
|
1957
|
+
size="sm"
|
|
1958
|
+
spacing={1}
|
|
1959
|
+
value={field.value}
|
|
1960
|
+
onValueChange={v => field.onChange(v)}
|
|
1961
|
+
className="flex-wrap"
|
|
1962
|
+
>
|
|
1963
|
+
{AUTHORING_BLOOM_OPTIONS.map(b => (
|
|
1964
|
+
<ToggleGroupItem
|
|
1965
|
+
key={b.value}
|
|
1966
|
+
value={b.value}
|
|
1967
|
+
title={b.hint}
|
|
1968
|
+
className="rounded-full px-3"
|
|
1969
|
+
>
|
|
1970
|
+
{b.label}
|
|
1971
|
+
</ToggleGroupItem>
|
|
1972
|
+
))}
|
|
1973
|
+
</ToggleGroup>
|
|
1974
|
+
</InspectorSection>
|
|
1975
|
+
)}
|
|
1976
|
+
/>
|
|
1977
|
+
|
|
1978
|
+
<FormField
|
|
1979
|
+
control={form.control}
|
|
1980
|
+
name="cogLevel"
|
|
1981
|
+
render={({ field }) => (
|
|
1982
|
+
<InspectorSection
|
|
1983
|
+
title="NBME cognitive level"
|
|
1984
|
+
description="Distinct from Bloom — broader buckets used by NBME item writers."
|
|
1985
|
+
>
|
|
1986
|
+
<ToggleGroup
|
|
1987
|
+
type="single"
|
|
1988
|
+
variant="outline"
|
|
1989
|
+
size="sm"
|
|
1990
|
+
spacing={1}
|
|
1991
|
+
value={field.value}
|
|
1992
|
+
onValueChange={v => field.onChange(v)}
|
|
1993
|
+
className="flex-wrap"
|
|
1994
|
+
>
|
|
1995
|
+
{AUTHORING_COG_LEVEL_OPTIONS.map(c => (
|
|
1996
|
+
<ToggleGroupItem
|
|
1997
|
+
key={c.value}
|
|
1998
|
+
value={c.value}
|
|
1999
|
+
title={c.hint}
|
|
2000
|
+
className="rounded-full px-3"
|
|
2001
|
+
>
|
|
2002
|
+
{c.label}
|
|
2003
|
+
</ToggleGroupItem>
|
|
2004
|
+
))}
|
|
2005
|
+
</ToggleGroup>
|
|
2006
|
+
</InspectorSection>
|
|
2007
|
+
)}
|
|
2008
|
+
/>
|
|
2009
|
+
|
|
2010
|
+
<InspectorSection title="Tags" htmlFor="qb-tag-input">
|
|
2011
|
+
{watchedTags.length > 0 ? (
|
|
2012
|
+
<div className="flex flex-wrap gap-1.5">
|
|
2013
|
+
{watchedTags.map(t => (
|
|
2014
|
+
<Badge key={t} variant="secondary" className="gap-1.5">
|
|
2015
|
+
<span>#{t}</span>
|
|
2016
|
+
<Tip side="top" label={`Remove tag ${t}`}>
|
|
2017
|
+
<button
|
|
2018
|
+
type="button"
|
|
2019
|
+
onClick={() => removeTag(t)}
|
|
2020
|
+
aria-label={`Remove tag ${t}`}
|
|
2021
|
+
className="-mr-0.5 inline-flex size-3.5 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
2022
|
+
>
|
|
2023
|
+
<i
|
|
2024
|
+
className="fa-light fa-xmark text-[9px]"
|
|
2025
|
+
aria-hidden="true"
|
|
2026
|
+
/>
|
|
2027
|
+
</button>
|
|
2028
|
+
</Tip>
|
|
2029
|
+
</Badge>
|
|
2030
|
+
))}
|
|
2031
|
+
</div>
|
|
2032
|
+
) : null}
|
|
2033
|
+
<Input
|
|
2034
|
+
id="qb-tag-input"
|
|
2035
|
+
value={tagDraft}
|
|
2036
|
+
onChange={e => setTagDraft(e.target.value)}
|
|
2037
|
+
onKeyDown={e => {
|
|
2038
|
+
if (e.key === "Enter" || e.key === ",") {
|
|
2039
|
+
e.preventDefault()
|
|
2040
|
+
commitTag()
|
|
2041
|
+
}
|
|
2042
|
+
}}
|
|
2043
|
+
onBlur={commitTag}
|
|
2044
|
+
placeholder="STEMI, antibiotics…"
|
|
2045
|
+
className="h-8 text-xs"
|
|
2046
|
+
/>
|
|
2047
|
+
</InspectorSection>
|
|
2048
|
+
</div>
|
|
2049
|
+
)}
|
|
2050
|
+
</aside>
|
|
2051
|
+
</div>
|
|
2052
|
+
</form>
|
|
2053
|
+
|
|
2054
|
+
{/* New folder — invoked from the location picker. Re-uses the
|
|
2055
|
+
shared `QuestionBankNewFolderSheet` (same shell as the folder
|
|
2056
|
+
hub) so the surface stays consistent. The created folder is
|
|
2057
|
+
appended to `localFolders` and immediately selected. */}
|
|
2058
|
+
<QuestionBankNewFolderSheet
|
|
2059
|
+
open={newFolderOpen}
|
|
2060
|
+
onOpenChange={setNewFolderOpen}
|
|
2061
|
+
parentFolderId={null}
|
|
2062
|
+
descriptionText="Drafts created from this composer can land in the new folder right away."
|
|
2063
|
+
onCreated={f => {
|
|
2064
|
+
const id = newFolderId()
|
|
2065
|
+
const created: QuestionBankFolder = { id, ...f }
|
|
2066
|
+
setLocalFolders(prev => [...prev, created])
|
|
2067
|
+
form.setValue("folderId", id, { shouldDirty: true, shouldValidate: false })
|
|
2068
|
+
}}
|
|
2069
|
+
/>
|
|
2070
|
+
</Form>
|
|
2071
|
+
)
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2075
|
+
// Option row — composes Checkbox + Input + Textarea, with the same
|
|
2076
|
+
// "ticked-row" treatment the placement form's compliance checklist uses.
|
|
2077
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2078
|
+
|
|
2079
|
+
interface OptionRowProps {
|
|
2080
|
+
letter: string
|
|
2081
|
+
option: { id: string; text: string; isCorrect: boolean; rationale: string }
|
|
2082
|
+
locked: boolean
|
|
2083
|
+
canRemove: boolean
|
|
2084
|
+
onTextChange: (t: string) => void
|
|
2085
|
+
onToggleCorrect: () => void
|
|
2086
|
+
onRationaleChange: (t: string) => void
|
|
2087
|
+
onRemove: () => void
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function OptionRow({
|
|
2091
|
+
letter,
|
|
2092
|
+
option,
|
|
2093
|
+
locked,
|
|
2094
|
+
canRemove,
|
|
2095
|
+
onTextChange,
|
|
2096
|
+
onToggleCorrect,
|
|
2097
|
+
onRationaleChange,
|
|
2098
|
+
onRemove,
|
|
2099
|
+
}: OptionRowProps) {
|
|
2100
|
+
const [rationaleOpen, setRationaleOpen] = React.useState(option.rationale.length > 0)
|
|
2101
|
+
const checkboxId = `qb-opt-${option.id}-correct`
|
|
2102
|
+
|
|
2103
|
+
return (
|
|
2104
|
+
<div
|
|
2105
|
+
className={cn(
|
|
2106
|
+
"flex flex-col gap-2 rounded-lg border border-border px-3 py-2.5 transition-colors",
|
|
2107
|
+
"has-[[data-state=checked]]:border-brand has-[[data-state=checked]]:bg-[var(--icon-disc-brand-bg)]",
|
|
2108
|
+
)}
|
|
2109
|
+
>
|
|
2110
|
+
<div className="flex items-center gap-3">
|
|
2111
|
+
<span
|
|
2112
|
+
className={cn(
|
|
2113
|
+
"inline-flex size-8 shrink-0 items-center justify-center rounded-md text-sm font-semibold",
|
|
2114
|
+
option.isCorrect
|
|
2115
|
+
? "bg-[var(--icon-disc-brand-bg)] text-[var(--icon-disc-brand-fg)]"
|
|
2116
|
+
: "bg-muted text-muted-foreground",
|
|
2117
|
+
)}
|
|
2118
|
+
aria-hidden="true"
|
|
2119
|
+
>
|
|
2120
|
+
{letter}
|
|
2121
|
+
</span>
|
|
2122
|
+
|
|
2123
|
+
<Input
|
|
2124
|
+
value={option.text}
|
|
2125
|
+
onChange={e => onTextChange(e.target.value)}
|
|
2126
|
+
placeholder={
|
|
2127
|
+
option.isCorrect
|
|
2128
|
+
? "Correct answer — phrased the way the SME would defend it"
|
|
2129
|
+
: "Plausible distractor — same length and grammar as the correct answer"
|
|
2130
|
+
}
|
|
2131
|
+
disabled={locked}
|
|
2132
|
+
aria-label={`Option ${letter} text`}
|
|
2133
|
+
/* Option text reads as h2 — same size as a section heading
|
|
2134
|
+
so the answer choices stand out from the meta chrome. */
|
|
2135
|
+
className="h-11 text-base font-medium md:text-lg"
|
|
2136
|
+
/>
|
|
2137
|
+
|
|
2138
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
2139
|
+
<div className="flex items-center gap-2 rounded-md px-2 py-1">
|
|
2140
|
+
<Checkbox
|
|
2141
|
+
id={checkboxId}
|
|
2142
|
+
checked={option.isCorrect}
|
|
2143
|
+
onCheckedChange={() => onToggleCorrect()}
|
|
2144
|
+
aria-label={
|
|
2145
|
+
option.isCorrect
|
|
2146
|
+
? `Option ${letter} marked correct`
|
|
2147
|
+
: `Mark option ${letter} correct`
|
|
2148
|
+
}
|
|
2149
|
+
/>
|
|
2150
|
+
<Label htmlFor={checkboxId} className="cursor-pointer text-xs font-medium">
|
|
2151
|
+
Correct
|
|
2152
|
+
</Label>
|
|
2153
|
+
</div>
|
|
2154
|
+
|
|
2155
|
+
{!locked ? (
|
|
2156
|
+
<Tip side="top" label={rationaleOpen ? "Hide rationale" : "Add rationale"}>
|
|
2157
|
+
<Button
|
|
2158
|
+
type="button"
|
|
2159
|
+
variant="ghost"
|
|
2160
|
+
size="icon-sm"
|
|
2161
|
+
onClick={() => setRationaleOpen(o => !o)}
|
|
2162
|
+
aria-expanded={rationaleOpen}
|
|
2163
|
+
aria-label={
|
|
2164
|
+
rationaleOpen
|
|
2165
|
+
? `Hide rationale for option ${letter}`
|
|
2166
|
+
: `Add rationale for option ${letter}`
|
|
2167
|
+
}
|
|
2168
|
+
>
|
|
2169
|
+
<i className="fa-light fa-message-pen" aria-hidden="true" />
|
|
2170
|
+
</Button>
|
|
2171
|
+
</Tip>
|
|
2172
|
+
) : null}
|
|
2173
|
+
|
|
2174
|
+
{canRemove ? (
|
|
2175
|
+
<Tip side="top" label="Remove option">
|
|
2176
|
+
<Button
|
|
2177
|
+
type="button"
|
|
2178
|
+
variant="ghost"
|
|
2179
|
+
size="icon-sm"
|
|
2180
|
+
onClick={onRemove}
|
|
2181
|
+
aria-label={`Remove option ${letter}`}
|
|
2182
|
+
>
|
|
2183
|
+
<i className="fa-light fa-xmark" aria-hidden="true" />
|
|
2184
|
+
</Button>
|
|
2185
|
+
</Tip>
|
|
2186
|
+
) : null}
|
|
2187
|
+
</div>
|
|
2188
|
+
</div>
|
|
2189
|
+
|
|
2190
|
+
{rationaleOpen && !locked ? (
|
|
2191
|
+
<div className="pl-10">
|
|
2192
|
+
<Textarea
|
|
2193
|
+
value={option.rationale}
|
|
2194
|
+
onChange={e => onRationaleChange(e.target.value)}
|
|
2195
|
+
placeholder={
|
|
2196
|
+
option.isCorrect
|
|
2197
|
+
? "Why this is the single best answer (mechanism, guideline)…"
|
|
2198
|
+
: "Why this distractor is plausible but wrong (common confusion)…"
|
|
2199
|
+
}
|
|
2200
|
+
rows={2}
|
|
2201
|
+
className="min-h-[64px] resize-y text-sm"
|
|
2202
|
+
aria-label={`Option ${letter} rationale`}
|
|
2203
|
+
/>
|
|
2204
|
+
</div>
|
|
2205
|
+
) : null}
|
|
2206
|
+
</div>
|
|
2207
|
+
)
|
|
2208
|
+
}
|