@exxatdesignux/ui 0.2.8 → 0.2.10

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 (125) hide show
  1. package/consumer-extras/cursor-skills/exxat-card-vs-list-rows/SKILL.md +20 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +33 -0
  3. package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +45 -0
  4. package/consumer-extras/cursor-skills/exxat-drawer-vs-dialog/SKILL.md +20 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +31 -5
  6. package/consumer-extras/cursor-skills/exxat-kpi-max-four/SKILL.md +19 -0
  7. package/consumer-extras/cursor-skills/exxat-kpi-trends/SKILL.md +27 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +1 -0
  9. package/consumer-extras/patterns/collaboration-access-pattern.md +114 -0
  10. package/consumer-extras/patterns/data-views-pattern.md +12 -4
  11. package/package.json +17 -4
  12. package/src/components/ui/banner.tsx +20 -7
  13. package/src/components/ui/date-picker-field.tsx +3 -3
  14. package/src/components/ui/dropdown-menu.tsx +17 -6
  15. package/src/components/ui/input-group.tsx +1 -1
  16. package/src/components/ui/input.tsx +1 -1
  17. package/src/components/ui/select.tsx +1 -1
  18. package/src/components/ui/separator.tsx +2 -2
  19. package/src/components/ui/sidebar.tsx +31 -3
  20. package/src/components/ui/textarea.tsx +1 -1
  21. package/src/globals.css +0 -1
  22. package/src/index.ts +1 -0
  23. package/src/lib/date-filter.ts +13 -4
  24. package/src/lib/dropdown-menu-surface.ts +13 -0
  25. package/template/.claude/skills/exxat-ds-skill/SKILL.md +27 -9
  26. package/template/.cursor/rules/exxat-data-tables.mdc +1 -0
  27. package/template/AGENTS.md +82 -27
  28. package/template/app/(app)/examples/page.tsx +2 -1
  29. package/template/app/(app)/help/page.tsx +6 -0
  30. package/template/app/(app)/layout.tsx +7 -4
  31. package/template/app/(app)/question-bank/find/page.tsx +12 -0
  32. package/template/app/(app)/question-bank/layout.tsx +46 -0
  33. package/template/app/(app)/question-bank/library/page.tsx +11 -0
  34. package/template/app/(app)/question-bank/list/page.tsx +12 -0
  35. package/template/app/(app)/question-bank/page.tsx +4 -3
  36. package/template/app/globals.css +1 -2
  37. package/template/components/app-sidebar.tsx +51 -13
  38. package/template/components/ask-leo-composer.tsx +173 -45
  39. package/template/components/ask-leo-sidebar.tsx +9 -1
  40. package/template/components/chart-area-interactive.tsx +3 -13
  41. package/template/components/charts-overview.tsx +33 -6
  42. package/template/components/collaboration-access-flow.tsx +144 -0
  43. package/template/components/compliance-page-header.tsx +1 -1
  44. package/template/components/compliance-table.tsx +2 -2
  45. package/template/components/dashboard-tabs.tsx +4 -3
  46. package/template/components/data-list-table-cells.tsx +1 -1
  47. package/template/components/data-list-table.tsx +1 -1
  48. package/template/components/data-table/index.tsx +5 -5
  49. package/template/components/data-table/use-table-state.ts +18 -2
  50. package/template/components/data-view-dashboard-charts-compliance.tsx +8 -5
  51. package/template/components/data-view-dashboard-charts-team.tsx +8 -5
  52. package/template/components/data-view-dashboard-charts.tsx +62 -227
  53. package/template/components/dedicated-search-recents.tsx +96 -0
  54. package/template/components/dedicated-search-url-composer.tsx +112 -0
  55. package/template/components/getting-started.tsx +1 -1
  56. package/template/components/hub-tree-panel-view.tsx +10 -26
  57. package/template/components/invite-collaborators-drawer.tsx +453 -0
  58. package/template/components/key-metrics.tsx +54 -8
  59. package/template/components/nav-documents.tsx +1 -1
  60. package/template/components/new-placement-form.tsx +3 -3
  61. package/template/components/page-header.tsx +76 -59
  62. package/template/components/placements-board-view.tsx +3 -3
  63. package/template/components/placements-page-header.tsx +1 -1
  64. package/template/components/placements-table-columns.tsx +3 -2
  65. package/template/components/product-switcher.tsx +0 -1
  66. package/template/components/question-bank-board-view.tsx +35 -47
  67. package/template/components/question-bank-client.tsx +293 -81
  68. package/template/components/question-bank-dashboard-charts.tsx +174 -0
  69. package/template/components/question-bank-favorite-button.tsx +46 -0
  70. package/template/components/question-bank-hub-client.tsx +436 -0
  71. package/template/components/question-bank-list-view.tsx +26 -19
  72. package/template/components/question-bank-new-folder-sheet.tsx +56 -42
  73. package/template/components/question-bank-os-folder-view.tsx +3 -14
  74. package/template/components/question-bank-page-header.tsx +85 -53
  75. package/template/components/question-bank-panel-activator.tsx +3 -4
  76. package/template/components/question-bank-secondary-nav.tsx +523 -65
  77. package/template/components/question-bank-table.tsx +125 -343
  78. package/template/components/secondary-panel.tsx +130 -63
  79. package/template/components/settings-client.tsx +3 -1
  80. package/template/components/sidebar-shell.tsx +2 -0
  81. package/template/components/sites-all-client.tsx +1 -1
  82. package/template/components/sites-table.tsx +1 -1
  83. package/template/components/system-banner-slot.tsx +2 -1
  84. package/template/components/table-properties/drawer.tsx +3 -3
  85. package/template/components/table-properties/sort-card.tsx +1 -1
  86. package/template/components/team-page-header.tsx +1 -1
  87. package/template/components/team-table.tsx +8 -4
  88. package/template/components/templates/dedicated-search-landing-template.tsx +58 -0
  89. package/template/components/templates/dedicated-search-results-template.tsx +19 -0
  90. package/template/components/templates/discovery-hub-template.tsx +273 -0
  91. package/template/components/templates/list-page.tsx +11 -4
  92. package/template/components/templates/nested-secondary-panel-shell.tsx +57 -0
  93. package/template/components/templates/secondary-panel-hub-template.tsx +54 -0
  94. package/template/docs/card-vs-rows-pattern.md +36 -0
  95. package/template/docs/collaboration-access-pattern.md +114 -0
  96. package/template/docs/data-views-pattern.md +12 -4
  97. package/template/docs/drawer-vs-dialog-pattern.md +50 -0
  98. package/template/docs/kpi-strip-max-four-pattern.md +29 -0
  99. package/template/docs/kpi-trend-pattern.md +43 -0
  100. package/template/fontawesome-subset.manifest.json +2 -2
  101. package/template/hooks/use-location-hash.ts +14 -8
  102. package/template/hooks/use-secondary-panel-hub-nav.ts +98 -0
  103. package/template/lib/ask-leo-route-context.ts +24 -0
  104. package/template/lib/collaborator-access.ts +92 -0
  105. package/template/lib/command-menu-config.ts +8 -1
  106. package/template/lib/command-menu-search-data.ts +11 -8
  107. package/template/lib/data-list-display-options.ts +1 -1
  108. package/template/lib/data-view-dashboard-placements-layout.ts +215 -0
  109. package/template/lib/date-filter.ts +1 -0
  110. package/template/lib/dedicated-search-recents.ts +76 -0
  111. package/template/lib/dedicated-search-url.ts +23 -0
  112. package/template/lib/discovery-hub.ts +15 -0
  113. package/template/lib/list-status-badges.ts +1 -21
  114. package/template/lib/mock/navigation.tsx +4 -2
  115. package/template/lib/mock/placements.ts +9 -9
  116. package/template/lib/mock/question-bank-folders.ts +7 -0
  117. package/template/lib/mock/question-bank-header-collaborators.ts +45 -5
  118. package/template/lib/mock/question-bank-inspector.ts +1 -2
  119. package/template/lib/mock/question-bank-kpi.ts +38 -26
  120. package/template/lib/mock/question-bank.ts +43 -16
  121. package/template/lib/question-bank-dedicated-search.ts +19 -0
  122. package/template/lib/question-bank-hub-search.ts +90 -0
  123. package/template/lib/question-bank-nav.ts +322 -6
  124. package/template/lib/question-bank-recent-searches.ts +22 -0
  125. package/template/package.json +0 -1
