@exxatdesignux/ui 0.1.0 → 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 (155) hide show
  1. package/bin/cli.mjs +176 -0
  2. package/bin/init.mjs +15 -1
  3. package/bin/sync-extras.mjs +65 -0
  4. package/consumer-extras/README.md +21 -0
  5. package/consumer-extras/cursor-skills/exxat-accessibility/SKILL.md +282 -0
  6. package/consumer-extras/cursor-skills/exxat-board-cards/SKILL.md +68 -0
  7. package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +99 -0
  8. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +713 -0
  9. package/consumer-extras/cursor-skills/exxat-fontawesome-icons/SKILL.md +31 -0
  10. package/consumer-extras/cursor-skills/exxat-list-page-view-shells/SKILL.md +36 -0
  11. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +27 -0
  12. package/consumer-extras/patterns/command-menu-pattern.md +45 -0
  13. package/consumer-extras/patterns/data-views-pattern.md +167 -0
  14. package/package.json +7 -3
  15. package/src/components/ui/sidebar.tsx +7 -2
  16. package/template/.agents/skills/shadcn/SKILL.md +242 -0
  17. package/template/.agents/skills/shadcn/agents/openai.yml +5 -0
  18. package/template/.agents/skills/shadcn/assets/shadcn-small.png +0 -0
  19. package/template/.agents/skills/shadcn/assets/shadcn.png +0 -0
  20. package/template/.agents/skills/shadcn/cli.md +257 -0
  21. package/template/.agents/skills/shadcn/customization.md +202 -0
  22. package/template/.agents/skills/shadcn/evals/evals.json +47 -0
  23. package/template/.agents/skills/shadcn/mcp.md +94 -0
  24. package/template/.agents/skills/shadcn/rules/base-vs-radix.md +306 -0
  25. package/template/.agents/skills/shadcn/rules/composition.md +195 -0
  26. package/template/.agents/skills/shadcn/rules/forms.md +192 -0
  27. package/template/.agents/skills/shadcn/rules/icons.md +101 -0
  28. package/template/.agents/skills/shadcn/rules/styling.md +162 -0
  29. package/template/.claude/skills/exxat-ds-skill/SKILL.md +712 -0
  30. package/template/.cursor/rules/exxat-accessibility.mdc +33 -0
  31. package/template/.cursor/rules/exxat-command-menu.mdc +23 -0
  32. package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +53 -0
  33. package/template/.cursor/rules/exxat-data-tables.mdc +31 -0
  34. package/template/.cursor/rules/exxat-ds-agents.mdc +26 -0
  35. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +100 -0
  36. package/template/.cursor/rules/exxat-list-page-connected-views.mdc +16 -0
  37. package/template/.cursor/rules/exxat-no-toast.mdc +26 -0
  38. package/template/.cursor/rules/exxat-page-vs-drawer.mdc +22 -0
  39. package/template/.cursor/rules/exxat-table-properties-drawer.mdc +40 -0
  40. package/template/AGENTS.md +52 -11
  41. package/template/app/(app)/dashboard/page.tsx +1 -1
  42. package/template/app/(app)/data-list/[id]/page.tsx +24 -8
  43. package/template/app/(app)/data-list/new/page.tsx +7 -4
  44. package/template/app/(app)/data-list/page.tsx +1 -1
  45. package/template/app/(app)/examples/page.tsx +41 -0
  46. package/template/app/(app)/question-bank/page.tsx +3 -3
  47. package/template/app/globals.css +1 -1
  48. package/template/components/app-sidebar.tsx +52 -35
  49. package/template/components/compliance-table.tsx +79 -0
  50. package/template/components/data-list-client.tsx +36 -25
  51. package/template/components/data-list-table.tsx +797 -10
  52. package/template/components/data-views/finder-panel-view.tsx +405 -0
  53. package/template/components/data-views/folder-grid-view.tsx +86 -0
  54. package/template/components/data-views/index.ts +59 -0
  55. package/template/components/data-views/list-page-split-details-placeholder.tsx +39 -0
  56. package/template/components/data-views/list-page-split-hub-chrome.tsx +60 -0
  57. package/template/components/data-views/list-page-split-hub-tokens.ts +16 -0
  58. package/template/components/data-views/list-page-tree-column-header.tsx +31 -0
  59. package/template/components/data-views/list-page-tree-panel-shell.tsx +91 -0
  60. package/template/components/data-views/list-page-view-frame.tsx +53 -0
  61. package/template/components/data-views/os-folder-glyph.tsx +121 -0
  62. package/template/components/folder-details-shell.tsx +230 -0
  63. package/template/components/hub-tree-panel-view.tsx +672 -0
  64. package/template/components/list-hub-status-badge.tsx +17 -3
  65. package/template/components/page-header.tsx +149 -7
  66. package/template/components/placements-page-header.tsx +14 -8
  67. package/template/components/placements-table-columns.tsx +8 -8
  68. package/template/components/question-bank-client.tsx +157 -39
  69. package/template/components/question-bank-new-folder-sheet.tsx +248 -0
  70. package/template/components/question-bank-os-folder-view.tsx +648 -0
  71. package/template/components/question-bank-page-header.tsx +31 -2
  72. package/template/components/question-bank-panel-activator.tsx +9 -0
  73. package/template/components/question-bank-secondary-nav.tsx +226 -0
  74. package/template/components/question-bank-table.tsx +707 -22
  75. package/template/components/secondary-panel.tsx +41 -107
  76. package/template/components/sites-table.tsx +66 -0
  77. package/template/components/team-client.tsx +7 -0
  78. package/template/components/team-table.tsx +156 -1
  79. package/template/components/templates/list-page.tsx +2 -2
  80. package/template/components/ui/avatar.tsx +1 -1
  81. package/template/components/ui/badge.tsx +1 -1
  82. package/template/components/ui/banner.tsx +1 -1
  83. package/template/components/ui/breadcrumb.tsx +1 -1
  84. package/template/components/ui/button.tsx +1 -1
  85. package/template/components/ui/calendar.tsx +1 -1
  86. package/template/components/ui/card.tsx +1 -1
  87. package/template/components/ui/chart.tsx +1 -1
  88. package/template/components/ui/checkbox.tsx +1 -1
  89. package/template/components/ui/coach-mark.tsx +1 -1
  90. package/template/components/ui/collapsible.tsx +1 -1
  91. package/template/components/ui/command.tsx +1 -1
  92. package/template/components/ui/date-picker-field.tsx +1 -1
  93. package/template/components/ui/dialog.tsx +1 -1
  94. package/template/components/ui/drag-handle-grip.tsx +1 -1
  95. package/template/components/ui/drawer.tsx +1 -1
  96. package/template/components/ui/dropdown-menu.tsx +1 -1
  97. package/template/components/ui/field.tsx +1 -1
  98. package/template/components/ui/form.tsx +1 -1
  99. package/template/components/ui/input-group.tsx +1 -1
  100. package/template/components/ui/input-mask.tsx +1 -1
  101. package/template/components/ui/input.tsx +1 -1
  102. package/template/components/ui/kbd.tsx +1 -1
  103. package/template/components/ui/label.tsx +1 -1
  104. package/template/components/ui/payment-card-fields.tsx +1 -1
  105. package/template/components/ui/popover.tsx +1 -1
  106. package/template/components/ui/radio-group.tsx +1 -1
  107. package/template/components/ui/resizable.tsx +68 -0
  108. package/template/components/ui/select.tsx +1 -1
  109. package/template/components/ui/selection-tile-grid.tsx +1 -1
  110. package/template/components/ui/separator.tsx +1 -1
  111. package/template/components/ui/sheet.tsx +1 -1
  112. package/template/components/ui/sidebar.tsx +1 -1
  113. package/template/components/ui/skeleton.tsx +1 -1
  114. package/template/components/ui/sonner.tsx +1 -1
  115. package/template/components/ui/status-badge.tsx +1 -1
  116. package/template/components/ui/table.tsx +1 -1
  117. package/template/components/ui/tabs.tsx +1 -1
  118. package/template/components/ui/textarea.tsx +1 -1
  119. package/template/components/ui/tip.tsx +1 -1
  120. package/template/components/ui/toggle-group.tsx +1 -1
  121. package/template/components/ui/toggle-switch.tsx +1 -1
  122. package/template/components/ui/toggle.tsx +1 -1
  123. package/template/components/ui/tooltip.tsx +1 -1
  124. package/template/components/ui/view-segmented-control.tsx +1 -1
  125. package/template/docs/data-views-pattern.md +7 -0
  126. package/template/hooks/use-app-theme.ts +1 -1
  127. package/template/hooks/use-coach-mark.ts +1 -1
  128. package/template/hooks/use-location-hash.ts +15 -0
  129. package/template/hooks/use-mobile.ts +1 -1
  130. package/template/hooks/use-mod-key-label.ts +1 -1
  131. package/template/hooks/use-sidebar-reflow-zoom.ts +40 -0
  132. package/template/lib/ask-leo-route-context.ts +25 -57
  133. package/template/lib/coach-mark-registry.ts +13 -13
  134. package/template/lib/command-menu-config.ts +28 -23
  135. package/template/lib/command-menu-search-data.ts +10 -9
  136. package/template/lib/data-list-view-surface.ts +12 -1
  137. package/template/lib/data-list-view.ts +6 -3
  138. package/template/lib/date-filter.ts +1 -1
  139. package/template/lib/mock/dashboard.ts +11 -11
  140. package/template/lib/mock/navigation.tsx +22 -63
  141. package/template/lib/mock/placements-kpi.ts +19 -19
  142. package/template/lib/mock/question-bank-folders.ts +167 -0
  143. package/template/lib/mock/question-bank-header-collaborators.ts +14 -0
  144. package/template/lib/mock/question-bank-inspector.ts +109 -0
  145. package/template/lib/mock/question-bank-kpi.ts +1 -1
  146. package/template/lib/mock/question-bank.ts +80 -0
  147. package/template/lib/question-bank-nav.ts +91 -0
  148. package/template/lib/utils.ts +1 -1
  149. package/template/next.config.mjs +8 -0
  150. package/template/package.json +1 -0
  151. package/template/public/folders/icons8-folder-windows-11.svg +1 -0
  152. package/template/app/(app)/compliance/page.tsx +0 -10
  153. package/template/app/(app)/rotations/page.tsx +0 -15
  154. package/template/app/(app)/sites/all/page.tsx +0 -13
  155. package/template/app/(app)/team/page.tsx +0 -10
