@exxatdesignux/ui 0.2.15 → 0.2.17
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 +23 -0
- package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -1
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +151 -3
- 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-mono-ids/SKILL.md +56 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +17 -1
- package/consumer-extras/patterns/collaboration-access-pattern.md +2 -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 +1 -0
- package/src/globals.css +21 -2
- package/src/theme.css +4 -2
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +23 -18
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/question-bank/layout.tsx +27 -7
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/globals.css +136 -2
- package/template/app/layout.tsx +41 -5
- package/template/components/app-sidebar.tsx +141 -59
- package/template/components/ask-leo-sidebar.tsx +1 -4
- package/template/components/brand-color-picker.tsx +344 -0
- package/template/components/compliance-list-view.tsx +33 -51
- package/template/components/compliance-table.tsx +24 -0
- package/template/components/data-table/index.tsx +68 -24
- 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 +243 -94
- package/template/components/data-views/data-row-list.tsx +183 -0
- package/template/components/data-views/finder-panel-view.tsx +2 -2
- package/template/components/data-views/index.ts +26 -3
- package/template/components/data-views/list-page-split-details-placeholder.tsx +3 -3
- package/template/components/data-views/list-page-split-hub-tokens.ts +1 -1
- package/template/components/data-views/list-page-tree-column-header.tsx +1 -1
- package/template/components/data-views/os-folder-glyph.tsx +8 -0
- package/template/components/data-views/outline-tree-menu.tsx +157 -0
- package/template/components/data-views/question-bank-folder-tree-branch.tsx +210 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +173 -379
- package/template/components/folder-details-shell.tsx +1 -1
- package/template/components/hub-tree-panel-view.tsx +88 -80
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +116 -51
- 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 +21 -11
- package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
- 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 +18 -132
- 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} +67 -58
- package/template/components/product-switcher.tsx +26 -11
- package/template/components/product-wordmark.tsx +285 -0
- package/template/components/question-bank-client.tsx +130 -70
- package/template/components/question-bank-hub-client.tsx +108 -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-page-header.tsx +18 -2
- package/template/components/question-bank-secondary-nav.tsx +12 -228
- package/template/components/question-bank-table.tsx +30 -5
- package/template/components/rotations-empty-state.tsx +3 -0
- package/template/components/secondary-panel.tsx +24 -4
- package/template/components/settings-appearance-card.tsx +584 -141
- package/template/components/site-header.tsx +56 -32
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +24 -0
- package/template/components/table-properties/drawer.tsx +1 -1
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +29 -3
- package/template/components/templates/dedicated-search-landing-template.tsx +86 -20
- package/template/components/templates/list-page.tsx +1 -3
- package/template/components/templates/nested-secondary-panel-shell.tsx +11 -6
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +51 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- package/template/docs/collaboration-access-pattern.md +2 -0
- package/template/docs/question-bank-hub-header-pattern.md +25 -0
- package/template/eslint.config.mjs +18 -0
- package/template/hooks/use-secondary-panel-hub-nav.ts +17 -1
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/data-list-persistence.ts +57 -257
- 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/mock/navigation.tsx +30 -1
- 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 +70 -0
- package/template/lib/raf-throttle.ts +45 -0
- package/template/lib/table-state-lifecycle.ts +474 -0
- package/template/next.config.mjs +156 -0
- package/template/package.json +6 -6
- package/template/stores/app-store.ts +46 -1
- package/template/components/command-menu-01.tsx +0 -133
- package/template/components/command-menu-02.tsx +0 -386
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Question bank hub — ListPageTemplate + KeyMetrics + QuestionBankTable (Team / Compliance pattern).
|
|
5
5
|
* URL hash syncs the active view tab; `?scope=` + `folderId=` sync with the secondary nav (`lib/question-bank-nav.ts`).
|
|
6
|
+
* (Primary sidebar “Library” must not treat that hash as “off-route” — `app-sidebar` `isNavActive` ignores hash for `href` without `#…`.)
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import * as React from "react"
|
|
@@ -14,15 +15,17 @@ import {
|
|
|
14
15
|
type DataListViewType,
|
|
15
16
|
} from "@/components/data-views"
|
|
16
17
|
import { QuestionBankPageHeader } from "@/components/question-bank-page-header"
|
|
18
|
+
import { QuestionBankNewFolderSheet } from "@/components/question-bank-new-folder-sheet"
|
|
17
19
|
import { CollaborationAccessFlow } from "@/components/collaboration-access-flow"
|
|
18
20
|
import { QuestionBankTable, type QuestionBankTableHandle } from "@/components/question-bank-table"
|
|
19
21
|
import { SecondaryPanelHubTemplate } from "@/components/templates/secondary-panel-hub-template"
|
|
20
22
|
import { QuestionBankAccessBridge, QuestionBankFolderBridge } from "@/components/secondary-panel"
|
|
21
23
|
import { KeyMetrics } from "@/components/key-metrics"
|
|
24
|
+
import { useSidebar } from "@/components/ui/sidebar"
|
|
22
25
|
import { useSecondaryPanelHubNav } from "@/hooks/use-secondary-panel-hub-nav"
|
|
23
26
|
import { QUESTION_BANK_ITEMS } from "@/lib/mock/question-bank"
|
|
24
27
|
import { QUESTION_BANK_HEADER_COLLABORATORS } from "@/lib/mock/question-bank-header-collaborators"
|
|
25
|
-
import { DEFAULT_QUESTION_BANK_FOLDERS } from "@/lib/mock/question-bank-folders"
|
|
28
|
+
import { DEFAULT_QUESTION_BANK_FOLDERS, type QuestionBankFolder } from "@/lib/mock/question-bank-folders"
|
|
26
29
|
import { questionBankKpiInsight, questionBankKpiMetrics } from "@/lib/mock/question-bank-kpi"
|
|
27
30
|
import {
|
|
28
31
|
applyQuestionBankHubDisplayFilters,
|
|
@@ -97,6 +100,8 @@ export function QuestionBankClient() {
|
|
|
97
100
|
parseNav: parseQuestionBankNav,
|
|
98
101
|
canonicalHref: questionBankCanonicalNavHref,
|
|
99
102
|
shouldReopenPanel: isQuestionBankDefaultNav,
|
|
103
|
+
/** Hub/find + list are full-width — layout closes the panel; do not fight it with `openPanel`. */
|
|
104
|
+
reopenPanelOnPathnames: [QUESTION_BANK_LIBRARY_PATH],
|
|
100
105
|
})
|
|
101
106
|
const isDedicatedSearch = isQuestionBankDedicatedSearchPathname(pathname)
|
|
102
107
|
const isHubFindSurface = pathname === QUESTION_BANK_HUB_FIND_PATH
|
|
@@ -173,6 +178,34 @@ export function QuestionBankClient() {
|
|
|
173
178
|
const [items, setItems] = React.useState(() => QUESTION_BANK_ITEMS.map(q => ({ ...q })))
|
|
174
179
|
const [folders, setFolders] = React.useState(() => DEFAULT_QUESTION_BANK_FOLDERS.map(f => ({ ...f })))
|
|
175
180
|
|
|
181
|
+
const [hubFolderCustomizeSheetOpen, setHubFolderCustomizeSheetOpen] = React.useState(false)
|
|
182
|
+
const [hubFolderCustomizeTarget, setHubFolderCustomizeTarget] = React.useState<QuestionBankFolder | null>(null)
|
|
183
|
+
|
|
184
|
+
const openHubScopedFolderCustomize = React.useCallback(() => {
|
|
185
|
+
if (navState.scope !== "folder" || !navState.folderId) return
|
|
186
|
+
const f = folders.find(x => x.id === navState.folderId)
|
|
187
|
+
if (!f) return
|
|
188
|
+
setHubFolderCustomizeTarget(f)
|
|
189
|
+
setHubFolderCustomizeSheetOpen(true)
|
|
190
|
+
}, [folders, navState.folderId, navState.scope])
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Open the full-page authoring composer (`/question-bank/new`).
|
|
194
|
+
* Pre-collapses the main sidebar (Placements pattern) so the user sees one
|
|
195
|
+
* smooth animation into the focused authoring flow. Folder scope, when
|
|
196
|
+
* present, is forwarded as `?folderId=` so the destination dropdown lands
|
|
197
|
+
* pre-selected on the right rail.
|
|
198
|
+
*/
|
|
199
|
+
const { setOpen: setMainSidebarOpen } = useSidebar()
|
|
200
|
+
const handleNewQuestion = React.useCallback(() => {
|
|
201
|
+
const folderQuery =
|
|
202
|
+
navState.scope === "folder" && navState.folderId
|
|
203
|
+
? `?folderId=${encodeURIComponent(navState.folderId)}`
|
|
204
|
+
: ""
|
|
205
|
+
setMainSidebarOpen(false)
|
|
206
|
+
window.setTimeout(() => router.push(`/question-bank/new${folderQuery}`), 260)
|
|
207
|
+
}, [navState.folderId, navState.scope, router, setMainSidebarOpen])
|
|
208
|
+
|
|
176
209
|
const filteredItems = React.useMemo(
|
|
177
210
|
() => applyQuestionBankHubDisplayFilters(items, folders, navState, landingFilters),
|
|
178
211
|
[items, folders, landingFilters, navState],
|
|
@@ -235,7 +268,7 @@ export function QuestionBankClient() {
|
|
|
235
268
|
title={dedicatedSearchTitle}
|
|
236
269
|
questionCount={count}
|
|
237
270
|
hideNewQuestion
|
|
238
|
-
onNewQuestion={
|
|
271
|
+
onNewQuestion={handleNewQuestion}
|
|
239
272
|
onExport={() => setExportOpen(true)}
|
|
240
273
|
/>
|
|
241
274
|
<DedicatedSearchUrlComposer
|
|
@@ -331,76 +364,103 @@ export function QuestionBankClient() {
|
|
|
331
364
|
resourceLabel={hubHeader.title}
|
|
332
365
|
>
|
|
333
366
|
{({ collaborators, openInvite }) => (
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
siteHeader={{
|
|
347
|
-
title: hubHeader.title,
|
|
348
|
-
breadcrumbs: hubHeader.breadcrumbs,
|
|
349
|
-
}}
|
|
350
|
-
>
|
|
351
|
-
<ListPageTemplate
|
|
352
|
-
defaultTabs={DEFAULT_TABS}
|
|
353
|
-
tabs={tabs}
|
|
354
|
-
onTabsChange={setTabs}
|
|
355
|
-
activeTabId={activeTabId}
|
|
356
|
-
onActiveTabChange={onActiveTabChange}
|
|
357
|
-
getTabCount={() => count}
|
|
358
|
-
tablePropertiesRef={tableRef}
|
|
359
|
-
header={(
|
|
360
|
-
<QuestionBankPageHeader
|
|
361
|
-
variant="collaboration"
|
|
362
|
-
title={hubHeader.title}
|
|
363
|
-
questionCount={count}
|
|
364
|
-
collaborators={collaborators}
|
|
365
|
-
onNewQuestion={() => {}}
|
|
366
|
-
onExport={() => setExportOpen(true)}
|
|
367
|
-
onAddCollaborator={openInvite}
|
|
368
|
-
onCollaboratorsOpen={openInvite}
|
|
369
|
-
showMetrics={showMetrics}
|
|
370
|
-
onToggleMetrics={() => setShowMetrics(v => !v)}
|
|
371
|
-
/>
|
|
372
|
-
)}
|
|
373
|
-
metrics={(
|
|
374
|
-
<KeyMetrics
|
|
375
|
-
variant="flat"
|
|
376
|
-
metrics={metrics}
|
|
377
|
-
insight={insight}
|
|
378
|
-
showHeader={false}
|
|
379
|
-
metricsSingleRow
|
|
380
|
-
/>
|
|
381
|
-
)}
|
|
382
|
-
showMetrics={showMetrics}
|
|
383
|
-
exportOpen={exportOpen}
|
|
384
|
-
onExportOpenChange={setExportOpen}
|
|
385
|
-
exportTotalRows={count}
|
|
386
|
-
renderContent={(tab, updateTab) => (
|
|
387
|
-
<QuestionBankTable
|
|
388
|
-
key={tab.id}
|
|
389
|
-
ref={tableRef}
|
|
390
|
-
items={items}
|
|
391
|
-
navState={navState}
|
|
392
|
-
urlListSearch={urlToolbarSearchSync}
|
|
393
|
-
landingFilters={null}
|
|
394
|
-
searchLanding={false}
|
|
395
|
-
folders={folders}
|
|
396
|
-
onFoldersChange={setFolders}
|
|
397
|
-
onItemsChange={setItems}
|
|
398
|
-
view={tab.viewType}
|
|
399
|
-
onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
|
|
400
|
-
/>
|
|
367
|
+
<>
|
|
368
|
+
<SecondaryPanelHubTemplate
|
|
369
|
+
bridges={(
|
|
370
|
+
<>
|
|
371
|
+
<QuestionBankFolderBridge
|
|
372
|
+
folders={folders}
|
|
373
|
+
onFoldersChange={setFolders}
|
|
374
|
+
items={items}
|
|
375
|
+
onItemsChange={setItems}
|
|
376
|
+
/>
|
|
377
|
+
<QuestionBankAccessBridge openManageAccess={openInvite} />
|
|
378
|
+
</>
|
|
401
379
|
)}
|
|
380
|
+
siteHeader={{
|
|
381
|
+
title: hubHeader.title,
|
|
382
|
+
breadcrumbs: hubHeader.breadcrumbs,
|
|
383
|
+
}}
|
|
384
|
+
>
|
|
385
|
+
<ListPageTemplate
|
|
386
|
+
defaultTabs={DEFAULT_TABS}
|
|
387
|
+
tabs={tabs}
|
|
388
|
+
onTabsChange={setTabs}
|
|
389
|
+
activeTabId={activeTabId}
|
|
390
|
+
onActiveTabChange={onActiveTabChange}
|
|
391
|
+
getTabCount={() => count}
|
|
392
|
+
tablePropertiesRef={tableRef}
|
|
393
|
+
header={(
|
|
394
|
+
<QuestionBankPageHeader
|
|
395
|
+
variant="collaboration"
|
|
396
|
+
title={hubHeader.title}
|
|
397
|
+
questionCount={count}
|
|
398
|
+
collaborators={collaborators}
|
|
399
|
+
onNewQuestion={handleNewQuestion}
|
|
400
|
+
onExport={() => setExportOpen(true)}
|
|
401
|
+
onAddCollaborator={openInvite}
|
|
402
|
+
onCollaboratorsOpen={openInvite}
|
|
403
|
+
showMetrics={showMetrics}
|
|
404
|
+
onToggleMetrics={() => setShowMetrics(v => !v)}
|
|
405
|
+
onCustomizeFolder={
|
|
406
|
+
navState.scope === "folder" && navState.folderId ? openHubScopedFolderCustomize : undefined
|
|
407
|
+
}
|
|
408
|
+
/>
|
|
409
|
+
)}
|
|
410
|
+
metrics={(
|
|
411
|
+
<KeyMetrics
|
|
412
|
+
variant="flat"
|
|
413
|
+
metrics={metrics}
|
|
414
|
+
insight={insight}
|
|
415
|
+
showHeader={false}
|
|
416
|
+
metricsSingleRow
|
|
417
|
+
/>
|
|
418
|
+
)}
|
|
419
|
+
showMetrics={showMetrics}
|
|
420
|
+
exportOpen={exportOpen}
|
|
421
|
+
onExportOpenChange={setExportOpen}
|
|
422
|
+
exportTotalRows={count}
|
|
423
|
+
renderContent={(tab, updateTab) => (
|
|
424
|
+
<QuestionBankTable
|
|
425
|
+
key={tab.id}
|
|
426
|
+
ref={tableRef}
|
|
427
|
+
items={items}
|
|
428
|
+
navState={navState}
|
|
429
|
+
urlListSearch={urlToolbarSearchSync}
|
|
430
|
+
landingFilters={null}
|
|
431
|
+
searchLanding={false}
|
|
432
|
+
folders={folders}
|
|
433
|
+
onFoldersChange={setFolders}
|
|
434
|
+
onItemsChange={setItems}
|
|
435
|
+
view={tab.viewType}
|
|
436
|
+
onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
|
|
437
|
+
/>
|
|
438
|
+
)}
|
|
439
|
+
/>
|
|
440
|
+
</SecondaryPanelHubTemplate>
|
|
441
|
+
<QuestionBankNewFolderSheet
|
|
442
|
+
open={hubFolderCustomizeSheetOpen}
|
|
443
|
+
onOpenChange={open => {
|
|
444
|
+
setHubFolderCustomizeSheetOpen(open)
|
|
445
|
+
if (!open) setHubFolderCustomizeTarget(null)
|
|
446
|
+
}}
|
|
447
|
+
parentFolderId={hubFolderCustomizeTarget?.parentId ?? null}
|
|
448
|
+
customizingFolder={hubFolderCustomizeTarget}
|
|
449
|
+
descriptionText="Update how this folder appears in the bank. Name, color, and icon apply everywhere the folder is shown."
|
|
450
|
+
onCreated={newFolder => {
|
|
451
|
+
const target = hubFolderCustomizeTarget
|
|
452
|
+
if (!target) return
|
|
453
|
+
setFolders(prev =>
|
|
454
|
+
prev.map(f =>
|
|
455
|
+
f.id === target.id
|
|
456
|
+
? { ...f, name: newFolder.name, icon: newFolder.icon, colorKey: newFolder.colorKey }
|
|
457
|
+
: f,
|
|
458
|
+
),
|
|
459
|
+
)
|
|
460
|
+
setHubFolderCustomizeTarget(null)
|
|
461
|
+
}}
|
|
402
462
|
/>
|
|
403
|
-
|
|
463
|
+
</>
|
|
404
464
|
)}
|
|
405
465
|
</CollaborationAccessFlow>
|
|
406
466
|
)
|
|
@@ -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,101 @@ 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: [
|
|
258
|
+
"radial-gradient(ellipse 118% 70% at 50% 100%, oklch(from var(--brand-color) l c h / 0.11) 0%, transparent 60%)",
|
|
259
|
+
"linear-gradient(180deg, var(--key-metrics-flat-grad-top) 0%, var(--key-metrics-flat-grad-mid) 48%, var(--background) 100%)",
|
|
260
|
+
].join(", "),
|
|
261
|
+
boxShadow: "0 18px 42px -26px color-mix(in oklch, var(--brand-color) 10%, transparent)",
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 md:px-6">
|
|
265
|
+
<div className="min-w-0">
|
|
266
|
+
<p className="sr-only">
|
|
267
|
+
Example searches rotate in the field. Type your own request in plain language, then press Enter to open
|
|
268
|
+
the library with that AI search applied to the question list. This control does not open Ask Leo.
|
|
269
|
+
</p>
|
|
270
|
+
<div
|
|
271
|
+
className={cn(
|
|
272
|
+
"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",
|
|
273
|
+
hubComposerExpanded ? "rounded-2xl p-1.5 shadow-md" : "rounded-full px-1 py-1",
|
|
274
|
+
)}
|
|
275
|
+
>
|
|
276
|
+
<AskLeoComposer
|
|
277
|
+
value={hubComposerValue}
|
|
278
|
+
onChange={setHubComposerValue}
|
|
279
|
+
onSubmit={onHubComposerSubmit}
|
|
280
|
+
onExpandedChange={setHubComposerExpanded}
|
|
281
|
+
animatedPlaceholders={[...HUB_COMPOSER_PLACEHOLDERS]}
|
|
282
|
+
animatedPlaceholderIntervalMs={4800}
|
|
283
|
+
animatedPlaceholderMaxLines={2}
|
|
284
|
+
leadingSlot="ai-mark"
|
|
285
|
+
inputLabel="AI search"
|
|
286
|
+
submitAppearance="search"
|
|
287
|
+
submitButtonAriaLabel="Run AI search"
|
|
288
|
+
placeholder="Search the bank…"
|
|
289
|
+
className="[&_form>div]:rounded-none [&_form>div]:border-0 [&_form>div]:bg-transparent [&_form>div]:shadow-none"
|
|
290
|
+
/>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{/* Create a question */}
|
|
295
|
+
<section aria-labelledby="qb-create" className="space-y-3">
|
|
296
|
+
<h2 id="qb-create" className="text-base font-semibold tracking-tight text-foreground">
|
|
297
|
+
Create a question
|
|
298
|
+
</h2>
|
|
299
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
300
|
+
{createTiles.map(tile => (
|
|
301
|
+
<button
|
|
302
|
+
key={tile.id}
|
|
303
|
+
type="button"
|
|
304
|
+
onClick={tile.onClick}
|
|
305
|
+
className={cn(
|
|
306
|
+
"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",
|
|
307
|
+
"hover:border-interactive-hover hover:bg-interactive-hover/30 hover:shadow-sm",
|
|
308
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
309
|
+
)}
|
|
310
|
+
>
|
|
311
|
+
<i
|
|
312
|
+
className={cn(
|
|
313
|
+
tile.badge === "AI" ? "fa-duotone fa-solid" : "fa-light",
|
|
314
|
+
tile.icon,
|
|
315
|
+
"text-xs",
|
|
316
|
+
tile.badge === "AI" ? "text-brand" : "text-muted-foreground",
|
|
317
|
+
)}
|
|
318
|
+
aria-hidden="true"
|
|
319
|
+
/>
|
|
320
|
+
{tile.label}
|
|
321
|
+
{tile.badge === "AI" && (
|
|
322
|
+
<span className="rounded-full bg-brand/10 px-1.5 py-px text-[9px] font-bold uppercase tracking-wider text-brand">
|
|
323
|
+
AI
|
|
324
|
+
</span>
|
|
325
|
+
)}
|
|
326
|
+
</button>
|
|
327
|
+
))}
|
|
328
|
+
</div>
|
|
329
|
+
</section>
|
|
267
330
|
</div>
|
|
268
|
-
</
|
|
331
|
+
</section>
|
|
269
332
|
|
|
270
333
|
{recents.length > 0 && (
|
|
271
|
-
<section aria-labelledby="qb-recent" className="space-y-4">
|
|
334
|
+
<section aria-labelledby="qb-recent" className="mx-auto w-full max-w-5xl space-y-4">
|
|
272
335
|
<div className="flex items-baseline justify-between gap-3">
|
|
273
336
|
<h2 id="qb-recent" className="text-base font-semibold tracking-tight text-foreground">
|
|
274
337
|
Continue where you left off
|
|
@@ -305,7 +368,7 @@ export function QuestionBankHubClient() {
|
|
|
305
368
|
</section>
|
|
306
369
|
)}
|
|
307
370
|
|
|
308
|
-
<section aria-labelledby="qb-browse" className="space-y-4">
|
|
371
|
+
<section aria-labelledby="qb-browse" className="mx-auto w-full max-w-5xl space-y-4">
|
|
309
372
|
<div className="flex items-baseline justify-between gap-3">
|
|
310
373
|
<h2 id="qb-browse" className="text-base font-semibold tracking-tight text-foreground">
|
|
311
374
|
Browse the library
|
|
@@ -360,76 +423,6 @@ export function QuestionBankHubClient() {
|
|
|
360
423
|
</div>
|
|
361
424
|
</section>
|
|
362
425
|
|
|
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
426
|
</div>
|
|
434
427
|
</PrimaryPageTemplate>
|
|
435
428
|
)
|
|
@@ -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
|
}
|