@exxatdesignux/ui 0.2.9 → 0.2.11

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 (126) 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 +4 -1
  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/.nvmrc +1 -1
  28. package/template/AGENTS.md +82 -27
  29. package/template/app/(app)/examples/page.tsx +2 -1
  30. package/template/app/(app)/help/page.tsx +6 -0
  31. package/template/app/(app)/layout.tsx +7 -4
  32. package/template/app/(app)/question-bank/find/page.tsx +12 -0
  33. package/template/app/(app)/question-bank/layout.tsx +46 -0
  34. package/template/app/(app)/question-bank/library/page.tsx +11 -0
  35. package/template/app/(app)/question-bank/list/page.tsx +12 -0
  36. package/template/app/(app)/question-bank/page.tsx +4 -3
  37. package/template/app/globals.css +1 -2
  38. package/template/components/app-sidebar.tsx +51 -13
  39. package/template/components/ask-leo-composer.tsx +173 -45
  40. package/template/components/ask-leo-sidebar.tsx +9 -1
  41. package/template/components/chart-area-interactive.tsx +3 -13
  42. package/template/components/charts-overview.tsx +33 -6
  43. package/template/components/collaboration-access-flow.tsx +144 -0
  44. package/template/components/compliance-page-header.tsx +1 -1
  45. package/template/components/compliance-table.tsx +2 -2
  46. package/template/components/dashboard-tabs.tsx +4 -3
  47. package/template/components/data-list-table-cells.tsx +1 -1
  48. package/template/components/data-list-table.tsx +1 -1
  49. package/template/components/data-table/index.tsx +5 -5
  50. package/template/components/data-table/use-table-state.ts +18 -2
  51. package/template/components/data-view-dashboard-charts-compliance.tsx +8 -5
  52. package/template/components/data-view-dashboard-charts-team.tsx +8 -5
  53. package/template/components/data-view-dashboard-charts.tsx +62 -227
  54. package/template/components/dedicated-search-recents.tsx +96 -0
  55. package/template/components/dedicated-search-url-composer.tsx +112 -0
  56. package/template/components/getting-started.tsx +1 -1
  57. package/template/components/hub-tree-panel-view.tsx +10 -26
  58. package/template/components/invite-collaborators-drawer.tsx +453 -0
  59. package/template/components/key-metrics.tsx +54 -8
  60. package/template/components/nav-documents.tsx +1 -1
  61. package/template/components/new-placement-form.tsx +3 -3
  62. package/template/components/page-header.tsx +76 -59
  63. package/template/components/placements-board-view.tsx +3 -3
  64. package/template/components/placements-page-header.tsx +1 -1
  65. package/template/components/placements-table-columns.tsx +3 -2
  66. package/template/components/product-switcher.tsx +0 -1
  67. package/template/components/question-bank-board-view.tsx +35 -47
  68. package/template/components/question-bank-client.tsx +293 -81
  69. package/template/components/question-bank-dashboard-charts.tsx +174 -0
  70. package/template/components/question-bank-favorite-button.tsx +46 -0
  71. package/template/components/question-bank-hub-client.tsx +436 -0
  72. package/template/components/question-bank-list-view.tsx +26 -19
  73. package/template/components/question-bank-new-folder-sheet.tsx +56 -42
  74. package/template/components/question-bank-os-folder-view.tsx +3 -14
  75. package/template/components/question-bank-page-header.tsx +85 -53
  76. package/template/components/question-bank-panel-activator.tsx +3 -4
  77. package/template/components/question-bank-secondary-nav.tsx +523 -65
  78. package/template/components/question-bank-table.tsx +125 -343
  79. package/template/components/secondary-panel.tsx +130 -63
  80. package/template/components/settings-client.tsx +3 -1
  81. package/template/components/sidebar-shell.tsx +2 -0
  82. package/template/components/sites-all-client.tsx +1 -1
  83. package/template/components/sites-table.tsx +1 -1
  84. package/template/components/system-banner-slot.tsx +2 -1
  85. package/template/components/table-properties/drawer.tsx +3 -3
  86. package/template/components/table-properties/sort-card.tsx +1 -1
  87. package/template/components/team-page-header.tsx +1 -1
  88. package/template/components/team-table.tsx +8 -4
  89. package/template/components/templates/dedicated-search-landing-template.tsx +58 -0
  90. package/template/components/templates/dedicated-search-results-template.tsx +19 -0
  91. package/template/components/templates/discovery-hub-template.tsx +273 -0
  92. package/template/components/templates/list-page.tsx +11 -4
  93. package/template/components/templates/nested-secondary-panel-shell.tsx +57 -0
  94. package/template/components/templates/secondary-panel-hub-template.tsx +54 -0
  95. package/template/docs/card-vs-rows-pattern.md +36 -0
  96. package/template/docs/collaboration-access-pattern.md +114 -0
  97. package/template/docs/data-views-pattern.md +12 -4
  98. package/template/docs/drawer-vs-dialog-pattern.md +50 -0
  99. package/template/docs/kpi-strip-max-four-pattern.md +29 -0
  100. package/template/docs/kpi-trend-pattern.md +43 -0
  101. package/template/fontawesome-subset.manifest.json +2 -2
  102. package/template/hooks/use-location-hash.ts +14 -8
  103. package/template/hooks/use-secondary-panel-hub-nav.ts +98 -0
  104. package/template/lib/ask-leo-route-context.ts +24 -0
  105. package/template/lib/collaborator-access.ts +92 -0
  106. package/template/lib/command-menu-config.ts +8 -1
  107. package/template/lib/command-menu-search-data.ts +11 -8
  108. package/template/lib/data-list-display-options.ts +1 -1
  109. package/template/lib/data-view-dashboard-placements-layout.ts +215 -0
  110. package/template/lib/date-filter.ts +1 -0
  111. package/template/lib/dedicated-search-recents.ts +76 -0
  112. package/template/lib/dedicated-search-url.ts +23 -0
  113. package/template/lib/discovery-hub.ts +15 -0
  114. package/template/lib/list-status-badges.ts +1 -21
  115. package/template/lib/mock/navigation.tsx +4 -2
  116. package/template/lib/mock/placements.ts +9 -9
  117. package/template/lib/mock/question-bank-folders.ts +7 -0
  118. package/template/lib/mock/question-bank-header-collaborators.ts +45 -5
  119. package/template/lib/mock/question-bank-inspector.ts +1 -2
  120. package/template/lib/mock/question-bank-kpi.ts +38 -26
  121. package/template/lib/mock/question-bank.ts +43 -16
  122. package/template/lib/question-bank-dedicated-search.ts +19 -0
  123. package/template/lib/question-bank-hub-search.ts +90 -0
  124. package/template/lib/question-bank-nav.ts +322 -6
  125. package/template/lib/question-bank-recent-searches.ts +22 -0
  126. package/template/package.json +1 -2