@@ -7,34 +7,61 @@
7
7
 
8
8
  import * as React from "react"
9
9
  import Link from "next/link"
10
- import { usePathname, useSearchParams } from "next/navigation"
10
+ import { usePathname, useRouter, useSearchParams } from "next/navigation"
11
11
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
12
+ import { Button } from "@/components/ui/button"
13
+ import {
14
+ Dialog,
15
+ DialogContent,
16
+ DialogDescription,
17
+ DialogFooter,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ } from "@/components/ui/dialog"
21
+ import {
22
+ DropdownMenu,
23
+ DropdownMenuContent,
24
+ DropdownMenuItem,
25
+ DropdownMenuSeparator,
26
+ DropdownMenuTrigger,
27
+ Shortcut,
28
+ } from "@/components/ui/dropdown-menu"
29
+ import { Kbd, KbdGroup } from "@/components/ui/kbd"
12
30
  import { Tip } from "@/components/ui/tip"
13
31
  import { cn } from "@/lib/utils"
14
32
  import type { QuestionBankFolder } from "@/lib/mock/question-bank-folders"
15
33
  import { QUESTION_BANK_FOLDER_ICON_COLORS } from "@/lib/mock/question-bank-folders"
16
- import { DEFAULT_QUESTION_BANK_FOLDERS } from "@/lib/mock/question-bank-folders"
34
+ import { DEFAULT_QUESTION_BANK_FOLDERS, newFolderId, collectFolderDescendantIds } from "@/lib/mock/question-bank-folders"
17
35
  import { useSecondaryPanel } from "@/components/secondary-panel"