@@ -31,11 +31,41 @@ import {
31
31
  } from "@/components/ui/dropdown-menu"
32
32
  import { Tip } from "@/components/ui/tip"
33
33
  import { KeyMetrics } from "@/components/key-metrics"
34
+ import {
35
+ ResizableHandle,
36
+ ResizablePanel,
37
+ ResizablePanelGroup,
38
+ } from "@/components/ui/resizable"
39
+ import {
40
+ Tooltip,
41
+ TooltipContent,
42
+ TooltipTrigger,
43
+ } from "@/components/ui/tooltip"
44
+ import { ListPageSplitHubChrome } from "@/components/data-views/list-page-split-hub-chrome"
45
+ import {
46
+ LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS,
47
+ LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS,
48
+ LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS,
49
+ } from "@/components/data-views/list-page-split-hub-tokens"
50
+ import { ListPageTreeColumnHeader } from "@/components/data-views/list-page-tree-column-header"
34
51
  import { QuestionBankBoardView, QUESTION_BANK_BOARD_GROUP_OPTIONS } from "@/components/question-bank-board-view"
35
52
  import { QuestionBankListView } from "@/components/question-bank-list-view"
53
+ import { QuestionBankOsFolderView } from "@/components/question-bank-os-folder-view"
54
+ import { QuestionBankNewFolderSheet } from "@/components/question-bank-new-folder-sheet"
55
+ import { FolderDetailsShell } from "@/components/folder-details-shell"
56
+ import { HubTreePanelView } from "@/components/hub-tree-panel-view"
57
+ import { OsFolderGlyph } from "@/components/data-views/os-folder-glyph"
58
+ import { Avatar, AvatarFallback } from "@/components/ui/avatar"
59
+ import { initialsFromDisplayName } from "@/lib/initials-from-name"
60
+ import { cn } from "@/lib/utils"
36
61
  import { CHART_KBD_ACTIVE_BAR } from "@/lib/chart-keyboard-selection"
