@exxatdesignux/ui 0.2.6 → 0.2.7

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.
Files changed (139) hide show
  1. package/package.json +2 -1
  2. package/template/.agents/skills/shadcn/SKILL.md +242 -0
  3. package/template/.agents/skills/shadcn/agents/openai.yml +5 -0
  4. package/template/.agents/skills/shadcn/assets/shadcn-small.png +0 -0
  5. package/template/.agents/skills/shadcn/assets/shadcn.png +0 -0
  6. package/template/.agents/skills/shadcn/cli.md +257 -0
  7. package/template/.agents/skills/shadcn/customization.md +202 -0
  8. package/template/.agents/skills/shadcn/evals/evals.json +47 -0
  9. package/template/.agents/skills/shadcn/mcp.md +94 -0
  10. package/template/.agents/skills/shadcn/rules/base-vs-radix.md +306 -0
  11. package/template/.agents/skills/shadcn/rules/composition.md +195 -0
  12. package/template/.agents/skills/shadcn/rules/forms.md +192 -0
  13. package/template/.agents/skills/shadcn/rules/icons.md +101 -0
  14. package/template/.agents/skills/shadcn/rules/styling.md +162 -0
  15. package/template/.claude/skills/exxat-ds-skill/SKILL.md +712 -0
  16. package/template/.cursor/rules/exxat-accessibility.mdc +33 -0
  17. package/template/.cursor/rules/exxat-command-menu.mdc +23 -0
  18. package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +53 -0
  19. package/template/.cursor/rules/exxat-data-tables.mdc +31 -0
  20. package/template/.cursor/rules/exxat-ds-agents.mdc +26 -0
  21. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +100 -0
  22. package/template/.cursor/rules/exxat-list-page-connected-views.mdc +16 -0
  23. package/template/.cursor/rules/exxat-no-toast.mdc +26 -0
  24. package/template/.cursor/rules/exxat-page-vs-drawer.mdc +22 -0
  25. package/template/.cursor/rules/exxat-table-properties-drawer.mdc +40 -0
  26. package/template/AGENTS.md +52 -11
  27. package/template/app/(app)/dashboard/page.tsx +1 -1
  28. package/template/app/(app)/data-list/[id]/page.tsx +24 -8
  29. package/template/app/(app)/data-list/new/page.tsx +7 -4
  30. package/template/app/(app)/data-list/page.tsx +1 -1
  31. package/template/app/(app)/examples/page.tsx +41 -0
  32. package/template/app/(app)/question-bank/page.tsx +3 -3
  33. package/template/app/globals.css +1 -1
  34. package/template/components/app-sidebar.tsx +52 -35
  35. package/template/components/compliance-table.tsx +79 -0
  36. package/template/components/data-list-client.tsx +36 -25
  37. package/template/components/data-list-table.tsx +797 -10
  38. package/template/components/data-views/finder-panel-view.tsx +405 -0
  39. package/template/components/data-views/folder-grid-view.tsx +86 -0
  40. package/template/components/data-views/index.ts +59 -0
  41. package/template/components/data-views/list-page-split-details-placeholder.tsx +39 -0
  42. package/template/components/data-views/list-page-split-hub-chrome.tsx +60 -0
  43. package/template/components/data-views/list-page-split-hub-tokens.ts +16 -0
  44. package/template/components/data-views/list-page-tree-column-header.tsx +31 -0
  45. package/template/components/data-views/list-page-tree-panel-shell.tsx +91 -0
  46. package/template/components/data-views/list-page-view-frame.tsx +53 -0
  47. package/template/components/data-views/os-folder-glyph.tsx +121 -0
  48. package/template/components/folder-details-shell.tsx +230 -0
  49. package/template/components/hub-tree-panel-view.tsx +672 -0
  50. package/template/components/list-hub-status-badge.tsx +17 -3
  51. package/template/components/placements-page-header.tsx +14 -8
  52. package/template/components/placements-table-columns.tsx +8 -8
  53. package/template/components/question-bank-client.tsx +157 -40
  54. package/template/components/question-bank-new-folder-sheet.tsx +248 -0
  55. package/template/components/question-bank-os-folder-view.tsx +648 -0
  56. package/template/components/question-bank-page-header.tsx +3 -3
  57. package/template/components/question-bank-panel-activator.tsx +9 -0
  58. package/template/components/question-bank-secondary-nav.tsx +226 -0
  59. package/template/components/question-bank-table.tsx +707 -22
  60. package/template/components/secondary-panel.tsx +41 -107
  61. package/template/components/sites-table.tsx +66 -0
  62. package/template/components/team-client.tsx +7 -0
  63. package/template/components/team-table.tsx +156 -1
  64. package/template/components/templates/list-page.tsx +2 -2
  65. package/template/components/ui/avatar.tsx +1 -1
  66. package/template/components/ui/badge.tsx +1 -1
  67. package/template/components/ui/banner.tsx +1 -1
  68. package/template/components/ui/breadcrumb.tsx +1 -1
  69. package/template/components/ui/button.tsx +1 -1
  70. package/template/components/ui/calendar.tsx +1 -1
  71. package/template/components/ui/card.tsx +1 -1
  72. package/template/components/ui/chart.tsx +1 -1
  73. package/template/components/ui/checkbox.tsx +1 -1
  74. package/template/components/ui/coach-mark.tsx +1 -1
  75. package/template/components/ui/collapsible.tsx +1 -1
  76. package/template/components/ui/command.tsx +1 -1
  77. package/template/components/ui/date-picker-field.tsx +1 -1
  78. package/template/components/ui/dialog.tsx +1 -1
  79. package/template/components/ui/drag-handle-grip.tsx +1 -1
  80. package/template/components/ui/drawer.tsx +1 -1
  81. package/template/components/ui/dropdown-menu.tsx +1 -1
  82. package/template/components/ui/field.tsx +1 -1
  83. package/template/components/ui/form.tsx +1 -1
  84. package/template/components/ui/input-group.tsx +1 -1
  85. package/template/components/ui/input-mask.tsx +1 -1
  86. package/template/components/ui/input.tsx +1 -1
  87. package/template/components/ui/kbd.tsx +1 -1
  88. package/template/components/ui/label.tsx +1 -1
  89. package/template/components/ui/payment-card-fields.tsx +1 -1
  90. package/template/components/ui/popover.tsx +1 -1
  91. package/template/components/ui/radio-group.tsx +1 -1
  92. package/template/components/ui/resizable.tsx +68 -0
  93. package/template/components/ui/select.tsx +1 -1
  94. package/template/components/ui/selection-tile-grid.tsx +1 -1
  95. package/template/components/ui/separator.tsx +1 -1
  96. package/template/components/ui/sheet.tsx +1 -1
  97. package/template/components/ui/sidebar.tsx +1 -1
  98. package/template/components/ui/skeleton.tsx +1 -1
  99. package/template/components/ui/sonner.tsx +1 -1
  100. package/template/components/ui/status-badge.tsx +1 -1
  101. package/template/components/ui/table.tsx +1 -1
  102. package/template/components/ui/tabs.tsx +1 -1
  103. package/template/components/ui/textarea.tsx +1 -1
  104. package/template/components/ui/tip.tsx +1 -1
  105. package/template/components/ui/toggle-group.tsx +1 -1
  106. package/template/components/ui/toggle-switch.tsx +1 -1
  107. package/template/components/ui/toggle.tsx +1 -1
  108. package/template/components/ui/tooltip.tsx +1 -1
  109. package/template/components/ui/view-segmented-control.tsx +1 -1
  110. package/template/docs/data-views-pattern.md +7 -0
  111. package/template/hooks/use-app-theme.ts +1 -1
  112. package/template/hooks/use-coach-mark.ts +1 -1
  113. package/template/hooks/use-location-hash.ts +15 -0
  114. package/template/hooks/use-mobile.ts +1 -1
  115. package/template/hooks/use-mod-key-label.ts +1 -1
  116. package/template/hooks/use-sidebar-reflow-zoom.ts +40 -0
  117. package/template/lib/ask-leo-route-context.ts +25 -57
  118. package/template/lib/coach-mark-registry.ts +13 -13
  119. package/template/lib/command-menu-config.ts +28 -23
  120. package/template/lib/command-menu-search-data.ts +10 -9
  121. package/template/lib/data-list-view-surface.ts +12 -1
  122. package/template/lib/data-list-view.ts +6 -3
  123. package/template/lib/date-filter.ts +1 -1
  124. package/template/lib/mock/dashboard.ts +11 -11
  125. package/template/lib/mock/navigation.tsx +22 -63
  126. package/template/lib/mock/placements-kpi.ts +19 -19
  127. package/template/lib/mock/question-bank-folders.ts +167 -0
  128. package/template/lib/mock/question-bank-inspector.ts +109 -0
  129. package/template/lib/mock/question-bank-kpi.ts +1 -1
  130. package/template/lib/mock/question-bank.ts +80 -0
  131. package/template/lib/question-bank-nav.ts +91 -0
  132. package/template/lib/utils.ts +1 -1
  133. package/template/next.config.mjs +8 -0
  134. package/template/package.json +1 -0
  135. package/template/public/folders/icons8-folder-windows-11.svg +1 -0
  136. package/template/app/(app)/compliance/page.tsx +0 -10
  137. package/template/app/(app)/rotations/page.tsx +0 -15
  138. package/template/app/(app)/sites/all/page.tsx +0 -13
  139. package/template/app/(app)/team/page.tsx +0 -10