36
+ import { QuestionBankNewFolderSheet } from "@/components/question-bank-new-folder-sheet"
18
37
  import {
38
+ isQuestionBankDedicatedSearchPathname,
19
39
  parseQuestionBankNav,
20
- questionBankNavHref,
40
+ QUESTION_BANK_FAVORITES_FOLDER_ID,
41
+ QUESTION_BANK_LIBRARY_HUB_PATHS,
42
+ isQuestionBankSearchNavActive,
43
+ questionBankFavoritesFolderHref,
44
+ questionBankHubScopeHref,
45
+ questionBankSearchLandingNavHref,
21
46
  type QuestionBankNavScope,
22
47
  } from "@/lib/question-bank-nav"
23
48
 
24
- function matchesQuery(q: string, ...parts: (string | undefined)[]) {
25
- if (!q) return true
26
- return parts.some(p => p && p.toLowerCase().includes(q))
27
- }
28
-
29
49
  function isNavActive(
30
50
  pathname: string,
31
51
  nav: ReturnType<typeof parseQuestionBankNav>,
32
52
  scope: QuestionBankNavScope,
33
53
  folderId?: string | null,
34
54
  ) {
35
- if (pathname !== "/question-bank") return false
36
- if (scope === "all") return nav.scope === "all"
37
- if (scope === "my") return nav.scope === "my"
55
+ if (!QUESTION_BANK_LIBRARY_HUB_PATHS.includes(pathname)) return false
56
+ // Dedicated search shells (list / hub-find) use the “Search” row for All/My — not these rows.
57
+ if (scope === "all") {
58
+ if (isQuestionBankDedicatedSearchPathname(pathname)) return false
59
+ return nav.scope === "all"
60
+ }
61
+ if (scope === "my") {
62
+ if (isQuestionBankDedicatedSearchPathname(pathname)) return false
63
+ return nav.scope === "my"
64
+ }
38
65
  if (scope === "folder" && folderId) {
39
66
  return nav.scope === "folder" && nav.folderId === folderId
40
67
  }
@@ -60,6 +87,7 @@ function NavRow({
60
87
  <Tip label={label} side="right">
61
88
  <Link
62
89
  href={href}
90
+ scroll={false}
63
91
  onClick={() => onClick?.()}
64
92
  aria-current={active ? "page" : undefined}
65
93
  className={cn(
@@ -80,43 +108,99 @@ function NavRow({
80
108
  )
81
109
  }
82
110
 
111
+ /** Icon-rail row — matches primary sidebar collapsed hit target (`size-9`). */
112
+ function IconNavRow({
113
+ href,
114
+ active,
115
+ iconClass,
116
+ label,
117
+ onClick,
118
+ }: {
119
+ href: string
120
+ active: boolean
121
+ iconClass: string
122
+ label: string
123
+ onClick?: () => void
124
+ }) {
125
+ return (
126
+ <li className="flex w-full justify-center" role="none">
127
+ <Tip label={label} side="right">
128
+ <Link
129
+ href={href}
130
+ scroll={false}
131
+ onClick={() => onClick?.()}
132
+ aria-current={active ? "page" : undefined}
133
+ className={cn(
134
+ "flex size-9 shrink-0 items-center justify-center rounded-md transition-colors",
135
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
136
+ active
137
+ ? "bg-sidebar-accent text-sidebar-accent-foreground"
138
+ : "text-sidebar-foreground hover:bg-sidebar-accent/50",
139
+ )}
140
+ >
141
+ <span className="text-center text-[15px] leading-none" aria-hidden>
142
+ <i className={cn(active ? "fa-solid" : "fa-light", iconClass)} aria-hidden />
143
+ </span>
144
+ </Link>
145
+ </Tip>
146
+ </li>
147
+ )
148
+ }
149
+
83
150
  function PanelFolderBranch({
84
151
  folder,
85
152
  folders,
86
- query,
87
153
  depth,
88
154
  pathname,
155
+ hubSearchParams,
89
156
  nav,
157
+ canManageFolders,
158
+ canManageAccess,
159
+ onAddSubfolder,
160
+ onCustomizeFolder,
161
+ onManageAccess,
162
+ onDeleteFolder,
90
163
  }: {
91
164
  folder: QuestionBankFolder
92
165
  folders: QuestionBankFolder[]
93
- query: string
94
166
  depth: number
95
167
  pathname: string
168
+ hubSearchParams: URLSearchParams
96
169
  nav: ReturnType<typeof parseQuestionBankNav>
170
+ canManageFolders: boolean
171
+ canManageAccess: boolean
172
+ onAddSubfolder: (parentId: string) => void
173
+ onCustomizeFolder: (folder: QuestionBankFolder) => void
174
+ onManageAccess: () => void
175
+ onDeleteFolder: (folder: QuestionBankFolder) => void
97
176
  }) {
98
- const childFolders = folders
99
- .filter(f => f.parentId === folder.id)
100
- .sort((a, b) => a.name.localeCompare(b.name))
101
-
102
- const visibleFolders = React.useMemo(
103
- () => childFolders.filter(f => matchesQuery(query, f.name)),
104
- [childFolders, query],
177
+ const childFolders = React.useMemo(
178
+ () =>
179
+ folders
180
+ .filter(f => f.parentId === folder.id)
181
+ .sort((a, b) => a.name.localeCompare(b.name)),
182
+ [folders, folder.id],
105
183
  )
106
184
 
107
185
  const hasSubfolders = childFolders.length > 0
108
186
  const indent = depth * 10
109
187
 
110
- if (query && !matchesQuery(query, folder.name) && visibleFolders.length === 0) {
111
- return null
112
- }
113
-
114
- const folderHref = questionBankNavHref({ scope: "folder", folderId: folder.id })
188
+ const folderHref = questionBankHubScopeHref(pathname, hubSearchParams, {
189
+ scope: "folder",
190
+ folderId: folder.id,
191
+ })
115
192
  const folderActive = isNavActive(pathname, nav, "folder", folder.id)
116
193
 
117
194
  return (
118
195
  <Collapsible defaultOpen={depth < 1} className="group">
119
- <div className="flex min-w-0 items-center rounded-md hover:bg-sidebar-accent/40">
196
+ <div
197
+ className={cn(
198
+ "group/row flex min-w-0 items-center rounded-md px-2 transition-colors",
199
+ folderActive
200
+ ? "bg-sidebar-accent text-sidebar-accent-foreground"
201
+ : "hover:bg-sidebar-accent/50",
202
+ )}
203
+ >
120
204
  <div style={{ width: indent }} className="shrink-0" aria-hidden />
121
205
  {hasSubfolders ? (
122
206
  <CollapsibleTrigger asChild>
@@ -137,13 +221,12 @@ function PanelFolderBranch({
137
221
  <Tip label={folder.name} side="right">
138
222
  <Link
139
223
  href={folderHref}
224
+ scroll={false}
140
225
  aria-current={folderActive ? "page" : undefined}
141
226
  className={cn(
142
- "flex min-w-0 flex-1 items-center gap-2 py-1.5 pr-2 text-left text-sm transition-colors",
143
- "rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
144
- folderActive
145
- ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
146
- : "text-sidebar-foreground hover:bg-sidebar-accent/50",
227
+ "flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left text-sm transition-colors",
228
+ "rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
229
+ folderActive ? "font-medium" : "text-sidebar-foreground",
147
230
  )}
148
231
  >
149
232
  <i
@@ -156,18 +239,79 @@ function PanelFolderBranch({
156
239
  ) : null}
157
240
  </Link>
158
241
  </Tip>
242
+ {canManageFolders ? (
243
+ <DropdownMenu>
244
+ <Tip label={`Folder actions for ${folder.name}`} side="right">
245
+ <DropdownMenuTrigger asChild>
246
+ <Button
247
+ type="button"
248
+ size="icon-xs"
249
+ variant="ghost"
250
+ aria-label={`Folder actions for ${folder.name}`}
251
+ className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/row:opacity-100 group-focus-within/row:opacity-100"
252
+ onClick={event => event.stopPropagation()}
253
+ >
254
+ <i className="fa-light fa-ellipsis text-xs" aria-hidden="true" />
255
+ </Button>
256
+ </DropdownMenuTrigger>
257
+ </Tip>
258
+ <DropdownMenuContent align="end">
259
+ <DropdownMenuItem
260
+ onSelect={() => {
261
+ window.setTimeout(() => onAddSubfolder(folder.id), 0)
262
+ }}
263
+ >
264
+ <i className="fa-light fa-plus text-xs" aria-hidden="true" />
265
+ Add folder
266
+ </DropdownMenuItem>
267
+ <DropdownMenuItem
268
+ onSelect={() => {
269
+ window.setTimeout(() => onCustomizeFolder(folder), 0)
270
+ }}
271
+ >
272
+ <i className="fa-light fa-wand-magic-sparkles text-xs" aria-hidden="true" />
273
+ Customize
274
+ </DropdownMenuItem>
275
+ <DropdownMenuItem
276
+ disabled={!canManageAccess}
277
+ onSelect={() => {
278
+ window.setTimeout(() => onManageAccess(), 0)
279
+ }}
280
+ >
281
+ <i className="fa-light fa-user-gear text-xs" aria-hidden="true" />
282
+ Manage access
283
+ </DropdownMenuItem>
284
+ <DropdownMenuSeparator />
285
+ <DropdownMenuItem
286
+ variant="destructive"
287
+ onSelect={() => {
288
+ window.setTimeout(() => onDeleteFolder(folder), 0)
289
+ }}
290
+ >
291
+ <i className="fa-light fa-trash text-xs" aria-hidden="true" />
292
+ Delete
293
+ </DropdownMenuItem>
294
+ </DropdownMenuContent>
295
+ </DropdownMenu>
296
+ ) : null}
159
297
  </div>
160
298
  {hasSubfolders ? (
161
299
  <CollapsibleContent>
162
- {visibleFolders.map(child => (
300
+ {childFolders.map(child => (
163
301
  <PanelFolderBranch
164
302
  key={child.id}
165
303
  folder={child}
166
304
  folders={folders}
167
- query={query}
168
305
  depth={depth + 1}
169
306
  pathname={pathname}
307
+ hubSearchParams={hubSearchParams}
170
308
  nav={nav}
309
+ canManageFolders={canManageFolders}
310
+ canManageAccess={canManageAccess}
311
+ onAddSubfolder={onAddSubfolder}
312
+ onCustomizeFolder={onCustomizeFolder}
313
+ onManageAccess={onManageAccess}
314
+ onDeleteFolder={onDeleteFolder}
171
315
  />
172
316
  ))}
173
317
  </CollapsibleContent>
@@ -176,51 +320,365 @@ function PanelFolderBranch({
176
320
  )
177
321
  }
178
322
 
179
- export function QuestionBankSecondaryNav({ query }: { query: string }) {
323
+ export function QuestionBankSecondaryNav() {
180
324
  const pathname = usePathname()
325
+ const router = useRouter()
181
326
  const searchParams = useSearchParams()
182
327
  const searchParamsKey = searchParams.toString()
183
- const { openPanel } = useSecondaryPanel()
328
+ const { openPanel, questionBankFolderBridge, questionBankAccessBridge, secondaryPanelCompact } =
329
+ useSecondaryPanel()
330
+ const [newFolderOpen, setNewFolderOpen] = React.useState(false)
331
+ const [newFolderParentId, setNewFolderParentId] = React.useState<string | null>(null)
332
+ const [customizingFolder, setCustomizingFolder] = React.useState<QuestionBankFolder | null>(null)
333
+ const [deleteFolder, setDeleteFolder] = React.useState<QuestionBankFolder | null>(null)
184
334
  const nav = React.useMemo(
185
335
  () => parseQuestionBankNav(new URLSearchParams(searchParamsKey)),
186
336
  [searchParamsKey],
187
337
  )
188
338
 
189
- const folders = DEFAULT_QUESTION_BANK_FOLDERS
190
- const q = query.trim().toLowerCase()
339
+ const folders = questionBankFolderBridge?.folders ?? DEFAULT_QUESTION_BANK_FOLDERS
340
+ const canManageFolders = questionBankFolderBridge != null
341
+ const canManageAccess = questionBankAccessBridge != null
191
342
 
192
- const roots = React.useMemo(
193
- () => folders.filter(f => f.parentId === null).sort((a, b) => a.name.localeCompare(b.name)),
343
+ /** Favorites is a primary nav row (with All / My / Search), not under “Folders”. */
344
+ const folderTreeRoots = React.useMemo(
345
+ () =>
346
+ folders
347
+ .filter(f => f.parentId === null && f.id !== QUESTION_BANK_FAVORITES_FOLDER_ID)
348
+ .sort((a, b) => a.name.localeCompare(b.name)),
194
349
  [folders],
195
350
  )
196
351
 
352
+ const openTopLevelFolder = React.useCallback(() => {
353
+ setCustomizingFolder(null)
354
+ setNewFolderParentId(nav.scope === "folder" ? nav.folderId : null)
355
+ setNewFolderOpen(true)
356
+ }, [nav.folderId, nav.scope])
357
+
358
+ const openSubfolder = React.useCallback((parentId: string) => {
359
+ setCustomizingFolder(null)
360
+ setNewFolderParentId(parentId)
361
+ setNewFolderOpen(true)
362
+ }, [])
363
+
364
+ const openCustomizeFolder = React.useCallback((folder: QuestionBankFolder) => {
365
+ setCustomizingFolder(folder)
366
+ setNewFolderParentId(folder.parentId)
367
+ setNewFolderOpen(true)
368
+ }, [])
369
+
370
+ const openManageAccess = React.useCallback(() => {
371
+ questionBankAccessBridge?.openManageAccess()
372
+ }, [questionBankAccessBridge])
373
+
374
+ const openDeleteFolder = React.useCallback((folder: QuestionBankFolder) => {
375
+ setDeleteFolder(folder)
376
+ }, [])
377
+
378
+ const commitDeleteFolder = React.useCallback(() => {
379
+ if (!deleteFolder || !questionBankFolderBridge) return
380
+ const victim = deleteFolder
381
+ const parent = victim.parentId
382
+ const desc = collectFolderDescendantIds(folders, victim.id)
383
+ const remaining = folders.filter(f => !desc.has(f.id))
384
+ if (remaining.length === 0) {
385
+ setDeleteFolder(null)
386
+ return
387
+ }
388
+ const parentStillExists = parent !== null && remaining.some(f => f.id === parent)
389
+ const fallbackRoot = remaining.find(f => f.parentId === null)?.id
390
+ const reassignTarget =
391
+ parentStillExists ? parent : (fallbackRoot ?? remaining[0]!.id)
392
+
393
+ questionBankFolderBridge.onFoldersChange(remaining)
394
+ questionBankFolderBridge.onItemsChange(prev =>
395
+ prev.map(item => (desc.has(item.folderId) ? { ...item, folderId: reassignTarget } : item)),
396
+ )
397
+
398
+ if (nav.scope === "folder" && nav.folderId && desc.has(nav.folderId)) {
399
+ router.replace(
400
+ questionBankHubScopeHref(
401
+ pathname,
402
+ new URLSearchParams(searchParamsKey),
403
+ parentStillExists
404
+ ? { scope: "folder", folderId: parent! }
405
+ : { scope: "all" },
406
+ ),
407
+ )
408
+ }
409
+
410
+ setDeleteFolder(null)
411
+ }, [deleteFolder, folders, nav.folderId, nav.scope, pathname, questionBankFolderBridge, router, searchParamsKey])
412
+
413
+ const sheetParentId = customizingFolder?.parentId ?? newFolderParentId
414
+
415
+ const flattenedFolderLinks = React.useMemo(() => {
416
+ const out: QuestionBankFolder[] = []
417
+ const walk = (folder: QuestionBankFolder) => {
418
+ out.push(folder)
419
+ folders
420
+ .filter(c => c.parentId === folder.id)
421
+ .sort((a, b) => a.name.localeCompare(b.name))
422
+ .forEach(walk)
423
+ }
424
+ folderTreeRoots.forEach(walk)
425
+ return out
426
+ }, [folderTreeRoots, folders])
427
+
428
+ const hubNavModals = (
429
+ <>
430
+ <QuestionBankNewFolderSheet
431
+ open={newFolderOpen}
432
+ onOpenChange={open => {
433
+ setNewFolderOpen(open)
434
+ if (!open) {
435
+ setCustomizingFolder(null)
436
+ setNewFolderParentId(null)
437
+ }
438
+ }}
439
+ parentFolderId={sheetParentId}
440
+ customizingFolder={customizingFolder}
441
+ descriptionText={
442
+ customizingFolder
443
+ ? "Update the folder name, color, and icon shown in the navigation and folder views."
444
+ : sheetParentId
445
+ ? "The folder is created inside the folder selected in the navigation."
446
+ : "Add a top-level folder to the question bank."
447
+ }
448
+ onCreated={folder => {
449
+ if (customizingFolder) {
450
+ questionBankFolderBridge?.onFoldersChange(prev =>
451
+ prev.map(item =>
452
+ item.id === customizingFolder.id
453
+ ? {
454
+ ...item,
455
+ name: folder.name,
456
+ icon: folder.icon,
457
+ colorKey: folder.colorKey,
458
+ }
459
+ : item,
460
+ ),
461
+ )
462
+ } else {
463
+ questionBankFolderBridge?.onFoldersChange(prev => [...prev, { ...folder, id: newFolderId() }])
464
+ }
465
+ setNewFolderOpen(false)
466
+ setCustomizingFolder(null)
467
+ setNewFolderParentId(null)
468
+ }}
469
+ />
470
+ {deleteFolder ? <Shortcut keys="Enter" onInvoke={commitDeleteFolder} /> : null}
471
+ <Dialog open={deleteFolder != null} onOpenChange={open => !open && setDeleteFolder(null)}>
472
+ <DialogContent className="max-w-sm">
473
+ <DialogHeader>
474
+ <DialogTitle>Delete folder?</DialogTitle>
475
+ <DialogDescription>
476
+ {deleteFolder
477
+ ? `${deleteFolder.name} and its subfolders will be removed. Questions inside move to the parent folder (or the first top-level folder).`
478
+ : null}
479
+ </DialogDescription>
480
+ </DialogHeader>
481
+ <DialogFooter className="gap-2 sm:gap-0">
482
+ <Button type="button" variant="outline" size="sm" onClick={() => setDeleteFolder(null)}>
483
+ Cancel
484
+ <KbdGroup className="ml-1.5">
485
+ <Kbd variant="bare">Esc</Kbd>
486
+ </KbdGroup>
487
+ </Button>
488
+ <Button type="button" variant="destructive" size="sm" onClick={commitDeleteFolder}>
489
+ Delete
490
+ <KbdGroup className="ml-1.5">
491
+ <Kbd variant="bare">⏎</Kbd>
492
+ </KbdGroup>
493
+ </Button>
494
+ </DialogFooter>
495
+ </DialogContent>
496
+ </Dialog>
497
+ </>
498
+ )
499
+
500
+ if (secondaryPanelCompact) {
501
+ return (
502
+ <>
503
+ <nav className="flex min-h-0 flex-1 flex-col" role="navigation" aria-label="Question bank">
504
+ <div className="flex flex-col items-center border-b border-sidebar-border/60 px-1 py-2">
505
+ <Tip label="Show labels" side="right">
506
+ <Button
507
+ type="button"
508
+ size="icon"
509
+ variant="ghost"
510
+ className="size-9 shrink-0"
511
+ aria-label="Show labels"
512
+ onClick={() => openPanel("question-bank")}
513
+ >
514
+ <i className="fa-light fa-angles-right text-[15px]" aria-hidden="true" />
515
+ </Button>
516
+ </Tip>
517
+ </div>
518
+ <ul className="flex flex-1 flex-col items-center gap-1 overflow-y-auto px-1 py-2" role="list">
519
+ <IconNavRow
520
+ href={questionBankHubScopeHref(pathname, searchParams, { scope: "all" })}
521
+ active={isNavActive(pathname, nav, "all")}
522
+ iconClass="fa-table-list"
523
+ label="All questions"
524
+ onClick={() => openPanel("question-bank")}
525
+ />
526
+ <IconNavRow
527
+ href={questionBankHubScopeHref(pathname, searchParams, { scope: "my" })}
528
+ active={isNavActive(pathname, nav, "my")}
529
+ iconClass="fa-user"
530
+ label="My questions"
531
+ onClick={() => openPanel("question-bank")}
532
+ />
533
+ <IconNavRow
534
+ href={questionBankFavoritesFolderHref(pathname, searchParams)}
535
+ active={isNavActive(pathname, nav, "folder", QUESTION_BANK_FAVORITES_FOLDER_ID)}
536
+ iconClass="fa-star"
537
+ label="Favorites"
538
+ onClick={() => openPanel("question-bank")}
539
+ />
540
+ <IconNavRow
541
+ href={questionBankSearchLandingNavHref(nav, searchParams)}
542
+ active={isQuestionBankSearchNavActive(pathname, nav)}
543
+ iconClass="fa-magnifying-glass"
544
+ label="Search"
545
+ onClick={() => openPanel("question-bank")}
546
+ />
547
+ <li className="flex w-full justify-center pt-1" role="none">
548
+ <DropdownMenu>
549
+ <DropdownMenuTrigger asChild>
550
+ <Button
551
+ type="button"
552
+ size="icon"
553
+ variant="ghost"
554
+ className="size-9 shrink-0 text-sidebar-foreground"
555
+ aria-label="Folders"
556
+ >
557
+ <i className="fa-light fa-folder-tree text-[15px]" aria-hidden="true" />
558
+ </Button>
559
+ </DropdownMenuTrigger>
560
+ <DropdownMenuContent side="right" align="start" className="max-h-72 overflow-y-auto">
561
+ {flattenedFolderLinks.length === 0 ? (
562
+ <div className="px-2 py-1.5 text-xs text-muted-foreground">No folders</div>
563
+ ) : (
564
+ flattenedFolderLinks.map(folder => {
565
+ const href = questionBankHubScopeHref(pathname, searchParams, {
566
+ scope: "folder",
567
+ folderId: folder.id,
568
+ })
569
+ const active = isNavActive(pathname, nav, "folder", folder.id)
570
+ return (
571
+ <DropdownMenuItem key={folder.id} asChild>
572
+ <Link
573
+ href={href}
574
+ scroll={false}
575
+ className={cn(active && "bg-accent")}
576
+ onClick={() => openPanel("question-bank")}
577
+ >
578
+ {folder.name}
579
+ </Link>
580
+ </DropdownMenuItem>
581
+ )
582
+ })
583
+ )}
584
+ </DropdownMenuContent>
585
+ </DropdownMenu>
586
+ </li>
587
+ </ul>
588
+ {canManageFolders ? (
589
+ <div className="flex flex-col items-center border-t border-sidebar-border/60 px-1 py-2">
590
+ <Tip label="Add folder" side="right">
591
+ <Button
592
+ type="button"
593
+ size="icon"
594
+ variant="ghost"
595
+ className="size-9 shrink-0 text-muted-foreground"
596
+ aria-label="Add folder"
597
+ onClick={openTopLevelFolder}
598
+ >
599
+ <i className="fa-light fa-plus text-[15px]" aria-hidden="true" />
600
+ </Button>
601
+ </Tip>
602
+ </div>
603
+ ) : null}
604
+ </nav>
605
+ {hubNavModals}
606
+ </>
607
+ )
608
+ }
609
+
197
610
  return (
198
- <div className="min-h-0 flex-1 overflow-y-auto px-3 pb-4" role="navigation" aria-label="Question bank">
199
- <ul className="space-y-0.5" role="list">
200
- <NavRow
201
- href={questionBankNavHref({ scope: "all" })}
202
- active={isNavActive(pathname, nav, "all")}
203
- iconClass="fa-table-list"
204
- label="All questions"
205
- onClick={() => openPanel("question-bank")}
206
- />
207
- <NavRow
208
- href={questionBankNavHref({ scope: "my" })}
209
- active={isNavActive(pathname, nav, "my")}
210
- iconClass="fa-user"
211
- label="My questions"
212
- />
213
- <li role="presentation" className="select-none">
214
- <span className="block px-2 pt-3 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">
215
- Folders
216
- </span>
217
- </li>
218
- {roots.map(folder => (
219
- <li key={folder.id} className="min-w-0">
220
- <PanelFolderBranch folder={folder} folders={folders} query={q} depth={0} pathname={pathname} nav={nav} />
611
+ <>
612
+ <div className="min-h-0 flex-1 overflow-y-auto px-3 pb-4" role="navigation" aria-label="Question bank">
613
+ <ul className="space-y-0.5" role="list">
614
+ <NavRow
615
+ href={questionBankHubScopeHref(pathname, searchParams, { scope: "all" })}
616
+ active={isNavActive(pathname, nav, "all")}
617
+ iconClass="fa-table-list"
618
+ label="All questions"
619
+ onClick={() => openPanel("question-bank")}
620
+ />
621
+ <NavRow
622
+ href={questionBankHubScopeHref(pathname, searchParams, { scope: "my" })}
623
+ active={isNavActive(pathname, nav, "my")}
624
+ iconClass="fa-user"
625
+ label="My questions"
626
+ />
627
+ <NavRow
628
+ href={questionBankFavoritesFolderHref(pathname, searchParams)}
629
+ active={isNavActive(pathname, nav, "folder", QUESTION_BANK_FAVORITES_FOLDER_ID)}
630
+ iconClass="fa-star"
631
+ label="Favorites"
632
+ onClick={() => openPanel("question-bank")}
633
+ />
634
+ <NavRow
635
+ href={questionBankSearchLandingNavHref(nav, searchParams)}
636
+ active={isQuestionBankSearchNavActive(pathname, nav)}
637
+ iconClass="fa-magnifying-glass"
638
+ label="Search"
639
+ onClick={() => openPanel("question-bank")}
640
+ />
641
+ <li role="presentation" className="select-none">
642
+ <div className="flex items-center justify-between gap-2 px-2 pt-3 pb-1">
643
+ <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">
644
+ Folders
645
+ </span>
646
+ <Tip label="Add folder" side="right">
647
+ <Button
648
+ type="button"
649
+ size="icon-xs"
650
+ variant="ghost"
651
+ className="text-muted-foreground"
652
+ aria-label="Add folder"
653
+ disabled={!canManageFolders}
654
+ onClick={openTopLevelFolder}
655
+ >
656
+ <i className="fa-light fa-plus" aria-hidden="true" />
657
+ </Button>
658
+ </Tip>
659
+ </div>
221
660
  </li>
222
- ))}
223
- </ul>
224
- </div>
661
+ {folderTreeRoots.map(folder => (
662
+ <li key={folder.id} className="min-w-0">
663
+ <PanelFolderBranch
664
+ folder={folder}
665
+ folders={folders}
666
+ depth={0}
667
+ pathname={pathname}
668
+ hubSearchParams={searchParams}
669
+ nav={nav}
670
+ canManageFolders={canManageFolders}
671
+ canManageAccess={canManageAccess}
672
+ onAddSubfolder={openSubfolder}
673
+ onCustomizeFolder={openCustomizeFolder}
674
+ onManageAccess={openManageAccess}
675
+ onDeleteFolder={openDeleteFolder}
676
+ />
677
+ </li>
678
+ ))}
679
+ </ul>
680
+ </div>
681
+ {hubNavModals}
682
+ </>
225
683
  )
226
684
  }