37
62
  import type { QuestionBankDifficulty, QuestionBankItem, QuestionBankType } from "@/lib/mock/question-bank"
63
+ import { newFolderId, type QuestionBankFolder, QUESTION_BANK_FOLDER_COLOR_STYLES, QUESTION_BANK_FOLDER_ICON_COLORS } from "@/lib/mock/question-bank-folders"
38
64
  import { questionBankKpiInsight, questionBankKpiMetrics } from "@/lib/mock/question-bank-kpi"
65
+ import {
66
+ filterQuestionBankItemsByNav,
67
+ type QuestionBankNavState,
68
+ } from "@/lib/question-bank-nav"
39
69
  import {
40
70
  QUESTION_BANK_STATUS_BADGE_CLASS,
41
71
  QUESTION_BANK_STATUS_ICON,
@@ -62,6 +92,17 @@ const BAR_CFG: ChartConfig = {
62
92
  count: { label: "Questions", color: "var(--color-chart-2)" },
63
93
  }
64
94
 
95
+ function newQuestionBankItemId() {
96
+ return `q-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
97
+ }
98
+
99
+ /** Folder id to use when adding a question from the root column (`parentId` null). */
100
+ function defaultFolderIdForColumnParent(parentId: string | null, folders: QuestionBankFolder[]): string | null {
101
+ if (parentId !== null) return parentId
102
+ const roots = [...folders].filter(f => f.parentId === null).sort((a, b) => a.name.localeCompare(b.name))
103
+ return roots[0]?.id ?? null
104
+ }
105
+
65
106
  function uniqueTopics(items: QuestionBankItem[]) {
66
107
  return [...new Set(items.map(i => i.topic))].sort().map(t => ({ value: t, label: t }))
67
108
  }
@@ -328,6 +369,114 @@ function QuestionsByStatusChart({ rows }: { rows: QuestionBankItem[] }) {
328
369
  )
329
370
  }
330
371
 
372
+ function QuestionBankFinderListRow({
373
+ row,
374
+ isSelected,
375
+ compact,
376
+ }: {
377
+ row: QuestionBankItem
378
+ isSelected: boolean
379
+ compact?: boolean
380
+ }) {
381
+ const initials = initialsFromDisplayName(row.author)
382
+ return (
383
+ <div
384
+ className={cn(
385
+ "flex w-full items-center px-3 py-2",
386
+ compact ? "gap-2 py-1.5 pl-2 pr-2.5" : "gap-3",
387
+ )}
388
+ >
389
+ <Avatar className={cn("shrink-0", compact ? "size-6" : "size-8")}>
390
+ <AvatarFallback
391
+ className={cn(
392
+ "font-semibold",
393
+ compact ? "text-[10px]" : "text-[11px]",
394
+ isSelected ? "bg-background/25 text-accent-foreground" : "bg-brand/15 text-brand",
395
+ )}
396
+ >
397
+ {initials}
398
+ </AvatarFallback>
399
+ </Avatar>
400
+ <div className="min-w-0 flex-1">
401
+ <p className={cn("line-clamp-2 font-medium leading-tight", compact ? "text-[11px]" : "text-xs")}>
402
+ {row.stem}
403
+ </p>
404
+ <p
405
+ className={cn(
406
+ "mt-0.5 truncate text-[11px] leading-tight",
407
+ isSelected ? "text-accent-foreground/85" : "text-muted-foreground",
408
+ )}
409
+ >
410
+ {row.topic} · {row.author}
411
+ </p>
412
+ </div>
413
+ {!isSelected && (
414
+ <ListHubStatusBadge
415
+ surface="board"
416
+ label={QUESTION_BANK_STATUS_LABEL[row.status]}
417
+ tintClassName={QUESTION_BANK_STATUS_BADGE_CLASS[row.status]}
418
+ icon={QUESTION_BANK_STATUS_ICON[row.status]}
419
+ />
420
+ )}
421
+ </div>
422
+ )
423
+ }
424
+
425
+ function QuestionBankFinderDetail({ row }: { row: QuestionBankItem }) {
426
+ return (
427
+ <div className="flex h-full min-h-0 flex-col">
428
+ <div className="flex shrink-0 flex-col gap-2 border-b border-border px-5 py-4">
429
+ <h2 className="line-clamp-4 text-base font-semibold leading-tight text-foreground">{row.stem}</h2>
430
+ <ListHubStatusBadge
431
+ surface="board"
432
+ label={QUESTION_BANK_STATUS_LABEL[row.status]}
433
+ tintClassName={QUESTION_BANK_STATUS_BADGE_CLASS[row.status]}
434
+ icon={QUESTION_BANK_STATUS_ICON[row.status]}
435
+ />
436
+ </div>
437
+ <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
438
+ <dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
439
+ <div className="flex flex-col gap-0.5">
440
+ <dt className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
441
+ <i className="fa-light fa-layer-group text-xs" aria-hidden="true" />
442
+ Topic
443
+ </dt>
444
+ <dd className="text-xs text-foreground">{row.topic}</dd>
445
+ </div>
446
+ <div className="flex flex-col gap-0.5">
447
+ <dt className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
448
+ <i className="fa-light fa-list-check text-xs" aria-hidden="true" />
449
+ Type
450
+ </dt>
451
+ <dd className="text-xs text-foreground">{TYPE_LABEL[row.type]}</dd>
452
+ </div>
453
+ <div className="flex flex-col gap-0.5">
454
+ <dt className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
455
+ <i className="fa-light fa-signal text-xs" aria-hidden="true" />
456
+ Difficulty
457
+ </dt>
458
+ <dd className="text-xs text-foreground">{DIFFICULTY_LABEL[row.difficulty]}</dd>
459
+ </div>
460
+ <div className="flex flex-col gap-0.5">
461
+ <dt className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
462
+ <i className="fa-light fa-user text-xs" aria-hidden="true" />
463
+ Author
464
+ </dt>
465
+ <dd className="text-xs text-foreground">{row.author}</dd>
466
+ </div>
467
+ <div className="flex flex-col gap-0.5 sm:col-span-2">
468
+ <dt className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
469
+ <i className="fa-light fa-calendar-days text-xs" aria-hidden="true" />
470
+ Updated
471
+ </dt>
472
+ <dd className="text-xs tabular-nums text-foreground">{row.updatedAt}</dd>
473
+ </div>
474
+ </dl>
475
+ </div>
476
+ </div>
477
+ )
478
+ }
479
+
331
480
  function QuestionsByTopicChart({ rows }: { rows: QuestionBankItem[] }) {
332
481
  const data = React.useMemo(() => aggregateByTopic(rows), [rows])
333
482
  if (rows.length === 0) {
@@ -403,13 +552,335 @@ function QuestionBankDashboardSimple({ rows }: { rows: QuestionBankItem[] }) {
403
552
  )
404
553
  }
405
554
 
555
+ interface HubFolderColumnsPanelProps {
556
+ folders: QuestionBankFolder[]
557
+ rows: QuestionBankItem[]
558
+ panelRenderDetail: (row: QuestionBankItem) => React.ReactNode
559
+ onAddFolder: (parentId: string | null) => void
560
+ onAddQuestion: (parentId: string | null) => void
561
+ onCustomizeFolder?: (folder: QuestionBankFolder) => void
562
+ }
563
+
564
+ type HierarchyItem = QuestionBankFolder | QuestionBankItem
565
+
566
+ function isFolder(item: HierarchyItem): item is QuestionBankFolder {
567
+ return 'parentId' in item
568
+ }
569
+
570
+ function isQuestion(item: HierarchyItem): item is QuestionBankItem {
571
+ return 'stem' in item
572
+ }
573
+
574
+ /** **Panel view** — multi-column folder explorer + optional detail column (Finder-style). */
575
+ function HubFolderColumnsPanel({
576
+ folders,
577
+ rows,
578
+ panelRenderDetail,
579
+ onAddFolder,
580
+ onAddQuestion,
581
+ onCustomizeFolder,
582
+ }: HubFolderColumnsPanelProps) {
583
+ // Track the selected path through the hierarchy
584
+ // Initialize with first folder selected by default
585
+ const [selectedPath, setSelectedPath] = React.useState<HierarchyItem[]>(() => {
586
+ const rootFolders = folders
587
+ .filter(f => f.parentId === null)
588
+ .sort((a, b) => a.name.localeCompare(b.name))
589
+ if (rootFolders.length > 0) {
590
+ return [rootFolders[0]]
591
+ }
592
+ return []
593
+ })
594
+
595
+ // Track if this is the first render for auto-selection
596
+ const isFirstRenderRef = React.useRef(true)
597
+
598
+ // Get root items (top-level folders)
599
+ const rootFolders = React.useMemo(() => {
600
+ return folders
601
+ .filter(f => f.parentId === null)
602
+ .sort((a, b) => a.name.localeCompare(b.name))
603
+ }, [folders])
604
+
605
+ // Handle selection at any depth
606
+ const handleSelect = (item: HierarchyItem, depth: number) => {
607
+ setSelectedPath(prev => [...prev.slice(0, depth), item])
608
+ }
609
+
610
+ // Auto-select first item at each level (only on first render)
611
+ React.useEffect(() => {
612
+ // Only auto-select if we're at a folder in the path and this is the first render
613
+ if (isFirstRenderRef.current && selectedPath.length > 0) {
614
+ const lastItem = selectedPath[selectedPath.length - 1]
615
+ if (isFolder(lastItem)) {
616
+ const folder = lastItem as QuestionBankFolder
617
+ // Get the items in this folder
618
+ const subfolders = folders.filter(f => f.parentId === folder.id).sort((a, b) => a.name.localeCompare(b.name))
619
+ const questionsInFolder = rows.filter(r => r.folderId === folder.id)
620
+ const items: HierarchyItem[] = [...subfolders, ...questionsInFolder]
621
+
622
+ // If there are items and nothing is selected at the next level, select the first item
623
+ if (items.length > 0 && !selectedPath[selectedPath.length + 1]) {
624
+ setSelectedPath(prev => [...prev, items[0]])
625
+ isFirstRenderRef.current = false
626
+ }
627
+ }
628
+ }
629
+ }, [])
630
+
631
+ // Build columns dynamically based on selected path
632
+ const columns: Array<{ items: HierarchyItem[]; depth: number; parentId?: string | null }> = React.useMemo(() => {
633
+ const cols: Array<{ items: HierarchyItem[]; depth: number; parentId?: string | null }> = [
634
+ { items: rootFolders, depth: 0, parentId: null },
635
+ ]
636
+
637
+ // For each selected folder in the path, add a column with its children
638
+ for (let i = 0; i < selectedPath.length; i++) {
639
+ const item = selectedPath[i]
640
+ if (isFolder(item)) {
641
+ // Get subfolders
642
+ const subfolders = folders
643
+ .filter(f => f.parentId === item.id)
644
+ .sort((a, b) => a.name.localeCompare(b.name))
645
+
646
+ // Get questions in this folder
647
+ const questionsInFolder = rows.filter(r => r.folderId === item.id)
648
+
649
+ // Combine folders and questions
650
+ const items: HierarchyItem[] = [...subfolders, ...questionsInFolder]
651
+
652
+ if (items.length > 0) {
653
+ cols.push({ items, depth: i + 1, parentId: item.id })
654
+ }
655
+ }
656
+ }
657
+
658
+ return cols
659
+ }, [selectedPath, rootFolders, folders, rows])
660
+
661
+ const selectedLeaf = selectedPath.length > 0 ? selectedPath.at(-1)! : null
662
+ const selectedQuestion =
663
+ selectedLeaf && isQuestion(selectedLeaf) ? (selectedLeaf as QuestionBankItem) : null
664
+ const selectedFolderLeaf =
665
+ selectedLeaf && isFolder(selectedLeaf) ? (selectedLeaf as QuestionBankFolder) : null
666
+
667
+ return (
668
+ <ResizablePanelGroup
669
+ direction="horizontal"
670
+ className="flex h-full min-h-0 w-full flex-1 overflow-hidden"
671
+ >
672
+ {/* Render all columns with handles between them */}
673
+ {columns.map(({ items, depth, parentId }, columnIdx) => (
674
+ <React.Fragment key={`col-${depth}`}>
675
+ {columnIdx > 0 && (
676
+ <ResizableHandle withHandle className={LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS} />
677
+ )}
678
+ <ResizablePanel
679
+ id={`col-${depth}`}
680
+ defaultSize={columnIdx === 0 ? 35 : columnIdx === 1 ? 35 : 30}
681
+ minSize={15}
682
+ className={LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS}
683
+ >
684
+ <ListPageTreeColumnHeader
685
+ title={
686
+ depth === 0
687
+ ? "Categories"
688
+ : selectedPath[depth - 1] && isFolder(selectedPath[depth - 1])
689
+ ? (selectedPath[depth - 1] as QuestionBankFolder).name
690
+ : "Items"
691
+ }
692
+ trailing={
693
+ <>
694
+ <span className="shrink-0 text-xs font-medium text-muted-foreground tabular-nums">
695
+ {items.length}
696
+ </span>
697
+ {depth < columns.length - 1 && items.length > 0 ? (
698
+ <div className="flex shrink-0 items-center gap-0.5">
699
+ <Tooltip>
700
+ <TooltipTrigger asChild>
701
+ <Button
702
+ size="icon-sm"
703
+ variant="ghost"
704
+ onClick={() => onAddFolder(parentId ?? null)}
705
+ aria-label="Add folder"
706
+ >
707
+ <i className="fa-light fa-folder-plus text-xs" aria-hidden="true" />
708
+ </Button>
709
+ </TooltipTrigger>
710
+ <TooltipContent side="top" sideOffset={4}>
711
+ Add folder
712
+ </TooltipContent>
713
+ </Tooltip>
714
+ <Tooltip>
715
+ <TooltipTrigger asChild>
716
+ <Button
717
+ size="icon-sm"
718
+ variant="ghost"
719
+ onClick={() => onAddQuestion(parentId ?? null)}
720
+ aria-label="Add question"
721
+ >
722
+ <i className="fa-light fa-plus text-xs" aria-hidden="true" />
723
+ </Button>
724
+ </TooltipTrigger>
725
+ <TooltipContent side="top" sideOffset={4}>
726
+ Add question
727
+ </TooltipContent>
728
+ </Tooltip>
729
+ </div>
730
+ ) : null}
731
+ </>
732
+ }
733
+ />
734
+
735
+ {/* Scrollable Items List */}
736
+ <div className="min-h-0 flex-1 overflow-y-auto py-1">
737
+ {items.map(item => {
738
+ const isSelected = selectedPath[depth]?.id === item.id
739
+ const isFolder_ = isFolder(item)
740
+ const folder = isFolder_ ? item : null
741
+ const question = isQuestion(item) ? item : null
742
+
743
+ // Get count for folders
744
+ const subfolderCount = isFolder_
745
+ ? folders.filter(f => f.parentId === item.id).length
746
+ : 0
747
+ const questionCount = isFolder_
748
+ ? rows.filter(r => r.folderId === item.id).length
749
+ : 0
750
+ const itemCount = subfolderCount + questionCount
751
+
752
+ return (
753
+ <div
754
+ key={item.id}
755
+ className="group flex items-center hover:bg-muted/50"
756
+ >
757
+ <button
758
+ onClick={() => handleSelect(item, depth)}
759
+ className={cn(
760
+ "flex flex-1 items-center gap-3 px-3 py-2 text-left text-sm transition-colors duration-75",
761
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
762
+ isSelected
763
+ ? "bg-accent text-accent-foreground"
764
+ : "text-foreground",
765
+ // Apply folder background color if it's a folder and not selected, but NOT in the first column
766
+ isFolder_ && !isSelected && folder?.colorKey && depth > 0
767
+ ? QUESTION_BANK_FOLDER_COLOR_STYLES[folder.colorKey]?.tile
768
+ : "",
769
+ )}
770
+ aria-selected={isSelected}
771
+ role="option"
772
+ >
773
+ {/* Icon - show for folders and questions */}
774
+ {isFolder_ ? (
775
+ <i className={cn(
776
+ "fa-folder text-sm shrink-0",
777
+ isSelected ? "fa-solid" : "fa-light",
778
+ // Apply folder color from customization (for both selected and unselected)
779
+ folder?.colorKey && QUESTION_BANK_FOLDER_ICON_COLORS[folder.colorKey]
780
+ )} aria-hidden="true" />
781
+ ) : (
782
+ <i className={cn("fa-file text-sm shrink-0", isSelected ? "fa-solid" : "fa-light")} aria-hidden="true" />
783
+ )}
784
+
785
+ {/* Name */}
786
+ <span className={cn(
787
+ "min-w-0 flex-1 truncate leading-tight",
788
+ isSelected && "font-medium"
789
+ )}>
790
+ {isFolder_ ? folder?.name : question?.stem}
791
+ </span>
792
+
793
+ {/* Count or metadata */}
794
+ <span className={cn(
795
+ "shrink-0 tabular-nums text-xs ml-auto",
796
+ isSelected ? "text-accent-foreground/70" : "text-muted-foreground",
797
+ )}>
798
+ {isFolder_ ? itemCount : (question?.type === 'multiple_choice' ? 'MCQ' : question?.difficulty?.charAt(0).toUpperCase())}
799
+ </span>
800
+ </button>
801
+
802
+ {/* Folder actions menu - only for folders */}
803
+ {isFolder_ && folder && (
804
+ <DropdownMenu>
805
+ <DropdownMenuTrigger asChild>
806
+ <Button
807
+ type="button"
808
+ size="icon-xs"
809
+ variant="ghost"
810
+ aria-label={`Actions for folder ${folder.name}`}
811
+ className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
812
+ >
813
+ <i className="fa-light fa-ellipsis text-xs" aria-hidden="true" />
814
+ </Button>
815
+ </DropdownMenuTrigger>
816
+ <DropdownMenuContent align="end" className="w-40">
817
+ <DropdownMenuItem onSelect={() => onCustomizeFolder?.(folder)}>
818
+ <i className="fa-light fa-wand-magic-sparkles text-xs" aria-hidden="true" />
819
+ Customize
820
+ </DropdownMenuItem>
821
+ </DropdownMenuContent>
822
+ </DropdownMenu>
823
+ )}
824
+ </div>
825
+ )
826
+ })}
827
+ </div>
828
+ </ResizablePanel>
829
+ </React.Fragment>
830
+ ))}
831
+
832
+ {/* Details panel — question (summary) or folder (aggregates) */}
833
+ {(selectedQuestion || selectedFolderLeaf) && (
834
+ <>
835
+ <ResizableHandle withHandle className={LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS} />
836
+ <ResizablePanel id="col-detail" defaultSize={30} minSize={20} className={LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS}>
837
+ {selectedQuestion ? (
838
+ <>
839
+ <ListPageTreeColumnHeader title="Details" className="px-4" />
840
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
841
+ {panelRenderDetail(selectedQuestion)}
842
+ </div>
843
+ </>
844
+ ) : selectedFolderLeaf ? (
845
+ <div className="min-h-0 flex-1 overflow-hidden">
846
+ <FolderDetailsShell
847
+ folder={selectedFolderLeaf}
848
+ folders={folders}
849
+ questions={rows}
850
+ />
851
+ </div>
852
+ ) : null}
853
+ </ResizablePanel>
854
+ </>
855
+ )}
856
+ </ResizablePanelGroup>
857
+ )
858
+ }
859
+
406
860
  export type QuestionBankTableHandle = OpenTablePropertiesHandle
407
861
 
408
862
  export const QuestionBankTable = React.forwardRef<
409
863
  QuestionBankTableHandle,
410
- { items: QuestionBankItem[]; view?: DataListViewType; onViewChange?: (v: DataListViewType) => void }
411
- >(function QuestionBankTable({ items, view = "table", onViewChange }, ref) {
412
- const columns = React.useMemo(() => buildQuestionBankColumns(items), [items])
864
+ {
865
+ items: QuestionBankItem[]
866
+ /** When set, table / board / tree rows are limited to this nav scope (secondary sidebar). */
867
+ navState?: QuestionBankNavState
868
+ view?: DataListViewType
869
+ onViewChange?: (v: DataListViewType) => void
870
+ folders: QuestionBankFolder[]
871
+ onFoldersChange: React.Dispatch<React.SetStateAction<QuestionBankFolder[]>>
872
+ onItemsChange: React.Dispatch<React.SetStateAction<QuestionBankItem[]>>
873
+ }
874
+ >(function QuestionBankTable(
875
+ { items, navState, view = "table", onViewChange, folders, onFoldersChange, onItemsChange },
876
+ ref,
877
+ ) {
878
+ const tableSourceItems = React.useMemo(() => {
879
+ const nav = navState ?? { scope: "all" as const, folderId: null }
880
+ return filterQuestionBankItemsByNav(items, folders, nav)
881
+ }, [items, folders, navState])
882
+
883
+ const columns = React.useMemo(() => buildQuestionBankColumns(tableSourceItems), [tableSourceItems])
413
884
  const filterFields = React.useMemo(() => columnsToFilterFields(columns), [columns])
414
885
  const fieldDefinitionsForDrawer = React.useMemo(
415
886
  () =>
@@ -429,6 +900,10 @@ export const QuestionBankTable = React.forwardRef<
429
900
  setDisplayOptions(prev => ({ ...prev, ...patch }))
430
901
  }, [])
431
902
 
903
+ const [newFolderOpen, setNewFolderOpen] = React.useState(false)
904
+ const [newFolderParentId, setNewFolderParentId] = React.useState<string | null>(null)
905
+ const [customizingFolder, setCustomizingFolder] = React.useState<QuestionBankFolder | null>(null)
906
+
432
907
  const [conditionalRules, setConditionalRules] = React.useState<ConditionalRule[]>([])
433
908
  const addConditionalRule = React.useCallback((rule: Omit<ConditionalRule, "id">) => {
434
909
  setConditionalRules(prev => [...prev, { ...rule, id: `cr-${Date.now()}` }])
@@ -440,7 +915,44 @@ export const QuestionBankTable = React.forwardRef<
440
915
  setConditionalRules(prev => prev.map(r => r.id === id ? { ...r, ...patch } : r))
441
916
  }, [])
442
917
 
443
- const tableState = useTableState(items, columns, { key: "updatedAt", dir: "desc" })
918
+ const tableState = useTableState(tableSourceItems, columns, { key: "updatedAt", dir: "desc" })
919
+
920
+ const openNewFolderForColumn = React.useCallback((parentId: string | null) => {
921
+ setNewFolderParentId(parentId)
922
+ setCustomizingFolder(null)
923
+ setNewFolderOpen(true)
924
+ }, [])
925
+
926
+ const openCustomizeFolderSheet = React.useCallback((folder: QuestionBankFolder) => {
927
+ setCustomizingFolder(folder)
928
+ setNewFolderOpen(true)
929
+ }, [])
930
+
931
+ const addQuestionInColumn = React.useCallback(
932
+ (parentId: string | null) => {
933
+ const folderId = defaultFolderIdForColumnParent(parentId, folders)
934
+ if (!folderId) return
935
+ const today = new Date()
936
+ const y = today.getFullYear()
937
+ const m = String(today.getMonth() + 1).padStart(2, "0")
938
+ const d = String(today.getDate()).padStart(2, "0")
939
+ onItemsChange(prev => [
940
+ ...prev,
941
+ {
942
+ id: newQuestionBankItemId(),
943
+ stem: "New question",
944
+ topic: "General",
945
+ type: "short_answer",
946
+ difficulty: "medium",
947
+ status: "draft",
948
+ author: "Demo user",
949
+ updatedAt: `${y}-${m}-${d}`,
950
+ folderId,
951
+ },
952
+ ])
953
+ },
954
+ [folders, onItemsChange],
955
+ )
444
956
 
445
957
  const renderFilterOptionValue = React.useCallback(
446
958
  (fieldKey: string, value: string): React.ReactNode => {
@@ -476,8 +988,62 @@ export const QuestionBankTable = React.forwardRef<
476
988
  ? displayOptions.boardGroupByColumnKey
477
989
  : "status"
478
990
 
991
+ const panelRenderDetail = (row: QuestionBankItem) => (
992
+ <div className="flex min-w-0 flex-col gap-4">
993
+ <div>
994
+ <h3 className="text-sm font-semibold text-foreground mb-2">Question</h3>
995
+ <p className="text-sm text-foreground">{row.stem}</p>
996
+ </div>
997
+ <div className="flex gap-3 flex-wrap">
998
+ <div className="flex flex-col gap-1">
999
+ <span className="text-xs font-medium text-muted-foreground">Type</span>
1000
+ <span className="text-sm text-foreground">{TYPE_LABEL[row.type]}</span>
1001
+ </div>
1002
+ <div className="flex flex-col gap-1">
1003
+ <span className="text-xs font-medium text-muted-foreground">Difficulty</span>
1004
+ <span className="text-sm text-foreground">{DIFFICULTY_LABEL[row.difficulty]}</span>
1005
+ </div>
1006
+ <div className="flex flex-col gap-1">
1007
+ <span className="text-xs font-medium text-muted-foreground">Status</span>
1008
+ <span className="text-sm text-foreground">
1009
+ {QUESTION_BANK_STATUS_LABEL[row.status]}
1010
+ </span>
1011
+ </div>
1012
+ </div>
1013
+ {row.topic && (
1014
+ <div>
1015
+ <span className="text-xs font-medium text-muted-foreground">Topic</span>
1016
+ <p className="text-sm text-foreground mt-1">{row.topic}</p>
1017
+ </div>
1018
+ )}
1019
+ {row.type === "multiple_choice" && row.options && row.options.length > 0 && (
1020
+ <div>
1021
+ <span className="text-xs font-medium text-muted-foreground block mb-2">Options</span>
1022
+ <div className="flex flex-col gap-2">
1023
+ {row.options.map((option, idx) => (
1024
+ <div key={idx} className="flex items-start gap-2 p-2 rounded bg-muted/30">
1025
+ <span className="text-xs font-medium text-muted-foreground mt-0.5 shrink-0">
1026
+ {String.fromCharCode(65 + idx)}.
1027
+ </span>
1028
+ <span className={cn(
1029
+ "text-sm",
1030
+ option.isCorrect ? "text-foreground font-medium" : "text-foreground/80"
1031
+ )}>
1032
+ {option.text}
1033
+ </span>
1034
+ {option.isCorrect && (
1035
+ <i className="fa-light fa-check text-emerald-600 text-sm ml-auto shrink-0" aria-hidden="true" />
1036
+ )}
1037
+ </div>
1038
+ ))}
1039
+ </div>
1040
+ </div>
1041
+ )}
1042
+ </div>
1043
+ )
1044
+
479
1045
  const drawerToolbarProps = {
480
- totalRows: items.length,
1046
+ totalRows: tableSourceItems.length,
481
1047
  filterFields,
482
1048
  fieldDefinitions: fieldDefinitionsForDrawer,
483
1049
  resolveColumnLabel,
@@ -495,7 +1061,7 @@ export const QuestionBankTable = React.forwardRef<
495
1061
  }
496
1062
 
497
1063
  const tableProps = {
498
- data: items,
1064
+ data: tableSourceItems,
499
1065
  columns,
500
1066
  getRowId: (row: QuestionBankItem) => row.id,
501
1067
  getRowSelectionLabel: (row: QuestionBankItem) => row.stem,
@@ -528,14 +1094,6 @@ export const QuestionBankTable = React.forwardRef<
528
1094
  },
529
1095
  }
530
1096
 
531
- if (view === "table") {
532
- return (
533
- <div className="pb-6">
534
- <DataTable<QuestionBankItem> {...tableProps} />
535
- </div>
536
- )
537
- }
538
-
539
1097
  const sharedToolbar = (
540
1098
  <DataTableToolbar
541
1099
  state={tableState}
@@ -547,6 +1105,14 @@ export const QuestionBankTable = React.forwardRef<
547
1105
  />
548
1106
  )
549
1107
 
1108
+ if (view === "table") {
1109
+ return (
1110
+ <div className="pb-6">
1111
+ <DataTable<QuestionBankItem> {...tableProps} />
1112
+ </div>
1113
+ )
1114
+ }
1115
+
550
1116
  if (view === "list") {
551
1117
  return (
552
1118
  <div className="flex min-h-0 flex-1 flex-col">
@@ -568,16 +1134,135 @@ export const QuestionBankTable = React.forwardRef<
568
1134
  )
569
1135
  }
570
1136
 
1137
+ if (view === "folder") {
1138
+ return (
1139
+ <div className="flex min-h-0 flex-1 flex-col">
1140
+ {sharedToolbar}
1141
+ <QuestionBankOsFolderView
1142
+ folders={folders}
1143
+ onFoldersChange={onFoldersChange}
1144
+ questions={tableState.rows as QuestionBankItem[]}
1145
+ onQuestionsChange={onItemsChange}
1146
+ />
1147
+ </div>
1148
+ )
1149
+ }
1150
+
1151
+ if (view === "panel") {
1152
+ return (
1153
+ <>
1154
+ <div className="flex min-h-0 flex-1 flex-col">
1155
+ {sharedToolbar}
1156
+ <ListPageSplitHubChrome aria-label="Question bank folder columns">
1157
+ <HubFolderColumnsPanel
1158
+ folders={folders}
1159
+ rows={tableState.rows as QuestionBankItem[]}
1160
+ panelRenderDetail={panelRenderDetail}
1161
+ onAddFolder={openNewFolderForColumn}
1162
+ onAddQuestion={addQuestionInColumn}
1163
+ onCustomizeFolder={openCustomizeFolderSheet}
1164
+ />
1165
+ </ListPageSplitHubChrome>
1166
+ </div>
1167
+ <QuestionBankNewFolderSheet
1168
+ open={newFolderOpen}
1169
+ onOpenChange={setNewFolderOpen}
1170
+ parentFolderId={customizingFolder?.parentId ?? newFolderParentId}
1171
+ customizingFolder={customizingFolder}
1172
+ onCreated={(newFolder) => {
1173
+ if (customizingFolder) {
1174
+ // Update existing folder
1175
+ onFoldersChange(prev =>
1176
+ prev.map(f =>
1177
+ f.id === customizingFolder.id
1178
+ ? {
1179
+ ...f,
1180
+ name: newFolder.name,
1181
+ icon: newFolder.icon,
1182
+ colorKey: newFolder.colorKey,
1183
+ }
1184
+ : f,
1185
+ ),
1186
+ )
1187
+ setCustomizingFolder(null)
1188
+ } else {
1189
+ // Create new folder
1190
+ onFoldersChange(prev => [
1191
+ ...prev,
1192
+ {
1193
+ id: `fld-${Date.now()}`,
1194
+ name: newFolder.name,
1195
+ icon: newFolder.icon,
1196
+ colorKey: newFolder.colorKey,
1197
+ parentId: newFolder.parentId,
1198
+ },
1199
+ ])
1200
+ }
1201
+ setNewFolderOpen(false)
1202
+ }}
1203
+ />
1204
+ </>
1205
+ )
1206
+ }
1207
+
1208
+ if (view === "tree-panel") {
1209
+ return (
1210
+ <>
1211
+ <div className="flex min-h-0 flex-1 flex-col">
1212
+ {sharedToolbar}
1213
+ <div className="flex min-h-0 flex-1 flex-col">
1214
+ <HubTreePanelView
1215
+ items={tableState.rows as QuestionBankItem[]}
1216
+ folders={folders}
1217
+ onItemsChange={onItemsChange}
1218
+ onFoldersChange={onFoldersChange}
1219
+ />
1220
+ </div>
1221
+ </div>
1222
+ <QuestionBankNewFolderSheet
1223
+ open={newFolderOpen}
1224
+ onOpenChange={setNewFolderOpen}
1225
+ parentFolderId={customizingFolder?.parentId ?? newFolderParentId}
1226
+ customizingFolder={customizingFolder}
1227
+ onCreated={(newFolder) => {
1228
+ if (customizingFolder) {
1229
+ // Update existing folder
1230
+ onFoldersChange(prev =>
1231
+ prev.map(f =>
1232
+ f.id === customizingFolder.id
1233
+ ? {
1234
+ ...f,
1235
+ name: newFolder.name,
1236
+ icon: newFolder.icon,
1237
+ colorKey: newFolder.colorKey,
1238
+ }
1239
+ : f,
1240
+ ),
1241
+ )
1242
+ setCustomizingFolder(null)
1243
+ } else {
1244
+ // Create new folder
1245
+ onFoldersChange(prev => [
1246
+ ...prev,
1247
+ {
1248
+ id: `fld-${Date.now()}`,
1249
+ name: newFolder.name,
1250
+ icon: newFolder.icon,
1251
+ colorKey: newFolder.colorKey,
1252
+ parentId: newFolder.parentId,
1253
+ },
1254
+ ])
1255
+ }
1256
+ setNewFolderOpen(false)
1257
+ }}
1258
+ />
1259
+ </>
1260
+ )
1261
+ }
1262
+
571
1263
  return (
572
1264
  <div className="flex min-h-0 flex-1 flex-col">
573
- <DataTableToolbar
574
- state={tableState}
575
- columns={columns}
576
- searchable={displayOptions.showToolbarSearch}
577
- searchAriaLabel="Search questions"
578
- renderFilterOptionValue={renderFilterOptionValue}
579
- toolbarSlot={s => <TablePropertiesDrawerButton {...drawerToolbarProps} state={s} />}
580
- />
1265
+ {sharedToolbar}
581
1266
  <QuestionBankDashboardSimple rows={tableState.rows as QuestionBankItem[]} />
582
1267
  </div>
583
1268
  )