@exxatdesignux/ui 0.2.6 → 0.2.8

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 (134) 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/components/app-sidebar.tsx +52 -35
  34. package/template/components/compliance-table.tsx +79 -0
  35. package/template/components/data-list-client.tsx +36 -25
  36. package/template/components/data-list-table.tsx +797 -10
  37. package/template/components/data-views/finder-panel-view.tsx +405 -0
  38. package/template/components/data-views/folder-grid-view.tsx +86 -0
  39. package/template/components/data-views/index.ts +59 -0
  40. package/template/components/data-views/list-page-split-details-placeholder.tsx +39 -0
  41. package/template/components/data-views/list-page-split-hub-chrome.tsx +60 -0
  42. package/template/components/data-views/list-page-split-hub-tokens.ts +16 -0
  43. package/template/components/data-views/list-page-tree-column-header.tsx +31 -0
  44. package/template/components/data-views/list-page-tree-panel-shell.tsx +91 -0
  45. package/template/components/data-views/list-page-view-frame.tsx +53 -0
  46. package/template/components/data-views/os-folder-glyph.tsx +121 -0
  47. package/template/components/folder-details-shell.tsx +230 -0
  48. package/template/components/hub-tree-panel-view.tsx +672 -0
  49. package/template/components/list-hub-status-badge.tsx +17 -3
  50. package/template/components/placements-page-header.tsx +14 -8
  51. package/template/components/placements-table-columns.tsx +8 -8
  52. package/template/components/question-bank-client.tsx +157 -40
  53. package/template/components/question-bank-new-folder-sheet.tsx +248 -0
  54. package/template/components/question-bank-os-folder-view.tsx +648 -0
  55. package/template/components/question-bank-page-header.tsx +3 -3
  56. package/template/components/question-bank-panel-activator.tsx +9 -0
  57. package/template/components/question-bank-secondary-nav.tsx +226 -0
  58. package/template/components/question-bank-table.tsx +707 -22
  59. package/template/components/secondary-panel.tsx +41 -107
  60. package/template/components/sites-table.tsx +66 -0
  61. package/template/components/team-client.tsx +7 -0
  62. package/template/components/team-table.tsx +156 -1
  63. package/template/components/templates/list-page.tsx +2 -2
  64. package/template/components/ui/avatar.tsx +1 -1
  65. package/template/components/ui/badge.tsx +1 -1
  66. package/template/components/ui/banner.tsx +1 -1
  67. package/template/components/ui/breadcrumb.tsx +1 -1
  68. package/template/components/ui/button.tsx +1 -1
  69. package/template/components/ui/calendar.tsx +1 -1
  70. package/template/components/ui/card.tsx +1 -1
  71. package/template/components/ui/chart.tsx +1 -1
  72. package/template/components/ui/checkbox.tsx +1 -1
  73. package/template/components/ui/coach-mark.tsx +1 -1
  74. package/template/components/ui/collapsible.tsx +1 -1
  75. package/template/components/ui/command.tsx +1 -1
  76. package/template/components/ui/date-picker-field.tsx +1 -1
  77. package/template/components/ui/dialog.tsx +1 -1
  78. package/template/components/ui/drag-handle-grip.tsx +1 -1
  79. package/template/components/ui/drawer.tsx +1 -1
  80. package/template/components/ui/dropdown-menu.tsx +1 -1
  81. package/template/components/ui/field.tsx +1 -1
  82. package/template/components/ui/form.tsx +1 -1
  83. package/template/components/ui/input-group.tsx +1 -1
  84. package/template/components/ui/input-mask.tsx +1 -1
  85. package/template/components/ui/input.tsx +1 -1
  86. package/template/components/ui/kbd.tsx +1 -1
  87. package/template/components/ui/label.tsx +1 -1
  88. package/template/components/ui/payment-card-fields.tsx +1 -1
  89. package/template/components/ui/popover.tsx +1 -1
  90. package/template/components/ui/radio-group.tsx +1 -1
  91. package/template/components/ui/resizable.tsx +68 -0
  92. package/template/components/ui/select.tsx +1 -1
  93. package/template/components/ui/selection-tile-grid.tsx +1 -1
  94. package/template/components/ui/separator.tsx +1 -1
  95. package/template/components/ui/sheet.tsx +1 -1
  96. package/template/components/ui/sidebar.tsx +1 -1
  97. package/template/components/ui/skeleton.tsx +1 -1
  98. package/template/components/ui/sonner.tsx +1 -1
  99. package/template/components/ui/status-badge.tsx +1 -1
  100. package/template/components/ui/table.tsx +1 -1
  101. package/template/components/ui/tabs.tsx +1 -1
  102. package/template/components/ui/textarea.tsx +1 -1
  103. package/template/components/ui/tip.tsx +1 -1
  104. package/template/components/ui/toggle-group.tsx +1 -1
  105. package/template/components/ui/toggle-switch.tsx +1 -1
  106. package/template/components/ui/toggle.tsx +1 -1
  107. package/template/components/ui/tooltip.tsx +1 -1
  108. package/template/components/ui/view-segmented-control.tsx +1 -1
  109. package/template/docs/data-views-pattern.md +7 -0
  110. package/template/fontawesome-subset.manifest.json +2 -2
  111. package/template/hooks/use-location-hash.ts +15 -0
  112. package/template/hooks/use-sidebar-reflow-zoom.ts +40 -0
  113. package/template/lib/ask-leo-route-context.ts +25 -57
  114. package/template/lib/coach-mark-registry.ts +13 -13
  115. package/template/lib/command-menu-config.ts +28 -23
  116. package/template/lib/command-menu-search-data.ts +10 -9
  117. package/template/lib/data-list-view-surface.ts +12 -1
  118. package/template/lib/data-list-view.ts +6 -3
  119. package/template/lib/mock/dashboard.ts +11 -11
  120. package/template/lib/mock/navigation.tsx +22 -63
  121. package/template/lib/mock/placements-kpi.ts +19 -19
  122. package/template/lib/mock/question-bank-folders.ts +167 -0
  123. package/template/lib/mock/question-bank-inspector.ts +109 -0
  124. package/template/lib/mock/question-bank-kpi.ts +1 -1
  125. package/template/lib/mock/question-bank.ts +80 -0
  126. package/template/lib/question-bank-nav.ts +91 -0
  127. package/template/next.config.mjs +8 -0
  128. package/template/package.json +1 -0
  129. package/template/public/folders/icons8-folder-windows-11.svg +1 -0
  130. package/template/scripts/fontawesome-subset-audit.mjs +2 -3
  131. package/template/app/(app)/compliance/page.tsx +0 -10
  132. package/template/app/(app)/rotations/page.tsx +0 -15
  133. package/template/app/(app)/sites/all/page.tsx +0 -13
  134. package/template/app/(app)/team/page.tsx +0 -10
