@exxatdesignux/ui 0.2.16 → 0.2.18
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.
- package/CHANGELOG.md +26 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
- package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
- package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +19 -0
- package/consumer-extras/patterns/data-views-pattern.md +2 -0
- package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
- package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
- package/package.json +3 -3
- package/src/components/ui/banner.tsx +2 -0
- package/src/components/ui/chart.tsx +57 -2
- package/src/components/ui/sidebar.tsx +3 -2
- package/src/globals.css +65 -14
- package/src/theme.css +3 -3
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +27 -17
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/error.tsx +22 -6
- package/template/app/(app)/layout.tsx +13 -6
- package/template/app/(app)/question-bank/layout.tsx +18 -5
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/global-error.tsx +63 -0
- package/template/app/globals.css +151 -14
- package/template/app/layout.tsx +43 -5
- package/template/components/app-sidebar.tsx +68 -33
- package/template/components/ask-leo-sidebar.tsx +0 -2
- package/template/components/brand-color-picker.tsx +344 -0
- package/template/components/compliance-list-view.tsx +33 -51
- package/template/components/compliance-table.tsx +4 -0
- package/template/components/data-table/index.tsx +99 -91
- package/template/components/data-table/pagination.tsx +0 -1
- package/template/components/data-table/types.ts +4 -1
- package/template/components/data-table/use-table-state.ts +276 -100
- package/template/components/data-views/data-row-list.tsx +183 -0
- package/template/components/data-views/index.ts +7 -3
- package/template/components/data-views/os-folder-glyph.tsx +8 -0
- package/template/components/dev-chunk-load-recovery.tsx +41 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +168 -317
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +122 -62
- package/template/components/new-placement-form.tsx +4 -2
- package/template/components/new-question-composer.tsx +2208 -0
- package/template/components/page-breadcrumb-trail.tsx +131 -0
- package/template/components/page-header.tsx +2 -1
- package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
- package/template/components/placement-detail.tsx +1 -1
- package/template/components/placements-board-view.tsx +1 -1
- package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
- package/template/components/placements-list-view.tsx +19 -133
- package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
- package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
- package/template/components/placements-table-columns.tsx +2 -2
- package/template/components/{data-list-table.tsx → placements-table.tsx} +42 -66
- package/template/components/product-switcher.tsx +24 -7
- package/template/components/product-wordmark.tsx +282 -0
- package/template/components/question-bank-client.tsx +20 -2
- package/template/components/question-bank-hub-client.tsx +105 -115
- package/template/components/question-bank-list-view.tsx +30 -54
- package/template/components/question-bank-new-folder-sheet.tsx +1 -1
- package/template/components/question-bank-secondary-nav.tsx +0 -3
- package/template/components/question-bank-table.tsx +19 -6
- package/template/components/rotations-empty-state.tsx +3 -0
- package/template/components/secondary-panel.tsx +23 -3
- package/template/components/settings-appearance-card.tsx +584 -141
- package/template/components/sidebar-shell.tsx +2 -1
- package/template/components/site-header.tsx +36 -31
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +4 -0
- package/template/components/table-properties/drawer-button.tsx +38 -20
- package/template/components/table-properties/drawer.tsx +17 -14
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +8 -3
- package/template/components/templates/list-page.tsx +12 -9
- package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +70 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- package/template/docs/data-views-pattern.md +2 -0
- package/template/docs/kpi-flat-band-pattern.md +57 -0
- package/template/docs/kpi-strip-max-four-pattern.md +1 -0
- package/template/docs/shell-surface-elevation-pattern.md +52 -0
- package/template/eslint.config.mjs +18 -0
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/chunk-load-error.ts +13 -0
- package/template/lib/conditional-rule-match.ts +87 -22
- package/template/lib/data-list-persistence.ts +57 -257
- package/template/lib/data-list-view.ts +6 -0
- package/template/lib/dev-log.test.ts +6 -5
- package/template/lib/exxat-palette.json +1462 -0
- package/template/lib/exxat-palette.ts +136 -0
- package/template/lib/list-page-table-properties.ts +1 -1
- package/template/lib/list-status-badges.ts +1 -1
- package/template/lib/mailto.ts +29 -0
- package/template/lib/placement-board-card-layout.ts +1 -1
- package/template/lib/product-brand.ts +268 -0
- package/template/lib/question-bank-authoring.ts +308 -0
- package/template/lib/question-bank-nav.ts +44 -0
- package/template/lib/raf-throttle.ts +45 -0
- package/template/lib/sidebar-state-cookie.ts +9 -0
- package/template/lib/table-state-lifecycle.ts +521 -0
- package/template/next.config.mjs +156 -0
- package/template/package.json +3 -3
- package/template/stores/app-store.ts +46 -1
|
@@ -8,10 +8,8 @@ import { OsFolderGlyph } from "@/components/data-views/os-folder-glyph"
|
|
|
8
8
|
import { AskLeoComposer } from "@/components/ask-leo-composer"
|
|
9
9
|
import { useAskLeo, useAskLeoPageContext } from "@/components/ask-leo-sidebar"
|
|
10
10
|
import { PrimaryPageTemplate } from "@/components/templates/primary-page-template"
|
|
11
|
-
import { Button } from "@/components/ui/button"
|
|
12
11
|
import { Shortcut } from "@/components/ui/dropdown-menu"
|
|
13
|
-
import {
|
|
14
|
-
import { Tip } from "@/components/ui/tip"
|
|
12
|
+
import { useSidebar } from "@/components/ui/sidebar"
|
|
15
13
|
import { useAltKeyLabel, useModKeyLabel } from "@/hooks/use-mod-key-label"
|
|
16
14
|
import {
|
|
17
15
|
DEFAULT_QUESTION_BANK_FOLDERS,
|
|
@@ -27,8 +25,10 @@ import {
|
|
|
27
25
|
} from "@/lib/question-bank-nav"
|
|
28
26
|
import { cn } from "@/lib/utils"
|
|
29
27
|
|
|
30
|
-
const
|
|
31
|
-
|
|
28
|
+
const NEW_QUESTION_AUTHORING_PATH = "/question-bank/new"
|
|
29
|
+
|
|
30
|
+
const DRAFT_WITH_LEO_PROMPT =
|
|
31
|
+
"Help me draft a new assessment question. Ask me for the topic, item type (NBME single best answer / NCLEX SATA / vignette / EMQ / T/F / short answer), Bloom level, and discipline, then propose stem, lead-in, answer options, and rationale I can paste into the authoring composer."
|
|
32
32
|
|
|
33
33
|
const TEMPLATE_PROMPT =
|
|
34
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."
|
|
@@ -140,7 +140,8 @@ function formatRelativeDate(iso: string): string {
|
|
|
140
140
|
|
|
141
141
|
export function QuestionBankHubClient() {
|
|
142
142
|
const router = useRouter()
|
|
143
|
-
const { openWithPrompt
|
|
143
|
+
const { openWithPrompt } = useAskLeo()
|
|
144
|
+
const { setOpen: setMainSidebarOpen } = useSidebar()
|
|
144
145
|
const mod = useModKeyLabel()
|
|
145
146
|
const alt = useAltKeyLabel()
|
|
146
147
|
|
|
@@ -160,8 +161,19 @@ export function QuestionBankHubClient() {
|
|
|
160
161
|
[openWithPrompt],
|
|
161
162
|
)
|
|
162
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Navigate to the full-page authoring composer (`/question-bank/new`).
|
|
166
|
+
* Mirrors the Placements "New placement" pre-collapse: animates the sidebar
|
|
167
|
+
* closed first so the user sees one smooth transition into the focused flow
|
|
168
|
+
* (the route also mounts `SidebarAutoCollapse` to lock it shut while there).
|
|
169
|
+
*/
|
|
163
170
|
const openCreateQuestion = React.useCallback(() => {
|
|
164
|
-
|
|
171
|
+
setMainSidebarOpen(false)
|
|
172
|
+
window.setTimeout(() => router.push(NEW_QUESTION_AUTHORING_PATH), 260)
|
|
173
|
+
}, [router, setMainSidebarOpen])
|
|
174
|
+
|
|
175
|
+
const openDraftWithLeo = React.useCallback(() => {
|
|
176
|
+
openWithPrompt(DRAFT_WITH_LEO_PROMPT)
|
|
165
177
|
}, [openWithPrompt])
|
|
166
178
|
|
|
167
179
|
const onHubComposerSubmit = React.useCallback(
|
|
@@ -183,25 +195,25 @@ export function QuestionBankHubClient() {
|
|
|
183
195
|
() => [
|
|
184
196
|
{
|
|
185
197
|
id: "scratch",
|
|
186
|
-
label: "
|
|
198
|
+
label: "Start from scratch",
|
|
187
199
|
description: "Start with an empty editor and build the item by hand.",
|
|
188
|
-
icon: "fa-
|
|
200
|
+
icon: "fa-plus",
|
|
189
201
|
iconTint: "bg-brand/15 text-brand",
|
|
190
202
|
onClick: openCreateQuestion,
|
|
191
203
|
shortcutKeys: createShortcut,
|
|
192
204
|
},
|
|
193
205
|
{
|
|
194
206
|
id: "ask-leo",
|
|
195
|
-
label: "Draft with
|
|
207
|
+
label: "Draft with Leo",
|
|
196
208
|
description: "Describe the outcome and let Leo propose stem, options, and rationale.",
|
|
197
209
|
icon: "fa-star-christmas",
|
|
198
210
|
iconTint: "bg-brand/15 text-brand",
|
|
199
211
|
badge: "AI",
|
|
200
|
-
onClick:
|
|
212
|
+
onClick: openDraftWithLeo,
|
|
201
213
|
},
|
|
202
214
|
{
|
|
203
215
|
id: "template",
|
|
204
|
-
label: "From
|
|
216
|
+
label: "From template",
|
|
205
217
|
description: "Pick MCQ, OSCE, short answer or true/false — Leo fills the scaffold.",
|
|
206
218
|
icon: "fa-clone",
|
|
207
219
|
iconTint: "bg-sky-500/15 text-sky-700 dark:text-sky-300",
|
|
@@ -209,14 +221,14 @@ export function QuestionBankHubClient() {
|
|
|
209
221
|
},
|
|
210
222
|
{
|
|
211
223
|
id: "import",
|
|
212
|
-
label: "Import
|
|
224
|
+
label: "Import",
|
|
213
225
|
description: "Bring in CSV, QTI, or paste from another tool — Leo will map the columns.",
|
|
214
226
|
icon: "fa-file-import",
|
|
215
227
|
iconTint: "bg-muted text-muted-foreground",
|
|
216
228
|
onClick: () => sendLeoSuggestion(IMPORT_PROMPT),
|
|
217
229
|
},
|
|
218
230
|
],
|
|
219
|
-
[openCreateQuestion, sendLeoSuggestion, createShortcut],
|
|
231
|
+
[openCreateQuestion, openDraftWithLeo, sendLeoSuggestion, createShortcut],
|
|
220
232
|
)
|
|
221
233
|
|
|
222
234
|
return (
|
|
@@ -225,50 +237,98 @@ export function QuestionBankHubClient() {
|
|
|
225
237
|
breadcrumbs: [{ label: "Dashboard", href: "/dashboard" }],
|
|
226
238
|
title: "Question hub",
|
|
227
239
|
}}
|
|
228
|
-
maxWidthClassName="max-w-
|
|
240
|
+
maxWidthClassName="max-w-none"
|
|
229
241
|
contentClassName="px-4 py-8 md:px-6 md:py-10"
|
|
230
242
|
>
|
|
231
243
|
<Shortcut keys={createShortcut} onInvoke={openCreateQuestion} />
|
|
232
244
|
{/* ⌘⌥K (Ask Leo toggle) is bound globally in AskLeoProvider — do not double-bind here. */}
|
|
233
245
|
|
|
234
246
|
<div className="flex min-h-0 flex-1 flex-col gap-10">
|
|
235
|
-
<header>
|
|
247
|
+
<header className="mx-auto w-full max-w-5xl">
|
|
236
248
|
<h1 className="text-2xl font-semibold tracking-tight text-foreground md:text-3xl" style={{ fontFamily: "var(--font-heading)" }}>
|
|
237
249
|
Question hub
|
|
238
250
|
</h1>
|
|
239
251
|
</header>
|
|
240
252
|
|
|
241
|
-
<
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
253
|
+
<section
|
|
254
|
+
aria-label="Search and create questions"
|
|
255
|
+
className="-mx-4 overflow-hidden px-4 py-6 md:-mx-6 md:px-6"
|
|
256
|
+
style={{
|
|
257
|
+
background: "var(--key-metrics-flat-band-radial)",
|
|
258
|
+
boxShadow: "var(--key-metrics-flat-band-shadow)",
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 md:px-6">
|
|
262
|
+
<div className="min-w-0">
|
|
263
|
+
<p className="sr-only">
|
|
264
|
+
Example searches rotate in the field. Type your own request in plain language, then press Enter to open
|
|
265
|
+
the library with that AI search applied to the question list. This control does not open Ask Leo.
|
|
266
|
+
</p>
|
|
267
|
+
<div
|
|
268
|
+
className={cn(
|
|
269
|
+
"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",
|
|
270
|
+
hubComposerExpanded ? "rounded-2xl p-1.5 shadow-md" : "rounded-full px-1 py-1",
|
|
271
|
+
)}
|
|
272
|
+
>
|
|
273
|
+
<AskLeoComposer
|
|
274
|
+
value={hubComposerValue}
|
|
275
|
+
onChange={setHubComposerValue}
|
|
276
|
+
onSubmit={onHubComposerSubmit}
|
|
277
|
+
onExpandedChange={setHubComposerExpanded}
|
|
278
|
+
animatedPlaceholders={[...HUB_COMPOSER_PLACEHOLDERS]}
|
|
279
|
+
animatedPlaceholderIntervalMs={4800}
|
|
280
|
+
animatedPlaceholderMaxLines={2}
|
|
281
|
+
leadingSlot="ai-mark"
|
|
282
|
+
inputLabel="AI search"
|
|
283
|
+
submitAppearance="search"
|
|
284
|
+
submitButtonAriaLabel="Run AI search"
|
|
285
|
+
placeholder="Search the bank…"
|
|
286
|
+
className="[&_form>div]:rounded-none [&_form>div]:border-0 [&_form>div]:bg-transparent [&_form>div]:shadow-none"
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
{/* Create a question */}
|
|
292
|
+
<section aria-labelledby="qb-create" className="space-y-3">
|
|
293
|
+
<h2 id="qb-create" className="text-base font-semibold tracking-tight text-foreground">
|
|
294
|
+
Create a question
|
|
295
|
+
</h2>
|
|
296
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
297
|
+
{createTiles.map(tile => (
|
|
298
|
+
<button
|
|
299
|
+
key={tile.id}
|
|
300
|
+
type="button"
|
|
301
|
+
onClick={tile.onClick}
|
|
302
|
+
className={cn(
|
|
303
|
+
"inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground transition",
|
|
304
|
+
"hover:border-interactive-hover hover:bg-interactive-hover/30 hover:shadow-sm",
|
|
305
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
306
|
+
)}
|
|
307
|
+
>
|
|
308
|
+
<i
|
|
309
|
+
className={cn(
|
|
310
|
+
tile.badge === "AI" ? "fa-duotone fa-solid" : "fa-light",
|
|
311
|
+
tile.icon,
|
|
312
|
+
"text-xs",
|
|
313
|
+
tile.badge === "AI" ? "text-brand" : "text-muted-foreground",
|
|
314
|
+
)}
|
|
315
|
+
aria-hidden="true"
|
|
316
|
+
/>
|
|
317
|
+
{tile.label}
|
|
318
|
+
{tile.badge === "AI" && (
|
|
319
|
+
<span className="rounded-full bg-brand/10 px-1.5 py-px text-[9px] font-bold uppercase tracking-wider text-brand">
|
|
320
|
+
AI
|
|
321
|
+
</span>
|
|
322
|
+
)}
|
|
323
|
+
</button>
|
|
324
|
+
))}
|
|
325
|
+
</div>
|
|
326
|
+
</section>
|
|
267
327
|
</div>
|
|
268
|
-
</
|
|
328
|
+
</section>
|
|
269
329
|
|
|
270
330
|
{recents.length > 0 && (
|
|
271
|
-
<section aria-labelledby="qb-recent" className="space-y-4">
|
|
331
|
+
<section aria-labelledby="qb-recent" className="mx-auto w-full max-w-5xl space-y-4">
|
|
272
332
|
<div className="flex items-baseline justify-between gap-3">
|
|
273
333
|
<h2 id="qb-recent" className="text-base font-semibold tracking-tight text-foreground">
|
|
274
334
|
Continue where you left off
|
|
@@ -305,7 +365,7 @@ export function QuestionBankHubClient() {
|
|
|
305
365
|
</section>
|
|
306
366
|
)}
|
|
307
367
|
|
|
308
|
-
<section aria-labelledby="qb-browse" className="space-y-4">
|
|
368
|
+
<section aria-labelledby="qb-browse" className="mx-auto w-full max-w-5xl space-y-4">
|
|
309
369
|
<div className="flex items-baseline justify-between gap-3">
|
|
310
370
|
<h2 id="qb-browse" className="text-base font-semibold tracking-tight text-foreground">
|
|
311
371
|
Browse the library
|
|
@@ -360,76 +420,6 @@ export function QuestionBankHubClient() {
|
|
|
360
420
|
</div>
|
|
361
421
|
</section>
|
|
362
422
|
|
|
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
423
|
</div>
|
|
434
424
|
</PrimaryPageTemplate>
|
|
435
425
|
)
|
|
@@ -2,48 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import type { QuestionBankItem } from "@/lib/mock/question-bank"
|
|
4
4
|
import { ListPageBoardCard } from "@/components/data-views/list-page-board-card"
|
|
5
|
+
import { DataRowList } from "@/components/data-views/data-row-list"
|
|
5
6
|
import { formatDateUS } from "@/lib/date-filter"
|
|
6
7
|
import {
|
|
7
8
|
QuestionBankFavoriteButton,
|
|
8
9
|
QUESTION_BANK_FAVORITE_HOVER_GROUP,
|
|
9
10
|
} from "@/components/question-bank-favorite-button"
|
|
10
11
|
|
|
11
|
-
function QuestionBankListRow({
|
|
12
|
-
row,
|
|
13
|
-
onToggleFavorite,
|
|
14
|
-
onRowActivate,
|
|
15
|
-
}: {
|
|
16
|
-
row: QuestionBankItem
|
|
17
|
-
onToggleFavorite: (row: QuestionBankItem) => void
|
|
18
|
-
onRowActivate?: (row: QuestionBankItem) => void
|
|
19
|
-
}) {
|
|
20
|
-
return (
|
|
21
|
-
<li>
|
|
22
|
-
<ListPageBoardCard
|
|
23
|
-
className={QUESTION_BANK_FAVORITE_HOVER_GROUP}
|
|
24
|
-
layout="row"
|
|
25
|
-
rowContainerClassName="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:gap-4"
|
|
26
|
-
onClick={onRowActivate ? () => onRowActivate(row) : undefined}
|
|
27
|
-
rowEnd={(
|
|
28
|
-
<div className="flex shrink-0 items-center gap-1">
|
|
29
|
-
<QuestionBankFavoriteButton row={row} onToggleFavorite={onToggleFavorite} />
|
|
30
|
-
<i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
|
|
31
|
-
</div>
|
|
32
|
-
)}
|
|
33
|
-
>
|
|
34
|
-
<div className="space-y-0.5">
|
|
35
|
-
<p className="line-clamp-2 text-sm font-semibold text-foreground">{row.stem}</p>
|
|
36
|
-
<p className="font-mono text-xs text-muted-foreground">{row.questionId}</p>
|
|
37
|
-
<p className="text-xs text-muted-foreground">
|
|
38
|
-
{row.topic} · Updated {formatDateUS(row.updatedAt)}
|
|
39
|
-
</p>
|
|
40
|
-
<p className="text-xs text-muted-foreground">{row.author}</p>
|
|
41
|
-
</div>
|
|
42
|
-
</ListPageBoardCard>
|
|
43
|
-
</li>
|
|
44
|
-
)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
12
|
export function QuestionBankListView({
|
|
48
13
|
rows,
|
|
49
14
|
onToggleFavorite,
|
|
@@ -54,24 +19,35 @@ export function QuestionBankListView({
|
|
|
54
19
|
/** When set (e.g. table selection), clicking a row toggles the same selection as the grid. */
|
|
55
20
|
onRowActivate?: (row: QuestionBankItem) => void
|
|
56
21
|
}) {
|
|
57
|
-
if (rows.length === 0) {
|
|
58
|
-
return (
|
|
59
|
-
<div className="px-4 py-16 text-center lg:px-6">
|
|
60
|
-
<p className="text-sm text-muted-foreground">No questions match your filters.</p>
|
|
61
|
-
</div>
|
|
62
|
-
)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
22
|
return (
|
|
66
|
-
<
|
|
67
|
-
{rows
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
23
|
+
<DataRowList<QuestionBankItem>
|
|
24
|
+
rows={rows}
|
|
25
|
+
getRowId={row => row.id}
|
|
26
|
+
emptyState="No questions match your filters."
|
|
27
|
+
ariaLabel="Questions"
|
|
28
|
+
renderRow={row => (
|
|
29
|
+
<ListPageBoardCard
|
|
30
|
+
className={QUESTION_BANK_FAVORITE_HOVER_GROUP}
|
|
31
|
+
layout="row"
|
|
32
|
+
rowContainerClassName="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:gap-4"
|
|
33
|
+
onClick={onRowActivate ? () => onRowActivate(row) : undefined}
|
|
34
|
+
rowEnd={
|
|
35
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
36
|
+
<QuestionBankFavoriteButton row={row} onToggleFavorite={onToggleFavorite} />
|
|
37
|
+
<i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
|
|
38
|
+
</div>
|
|
39
|
+
}
|
|
40
|
+
>
|
|
41
|
+
<div className="space-y-0.5">
|
|
42
|
+
<p className="line-clamp-2 text-sm font-semibold text-foreground">{row.stem}</p>
|
|
43
|
+
<p className="font-mono text-xs text-muted-foreground">{row.questionId}</p>
|
|
44
|
+
<p className="text-xs text-muted-foreground">
|
|
45
|
+
{row.topic} · Updated {formatDateUS(row.updatedAt)}
|
|
46
|
+
</p>
|
|
47
|
+
<p className="text-xs text-muted-foreground">{row.author}</p>
|
|
48
|
+
</div>
|
|
49
|
+
</ListPageBoardCard>
|
|
50
|
+
)}
|
|
51
|
+
/>
|
|
76
52
|
)
|
|
77
53
|
}
|
|
@@ -131,7 +131,7 @@ export function QuestionBankNewFolderSheet({
|
|
|
131
131
|
side="right"
|
|
132
132
|
showCloseButton={false}
|
|
133
133
|
showOverlay={false}
|
|
134
|
-
className="z-[
|
|
134
|
+
className="z-[80] 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
137
|
<Shortcut keys="Enter" disabled={createDisabled} onInvoke={commit} />
|
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
DropdownMenu,
|
|
22
22
|
DropdownMenuContent,
|
|
23
23
|
DropdownMenuItem,
|
|
24
|
-
DropdownMenuSeparator,
|
|
25
24
|
DropdownMenuTrigger,
|
|
26
25
|
Shortcut,
|
|
27
26
|
} from "@/components/ui/dropdown-menu"
|
|
@@ -36,10 +35,8 @@ import {
|
|
|
36
35
|
isQuestionBankNavActive,
|
|
37
36
|
parseQuestionBankNav,
|
|
38
37
|
QUESTION_BANK_FAVORITES_FOLDER_ID,
|
|
39
|
-
QUESTION_BANK_LIBRARY_HUB_PATHS,
|
|
40
38
|
questionBankFavoritesFolderHref,
|
|
41
39
|
questionBankHubScopeHref,
|
|
42
|
-
type QuestionBankNavScope,
|
|
43
40
|
} from "@/lib/question-bank-nav"
|
|
44
41
|
import { QuestionBankFolderTreeBranch } from "@/components/data-views/question-bank-folder-tree-branch"
|
|
45
42
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import * as React from "react"
|
|
8
8
|
import dynamic from "next/dynamic"
|
|
9
|
+
import { mailtoHref } from "@/lib/mailto"
|
|
9
10
|
import { DataTable, DataTableToolbar } from "@/components/data-table"
|
|
10
11
|
import type { DataListViewType } from "@/lib/data-list-view"
|
|
11
12
|
import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
|
|
@@ -281,7 +282,7 @@ function buildQuestionBankColumns(
|
|
|
281
282
|
<span className="truncate text-sm font-medium text-foreground">{row.author}</span>
|
|
282
283
|
{row.authorEmail ? (
|
|
283
284
|
<a
|
|
284
|
-
href={
|
|
285
|
+
href={mailtoHref(row.authorEmail)}
|
|
285
286
|
className="truncate text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
|
286
287
|
onClick={e => e.stopPropagation()}
|
|
287
288
|
>
|
|
@@ -382,25 +383,26 @@ function HubFolderColumnsPanel({
|
|
|
382
383
|
setSelectedPath(prev => [...prev.slice(0, depth), item])
|
|
383
384
|
}
|
|
384
385
|
|
|
385
|
-
// Auto-select first item at each level (only on first render)
|
|
386
|
+
// Auto-select first item at each level (only on first render). Intentional
|
|
387
|
+
// empty deps: we want this to run exactly once on mount; depending on the
|
|
388
|
+
// referenced values (folders / rows / selectedPath) would re-run on every
|
|
389
|
+
// edit and keep re-seeding the selection, undoing the user's choice.
|
|
386
390
|
React.useEffect(() => {
|
|
387
|
-
// Only auto-select if we're at a folder in the path and this is the first render
|
|
388
391
|
if (isFirstRenderRef.current && selectedPath.length > 0) {
|
|
389
392
|
const lastItem = selectedPath[selectedPath.length - 1]
|
|
390
393
|
if (isFolder(lastItem)) {
|
|
391
394
|
const folder = lastItem as QuestionBankFolder
|
|
392
|
-
// Get the items in this folder
|
|
393
395
|
const subfolders = folders.filter(f => f.parentId === folder.id).sort((a, b) => a.name.localeCompare(b.name))
|
|
394
396
|
const questionsInFolder = rows.filter(r => r.folderId === folder.id)
|
|
395
397
|
const items: HierarchyItem[] = [...subfolders, ...questionsInFolder]
|
|
396
398
|
|
|
397
|
-
// If there are items and nothing is selected at the next level, select the first item
|
|
398
399
|
if (items.length > 0 && !selectedPath[selectedPath.length + 1]) {
|
|
399
400
|
setSelectedPath(prev => [...prev, items[0]])
|
|
400
401
|
isFirstRenderRef.current = false
|
|
401
402
|
}
|
|
402
403
|
}
|
|
403
404
|
}
|
|
405
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
404
406
|
}, [])
|
|
405
407
|
|
|
406
408
|
// Build columns dynamically based on selected path
|
|
@@ -653,7 +655,18 @@ export const QuestionBankTable = React.forwardRef<
|
|
|
653
655
|
onItemsChange: React.Dispatch<React.SetStateAction<QuestionBankItem[]>>
|
|
654
656
|
}
|
|
655
657
|
>(function QuestionBankTable(
|
|
656
|
-
{
|
|
658
|
+
{
|
|
659
|
+
items,
|
|
660
|
+
navState,
|
|
661
|
+
urlListSearch,
|
|
662
|
+
searchLanding,
|
|
663
|
+
landingFilters,
|
|
664
|
+
view = "table",
|
|
665
|
+
onViewChange,
|
|
666
|
+
folders,
|
|
667
|
+
onFoldersChange,
|
|
668
|
+
onItemsChange,
|
|
669
|
+
},
|
|
657
670
|
ref,
|
|
658
671
|
) {
|
|
659
672
|
const tableSourceItems = React.useMemo(() => {
|
|
@@ -17,6 +17,9 @@ export function RotationsEmptyState() {
|
|
|
17
17
|
className="flex flex-1 flex-col items-center justify-center rounded-xl border border-dashed border-border/80 bg-muted/25 px-6 py-12 text-center min-h-[min(420px,calc(100svh-var(--header-height)-6rem))]"
|
|
18
18
|
>
|
|
19
19
|
<div className="mb-6 w-full max-w-[min(100%,280px)] shrink-0">
|
|
20
|
+
{/* Static SVG hero, above the fold — next/image can't optimize SVGs
|
|
21
|
+
without `dangerouslyAllowSVG`, and lazy-loading is wrong here. */}
|
|
22
|
+
{/* eslint-disable-next-line @next/next/no-img-element -- SVG; next/image can't optimize without dangerouslyAllowSVG */}
|
|
20
23
|
<img
|
|
21
24
|
src="/Illustration/Rotation.svg"
|
|
22
25
|
alt=""
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import * as React from "react"
|
|
12
|
-
import { cn } from "@/lib/utils"
|
|
13
12
|
import { useSidebar } from "@/components/ui/sidebar"
|
|
14
13
|
import { Tip } from "@/components/ui/tip"
|
|
15
14
|
import { Button } from "@/components/ui/button"
|
|
16
15
|
import { QuestionBankSecondaryNav } from "@/components/question-bank-secondary-nav"
|
|
17
16
|
import { NestedSecondaryPanelShell } from "@/components/templates/nested-secondary-panel-shell"
|
|
17
|
+
import { useSidebarReflowZoom } from "@/hooks/use-sidebar-reflow-zoom"
|
|
18
18
|
import type { QuestionBankItem } from "@/lib/mock/question-bank"
|
|
19
19
|
import type { QuestionBankFolder } from "@/lib/mock/question-bank-folders"
|
|
20
20
|
|
|
@@ -87,13 +87,33 @@ export function SecondaryPanelProvider({ children }: { children: React.ReactNode
|
|
|
87
87
|
React.useState<QuestionBankAccessBridge | null>(null)
|
|
88
88
|
const { setOpen } = useSidebar()
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Browser zoom ≥ 200% (or very short viewport) — same `useSidebarReflowZoom`
|
|
92
|
+
* signal the primary sidebar uses (WCAG 1.4.10). At that scale the 16rem
|
|
93
|
+
* secondary rail crowds out primary content, so we **auto-collapse it to
|
|
94
|
+
* the icon variant on entering high zoom**. We don't keep overriding —
|
|
95
|
+
* users can re-expand once collapsed; the next zoom-out → zoom-in cycle
|
|
96
|
+
* re-collapses. This matches the pattern users get from clicking the
|
|
97
|
+
* panel's own "Collapse to icons" button.
|
|
98
|
+
*/
|
|
99
|
+
const reflowZoom = useSidebarReflowZoom()
|
|
100
|
+
const wasReflowZoomRef = React.useRef(false)
|
|
101
|
+
React.useEffect(() => {
|
|
102
|
+
if (reflowZoom && !wasReflowZoomRef.current) {
|
|
103
|
+
setSecondaryPanelCompact(true)
|
|
104
|
+
}
|
|
105
|
+
wasReflowZoomRef.current = reflowZoom
|
|
106
|
+
}, [reflowZoom])
|
|
107
|
+
|
|
90
108
|
const openPanel = React.useCallback(
|
|
91
109
|
(id: string) => {
|
|
92
|
-
|
|
110
|
+
// High zoom → keep the icon rail (auto-collapse rule above). At normal
|
|
111
|
+
// zoom this stays the legacy behavior (full-width on open).
|
|
112
|
+
setSecondaryPanelCompact(reflowZoom)
|
|
93
113
|
setActivePanel(id)
|
|
94
114
|
setOpen(false) // collapse main sidebar to icon rail
|
|
95
115
|
},
|
|
96
|
-
[setOpen],
|
|
116
|
+
[setOpen, reflowZoom],
|
|
97
117
|
)
|
|
98
118
|
|
|
99
119
|
const closePanel = React.useCallback((opts?: ClosePanelOptions) => {
|