@@ -0,0 +1,436 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import Link from "next/link"
5
+ import { useRouter } from "next/navigation"
6
+
7
+ import { OsFolderGlyph } from "@/components/data-views/os-folder-glyph"
8
+ import { AskLeoComposer } from "@/components/ask-leo-composer"
9
+ import { useAskLeo, useAskLeoPageContext } from "@/components/ask-leo-sidebar"
10
+ import { PrimaryPageTemplate } from "@/components/templates/primary-page-template"
11
+ import { Button } from "@/components/ui/button"
12
+ import { Shortcut } from "@/components/ui/dropdown-menu"
13
+ import { Kbd, KbdGroup } from "@/components/ui/kbd"
14
+ import { Tip } from "@/components/ui/tip"
15
+ import { useAltKeyLabel, useModKeyLabel } from "@/hooks/use-mod-key-label"
16
+ import {
17
+ DEFAULT_QUESTION_BANK_FOLDERS,
18
+ type QuestionBankFolder,
19
+ type QuestionBankFolderColorKey,
20
+ } from "@/lib/mock/question-bank-folders"
21
+ import { QUESTION_BANK_ITEMS, type QuestionBankItem } from "@/lib/mock/question-bank"
22
+ import { QUESTION_BANK_HUB_ASK_LEO_PROMPTS } from "@/lib/question-bank-hub-search"
23
+ import {
24
+ QUESTION_BANK_LIBRARY_PATH,
25
+ QUESTION_BANK_NAV_MY_AUTHOR,
26
+ questionBankNavHref,
27
+ } from "@/lib/question-bank-nav"
28
+ import { cn } from "@/lib/utils"
29
+
30
+ const NEW_QUESTION_PROMPT =
31
+ "Help me create a new assessment question. Start by asking for topic, item type, difficulty, and learning objective, then draft the stem, answer choices, and rationale."
32
+
33
+ const TEMPLATE_PROMPT =
34
+ "Walk me through choosing a question template (MCQ, OSCE, short answer, true/false) and produce a starter item with stem, options, rationale, and tags."
35
+
36
+ const IMPORT_PROMPT =
37
+ "Guide me through importing assessment questions in bulk. Ask about source format (CSV, QTI, copy/paste), then outline what columns and mappings I need."
38
+
39
+ /** Rotating example queries — read like something a user would actually type into search. */
40
+ const HUB_COMPOSER_PLACEHOLDERS = [
41
+ "all the questions I used in last year's assessment",
42
+ "what did we run for the spring OSCE checkout?",
43
+ "Clinical folder, cardiology tagged, for third-year summative",
44
+ "find QB-2024-0012 and anything like it",
45
+ "that anemia vignette from the shelf-style block",
46
+ "MCQs I still need before next week's diabetes station",
47
+ ] as const
48
+
49
+ interface ScopeChip {
50
+ id: string
51
+ label: string
52
+ href: string
53
+ count: number
54
+ folderGlyph: {
55
+ colorKey: QuestionBankFolderColorKey
56
+ icon: string
57
+ variant?: "solid" | "outline"
58
+ }
59
+ }
60
+
61
+ interface CreateTile {
62
+ id: string
63
+ label: string
64
+ description: string
65
+ icon: string
66
+ iconTint: string
67
+ onClick: () => void
68
+ badge?: "AI" | null
69
+ shortcutKeys?: string
70
+ }
71
+
72
+ interface FolderTile extends QuestionBankFolder {
73
+ count: number
74
+ }
75
+
76
+ function buildScopeChips(items: QuestionBankItem[]): ScopeChip[] {
77
+ const mine = items.filter(
78
+ i => i.author === QUESTION_BANK_NAV_MY_AUTHOR || i.createdBy === QUESTION_BANK_NAV_MY_AUTHOR,
79
+ ).length
80
+ return [
81
+ {
82
+ id: "all",
83
+ label: "All",
84
+ count: items.length,
85
+ href: QUESTION_BANK_LIBRARY_PATH,
86
+ folderGlyph: { colorKey: "muted", icon: "fa-layer-group", variant: "outline" },
87
+ },
88
+ {
89
+ id: "my",
90
+ label: "Mine",
91
+ count: mine,
92
+ href: questionBankNavHref({ scope: "my" }),
93
+ folderGlyph: { colorKey: "brand", icon: "fa-user" },
94
+ },
95
+ ]
96
+ }
97
+
98
+ function buildFolderTiles(items: QuestionBankItem[], folders: QuestionBankFolder[]): FolderTile[] {
99
+ const byFolder = new Map<string, number>()
100
+ for (const i of items) byFolder.set(i.folderId, (byFolder.get(i.folderId) ?? 0) + 1)
101
+ return folders
102
+ .filter(f => f.parentId === null)
103
+ .map(f => ({ ...f, count: byFolder.get(f.id) ?? 0 }))
104
+ }
105
+
106
+ // Static derivations of immutable mock data — computed once at module load,
107
+ // not per render of the hub. Re-derive only if the underlying mock arrays change.
108
+ const HUB_SCOPES = buildScopeChips(QUESTION_BANK_ITEMS)
109
+ const HUB_FOLDER_TILES = buildFolderTiles(QUESTION_BANK_ITEMS, DEFAULT_QUESTION_BANK_FOLDERS)
110
+
111
+ function isMineQuestionBankItem(i: QuestionBankItem): boolean {
112
+ return i.author === QUESTION_BANK_NAV_MY_AUTHOR || i.createdBy === QUESTION_BANK_NAV_MY_AUTHOR
113
+ }
114
+
115
+ const HUB_MINE_RECENTS = [...QUESTION_BANK_ITEMS]
116
+ .filter(isMineQuestionBankItem)
117
+ .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
118
+ .slice(0, 3)
119
+
120
+ const HUB_ASK_LEO_PAGE_CONTEXT = {
121
+ title: "Question hub",
122
+ description:
123
+ "Browse and organize assessment items with AI-assisted workflows. The hub search field opens discovery results on `/question-bank/find` with your wording applied to the list; use the library’s Search in the sidebar for `/question-bank/list`. Pick a suggestion below when you want a full Ask Leo thread.",
124
+ suggestions: [...QUESTION_BANK_HUB_ASK_LEO_PROMPTS],
125
+ data: { surface: "question-bank-discovery-hub" as const },
126
+ }
127
+
128
+ function formatRelativeDate(iso: string): string {
129
+ const now = Date.now()
130
+ const t = new Date(iso).getTime()
131
+ if (Number.isNaN(t)) return iso
132
+ const diffDays = Math.round((now - t) / 86_400_000)
133
+ if (diffDays <= 0) return "today"
134
+ if (diffDays === 1) return "yesterday"
135
+ if (diffDays < 7) return `${diffDays}d ago`
136
+ if (diffDays < 30) return `${Math.round(diffDays / 7)}w ago`
137
+ if (diffDays < 365) return `${Math.round(diffDays / 30)}mo ago`
138
+ return `${Math.round(diffDays / 365)}y ago`
139
+ }
140
+
141
+ export function QuestionBankHubClient() {
142
+ const router = useRouter()
143
+ const { openWithPrompt, toggle } = useAskLeo()
144
+ const mod = useModKeyLabel()
145
+ const alt = useAltKeyLabel()
146
+
147
+ const [hubComposerValue, setHubComposerValue] = React.useState("")
148
+ const [hubComposerExpanded, setHubComposerExpanded] = React.useState(false)
149
+
150
+ const scopes = HUB_SCOPES
151
+ const folderTiles = HUB_FOLDER_TILES
152
+ const recents = HUB_MINE_RECENTS
153
+
154
+ useAskLeoPageContext(HUB_ASK_LEO_PAGE_CONTEXT)
155
+
156
+ const sendLeoSuggestion = React.useCallback(
157
+ (prompt: string) => {
158
+ openWithPrompt(prompt)
159
+ },
160
+ [openWithPrompt],
161
+ )
162
+
163
+ const openCreateQuestion = React.useCallback(() => {
164
+ openWithPrompt(NEW_QUESTION_PROMPT)
165
+ }, [openWithPrompt])
166
+
167
+ const onHubComposerSubmit = React.useCallback(
168
+ (message: string) => {
169
+ router.push(questionBankNavHref({ scope: "all", q: message.trim(), hubFind: true }))
170
+ },
171
+ [router],
172
+ )
173
+
174
+ const createShortcut = `${mod}${alt}N`
175
+
176
+ const hubFolderBrowserTileClass = cn(
177
+ "flex w-full flex-col items-center gap-2 rounded-xl border border-transparent p-3 text-center transition-colors",
178
+ "hover:border-border/80 hover:bg-muted/35",
179
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
180
+ )
181
+
182
+ const createTiles = React.useMemo<CreateTile[]>(
183
+ () => [
184
+ {
185
+ id: "scratch",
186
+ label: "From scratch",
187
+ description: "Start with an empty editor and build the item by hand.",
188
+ icon: "fa-pen-to-square",
189
+ iconTint: "bg-brand/15 text-brand",
190
+ onClick: openCreateQuestion,
191
+ shortcutKeys: createShortcut,
192
+ },
193
+ {
194
+ id: "ask-leo",
195
+ label: "Draft with Ask Leo",
196
+ description: "Describe the outcome and let Leo propose stem, options, and rationale.",
197
+ icon: "fa-star-christmas",
198
+ iconTint: "bg-brand/15 text-brand",
199
+ badge: "AI",
200
+ onClick: openCreateQuestion,
201
+ },
202
+ {
203
+ id: "template",
204
+ label: "From a template",
205
+ description: "Pick MCQ, OSCE, short answer or true/false — Leo fills the scaffold.",
206
+ icon: "fa-clone",
207
+ iconTint: "bg-sky-500/15 text-sky-700 dark:text-sky-300",
208
+ onClick: () => sendLeoSuggestion(TEMPLATE_PROMPT),
209
+ },
210
+ {
211
+ id: "import",
212
+ label: "Import in bulk",
213
+ description: "Bring in CSV, QTI, or paste from another tool — Leo will map the columns.",
214
+ icon: "fa-file-import",
215
+ iconTint: "bg-muted text-muted-foreground",
216
+ onClick: () => sendLeoSuggestion(IMPORT_PROMPT),
217
+ },
218
+ ],
219
+ [openCreateQuestion, sendLeoSuggestion, createShortcut],
220
+ )
221
+
222
+ return (
223
+ <PrimaryPageTemplate
224
+ siteHeader={{
225
+ breadcrumbs: [{ label: "Dashboard", href: "/dashboard" }],
226
+ title: "Question hub",
227
+ }}
228
+ maxWidthClassName="max-w-5xl"
229
+ contentClassName="px-4 py-8 md:px-6 md:py-10"
230
+ >
231
+ <Shortcut keys={createShortcut} onInvoke={openCreateQuestion} />
232
+ {/* ⌘⌥K (Ask Leo toggle) is bound globally in AskLeoProvider — do not double-bind here. */}
233
+
234
+ <div className="flex min-h-0 flex-1 flex-col gap-10">
235
+ <header>
236
+ <h1 className="text-2xl font-semibold tracking-tight text-foreground md:text-3xl" style={{ fontFamily: "var(--font-heading)" }}>
237
+ Question hub
238
+ </h1>
239
+ </header>
240
+
241
+ <div className="min-w-0">
242
+ <p className="sr-only">
243
+ Example searches rotate in the field. Type your own request in plain language, then press Enter to open
244
+ the library with that AI search applied to the question list. This control does not open Ask Leo.
245
+ </p>
246
+ <div
247
+ className={cn(
248
+ "min-w-0 max-w-full border border-[color:var(--control-border)] bg-card shadow-sm transition-[border-radius,padding,box-shadow] duration-200 ease-out",
249
+ hubComposerExpanded ? "rounded-2xl p-1.5 shadow-md" : "rounded-full px-1 py-1",
250
+ )}
251
+ >
252
+ <AskLeoComposer
253
+ value={hubComposerValue}
254
+ onChange={setHubComposerValue}
255
+ onSubmit={onHubComposerSubmit}
256
+ onExpandedChange={setHubComposerExpanded}
257
+ animatedPlaceholders={[...HUB_COMPOSER_PLACEHOLDERS]}
258
+ animatedPlaceholderIntervalMs={4800}
259
+ animatedPlaceholderMaxLines={2}
260
+ leadingSlot="ai-mark"
261
+ inputLabel="AI search"
262
+ submitAppearance="search"
263
+ submitButtonAriaLabel="Run AI search"
264
+ placeholder="Search the bank…"
265
+ className="[&_form>div]:rounded-none [&_form>div]:border-0 [&_form>div]:bg-transparent [&_form>div]:shadow-none"
266
+ />
267
+ </div>
268
+ </div>
269
+
270
+ {recents.length > 0 && (
271
+ <section aria-labelledby="qb-recent" className="space-y-4">
272
+ <div className="flex items-baseline justify-between gap-3">
273
+ <h2 id="qb-recent" className="text-base font-semibold tracking-tight text-foreground">
274
+ Continue where you left off
275
+ </h2>
276
+ <Link
277
+ href={questionBankNavHref({ scope: "my" })}
278
+ className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
279
+ >
280
+ View all
281
+ <i className="fa-light fa-arrow-right ms-1.5 text-xs" aria-hidden="true" />
282
+ </Link>
283
+ </div>
284
+ <ul className="grid gap-3 md:grid-cols-3" role="list">
285
+ {recents.map(item => (
286
+ <li key={item.id}>
287
+ <Link
288
+ href={questionBankNavHref({ scope: "my" })}
289
+ className="group flex h-full flex-col gap-3 rounded-xl border border-border bg-card p-4 transition hover:border-interactive-hover hover:bg-interactive-hover/30 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
290
+ >
291
+ <div className="flex items-center gap-2">
292
+ <span className="truncate font-mono text-xs text-muted-foreground">{item.questionId}</span>
293
+ <span className="truncate text-xs text-muted-foreground">{item.topic}</span>
294
+ </div>
295
+ <p className="line-clamp-3 text-sm font-medium text-foreground group-hover:text-foreground">
296
+ {item.stem}
297
+ </p>
298
+ <div className="mt-auto flex justify-end text-xs text-muted-foreground">
299
+ <span className="shrink-0">{formatRelativeDate(item.updatedAt)}</span>
300
+ </div>
301
+ </Link>
302
+ </li>
303
+ ))}
304
+ </ul>
305
+ </section>
306
+ )}
307
+
308
+ <section aria-labelledby="qb-browse" className="space-y-4">
309
+ <div className="flex items-baseline justify-between gap-3">
310
+ <h2 id="qb-browse" className="text-base font-semibold tracking-tight text-foreground">
311
+ Browse the library
312
+ </h2>
313
+ <Link
314
+ href={QUESTION_BANK_LIBRARY_PATH}
315
+ className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
316
+ >
317
+ Open full library
318
+ <i className="fa-light fa-arrow-right ms-1.5 text-xs" aria-hidden="true" />
319
+ </Link>
320
+ </div>
321
+
322
+ <div
323
+ 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"
324
+ role="list"
325
+ aria-label="Library scopes and top folders"
326
+ >
327
+ {scopes.map(s => (
328
+ <div key={s.id} role="listitem" className="min-w-0">
329
+ <Link
330
+ href={s.href}
331
+ className={hubFolderBrowserTileClass}
332
+ >
333
+ <OsFolderGlyph
334
+ colorKey={s.folderGlyph.colorKey}
335
+ icon={s.folderGlyph.icon}
336
+ size="md"
337
+ variant={s.folderGlyph.variant ?? "solid"}
338
+ />
339
+ <span className="line-clamp-2 w-full text-xs font-medium text-foreground">{s.label}</span>
340
+ <span className="text-[11px] leading-snug text-muted-foreground tabular-nums">
341
+ {s.count} {s.count === 1 ? "question" : "questions"}
342
+ </span>
343
+ </Link>
344
+ </div>
345
+ ))}
346
+ {folderTiles.map(f => (
347
+ <div key={f.id} role="listitem" className="min-w-0">
348
+ <Link
349
+ href={questionBankNavHref({ scope: "folder", folderId: f.id })}
350
+ className={hubFolderBrowserTileClass}
351
+ >
352
+ <OsFolderGlyph colorKey={f.colorKey} icon={f.icon} size="md" />
353
+ <span className="line-clamp-2 w-full text-xs font-medium text-foreground">{f.name}</span>
354
+ <span className="text-[11px] leading-snug text-muted-foreground tabular-nums">
355
+ {f.count} {f.count === 1 ? "question" : "questions"}
356
+ </span>
357
+ </Link>
358
+ </div>
359
+ ))}
360
+ </div>
361
+ </section>
362
+
363
+ <section aria-labelledby="qb-create" className="space-y-4">
364
+ <div className="flex flex-wrap items-baseline justify-between gap-3">
365
+ <h2 id="qb-create" className="text-base font-semibold tracking-tight text-foreground">
366
+ Create a question
367
+ </h2>
368
+ <Tip
369
+ label={(
370
+ <span className="inline-flex items-center gap-1.5">
371
+ Ask Leo
372
+ <KbdGroup>
373
+ <Kbd>{mod}</Kbd>
374
+ <Kbd>{alt}</Kbd>
375
+ <Kbd>K</Kbd>
376
+ </KbdGroup>
377
+ </span>
378
+ )}
379
+ >
380
+ <Button type="button" variant="ghost" size="sm" onClick={toggle}>
381
+ <i className="fa-duotone fa-solid fa-star-christmas text-brand" aria-hidden="true" />
382
+ Open Ask Leo
383
+ </Button>
384
+ </Tip>
385
+ </div>
386
+
387
+ <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4" role="list">
388
+ {createTiles.map(tile => (
389
+ <li key={tile.id}>
390
+ <button
391
+ type="button"
392
+ onClick={tile.onClick}
393
+ className="group flex h-full w-full flex-col items-start gap-3 rounded-xl border border-border bg-card p-4 text-left transition hover:border-interactive-hover hover:bg-interactive-hover/30 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
394
+ >
395
+ <span className="flex w-full items-center justify-between">
396
+ <span
397
+ className={cn(
398
+ "inline-flex h-10 w-10 items-center justify-center rounded-lg",
399
+ tile.iconTint,
400
+ )}
401
+ aria-hidden="true"
402
+ >
403
+ <i
404
+ className={cn(
405
+ tile.badge === "AI" ? "fa-duotone fa-solid" : "fa-light",
406
+ tile.icon,
407
+ "text-lg",
408
+ )}
409
+ />
410
+ </span>
411
+ {tile.badge === "AI" && (
412
+ <span className="inline-flex items-center rounded-full bg-brand/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand">
413
+ AI
414
+ </span>
415
+ )}
416
+ </span>
417
+ <span className="space-y-1">
418
+ <span className="block text-sm font-semibold text-foreground">{tile.label}</span>
419
+ <span className="block text-xs leading-relaxed text-muted-foreground">
420
+ {tile.description}
421
+ </span>
422
+ </span>
423
+ {tile.shortcutKeys && (
424
+ <KbdGroup className="mt-auto">
425
+ <Kbd variant="bare">{tile.shortcutKeys}</Kbd>
426
+ </KbdGroup>
427
+ )}
428
+ </button>
429
+ </li>
430
+ ))}
431
+ </ul>
432
+ </section>
433
+ </div>
434
+ </PrimaryPageTemplate>
435
+ )
436
+ }
@@ -1,37 +1,38 @@
1
1
  "use client"