@@ -0,0 +1,648 @@
1
+ "use client"
2
+
3
+ /**
4
+ * OS-style icon folder view for Question bank — hierarchy, appearance (color + icon),
5
+ * create (floating sheet drawer like `ExportDrawer` + preview), inline rename, move / delete, and move questions between folders.
6
+ */
7
+
8
+ import * as React from "react"
9
+ import { Button } from "@/components/ui/button"
10
+ import {
11
+ Dialog,
12
+ DialogContent,
13
+ DialogDescription,
14
+ DialogFooter,
15
+ DialogHeader,
16
+ DialogTitle,
17
+ } from "@/components/ui/dialog"
18
+ import {
19
+ DropdownMenu,
20
+ DropdownMenuContent,
21
+ DropdownMenuItem,
22
+ DropdownMenuSeparator,
23
+ DropdownMenuSub,
24
+ DropdownMenuSubContent,
25
+ DropdownMenuSubTrigger,
26
+ DropdownMenuTrigger,
27
+ } from "@/components/ui/dropdown-menu"
28
+ import { Input } from "@/components/ui/input"
29
+ import { Label } from "@/components/ui/label"
30
+ import { Tip } from "@/components/ui/tip"
31
+ import { cn } from "@/lib/utils"
32
+ import type { QuestionBankItem } from "@/lib/mock/question-bank"
33
+ import {
34
+ collectFolderDescendantIds,
35
+ isValidFolderMove,
36
+ newFolderId,
37
+ QUESTION_BANK_FOLDER_COLOR_STYLES,
38
+ QUESTION_BANK_FOLDER_ICON_OPTIONS,
39
+ type QuestionBankFolder,
40
+ type QuestionBankFolderColorKey,
41
+ } from "@/lib/mock/question-bank-folders"
42
+ import {
43
+ QUESTION_BANK_STATUS_BADGE_CLASS,
44
+ QUESTION_BANK_STATUS_ICON,
45
+ QUESTION_BANK_STATUS_LABEL,
46
+ } from "@/lib/list-status-badges"
47
+ import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
48
+ import {
49
+ ListPageViewFrame,
50
+ LIST_PAGE_VIEW_FRAME_MAX_WIDE,
51
+ } from "@/components/data-views/list-page-view-frame"
52
+ import { OsFolderGlyph } from "@/components/data-views/os-folder-glyph"
53
+ import { QuestionBankNewFolderSheet } from "@/components/question-bank-new-folder-sheet"
54
+
55
+ const COLOR_OPTIONS: QuestionBankFolderColorKey[] = [
56
+ "brand",
57
+ "success",
58
+ "warning",
59
+ "destructive",
60
+ "muted",
61
+ "chart1",
62
+ "chart2",
63
+ "chart3",
64
+ ]
65
+
66
+ export interface QuestionBankOsFolderViewProps {
67
+ folders: QuestionBankFolder[]
68
+ onFoldersChange: React.Dispatch<React.SetStateAction<QuestionBankFolder[]>>
69
+ questions: QuestionBankItem[]
70
+ onQuestionsChange: React.Dispatch<React.SetStateAction<QuestionBankItem[]>>
71
+ }
72
+
73
+ function folderTrail(folders: QuestionBankFolder[], folderId: string | null): QuestionBankFolder[] {
74
+ if (!folderId) return []
75
+ const byId = new Map(folders.map(f => [f.id, f]))
76
+ const trail: QuestionBankFolder[] = []
77
+ let cur: string | null = folderId
78
+ while (cur) {
79
+ const f = byId.get(cur)
80
+ if (!f) break
81
+ trail.unshift(f)
82
+ cur = f.parentId
83
+ }
84
+ return trail
85
+ }
86
+
87
+ function folderHoverCounts(
88
+ folder: QuestionBankFolder,
89
+ folders: QuestionBankFolder[],
90
+ questions: QuestionBankItem[],
91
+ ) {
92
+ const subfolders = folders.filter(f => f.parentId === folder.id).length
93
+ const questionsInFolder = questions.filter(q => q.folderId === folder.id).length
94
+ return { subfolders, questionsInFolder }
95
+ }
96
+
97
+ function validMoveTargets(
98
+ folders: QuestionBankFolder[],
99
+ movingId: string,
100
+ ): Array<{ id: string | null; label: string }> {
101
+ const out: Array<{ id: string | null; label: string }> = [{ id: null, label: "Question bank (root)" }]
102
+ for (const f of folders) {
103
+ if (f.id === movingId) continue
104
+ if (!isValidFolderMove(folders, movingId, f.id)) continue
105
+ out.push({ id: f.id, label: f.name })
106
+ }
107
+ return out
108
+ }
109
+
110
+ export function QuestionBankOsFolderView({
111
+ folders,
112
+ onFoldersChange,
113
+ questions,
114
+ onQuestionsChange,
115
+ }: QuestionBankOsFolderViewProps) {
116
+ const [currentId, setCurrentId] = React.useState<string | null>(null)
117
+
118
+ const childFolders = React.useMemo(
119
+ () => folders.filter(f => f.parentId === currentId),
120
+ [folders, currentId],
121
+ )
122
+
123
+ const filesHere = React.useMemo(
124
+ () => questions.filter(q => q.folderId === currentId),
125
+ [questions, currentId],
126
+ )
127
+
128
+ const trail = React.useMemo(() => folderTrail(folders, currentId), [folders, currentId])
129
+
130
+ const [createFolderOpen, setCreateFolderOpen] = React.useState(false)
131
+ const [customizeFolderOpen, setCustomizeFolderOpen] = React.useState(false)
132
+ const [customizingFolder, setCustomizingFolder] = React.useState<QuestionBankFolder | null>(null)
133
+
134
+ const [renamingFolderId, setRenamingFolderId] = React.useState<string | null>(null)
135
+ const [renameValue, setRenameValue] = React.useState("")
136
+ const renameInputRef = React.useRef<HTMLInputElement>(null)
137
+
138
+ const [appearanceDialog, setAppearanceDialog] = React.useState<QuestionBankFolder | null>(null)
139
+ const [moveFolderId, setMoveFolderId] = React.useState<string | null>(null)
140
+ const [deleteFolderId, setDeleteFolderId] = React.useState<string | null>(null)
141
+
142
+ React.useEffect(() => {
143
+ if (renamingFolderId && renameInputRef.current) {
144
+ renameInputRef.current.focus()
145
+ renameInputRef.current.select()
146
+ }
147
+ }, [renamingFolderId])
148
+
149
+ function openCreateFolderPanel() {
150
+ setCreateFolderOpen(true)
151
+ }
152
+
153
+ function startRename(folder: QuestionBankFolder) {
154
+ setRenamingFolderId(folder.id)
155
+ setRenameValue(folder.name)
156
+ }
157
+
158
+ function commitRename() {
159
+ if (!renamingFolderId) return
160
+ const v = renameValue.trim()
161
+ const folder = folders.find(f => f.id === renamingFolderId)
162
+ if (!folder) {
163
+ setRenamingFolderId(null)
164
+ return
165
+ }
166
+ if (!v) {
167
+ setRenameValue(folder.name)
168
+ setRenamingFolderId(null)
169
+ return
170
+ }
171
+ onFoldersChange(prev => prev.map(f => (f.id === renamingFolderId ? { ...f, name: v } : f)))
172
+ setRenamingFolderId(null)
173
+ }
174
+
175
+ function cancelRename() {
176
+ setRenamingFolderId(null)
177
+ setRenameValue("")
178
+ }
179
+
180
+ function commitMoveFolder(targetParentId: string | null) {
181
+ if (!moveFolderId) return
182
+ if (!isValidFolderMove(folders, moveFolderId, targetParentId)) return
183
+ onFoldersChange(prev =>
184
+ prev.map(f => (f.id === moveFolderId ? { ...f, parentId: targetParentId } : f)),
185
+ )
186
+ setMoveFolderId(null)
187
+ }
188
+
189
+ function commitDeleteFolder() {
190
+ if (!deleteFolderId) return
191
+ const victim = folders.find(f => f.id === deleteFolderId)
192
+ if (!victim) return
193
+ const parent = victim.parentId
194
+ const desc = collectFolderDescendantIds(folders, deleteFolderId)
195
+ const remaining = folders.filter(f => !desc.has(f.id))
196
+ if (remaining.length === 0) {
197
+ setDeleteFolderId(null)
198
+ return
199
+ }
200
+ const parentStillExists = parent !== null && remaining.some(f => f.id === parent)
201
+ const fallbackRoot = remaining.find(f => f.parentId === null)?.id
202
+ const reassignTarget =
203
+ parentStillExists ? parent : (fallbackRoot ?? remaining[0]!.id)
204
+
205
+ onFoldersChange(remaining)
206
+ onQuestionsChange(prev =>
207
+ prev.map(q => (desc.has(q.folderId) ? { ...q, folderId: reassignTarget } : q)),
208
+ )
209
+ if (currentId && desc.has(currentId)) setCurrentId(parentStillExists ? parent : null)
210
+ setDeleteFolderId(null)
211
+ }
212
+
213
+ function moveQuestionToFolder(questionId: string, folderId: string) {
214
+ onQuestionsChange(prev =>
215
+ prev.map(q => (q.id === questionId ? { ...q, folderId } : q)),
216
+ )
217
+ }
218
+
219
+ return (
220
+ <ListPageViewFrame
221
+ className="flex min-h-0 flex-1 flex-col gap-3"
222
+ maxWidthClassName={LIST_PAGE_VIEW_FRAME_MAX_WIDE}
223
+ >
224
+ {/* Breadcrumb navigation */}
225
+ <nav aria-label="Folder breadcrumb" className="flex items-center gap-1.5 min-w-0 overflow-hidden pb-4">
226
+ <span className="flex items-center gap-1.5 shrink-0">
227
+ <button
228
+ type="button"
229
+ onClick={() => setCurrentId(null)}
230
+ className="font-sans text-sm text-muted-foreground hover:text-interactive-hover-foreground transition-colors tracking-normal"
231
+ >
232
+ Question bank
233
+ </button>
234
+ {trail.length > 0 && (
235
+ <i className="fa-light fa-chevron-right text-xs text-muted-foreground/50" aria-hidden="true" />
236
+ )}
237
+ </span>
238
+ {trail.map((f, i) => (
239
+ <span key={f.id} className="flex items-center gap-1.5 shrink-0">
240
+ <button
241
+ type="button"
242
+ onClick={() => setCurrentId(f.id)}
243
+ className="font-sans text-sm text-muted-foreground hover:text-interactive-hover-foreground transition-colors tracking-normal"
244
+ >
245
+ {f.name}
246
+ </button>
247
+ {i < trail.length - 1 && (
248
+ <i className="fa-light fa-chevron-right text-xs text-muted-foreground/50" aria-hidden="true" />
249
+ )}
250
+ </span>
251
+ ))}
252
+ </nav>
253
+
254
+ {/* Icon grid — new folder first, then folders + question files */}
255
+ <div
256
+ className="grid w-full grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"
257
+ role="list"
258
+ aria-label="Folders and questions in this location"
259
+ >
260
+ {/* First tile: create folder */}
261
+ <div role="listitem" className="relative">
262
+ <Tip
263
+ side="bottom"
264
+ label={(
265
+ <span className="flex max-w-[14rem] flex-col gap-1.5 text-start">
266
+ <span className="text-xs font-semibold text-background">New folder</span>
267
+ <span className="text-[11px] leading-snug text-background/85">
268
+ Opens the same floating panel as Export to name the folder, pick tint and icon, then create it here.
269
+ </span>
270
+ </span>
271
+ )}
272
+ >
273
+ <button
274
+ type="button"
275
+ className={cn(
276
+ "flex w-full flex-col items-center gap-2 rounded-xl border border-dashed border-muted-foreground/45 bg-muted/10 p-3 text-center transition-all",
277
+ "hover:border-muted-foreground/70 hover:bg-muted/20",
278
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
279
+ )}
280
+ onClick={openCreateFolderPanel}
281
+ aria-label="Create new folder"
282
+ >
283
+ <OsFolderGlyph
284
+ colorKey="muted"
285
+ icon="fa-folder-plus"
286
+ size="md"
287
+ variant="outline"
288
+ />
289
+ <span className="line-clamp-2 w-full text-xs font-medium text-muted-foreground">
290
+ New folder
291
+ </span>
292
+ </button>
293
+ </Tip>
294
+ </div>
295
+
296
+ {childFolders.map(folder => {
297
+ const isRenaming = renamingFolderId === folder.id
298
+ const { subfolders, questionsInFolder } = folderHoverCounts(folder, folders, questions)
299
+
300
+ return (
301
+ <div key={folder.id} role="listitem" className="group relative">
302
+ {isRenaming ? (
303
+ <div
304
+ className={cn(
305
+ "flex w-full flex-col items-center gap-2 rounded-xl border border-border/70 bg-card/40 p-3 text-center shadow-sm",
306
+ )}
307
+ >
308
+ <OsFolderGlyph
309
+ colorKey={folder.colorKey}
310
+ icon={folder.icon}
311
+ size="md"
312
+ />
313
+ <Label htmlFor={`rename-${folder.id}`} className="sr-only">
314
+ Rename folder {folder.name}
315
+ </Label>
316
+ <Input
317
+ id={`rename-${folder.id}`}
318
+ ref={renameInputRef}
319
+ value={renameValue}
320
+ onChange={e => setRenameValue(e.target.value)}
321
+ onBlur={commitRename}
322
+ onKeyDown={e => {
323
+ if (e.key === "Enter") {
324
+ e.preventDefault()
325
+ commitRename()
326
+ }
327
+ if (e.key === "Escape") {
328
+ e.preventDefault()
329
+ cancelRename()
330
+ }
331
+ }}
332
+ className="h-8 text-center text-xs font-medium"
333
+ aria-describedby={`rename-hint-${folder.id}`}
334
+ />
335
+ <p id={`rename-hint-${folder.id}`} className="sr-only">
336
+ Press Enter to save, Escape to cancel.
337
+ </p>
338
+ </div>
339
+ ) : (
340
+ <Tip
341
+ side="bottom"
342
+ label={(
343
+ <span className="flex max-w-[14rem] flex-col gap-1.5 text-start">
344
+ <span className="text-xs font-semibold text-background">{folder.name}</span>
345
+ <span className="text-[11px] leading-snug text-background/85">
346
+ Double-click to open. Use the menu (⋯) for rename, appearance, move, or delete.
347
+ </span>
348
+ <span className="text-[11px] text-background/80">
349
+ {questionsInFolder} question{questionsInFolder === 1 ? "" : "s"} in this folder
350
+ {" · "}
351
+ {subfolders} subfolder{subfolders === 1 ? "" : "s"}
352
+ </span>
353
+ </span>
354
+ )}
355
+ >
356
+ <button
357
+ type="button"
358
+ className={cn(
359
+ "flex w-full flex-col items-center gap-2 rounded-xl border border-transparent p-3 text-center transition-all",
360
+ "hover:border-border/80 hover:bg-muted/35",
361
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
362
+ )}
363
+ onDoubleClick={() => setCurrentId(folder.id)}
364
+ aria-label={`Open folder ${folder.name}`}
365
+ >
366
+ <OsFolderGlyph
367
+ colorKey={folder.colorKey}
368
+ icon={folder.icon}
369
+ size="md"
370
+ />
371
+ <span className="line-clamp-2 w-full text-xs font-medium text-foreground">
372
+ {folder.name}
373
+ </span>
374
+ </button>
375
+ </Tip>
376
+ )}
377
+ {!isRenaming && (
378
+ <div className="absolute end-1.5 top-1.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
379
+ <DropdownMenu>
380
+ <Tip label="Folder actions" side="bottom">
381
+ <DropdownMenuTrigger asChild>
382
+ <Button
383
+ type="button"
384
+ size="icon-xs"
385
+ variant="ghost"
386
+ aria-label={`Actions for folder ${folder.name}`}
387
+ >
388
+ <i className="fa-light fa-ellipsis text-xs" aria-hidden="true" />
389
+ </Button>
390
+ </DropdownMenuTrigger>
391
+ </Tip>
392
+ <DropdownMenuContent align="end" className="w-40">
393
+ <DropdownMenuItem
394
+ onSelect={() => {
395
+ setTimeout(() => startRename(folder), 0)
396
+ }}
397
+ >
398
+ <i className="fa-light fa-pen text-xs" aria-hidden="true" />
399
+ Rename
400
+ </DropdownMenuItem>
401
+ <DropdownMenuItem onSelect={() => {
402
+ setCustomizingFolder(folder)
403
+ setCustomizeFolderOpen(true)
404
+ }}>
405
+ <i className="fa-light fa-wand-magic-sparkles text-xs" aria-hidden="true" />
406
+ Customize
407
+ </DropdownMenuItem>
408
+ <DropdownMenuItem onSelect={() => setMoveFolderId(folder.id)}>
409
+ <i className="fa-light fa-arrow-right-arrow-left text-xs" aria-hidden="true" />
410
+ Move
411
+ </DropdownMenuItem>
412
+ <DropdownMenuSeparator />
413
+ <DropdownMenuItem
414
+ className="text-destructive focus:text-destructive"
415
+ onSelect={() => setDeleteFolderId(folder.id)}
416
+ >
417
+ <i className="fa-light fa-trash text-xs" aria-hidden="true" />
418
+ Delete
419
+ </DropdownMenuItem>
420
+ </DropdownMenuContent>
421
+ </DropdownMenu>
422
+ </div>
423
+ )}
424
+ </div>
425
+ )
426
+ })}
427
+
428
+ {filesHere.map(q => (
429
+ <div key={q.id} role="listitem" className="group relative">
430
+ <div
431
+ className="flex w-full flex-col items-center gap-3 rounded-2xl border border-border bg-card p-4 text-center shadow-sm"
432
+ >
433
+ <div className="flex size-[4.5rem] items-center justify-center rounded-xl bg-muted">
434
+ <i className="fa-solid fa-file-lines text-3xl text-muted-foreground" aria-hidden="true" />
435
+ </div>
436
+ <span className="line-clamp-2 w-full text-xs font-medium text-foreground">{q.stem}</span>
437
+ <ListHubStatusBadge
438
+ surface="board"
439
+ label={QUESTION_BANK_STATUS_LABEL[q.status]}
440
+ tintClassName={QUESTION_BANK_STATUS_BADGE_CLASS[q.status]}
441
+ icon={QUESTION_BANK_STATUS_ICON[q.status]}
442
+ />
443
+ </div>
444
+ <div className="absolute end-1.5 top-1.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
445
+ <DropdownMenu>
446
+ <Tip label="Question actions" side="bottom">
447
+ <DropdownMenuTrigger asChild>
448
+ <Button
449
+ type="button"
450
+ size="icon-xs"
451
+ variant="ghost"
452
+ aria-label={`Move question ${q.stem}`}
453
+ >
454
+ <i className="fa-light fa-ellipsis text-xs" aria-hidden="true" />
455
+ </Button>
456
+ </DropdownMenuTrigger>
457
+ </Tip>
458
+ <DropdownMenuContent align="end" className="w-56">
459
+ <DropdownMenuSub>
460
+ <DropdownMenuSubTrigger>
461
+ <i className="fa-solid fa-folder-arrow-up me-2 text-xs" aria-hidden="true" />
462
+ Move to folder
463
+ </DropdownMenuSubTrigger>
464
+ <DropdownMenuSubContent className="max-h-64 overflow-y-auto">
465
+ {folders
466
+ .filter(f => f.id !== q.folderId)
467
+ .map(f => (
468
+ <DropdownMenuItem key={f.id} onSelect={() => moveQuestionToFolder(q.id, f.id)}>
469
+ <i className={cn("fa-solid me-2 text-xs", f.icon)} aria-hidden="true" />
470
+ {f.name}
471
+ </DropdownMenuItem>
472
+ ))}
473
+ </DropdownMenuSubContent>
474
+ </DropdownMenuSub>
475
+ </DropdownMenuContent>
476
+ </DropdownMenu>
477
+ </div>
478
+ </div>
479
+ ))}
480
+ </div>
481
+
482
+ {childFolders.length === 0 && filesHere.length === 0 && (
483
+ <p className="py-8 text-center text-sm text-muted-foreground">
484
+ This folder is empty. Add a folder with the first tile, or open another location.
485
+ </p>
486
+ )}
487
+
488
+ <QuestionBankNewFolderSheet
489
+ open={createFolderOpen}
490
+ onOpenChange={setCreateFolderOpen}
491
+ parentFolderId={currentId}
492
+ onCreated={data =>
493
+ onFoldersChange(prev => [...prev, { ...data, id: newFolderId() }])
494
+ }
495
+ />
496
+
497
+ {/* Customize folder */}
498
+ <QuestionBankNewFolderSheet
499
+ open={customizeFolderOpen}
500
+ onOpenChange={setCustomizeFolderOpen}
501
+ parentFolderId={customizingFolder?.parentId ?? null}
502
+ customizingFolder={customizingFolder}
503
+ onCreated={data => {
504
+ if (customizingFolder) {
505
+ onFoldersChange(prev =>
506
+ prev.map(f =>
507
+ f.id === customizingFolder.id
508
+ ? {
509
+ ...f,
510
+ name: data.name,
511
+ icon: data.icon,
512
+ colorKey: data.colorKey,
513
+ }
514
+ : f,
515
+ ),
516
+ )
517
+ }
518
+ setCustomizingFolder(null)
519
+ setCustomizeFolderOpen(false)
520
+ }}
521
+ />
522
+
523
+ {/* Appearance (existing folders) */}
524
+ <Dialog open={!!appearanceDialog} onOpenChange={open => !open && setAppearanceDialog(null)}>
525
+ <DialogContent className="max-w-md">
526
+ <DialogHeader>
527
+ <DialogTitle>Folder appearance</DialogTitle>
528
+ <DialogDescription>
529
+ Pick a tint and icon. These are demo-only until wired to your API.
530
+ </DialogDescription>
531
+ </DialogHeader>
532
+ {appearanceDialog && (
533
+ <div className="flex flex-col gap-4 py-2">
534
+ <div>
535
+ <p className="mb-2 text-xs font-medium text-muted-foreground">Color</p>
536
+ <div className="flex flex-wrap gap-2">
537
+ {COLOR_OPTIONS.map(c => (
538
+ <button
539
+ key={c}
540
+ type="button"
541
+ aria-label={`Color ${c}`}
542
+ aria-pressed={appearanceDialog.colorKey === c}
543
+ className={cn(
544
+ "size-9 rounded-lg border-2 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
545
+ QUESTION_BANK_FOLDER_COLOR_STYLES[c].tile,
546
+ appearanceDialog.colorKey === c ? "ring-2 ring-ring" : "border-transparent opacity-80 hover:opacity-100",
547
+ )}
548
+ onClick={() =>
549
+ onFoldersChange(prev =>
550
+ prev.map(f =>
551
+ f.id === appearanceDialog.id ? { ...f, colorKey: c } : f,
552
+ ),
553
+ )
554
+ }
555
+ />
556
+ ))}
557
+ </div>
558
+ </div>
559
+ <div>
560
+ <p className="mb-2 text-xs font-medium text-muted-foreground">Icon</p>
561
+ <div className="grid max-h-40 grid-cols-5 gap-2 overflow-y-auto rounded-md border border-border p-2">
562
+ {QUESTION_BANK_FOLDER_ICON_OPTIONS.map(ic => (
563
+ <button
564
+ key={ic}
565
+ type="button"
566
+ aria-label={`Icon ${ic}`}
567
+ aria-pressed={appearanceDialog.icon === ic}
568
+ className={cn(
569
+ "flex size-9 items-center justify-center rounded-md border text-sm transition-colors",
570
+ appearanceDialog.icon === ic
571
+ ? "border-brand bg-brand/10 text-brand"
572
+ : "border-transparent hover:bg-muted",
573
+ )}
574
+ onClick={() =>
575
+ onFoldersChange(prev =>
576
+ prev.map(f =>
577
+ f.id === appearanceDialog.id ? { ...f, icon: ic } : f,
578
+ ),
579
+ )
580
+ }
581
+ >
582
+ <i className={cn("fa-solid", ic)} aria-hidden="true" />
583
+ </button>
584
+ ))}
585
+ </div>
586
+ </div>
587
+ </div>
588
+ )}
589
+ <DialogFooter>
590
+ <Button type="button" size="sm" onClick={() => setAppearanceDialog(null)}>
591
+ Done
592
+ </Button>
593
+ </DialogFooter>
594
+ </DialogContent>
595
+ </Dialog>
596
+
597
+ {/* Move folder */}
598
+ <Dialog open={moveFolderId !== null} onOpenChange={open => !open && setMoveFolderId(null)}>
599
+ <DialogContent className="max-w-sm">
600
+ <DialogHeader>
601
+ <DialogTitle>Move folder</DialogTitle>
602
+ <DialogDescription>Choose a new parent. Subfolders stay with this folder.</DialogDescription>
603
+ </DialogHeader>
604
+ <div className="flex max-h-64 flex-col gap-1 overflow-y-auto py-2">
605
+ {moveFolderId &&
606
+ validMoveTargets(folders, moveFolderId).map(t => (
607
+ <Button
608
+ key={t.id ?? "root"}
609
+ type="button"
610
+ variant="ghost"
611
+ size="sm"
612
+ className="justify-start font-normal"
613
+ onClick={() => commitMoveFolder(t.id)}
614
+ >
615
+ {t.label}
616
+ </Button>
617
+ ))}
618
+ </div>
619
+ <DialogFooter>
620
+ <Button type="button" variant="outline" size="sm" onClick={() => setMoveFolderId(null)}>
621
+ Cancel
622
+ </Button>
623
+ </DialogFooter>
624
+ </DialogContent>
625
+ </Dialog>
626
+
627
+ {/* Delete confirm */}
628
+ <Dialog open={deleteFolderId !== null} onOpenChange={open => !open && setDeleteFolderId(null)}>
629
+ <DialogContent className="max-w-sm">
630
+ <DialogHeader>
631
+ <DialogTitle>Delete folder?</DialogTitle>
632
+ <DialogDescription>
633
+ Subfolders are removed. Questions inside move to the parent folder (or the first top-level folder).
634
+ </DialogDescription>
635
+ </DialogHeader>
636
+ <DialogFooter className="gap-2 sm:gap-0">
637
+ <Button type="button" variant="outline" size="sm" onClick={() => setDeleteFolderId(null)}>
638
+ Cancel
639
+ </Button>
640
+ <Button type="button" variant="destructive" size="sm" onClick={commitDeleteFolder}>
641
+ Delete
642
+ </Button>
643
+ </DialogFooter>
644
+ </DialogContent>
645
+ </Dialog>
646
+ </ListPageViewFrame>
647
+ )
648
+ }
@@ -18,8 +18,8 @@ import { Tip } from "@/components/ui/tip"
18
18
  import { QUESTION_BANK_HEADER_COLLABORATORS } from "@/lib/mock/question-bank-header-collaborators"
19
19
 
20
20
  export interface QuestionBankPageHeaderProps {
21
- /** Defaults to “Question bank” when omitted (template hub). */
22
- title?: string
21
+ /** Scoped hub title (All / My / folder name) — keep in sync with `SiteHeader`. */
22
+ title: string
23
23
  questionCount: number
24
24
  onNewQuestion: () => void
25
25
  onExport: () => void
@@ -37,7 +37,7 @@ export interface QuestionBankPageHeaderProps {
37
37
  }
38
38
 
39
39
  export function QuestionBankPageHeader({
40
- title = "Question bank",
40
+ title,
41
41
  questionCount,
42
42
  onNewQuestion,
43
43
  onExport,
@@ -0,0 +1,9 @@
1
+ "use client"
2
+
3
+ import { useAutoPanel } from "@/components/secondary-panel"
4
+
5
+ /** Opens the Question bank secondary panel while this route is mounted (same pattern as rotations). */
6
+ export function QuestionBankPanelActivator() {
7
+ useAutoPanel("question-bank")
8
+ return null
9
+ }