@@ -0,0 +1,672 @@
1
+ "use client"
2
+
3
+ /**
4
+ * **Tree view** — `ListPageTreePanelShell` + outline tree + read-only details (`FolderDetailsShell`).
5
+ * Hub wiring (folders, mock sheet) stays in the caller; this module hosts question-bank demo wiring only.
6
+ */
7
+
8
+ import * as React from "react"
9
+ import {
10
+ ArrowRightLeft,
11
+ CheckCircle2Icon,
12
+ ChevronRightIcon,
13
+ FileIcon,
14
+ FolderIcon,
15
+ Hourglass,
16
+ PencilLine,
17
+ X,
18
+ } from "lucide-react"
19
+ import { ListHubStatusBadge, LIST_HUB_INSPECTOR_CHIP_SHELL } from "@/components/list-hub-status-badge"
20
+ import { Badge } from "@/components/ui/badge"
21
+ import { Button } from "@/components/ui/button"
22
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
23
+ import { Separator } from "@/components/ui/separator"
24
+ import { Tip } from "@/components/ui/tip"
25
+ import {
26
+ Tooltip,
27
+ TooltipContent,
28
+ TooltipTrigger,
29
+ } from "@/components/ui/tooltip"
30
+ import { cn } from "@/lib/utils"
31
+ import { ListPageTreePanelShell } from "@/components/data-views/list-page-tree-panel-shell"
32
+ import { ListPageSplitDetailsPlaceholder } from "@/components/data-views/list-page-split-details-placeholder"
33
+ import { ListPageTreeColumnHeader } from "@/components/data-views/list-page-tree-column-header"
34
+ import { QuestionBankNewFolderSheet } from "@/components/question-bank-new-folder-sheet"
35
+ import type {
36
+ QuestionBankItem,
37
+ QuestionBankDifficulty,
38
+ QuestionBankStatus,
39
+ } from "@/lib/mock/question-bank"
40
+ import type { QuestionBankFolder, QuestionBankFolderColorKey } from "@/lib/mock/question-bank-folders"
41
+ import { QUESTION_BANK_STATUS_BADGE_CLASS, QUESTION_BANK_STATUS_ICON } from "@/lib/list-status-badges"
42
+ import { formatDateUS } from "@/lib/date-filter"
43
+ import {
44
+ deriveBloomLevel,
45
+ deriveLastEditedLine,
46
+ deriveQuestionItemCode,
47
+ deriveTags,
48
+ QUESTION_TYPE_ABBREV,
49
+ } from "@/lib/mock/question-bank-inspector"
50
+ import { FolderDetailsShell } from "@/components/folder-details-shell"
51
+ import { initialsFromDisplayName } from "@/lib/initials-from-name"
52
+
53
+ const DIFFICULTY_LABEL: Record<QuestionBankDifficulty, string> = {
54
+ easy: "Easy",
55
+ medium: "Medium",
56
+ hard: "Hard",
57
+ }
58
+
59
+ /** Inspector header label — “Saved” for published matches reviewer checklist language. */
60
+ const INSPECTOR_STATUS_LABEL: Record<QuestionBankStatus, string> = {
61
+ published: "Saved",
62
+ draft: "Draft",
63
+ in_review: "In review",
64
+ }
65
+
66
+ // ============================================================================
67
+ // TreeItem — recursive folder/question renderer using Collapsible
68
+ // ============================================================================
69
+
70
+ interface TreeItemProps {
71
+ folder: QuestionBankFolder
72
+ folders: QuestionBankFolder[]
73
+ questions: QuestionBankItem[]
74
+ selectedItemId: string | null
75
+ depth?: number
76
+ onSelectItem: (itemId: string) => void
77
+ }
78
+
79
+ function TreeItem({
80
+ folder,
81
+ folders,
82
+ questions,
83
+ selectedItemId,
84
+ depth = 0,
85
+ onSelectItem,
86
+ }: TreeItemProps) {
87
+ const childFolders = folders
88
+ .filter(f => f.parentId === folder.id)
89
+ .sort((a, b) => a.name.localeCompare(b.name))
90
+ const childQuestions = questions
91
+ .filter(q => q.folderId === folder.id)
92
+
93
+ const hasChildren = childFolders.length > 0 || childQuestions.length > 0
94
+ const isFolderSelected = selectedItemId === folder.id
95
+ const indent = depth * 12
96
+
97
+ return (
98
+ <Collapsible>
99
+ {/* Folder row */}
100
+ <div className="group flex items-center hover:bg-muted/50">
101
+ <div style={{ width: indent }} className="shrink-0" />
102
+
103
+ {/* Expand chevron or spacer */}
104
+ {hasChildren ? (
105
+ <CollapsibleTrigger asChild>
106
+ <button
107
+ type="button"
108
+ className="flex h-8 w-5 shrink-0 items-center justify-center text-muted-foreground hover:text-foreground focus-visible:outline-none"
109
+ >
110
+ <ChevronRightIcon className="h-3.5 w-3.5 transition-transform duration-150 group-data-[state=open]:rotate-90 [[data-state=open]_&]:rotate-90" />
111
+ </button>
112
+ </CollapsibleTrigger>
113
+ ) : (
114
+ <div className="w-5 shrink-0" />
115
+ )}
116
+
117
+ {/* Folder button */}
118
+ <button
119
+ type="button"
120
+ onClick={() => onSelectItem(folder.id)}
121
+ className={cn(
122
+ "flex flex-1 items-center gap-2 py-1.5 pr-3 text-left text-sm transition-colors duration-75",
123
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
124
+ isFolderSelected
125
+ ? "bg-accent text-accent-foreground"
126
+ : "text-foreground",
127
+ )}
128
+ aria-selected={isFolderSelected}
129
+ role="option"
130
+ >
131
+ <FolderIcon
132
+ className={cn(
133
+ "h-4 w-4 shrink-0",
134
+ isFolderSelected ? "fill-current opacity-80" : "text-muted-foreground",
135
+ )}
136
+ />
137
+ <span className="truncate leading-tight">{folder.name}</span>
138
+ {hasChildren && (
139
+ <span className="ml-auto shrink-0 text-xs tabular-nums text-muted-foreground">
140
+ {childFolders.length + childQuestions.length}
141
+ </span>
142
+ )}
143
+ </button>
144
+ </div>
145
+
146
+ {/* Children */}
147
+ {hasChildren && (
148
+ <CollapsibleContent>
149
+ {childFolders.map(child => (
150
+ <TreeItem
151
+ key={child.id}
152
+ folder={child}
153
+ folders={folders}
154
+ questions={questions}
155
+ selectedItemId={selectedItemId}
156
+ depth={depth + 1}
157
+ onSelectItem={onSelectItem}
158
+ />
159
+ ))}
160
+ {childQuestions.map(question => {
161
+ const isSelected = selectedItemId === question.id
162
+ return (
163
+ <div key={question.id} className="group flex items-center hover:bg-muted/50">
164
+ <div style={{ width: indent + 12 + 20 }} className="shrink-0" />
165
+ <button
166
+ type="button"
167
+ onClick={() => onSelectItem(question.id)}
168
+ className={cn(
169
+ "flex flex-1 items-center gap-2 py-1.5 pr-3 text-left text-sm transition-colors duration-75",
170
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
171
+ isSelected
172
+ ? "bg-accent text-accent-foreground"
173
+ : "text-foreground",
174
+ )}
175
+ aria-selected={isSelected}
176
+ role="option"
177
+ >
178
+ <FileIcon
179
+ className={cn(
180
+ "h-3.5 w-3.5 shrink-0",
181
+ isSelected ? "fill-current opacity-80" : "text-muted-foreground",
182
+ )}
183
+ />
184
+ <span className="truncate leading-tight">{question.stem}</span>
185
+ </button>
186
+ </div>
187
+ )
188
+ })}
189
+ </CollapsibleContent>
190
+ )}
191
+ </Collapsible>
192
+ )
193
+ }
194
+
195
+ // ============================================================================
196
+ // DetailsPanel — right panel content (folder or question details)
197
+ // ============================================================================
198
+
199
+ function DetailSection({
200
+ title,
201
+ children,
202
+ className,
203
+ }: {
204
+ title: string
205
+ children: React.ReactNode
206
+ className?: string
207
+ }) {
208
+ return (
209
+ <section className={cn("min-w-0", className)}>
210
+ <h3 className="mb-2 text-xs font-medium text-muted-foreground">{title}</h3>
211
+ {children}
212
+ </section>
213
+ )
214
+ }
215
+
216
+ function InspectorSectionTitle({ children, id }: { children: React.ReactNode; id?: string }) {
217
+ return (
218
+ <p
219
+ id={id}
220
+ className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
221
+ >
222
+ {children}
223
+ </p>
224
+ )
225
+ }
226
+
227
+ interface DetailsPanelProps {
228
+ selectedItemId: string | null
229
+ folders: QuestionBankFolder[]
230
+ questions: QuestionBankItem[]
231
+ /** Clears tree selection (header dismiss). */
232
+ onClearSelection?: () => void
233
+ }
234
+
235
+ function DetailsPanel({ selectedItemId, folders, questions, onClearSelection }: DetailsPanelProps) {
236
+ if (!selectedItemId) {
237
+ return (
238
+ <ListPageSplitDetailsPlaceholder title="Nothing selected" />
239
+ )
240
+ }
241
+
242
+ const folder = folders.find(f => f.id === selectedItemId)
243
+ if (folder) {
244
+ return (
245
+ <FolderDetailsShell
246
+ folder={folder}
247
+ folders={folders}
248
+ questions={questions}
249
+ onClearSelection={onClearSelection}
250
+ />
251
+ )
252
+ }
253
+
254
+ const question = questions.find(q => q.id === selectedItemId)
255
+ if (question) {
256
+ const parentFolder = folders.find(f => f.id === question.folderId)
257
+ const folderLeafName = parentFolder?.name ?? "—"
258
+ const itemCode = deriveQuestionItemCode(question)
259
+ const bloom = deriveBloomLevel(question)
260
+ const tags = deriveTags(question)
261
+ const createdBy = question.createdBy ?? question.author
262
+ const creatorInitials = initialsFromDisplayName(createdBy)
263
+ const createdAtLabel = formatDateUS(question.createdAt ?? question.updatedAt)
264
+ const lastEditedLine = deriveLastEditedLine(question)
265
+ const versionLabel = question.version ?? "v1"
266
+
267
+ return (
268
+ <div className="flex h-full min-h-0 flex-col overflow-hidden bg-card">
269
+ <header className="shrink-0 border-b border-border/60 bg-muted/10 px-4 pb-4 pt-3">
270
+ <div className="flex items-start justify-between gap-3">
271
+ <ListHubStatusBadge
272
+ surface="detail"
273
+ label={INSPECTOR_STATUS_LABEL[question.status]}
274
+ tintClassName={QUESTION_BANK_STATUS_BADGE_CLASS[question.status]}
275
+ icon={QUESTION_BANK_STATUS_ICON[question.status]}
276
+ />
277
+ {onClearSelection ? (
278
+ <Tip label="Close details" side="bottom">
279
+ <Button
280
+ type="button"
281
+ variant="ghost"
282
+ size="icon-sm"
283
+ className="shrink-0 text-muted-foreground hover:text-foreground"
284
+ onClick={onClearSelection}
285
+ aria-label="Close details"
286
+ >
287
+ <X className="size-4" aria-hidden />
288
+ </Button>
289
+ </Tip>
290
+ ) : null}
291
+ </div>
292
+ <h2 className="mt-3 text-lg font-semibold leading-snug tracking-tight text-foreground">{question.stem}</h2>
293
+ <div className="mt-3 flex flex-wrap gap-2">
294
+ <Tip label="Opens the full editor when your bank workflow is connected." side="bottom">
295
+ <span className="inline-flex">
296
+ <Button type="button" size="sm" className="gap-1.5 shadow-sm" disabled>
297
+ <PencilLine className="size-3.5" aria-hidden />
298
+ Edit question
299
+ </Button>
300
+ </span>
301
+ </Tip>
302
+ <Tip
303
+ label={
304
+ question.status === "draft"
305
+ ? "Already a draft."
306
+ : "Revert connects when your assessments API is wired."
307
+ }
308
+ side="bottom"
309
+ >
310
+ <span className="inline-flex">
311
+ <Button type="button" variant="outline" size="sm" className="gap-1.5" disabled={question.status === "draft"}>
312
+ <Hourglass className="size-3.5" aria-hidden />
313
+ Revert to draft
314
+ </Button>
315
+ </span>
316
+ </Tip>
317
+ <Tip label="Move question connects when folder APIs are wired." side="bottom">
318
+ <span className="inline-flex">
319
+ <Button type="button" variant="outline" size="sm" className="gap-1.5" disabled>
320
+ <ArrowRightLeft className="size-3.5" aria-hidden />
321
+ Move
322
+ </Button>
323
+ </span>
324
+ </Tip>
325
+ </div>
326
+ </header>
327
+
328
+ <div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
329
+ <div className="space-y-5 px-4 py-4">
330
+ <dl className="space-y-2.5 text-sm">
331
+ <div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-3 gap-y-0.5">
332
+ <dt className="text-muted-foreground">Bloom&apos;s</dt>
333
+ <dd className="font-medium text-foreground">{bloom}</dd>
334
+ </div>
335
+ <div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-3 gap-y-0.5">
336
+ <dt className="text-muted-foreground">Difficulty</dt>
337
+ <dd className="font-medium text-foreground">{DIFFICULTY_LABEL[question.difficulty]}</dd>
338
+ </div>
339
+ <div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-3 gap-y-0.5">
340
+ <dt className="text-muted-foreground">Type</dt>
341
+ <dd className="font-medium text-foreground">{QUESTION_TYPE_ABBREV[question.type]}</dd>
342
+ </div>
343
+ <div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-3 gap-y-0.5">
344
+ <dt className="text-muted-foreground">Folder</dt>
345
+ <dd className="min-w-0 font-medium text-foreground">{folderLeafName}</dd>
346
+ </div>
347
+ <div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] items-center gap-x-3 gap-y-0.5">
348
+ <dt className="text-muted-foreground">Code</dt>
349
+ <dd>
350
+ <span className="inline-flex rounded-md border border-rose-200/90 bg-rose-50 px-2 py-0.5 font-mono text-xs font-medium leading-none text-rose-950 shadow-sm dark:border-rose-500/35 dark:bg-rose-950/45 dark:text-rose-50">
351
+ {itemCode}
352
+ </span>
353
+ </dd>
354
+ </div>
355
+ </dl>
356
+
357
+ {tags.length > 0 ? (
358
+ <div>
359
+ <InspectorSectionTitle>Tags</InspectorSectionTitle>
360
+ <div className="flex flex-wrap gap-2" role="list" aria-label="Question tags">
361
+ {tags.map(raw => {
362
+ const label = raw.replace(/^#/, "").trim()
363
+ return (
364
+ <Badge
365
+ key={label}
366
+ variant="outline"
367
+ role="listitem"
368
+ className={cn(
369
+ LIST_HUB_INSPECTOR_CHIP_SHELL,
370
+ "border-border/60 bg-muted/15 font-normal text-foreground",
371
+ )}
372
+ >
373
+ #{label}
374
+ </Badge>
375
+ )
376
+ })}
377
+ </div>
378
+ </div>
379
+ ) : null}
380
+
381
+ <Separator className="bg-border/60" />
382
+
383
+ <section className="min-w-0">
384
+ <InspectorSectionTitle>Creator &amp; history</InspectorSectionTitle>
385
+ <div className="space-y-3 text-sm">
386
+ <div className="flex gap-3">
387
+ <span
388
+ className="flex size-8 shrink-0 items-center justify-center rounded-full bg-pink-500/15 text-xs font-semibold text-pink-950 dark:bg-pink-500/25 dark:text-pink-50"
389
+ aria-hidden
390
+ >
391
+ {creatorInitials}
392
+ </span>
393
+ <div className="min-w-0">
394
+ <p className="text-xs text-muted-foreground">Created by</p>
395
+ <p className="font-medium leading-snug text-foreground">{createdBy}</p>
396
+ <p className="mt-0.5 text-xs text-muted-foreground">{createdAtLabel}</p>
397
+ </div>
398
+ </div>
399
+ <div>
400
+ <p className="text-xs text-muted-foreground">Last edited</p>
401
+ <p className="font-medium text-foreground">{lastEditedLine}</p>
402
+ </div>
403
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
404
+ <p className="text-sm">
405
+ <span className="text-muted-foreground">Version </span>
406
+ <span className="font-medium tabular-nums text-foreground">{versionLabel}</span>
407
+ </p>
408
+ <Tip label="Version history opens when connected to your CMS.">
409
+ <span className="inline-flex">
410
+ <Button
411
+ type="button"
412
+ variant="link"
413
+ className="h-auto gap-1 px-0 py-0 text-xs font-normal"
414
+ disabled
415
+ >
416
+ Version history
417
+ <ChevronRightIcon className="size-3.5 opacity-70" aria-hidden />
418
+ </Button>
419
+ </span>
420
+ </Tip>
421
+ </div>
422
+ </div>
423
+ </section>
424
+
425
+ <Separator className="bg-border/60" />
426
+
427
+ <section className="min-w-0">
428
+ <InspectorSectionTitle>Usage</InspectorSectionTitle>
429
+ <dl className="space-y-2 text-sm">
430
+ <div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-3">
431
+ <dt className="text-muted-foreground">Used in</dt>
432
+ <dd className="font-medium text-foreground">
433
+ {question.examUsageCount != null ? `${question.examUsageCount} exams` : "—"}
434
+ </dd>
435
+ </div>
436
+ <div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-3">
437
+ <dt className="text-muted-foreground">PBI</dt>
438
+ <dd className="font-semibold tabular-nums text-foreground">
439
+ {question.pbi != null ? question.pbi.toFixed(2) : "—"}
440
+ </dd>
441
+ </div>
442
+ <div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-3">
443
+ <dt className="text-muted-foreground">Avg score</dt>
444
+ <dd className="font-medium text-foreground">
445
+ {question.avgScoreCorrectPct != null ? `${question.avgScoreCorrectPct}% correct` : "—"}
446
+ </dd>
447
+ </div>
448
+ <div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-3">
449
+ <dt className="text-muted-foreground">Last used</dt>
450
+ <dd className="font-medium text-foreground">{question.lastUsedLabel ?? "—"}</dd>
451
+ </div>
452
+ </dl>
453
+ </section>
454
+
455
+ {question.type === "multiple_choice" && question.options && question.options.length > 0 ? (
456
+ <>
457
+ <Separator className="bg-border/60" />
458
+ <DetailSection title="Answer choices">
459
+ <ul className="flex flex-col gap-2" aria-label="Multiple choice options">
460
+ {question.options.map((opt, idx) => {
461
+ const letter = String.fromCharCode(65 + idx)
462
+ const isCorrect = Boolean(opt.isCorrect)
463
+ return (
464
+ <li
465
+ key={`${question.id}-opt-${idx}`}
466
+ className={cn(
467
+ "flex items-start gap-2.5 rounded-lg border px-3 py-2.5 text-sm transition-colors",
468
+ isCorrect
469
+ ? "border-emerald-500/40 bg-emerald-500/10 shadow-[inset_0_0_0_1px] shadow-emerald-500/15"
470
+ : "border-border/50 bg-muted/10",
471
+ )}
472
+ >
473
+ <span
474
+ className={cn(
475
+ "flex size-7 shrink-0 items-center justify-center rounded-md border text-xs font-semibold tabular-nums",
476
+ isCorrect
477
+ ? "border-emerald-500/40 bg-emerald-500/15 text-emerald-900 dark:text-emerald-100"
478
+ : "border-border/60 bg-background text-muted-foreground",
479
+ )}
480
+ >
481
+ {letter}
482
+ </span>
483
+ <span
484
+ className={cn(
485
+ "min-w-0 flex-1 leading-snug",
486
+ isCorrect ? "font-medium text-foreground" : "text-foreground/90",
487
+ )}
488
+ >
489
+ {opt.text}
490
+ </span>
491
+ {isCorrect ? (
492
+ <span className="flex shrink-0 items-center gap-1 text-emerald-600 dark:text-emerald-400">
493
+ <CheckCircle2Icon className="size-4" aria-hidden />
494
+ <span className="sr-only">Correct answer</span>
495
+ </span>
496
+ ) : null}
497
+ </li>
498
+ )
499
+ })}
500
+ </ul>
501
+ </DetailSection>
502
+ </>
503
+ ) : null}
504
+
505
+ {question.type === "true_false" ? (
506
+ <>
507
+ <Separator className="bg-border/60" />
508
+ <DetailSection title="Response format">
509
+ <p className="rounded-lg border border-border/50 bg-muted/10 px-3 py-3 text-xs leading-relaxed text-muted-foreground">
510
+ Learners choose <span className="font-medium text-foreground">True</span> or{" "}
511
+ <span className="font-medium text-foreground">False</span>. No options list is shown in the bank
512
+ preview.
513
+ </p>
514
+ </DetailSection>
515
+ </>
516
+ ) : null}
517
+
518
+ {question.type === "short_answer" ? (
519
+ <>
520
+ <Separator className="bg-border/60" />
521
+ <DetailSection title="Response format">
522
+ <p className="rounded-lg border border-border/50 bg-muted/10 px-3 py-3 text-xs leading-relaxed text-muted-foreground">
523
+ Free-text response; grading rules and sample answers are managed when the question is edited in the
524
+ full editor.
525
+ </p>
526
+ </DetailSection>
527
+ </>
528
+ ) : null}
529
+ </div>
530
+ </div>
531
+ </div>
532
+ )
533
+ }
534
+
535
+ return (
536
+ <div className="flex h-full min-h-0 flex-col items-center justify-center bg-gradient-to-b from-muted/25 to-card px-6 py-10 text-center text-muted-foreground">
537
+ <div className="mb-3 flex size-12 items-center justify-center rounded-xl border border-border/60 bg-muted/20">
538
+ <FileIcon className="size-6 opacity-50" aria-hidden />
539
+ </div>
540
+ <p className="text-sm font-medium text-foreground">Item not found</p>
541
+ <p className="mt-1 max-w-[14rem] text-xs leading-relaxed text-muted-foreground">
542
+ This selection is no longer in the tree. Choose another folder or question.
543
+ </p>
544
+ </div>
545
+ )
546
+ }
547
+
548
+ // ============================================================================
549
+ // HubTreePanelView — tree + details wiring (question bank demo; reusable shell above)
550
+ // ============================================================================
551
+
552
+ export interface HubTreePanelViewProps {
553
+ items: QuestionBankItem[]
554
+ folders: QuestionBankFolder[]
555
+ onItemsChange: (items: QuestionBankItem[]) => void
556
+ onFoldersChange: (folders: QuestionBankFolder[]) => void
557
+ }
558
+
559
+ export function HubTreePanelView({
560
+ items,
561
+ folders,
562
+ onFoldersChange,
563
+ }: HubTreePanelViewProps) {
564
+ const [selectedItemId, setSelectedItemId] = React.useState<string | null>(null)
565
+ const [newFolderOpen, setNewFolderOpen] = React.useState(false)
566
+ const [newFolderParentId, setNewFolderParentId] = React.useState<string | null>(null)
567
+ const [customizingFolder, setCustomizingFolder] = React.useState<QuestionBankFolder | null>(null)
568
+
569
+ const rootFolders = React.useMemo(
570
+ () => folders.filter(f => f.parentId === null).sort((a, b) => a.name.localeCompare(b.name)),
571
+ [folders],
572
+ )
573
+
574
+ const handleNewFolderCreated = React.useCallback(
575
+ (newFolder: { name: string; icon: string; colorKey: QuestionBankFolderColorKey; parentId: string | null }) => {
576
+ if (customizingFolder) {
577
+ onFoldersChange(
578
+ folders.map(f =>
579
+ f.id === customizingFolder.id
580
+ ? { ...f, name: newFolder.name, icon: newFolder.icon, colorKey: newFolder.colorKey }
581
+ : f,
582
+ ),
583
+ )
584
+ setCustomizingFolder(null)
585
+ } else {
586
+ onFoldersChange([
587
+ ...folders,
588
+ {
589
+ id: `fld-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
590
+ name: newFolder.name,
591
+ icon: newFolder.icon,
592
+ colorKey: newFolder.colorKey,
593
+ parentId: newFolder.parentId,
594
+ },
595
+ ])
596
+ }
597
+ setNewFolderOpen(false)
598
+ },
599
+ [customizingFolder, folders, onFoldersChange],
600
+ )
601
+
602
+ return (
603
+ <>
604
+ <ListPageTreePanelShell
605
+ resizableGroupId="hub-tree-panel"
606
+ ariaLabel="Folder tree and details"
607
+ tree={
608
+ <>
609
+ <ListPageTreeColumnHeader
610
+ title="Questions"
611
+ trailing={
612
+ <Tooltip>
613
+ <TooltipTrigger asChild>
614
+ <Button
615
+ size="icon-sm"
616
+ variant="ghost"
617
+ onClick={() => {
618
+ setNewFolderParentId(null)
619
+ setCustomizingFolder(null)
620
+ setNewFolderOpen(true)
621
+ }}
622
+ aria-label="Add folder"
623
+ >
624
+ <i className="fa-light fa-folder-plus text-xs" aria-hidden="true" />
625
+ </Button>
626
+ </TooltipTrigger>
627
+ <TooltipContent side="top" sideOffset={4}>
628
+ Add folder
629
+ </TooltipContent>
630
+ </Tooltip>
631
+ }
632
+ />
633
+
634
+ <div className="min-h-0 flex-1 overflow-y-auto py-1" role="listbox" aria-label="Folder tree">
635
+ {rootFolders.length === 0 ? (
636
+ <p className="px-3 py-4 text-sm text-muted-foreground">No folders</p>
637
+ ) : (
638
+ rootFolders.map(folder => (
639
+ <TreeItem
640
+ key={folder.id}
641
+ folder={folder}
642
+ folders={folders}
643
+ questions={items}
644
+ selectedItemId={selectedItemId}
645
+ depth={0}
646
+ onSelectItem={setSelectedItemId}
647
+ />
648
+ ))
649
+ )}
650
+ </div>
651
+ </>
652
+ }
653
+ details={
654
+ <DetailsPanel
655
+ selectedItemId={selectedItemId}
656
+ folders={folders}
657
+ questions={items}
658
+ onClearSelection={() => setSelectedItemId(null)}
659
+ />
660
+ }
661
+ />
662
+
663
+ <QuestionBankNewFolderSheet
664
+ open={newFolderOpen}
665
+ onOpenChange={setNewFolderOpen}
666
+ parentFolderId={customizingFolder?.parentId ?? newFolderParentId}
667
+ customizingFolder={customizingFolder}
668
+ onCreated={handleNewFolderCreated}
669
+ />
670
+ </>
671
+ )
672
+ }