2
2
 
3
- import * as React from "react"
4
3
  import type { QuestionBankItem } from "@/lib/mock/question-bank"
5
- import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
6
4
  import { ListPageBoardCard } from "@/components/data-views/list-page-board-card"
5
+ import { formatDateUS } from "@/lib/date-filter"
7
6
  import {
8
- QUESTION_BANK_STATUS_BADGE_CLASS,
9
- QUESTION_BANK_STATUS_ICON,
10
- QUESTION_BANK_STATUS_LABEL,
11
- } from "@/lib/list-status-badges"
7
+ QuestionBankFavoriteButton,
8
+ QUESTION_BANK_FAVORITE_HOVER_GROUP,
9
+ } from "@/components/question-bank-favorite-button"
12
10
 
13
- function QuestionBankListRow({ row }: { row: QuestionBankItem }) {
11
+ function QuestionBankListRow({
12
+ row,
13
+ onToggleFavorite,
14
+ }: {
15
+ row: QuestionBankItem
16
+ onToggleFavorite: (row: QuestionBankItem) => void
17
+ }) {
14
18
  return (
15
19
  <li>
16
20
  <ListPageBoardCard
21
+ className={QUESTION_BANK_FAVORITE_HOVER_GROUP}
17
22
  layout="row"
18
23
  rowContainerClassName="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:gap-4"
19
- rowEnd={
20
- <div className="flex shrink-0 items-center gap-2">
21
- <ListHubStatusBadge
22
- surface="board"
23
- label={QUESTION_BANK_STATUS_LABEL[row.status]}
24
- tintClassName={QUESTION_BANK_STATUS_BADGE_CLASS[row.status]}
25
- icon={QUESTION_BANK_STATUS_ICON[row.status]}
26
- />
24
+ rowEnd={(
25
+ <div className="flex shrink-0 items-center gap-1">
26
+ <QuestionBankFavoriteButton row={row} onToggleFavorite={onToggleFavorite} />
27
27
  <i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
28
28
  </div>
29
- }
29
+ )}
30
30
  >
31
31
  <div className="space-y-0.5">
32
32
  <p className="line-clamp-2 text-sm font-semibold text-foreground">{row.stem}</p>
33
+ <p className="font-mono text-xs text-muted-foreground">{row.questionId}</p>
33
34
  <p className="text-xs text-muted-foreground">
34
- {row.topic} · Updated {row.updatedAt}
35
+ {row.topic} · Updated {formatDateUS(row.updatedAt)}
35
36
  </p>
36
37
  <p className="text-xs text-muted-foreground">{row.author}</p>
37
38
  </div>
@@ -40,7 +41,13 @@ function QuestionBankListRow({ row }: { row: QuestionBankItem }) {
40
41
  )
41
42
  }
42
43
 
43
- export function QuestionBankListView({ rows }: { rows: QuestionBankItem[] }) {
44
+ export function QuestionBankListView({
45
+ rows,
46
+ onToggleFavorite,
47
+ }: {
48
+ rows: QuestionBankItem[]
49
+ onToggleFavorite: (row: QuestionBankItem) => void
50
+ }) {
44
51
  if (rows.length === 0) {
45
52
  return (
46
53
  <div className="px-4 py-16 text-center lg:px-6">
@@ -52,7 +59,7 @@ export function QuestionBankListView({ rows }: { rows: QuestionBankItem[] }) {
52
59
  return (
53
60
  <ul className="flex list-none flex-col gap-2 px-4 pb-8 pt-2 lg:px-6">
54
61
  {rows.map(row => (
55
- <QuestionBankListRow key={row.id} row={row} />
62
+ <QuestionBankListRow key={row.id} row={row} onToggleFavorite={onToggleFavorite} />
56
63
  ))}
57
64
  </ul>
58
65
  )
@@ -131,32 +131,41 @@ export function QuestionBankNewFolderSheet({
131
131
  side="right"
132
132
  showCloseButton={false}
133
133
  showOverlay={false}
134
- className="z-[60] flex w-full max-w-md flex-col gap-0 overflow-hidden rounded-xl border border-border p-0 shadow-xl sm:max-w-md"
134
+ className="z-[60] w-80 sm:max-w-80 flex flex-col gap-0 overflow-hidden rounded-xl border border-border p-0 shadow-xl"
135
135
  style={{ top: "0.5rem", bottom: "0.5rem", right: "0.5rem", height: "calc(100vh - 1rem)" }}
136
136
  >
137
- {open ? (
138
- <>
139
- <Shortcut keys="Enter" onInvoke={() => !createDisabled && commit()} />
140
- <Shortcut keys="Esc" onInvoke={() => onOpenChange(false)} />
141
-
142
- <div className="flex items-center justify-between gap-3 px-4 pt-5 pb-6">
143
- <SheetTitle className="text-base font-semibold leading-tight">
144
- {customizingFolder ? "Customize Folder" : "New folder"}
145
- </SheetTitle>
146
- <Tip label="Close" side="bottom">
147
- <Button
148
- type="button"
149
- variant="ghost"
150
- size="icon-sm"
151
- aria-label="Close"
152
- onClick={() => onOpenChange(false)}
153
- >
154
- <i className="fa-light fa-xmark text-[13px]" aria-hidden="true" />
155
- </Button>
156
- </Tip>
157
- </div>
158
-
159
- <div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 pb-4 pt-2">
137
+ <Shortcut keys="Enter" disabled={createDisabled} onInvoke={commit} />
138
+
139
+ <div className="flex items-center justify-between gap-3 px-4 pt-5 pb-3">
140
+ <SheetTitle className="text-base font-semibold leading-tight">
141
+ {customizingFolder ? "Customize folder" : "New folder"}
142
+ </SheetTitle>
143
+ <Tip label="Close" side="bottom">
144
+ <Button
145
+ type="button"
146
+ variant="ghost"
147
+ size="icon-sm"
148
+ aria-label="Close"
149
+ onClick={() => onOpenChange(false)}
150
+ >
151
+ <i className="fa-light fa-xmark text-[13px]" aria-hidden="true" />
152
+ </Button>
153
+ </Tip>
154
+ </div>
155
+
156
+ <p id="new-folder-panel-desc" className="px-4 pb-3 text-sm text-muted-foreground -mt-1">
157
+ {descriptionText}
158
+ </p>
159
+
160
+ <form
161
+ id="new-folder-form"
162
+ className="flex min-h-0 flex-1 flex-col"
163
+ onSubmit={event => {
164
+ event.preventDefault()
165
+ commit()
166
+ }}
167
+ >
168
+ <div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 pb-4">
160
169
  <div className="flex flex-col items-center gap-4">
161
170
  <FolderTilePreview
162
171
  name={draft.name}
@@ -224,24 +233,29 @@ export function QuestionBankNewFolderSheet({
224
233
  ))}
225
234
  </div>
226
235
  </div>
227
- </div>
228
-
229
- <div className="mt-auto flex flex-row flex-wrap justify-end gap-2 border-t border-border px-4 py-4">
230
- <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
231
- Cancel
232
- <KbdGroup className="ms-1.5">
233
- <Kbd variant="bare">Esc</Kbd>
234
- </KbdGroup>
235
- </Button>
236
- <Button type="button" disabled={createDisabled} onClick={commit}>
237
- {customizingFolder ? "Update folder" : "Create folder"}
238
- <KbdGroup className="ms-1.5">
239
- <Kbd variant="bare">⏎</Kbd>
240
- </KbdGroup>
241
- </Button>
242
- </div>
243
- </>
244
- ) : null}
236
+ </div>
237
+
238
+ <div className="flex items-center gap-2 border-t border-border px-4 py-3">
239
+ <Button
240
+ type="button"
241
+ variant="outline"
242
+ className="flex-1"
243
+ onClick={() => onOpenChange(false)}
244
+ >
245
+ Cancel
246
+ <KbdGroup className="ml-1.5"><Kbd variant="bare">Esc</Kbd></KbdGroup>
247
+ </Button>
248
+ <Button
249
+ type="submit"
250
+ className="flex-1"
251
+ disabled={createDisabled}
252
+ >
253
+ <i className="fa-light fa-folder text-[13px]" aria-hidden="true" />
254
+ {customizingFolder ? "Update folder" : "Create folder"}
255
+ <KbdGroup className="ml-1.5"><Kbd variant="bare">⏎</Kbd></KbdGroup>
256
+ </Button>
257
+ </div>
258
+ </form>
245
259
  </SheetContent>
246
260
  </Sheet>
247
261
  )
@@ -39,12 +39,6 @@ import {
39
39
  type QuestionBankFolder,
40
40
  type QuestionBankFolderColorKey,
41
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
42
  import {
49
43
  ListPageViewFrame,
50
44
  LIST_PAGE_VIEW_FRAME_MAX_WIDE,
@@ -389,7 +383,7 @@ export function QuestionBankOsFolderView({
389
383
  </Button>
390
384
  </DropdownMenuTrigger>
391
385
  </Tip>
392
- <DropdownMenuContent align="end" className="w-40">
386
+ <DropdownMenuContent align="end">
393
387
  <DropdownMenuItem
394
388
  onSelect={() => {
395
389
  setTimeout(() => startRename(folder), 0)
@@ -434,12 +428,7 @@ export function QuestionBankOsFolderView({
434
428
  <i className="fa-solid fa-file-lines text-3xl text-muted-foreground" aria-hidden="true" />
435
429
  </div>
436
430
  <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
- />
431
+ <span className="w-full font-mono text-xs text-muted-foreground">{q.questionId}</span>
443
432
  </div>
444
433
  <div className="absolute end-1.5 top-1.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
445
434
  <DropdownMenu>
@@ -455,7 +444,7 @@ export function QuestionBankOsFolderView({
455
444
  </Button>
456
445
  </DropdownMenuTrigger>
457
446
  </Tip>
458
- <DropdownMenuContent align="end" className="w-56">
447
+ <DropdownMenuContent align="end">
459
448
  <DropdownMenuSub>
460
449
  <DropdownMenuSubTrigger>
461
450
  <i className="fa-solid fa-folder-arrow-up me-2 text-xs" aria-hidden="true" />