@exxatdesignux/ui 0.1.0 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +176 -0
- package/bin/init.mjs +15 -1
- package/bin/sync-extras.mjs +65 -0
- package/consumer-extras/README.md +21 -0
- package/consumer-extras/cursor-skills/exxat-accessibility/SKILL.md +282 -0
- package/consumer-extras/cursor-skills/exxat-board-cards/SKILL.md +68 -0
- package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +99 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +713 -0
- package/consumer-extras/cursor-skills/exxat-fontawesome-icons/SKILL.md +31 -0
- package/consumer-extras/cursor-skills/exxat-list-page-view-shells/SKILL.md +36 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +27 -0
- package/consumer-extras/patterns/command-menu-pattern.md +45 -0
- package/consumer-extras/patterns/data-views-pattern.md +167 -0
- package/package.json +7 -3
- package/src/components/ui/sidebar.tsx +7 -2
- package/template/.agents/skills/shadcn/SKILL.md +242 -0
- package/template/.agents/skills/shadcn/agents/openai.yml +5 -0
- package/template/.agents/skills/shadcn/assets/shadcn-small.png +0 -0
- package/template/.agents/skills/shadcn/assets/shadcn.png +0 -0
- package/template/.agents/skills/shadcn/cli.md +257 -0
- package/template/.agents/skills/shadcn/customization.md +202 -0
- package/template/.agents/skills/shadcn/evals/evals.json +47 -0
- package/template/.agents/skills/shadcn/mcp.md +94 -0
- package/template/.agents/skills/shadcn/rules/base-vs-radix.md +306 -0
- package/template/.agents/skills/shadcn/rules/composition.md +195 -0
- package/template/.agents/skills/shadcn/rules/forms.md +192 -0
- package/template/.agents/skills/shadcn/rules/icons.md +101 -0
- package/template/.agents/skills/shadcn/rules/styling.md +162 -0
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +712 -0
- package/template/.cursor/rules/exxat-accessibility.mdc +33 -0
- package/template/.cursor/rules/exxat-command-menu.mdc +23 -0
- package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +53 -0
- package/template/.cursor/rules/exxat-data-tables.mdc +31 -0
- package/template/.cursor/rules/exxat-ds-agents.mdc +26 -0
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +100 -0
- package/template/.cursor/rules/exxat-list-page-connected-views.mdc +16 -0
- package/template/.cursor/rules/exxat-no-toast.mdc +26 -0
- package/template/.cursor/rules/exxat-page-vs-drawer.mdc +22 -0
- package/template/.cursor/rules/exxat-table-properties-drawer.mdc +40 -0
- package/template/AGENTS.md +52 -11
- package/template/app/(app)/dashboard/page.tsx +1 -1
- package/template/app/(app)/data-list/[id]/page.tsx +24 -8
- package/template/app/(app)/data-list/new/page.tsx +7 -4
- package/template/app/(app)/data-list/page.tsx +1 -1
- package/template/app/(app)/examples/page.tsx +41 -0
- package/template/app/(app)/question-bank/page.tsx +3 -3
- package/template/app/globals.css +1 -1
- package/template/components/app-sidebar.tsx +52 -35
- package/template/components/compliance-table.tsx +79 -0
- package/template/components/data-list-client.tsx +36 -25
- package/template/components/data-list-table.tsx +797 -10
- package/template/components/data-views/finder-panel-view.tsx +405 -0
- package/template/components/data-views/folder-grid-view.tsx +86 -0
- package/template/components/data-views/index.ts +59 -0
- package/template/components/data-views/list-page-split-details-placeholder.tsx +39 -0
- package/template/components/data-views/list-page-split-hub-chrome.tsx +60 -0
- package/template/components/data-views/list-page-split-hub-tokens.ts +16 -0
- package/template/components/data-views/list-page-tree-column-header.tsx +31 -0
- package/template/components/data-views/list-page-tree-panel-shell.tsx +91 -0
- package/template/components/data-views/list-page-view-frame.tsx +53 -0
- package/template/components/data-views/os-folder-glyph.tsx +121 -0
- package/template/components/folder-details-shell.tsx +230 -0
- package/template/components/hub-tree-panel-view.tsx +672 -0
- package/template/components/list-hub-status-badge.tsx +17 -3
- package/template/components/page-header.tsx +149 -7
- package/template/components/placements-page-header.tsx +14 -8
- package/template/components/placements-table-columns.tsx +8 -8
- package/template/components/question-bank-client.tsx +157 -39
- package/template/components/question-bank-new-folder-sheet.tsx +248 -0
- package/template/components/question-bank-os-folder-view.tsx +648 -0
- package/template/components/question-bank-page-header.tsx +31 -2
- package/template/components/question-bank-panel-activator.tsx +9 -0
- package/template/components/question-bank-secondary-nav.tsx +226 -0
- package/template/components/question-bank-table.tsx +707 -22
- package/template/components/secondary-panel.tsx +41 -107
- package/template/components/sites-table.tsx +66 -0
- package/template/components/team-client.tsx +7 -0
- package/template/components/team-table.tsx +156 -1
- package/template/components/templates/list-page.tsx +2 -2
- package/template/components/ui/avatar.tsx +1 -1
- package/template/components/ui/badge.tsx +1 -1
- package/template/components/ui/banner.tsx +1 -1
- package/template/components/ui/breadcrumb.tsx +1 -1
- package/template/components/ui/button.tsx +1 -1
- package/template/components/ui/calendar.tsx +1 -1
- package/template/components/ui/card.tsx +1 -1
- package/template/components/ui/chart.tsx +1 -1
- package/template/components/ui/checkbox.tsx +1 -1
- package/template/components/ui/coach-mark.tsx +1 -1
- package/template/components/ui/collapsible.tsx +1 -1
- package/template/components/ui/command.tsx +1 -1
- package/template/components/ui/date-picker-field.tsx +1 -1
- package/template/components/ui/dialog.tsx +1 -1
- package/template/components/ui/drag-handle-grip.tsx +1 -1
- package/template/components/ui/drawer.tsx +1 -1
- package/template/components/ui/dropdown-menu.tsx +1 -1
- package/template/components/ui/field.tsx +1 -1
- package/template/components/ui/form.tsx +1 -1
- package/template/components/ui/input-group.tsx +1 -1
- package/template/components/ui/input-mask.tsx +1 -1
- package/template/components/ui/input.tsx +1 -1
- package/template/components/ui/kbd.tsx +1 -1
- package/template/components/ui/label.tsx +1 -1
- package/template/components/ui/payment-card-fields.tsx +1 -1
- package/template/components/ui/popover.tsx +1 -1
- package/template/components/ui/radio-group.tsx +1 -1
- package/template/components/ui/resizable.tsx +68 -0
- package/template/components/ui/select.tsx +1 -1
- package/template/components/ui/selection-tile-grid.tsx +1 -1
- package/template/components/ui/separator.tsx +1 -1
- package/template/components/ui/sheet.tsx +1 -1
- package/template/components/ui/sidebar.tsx +1 -1
- package/template/components/ui/skeleton.tsx +1 -1
- package/template/components/ui/sonner.tsx +1 -1
- package/template/components/ui/status-badge.tsx +1 -1
- package/template/components/ui/table.tsx +1 -1
- package/template/components/ui/tabs.tsx +1 -1
- package/template/components/ui/textarea.tsx +1 -1
- package/template/components/ui/tip.tsx +1 -1
- package/template/components/ui/toggle-group.tsx +1 -1
- package/template/components/ui/toggle-switch.tsx +1 -1
- package/template/components/ui/toggle.tsx +1 -1
- package/template/components/ui/tooltip.tsx +1 -1
- package/template/components/ui/view-segmented-control.tsx +1 -1
- package/template/docs/data-views-pattern.md +7 -0
- package/template/hooks/use-app-theme.ts +1 -1
- package/template/hooks/use-coach-mark.ts +1 -1
- package/template/hooks/use-location-hash.ts +15 -0
- package/template/hooks/use-mobile.ts +1 -1
- package/template/hooks/use-mod-key-label.ts +1 -1
- package/template/hooks/use-sidebar-reflow-zoom.ts +40 -0
- package/template/lib/ask-leo-route-context.ts +25 -57
- package/template/lib/coach-mark-registry.ts +13 -13
- package/template/lib/command-menu-config.ts +28 -23
- package/template/lib/command-menu-search-data.ts +10 -9
- package/template/lib/data-list-view-surface.ts +12 -1
- package/template/lib/data-list-view.ts +6 -3
- package/template/lib/date-filter.ts +1 -1
- package/template/lib/mock/dashboard.ts +11 -11
- package/template/lib/mock/navigation.tsx +22 -63
- package/template/lib/mock/placements-kpi.ts +19 -19
- package/template/lib/mock/question-bank-folders.ts +167 -0
- package/template/lib/mock/question-bank-header-collaborators.ts +14 -0
- package/template/lib/mock/question-bank-inspector.ts +109 -0
- package/template/lib/mock/question-bank-kpi.ts +1 -1
- package/template/lib/mock/question-bank.ts +80 -0
- package/template/lib/question-bank-nav.ts +91 -0
- package/template/lib/utils.ts +1 -1
- package/template/next.config.mjs +8 -0
- package/template/package.json +1 -0
- package/template/public/folders/icons8-folder-windows-11.svg +1 -0
- package/template/app/(app)/compliance/page.tsx +0 -10
- package/template/app/(app)/rotations/page.tsx +0 -15
- package/template/app/(app)/sites/all/page.tsx +0 -13
- package/template/app/(app)/team/page.tsx +0 -10
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* **Tree view** — `ListPageTreePanelShell` + outline tree + read-only details (`FolderDetailsShell`).
|
|
5
|
+
* Hub wiring (folders, mock sheet) stays in the caller; this module hosts question-bank demo wiring only.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as React from "react"
|
|
9
|
+
import {
|
|
10
|
+
ArrowRightLeft,
|
|
11
|
+
CheckCircle2Icon,
|
|
12
|
+
ChevronRightIcon,
|
|
13
|
+
FileIcon,
|
|
14
|
+
FolderIcon,
|
|
15
|
+
Hourglass,
|
|
16
|
+
PencilLine,
|
|
17
|
+
X,
|
|
18
|
+
} from "lucide-react"
|
|
19
|
+
import { ListHubStatusBadge, LIST_HUB_INSPECTOR_CHIP_SHELL } from "@/components/list-hub-status-badge"
|
|
20
|
+
import { Badge } from "@/components/ui/badge"
|
|
21
|
+
import { Button } from "@/components/ui/button"
|
|
22
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
|
23
|
+
import { Separator } from "@/components/ui/separator"
|
|
24
|
+
import { Tip } from "@/components/ui/tip"
|
|
25
|
+
import {
|
|
26
|
+
Tooltip,
|
|
27
|
+
TooltipContent,
|
|
28
|
+
TooltipTrigger,
|
|
29
|
+
} from "@/components/ui/tooltip"
|
|
30
|
+
import { cn } from "@/lib/utils"
|
|
31
|
+
import { ListPageTreePanelShell } from "@/components/data-views/list-page-tree-panel-shell"
|
|
32
|
+
import { ListPageSplitDetailsPlaceholder } from "@/components/data-views/list-page-split-details-placeholder"
|
|
33
|
+
import { ListPageTreeColumnHeader } from "@/components/data-views/list-page-tree-column-header"
|
|
34
|
+
import { QuestionBankNewFolderSheet } from "@/components/question-bank-new-folder-sheet"
|
|
35
|
+
import type {
|
|
36
|
+
QuestionBankItem,
|
|
37
|
+
QuestionBankDifficulty,
|
|
38
|
+
QuestionBankStatus,
|
|
39
|
+
} from "@/lib/mock/question-bank"
|
|
40
|
+
import type { QuestionBankFolder, QuestionBankFolderColorKey } from "@/lib/mock/question-bank-folders"
|
|
41
|
+
import { QUESTION_BANK_STATUS_BADGE_CLASS, QUESTION_BANK_STATUS_ICON } from "@/lib/list-status-badges"
|
|
42
|
+
import { formatDateUS } from "@/lib/date-filter"
|
|
43
|
+
import {
|
|
44
|
+
deriveBloomLevel,
|
|
45
|
+
deriveLastEditedLine,
|
|
46
|
+
deriveQuestionItemCode,
|
|
47
|
+
deriveTags,
|
|
48
|
+
QUESTION_TYPE_ABBREV,
|
|
49
|
+
} from "@/lib/mock/question-bank-inspector"
|
|
50
|
+
import { FolderDetailsShell } from "@/components/folder-details-shell"
|
|
51
|
+
import { initialsFromDisplayName } from "@/lib/initials-from-name"
|
|
52
|
+
|
|
53
|
+
const DIFFICULTY_LABEL: Record<QuestionBankDifficulty, string> = {
|
|
54
|
+
easy: "Easy",
|
|
55
|
+
medium: "Medium",
|
|
56
|
+
hard: "Hard",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Inspector header label — “Saved” for published matches reviewer checklist language. */
|
|
60
|
+
const INSPECTOR_STATUS_LABEL: Record<QuestionBankStatus, string> = {
|
|
61
|
+
published: "Saved",
|
|
62
|
+
draft: "Draft",
|
|
63
|
+
in_review: "In review",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ============================================================================
|
|
67
|
+
// TreeItem — recursive folder/question renderer using Collapsible
|
|
68
|
+
// ============================================================================
|
|
69
|
+
|
|
70
|
+
interface TreeItemProps {
|
|
71
|
+
folder: QuestionBankFolder
|
|
72
|
+
folders: QuestionBankFolder[]
|
|
73
|
+
questions: QuestionBankItem[]
|
|
74
|
+
selectedItemId: string | null
|
|
75
|
+
depth?: number
|
|
76
|
+
onSelectItem: (itemId: string) => void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function TreeItem({
|
|
80
|
+
folder,
|
|
81
|
+
folders,
|
|
82
|
+
questions,
|
|
83
|
+
selectedItemId,
|
|
84
|
+
depth = 0,
|
|
85
|
+
onSelectItem,
|
|
86
|
+
}: TreeItemProps) {
|
|
87
|
+
const childFolders = folders
|
|
88
|
+
.filter(f => f.parentId === folder.id)
|
|
89
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
90
|
+
const childQuestions = questions
|
|
91
|
+
.filter(q => q.folderId === folder.id)
|
|
92
|
+
|
|
93
|
+
const hasChildren = childFolders.length > 0 || childQuestions.length > 0
|
|
94
|
+
const isFolderSelected = selectedItemId === folder.id
|
|
95
|
+
const indent = depth * 12
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Collapsible>
|
|
99
|
+
{/* Folder row */}
|
|
100
|
+
<div className="group flex items-center hover:bg-muted/50">
|
|
101
|
+
<div style={{ width: indent }} className="shrink-0" />
|
|
102
|
+
|
|
103
|
+
{/* Expand chevron or spacer */}
|
|
104
|
+
{hasChildren ? (
|
|
105
|
+
<CollapsibleTrigger asChild>
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
className="flex h-8 w-5 shrink-0 items-center justify-center text-muted-foreground hover:text-foreground focus-visible:outline-none"
|
|
109
|
+
>
|
|
110
|
+
<ChevronRightIcon className="h-3.5 w-3.5 transition-transform duration-150 group-data-[state=open]:rotate-90 [[data-state=open]_&]:rotate-90" />
|
|
111
|
+
</button>
|
|
112
|
+
</CollapsibleTrigger>
|
|
113
|
+
) : (
|
|
114
|
+
<div className="w-5 shrink-0" />
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{/* Folder button */}
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
onClick={() => onSelectItem(folder.id)}
|
|
121
|
+
className={cn(
|
|
122
|
+
"flex flex-1 items-center gap-2 py-1.5 pr-3 text-left text-sm transition-colors duration-75",
|
|
123
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
|
124
|
+
isFolderSelected
|
|
125
|
+
? "bg-accent text-accent-foreground"
|
|
126
|
+
: "text-foreground",
|
|
127
|
+
)}
|
|
128
|
+
aria-selected={isFolderSelected}
|
|
129
|
+
role="option"
|
|
130
|
+
>
|
|
131
|
+
<FolderIcon
|
|
132
|
+
className={cn(
|
|
133
|
+
"h-4 w-4 shrink-0",
|
|
134
|
+
isFolderSelected ? "fill-current opacity-80" : "text-muted-foreground",
|
|
135
|
+
)}
|
|
136
|
+
/>
|
|
137
|
+
<span className="truncate leading-tight">{folder.name}</span>
|
|
138
|
+
{hasChildren && (
|
|
139
|
+
<span className="ml-auto shrink-0 text-xs tabular-nums text-muted-foreground">
|
|
140
|
+
{childFolders.length + childQuestions.length}
|
|
141
|
+
</span>
|
|
142
|
+
)}
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{/* Children */}
|
|
147
|
+
{hasChildren && (
|
|
148
|
+
<CollapsibleContent>
|
|
149
|
+
{childFolders.map(child => (
|
|
150
|
+
<TreeItem
|
|
151
|
+
key={child.id}
|
|
152
|
+
folder={child}
|
|
153
|
+
folders={folders}
|
|
154
|
+
questions={questions}
|
|
155
|
+
selectedItemId={selectedItemId}
|
|
156
|
+
depth={depth + 1}
|
|
157
|
+
onSelectItem={onSelectItem}
|
|
158
|
+
/>
|
|
159
|
+
))}
|
|
160
|
+
{childQuestions.map(question => {
|
|
161
|
+
const isSelected = selectedItemId === question.id
|
|
162
|
+
return (
|
|
163
|
+
<div key={question.id} className="group flex items-center hover:bg-muted/50">
|
|
164
|
+
<div style={{ width: indent + 12 + 20 }} className="shrink-0" />
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
onClick={() => onSelectItem(question.id)}
|
|
168
|
+
className={cn(
|
|
169
|
+
"flex flex-1 items-center gap-2 py-1.5 pr-3 text-left text-sm transition-colors duration-75",
|
|
170
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
|
171
|
+
isSelected
|
|
172
|
+
? "bg-accent text-accent-foreground"
|
|
173
|
+
: "text-foreground",
|
|
174
|
+
)}
|
|
175
|
+
aria-selected={isSelected}
|
|
176
|
+
role="option"
|
|
177
|
+
>
|
|
178
|
+
<FileIcon
|
|
179
|
+
className={cn(
|
|
180
|
+
"h-3.5 w-3.5 shrink-0",
|
|
181
|
+
isSelected ? "fill-current opacity-80" : "text-muted-foreground",
|
|
182
|
+
)}
|
|
183
|
+
/>
|
|
184
|
+
<span className="truncate leading-tight">{question.stem}</span>
|
|
185
|
+
</button>
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
})}
|
|
189
|
+
</CollapsibleContent>
|
|
190
|
+
)}
|
|
191
|
+
</Collapsible>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ============================================================================
|
|
196
|
+
// DetailsPanel — right panel content (folder or question details)
|
|
197
|
+
// ============================================================================
|
|
198
|
+
|
|
199
|
+
function DetailSection({
|
|
200
|
+
title,
|
|
201
|
+
children,
|
|
202
|
+
className,
|
|
203
|
+
}: {
|
|
204
|
+
title: string
|
|
205
|
+
children: React.ReactNode
|
|
206
|
+
className?: string
|
|
207
|
+
}) {
|
|
208
|
+
return (
|
|
209
|
+
<section className={cn("min-w-0", className)}>
|
|
210
|
+
<h3 className="mb-2 text-xs font-medium text-muted-foreground">{title}</h3>
|
|
211
|
+
{children}
|
|
212
|
+
</section>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function InspectorSectionTitle({ children, id }: { children: React.ReactNode; id?: string }) {
|
|
217
|
+
return (
|
|
218
|
+
<p
|
|
219
|
+
id={id}
|
|
220
|
+
className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
|
|
221
|
+
>
|
|
222
|
+
{children}
|
|
223
|
+
</p>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
interface DetailsPanelProps {
|
|
228
|
+
selectedItemId: string | null
|
|
229
|
+
folders: QuestionBankFolder[]
|
|
230
|
+
questions: QuestionBankItem[]
|
|
231
|
+
/** Clears tree selection (header dismiss). */
|
|
232
|
+
onClearSelection?: () => void
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function DetailsPanel({ selectedItemId, folders, questions, onClearSelection }: DetailsPanelProps) {
|
|
236
|
+
if (!selectedItemId) {
|
|
237
|
+
return (
|
|
238
|
+
<ListPageSplitDetailsPlaceholder title="Nothing selected" />
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const folder = folders.find(f => f.id === selectedItemId)
|
|
243
|
+
if (folder) {
|
|
244
|
+
return (
|
|
245
|
+
<FolderDetailsShell
|
|
246
|
+
folder={folder}
|
|
247
|
+
folders={folders}
|
|
248
|
+
questions={questions}
|
|
249
|
+
onClearSelection={onClearSelection}
|
|
250
|
+
/>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const question = questions.find(q => q.id === selectedItemId)
|
|
255
|
+
if (question) {
|
|
256
|
+
const parentFolder = folders.find(f => f.id === question.folderId)
|
|
257
|
+
const folderLeafName = parentFolder?.name ?? "—"
|
|
258
|
+
const itemCode = deriveQuestionItemCode(question)
|
|
259
|
+
const bloom = deriveBloomLevel(question)
|
|
260
|
+
const tags = deriveTags(question)
|
|
261
|
+
const createdBy = question.createdBy ?? question.author
|
|
262
|
+
const creatorInitials = initialsFromDisplayName(createdBy)
|
|
263
|
+
const createdAtLabel = formatDateUS(question.createdAt ?? question.updatedAt)
|
|
264
|
+
const lastEditedLine = deriveLastEditedLine(question)
|
|
265
|
+
const versionLabel = question.version ?? "v1"
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-card">
|
|
269
|
+
<header className="shrink-0 border-b border-border/60 bg-muted/10 px-4 pb-4 pt-3">
|
|
270
|
+
<div className="flex items-start justify-between gap-3">
|
|
271
|
+
<ListHubStatusBadge
|
|
272
|
+
surface="detail"
|
|
273
|
+
label={INSPECTOR_STATUS_LABEL[question.status]}
|
|
274
|
+
tintClassName={QUESTION_BANK_STATUS_BADGE_CLASS[question.status]}
|
|
275
|
+
icon={QUESTION_BANK_STATUS_ICON[question.status]}
|
|
276
|
+
/>
|
|
277
|
+
{onClearSelection ? (
|
|
278
|
+
<Tip label="Close details" side="bottom">
|
|
279
|
+
<Button
|
|
280
|
+
type="button"
|
|
281
|
+
variant="ghost"
|
|
282
|
+
size="icon-sm"
|
|
283
|
+
className="shrink-0 text-muted-foreground hover:text-foreground"
|
|
284
|
+
onClick={onClearSelection}
|
|
285
|
+
aria-label="Close details"
|
|
286
|
+
>
|
|
287
|
+
<X className="size-4" aria-hidden />
|
|
288
|
+
</Button>
|
|
289
|
+
</Tip>
|
|
290
|
+
) : null}
|
|
291
|
+
</div>
|
|
292
|
+
<h2 className="mt-3 text-lg font-semibold leading-snug tracking-tight text-foreground">{question.stem}</h2>
|
|
293
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
294
|
+
<Tip label="Opens the full editor when your bank workflow is connected." side="bottom">
|
|
295
|
+
<span className="inline-flex">
|
|
296
|
+
<Button type="button" size="sm" className="gap-1.5 shadow-sm" disabled>
|
|
297
|
+
<PencilLine className="size-3.5" aria-hidden />
|
|
298
|
+
Edit question
|
|
299
|
+
</Button>
|
|
300
|
+
</span>
|
|
301
|
+
</Tip>
|
|
302
|
+
<Tip
|
|
303
|
+
label={
|
|
304
|
+
question.status === "draft"
|
|
305
|
+
? "Already a draft."
|
|
306
|
+
: "Revert connects when your assessments API is wired."
|
|
307
|
+
}
|
|
308
|
+
side="bottom"
|
|
309
|
+
>
|
|
310
|
+
<span className="inline-flex">
|
|
311
|
+
<Button type="button" variant="outline" size="sm" className="gap-1.5" disabled={question.status === "draft"}>
|
|
312
|
+
<Hourglass className="size-3.5" aria-hidden />
|
|
313
|
+
Revert to draft
|
|
314
|
+
</Button>
|
|
315
|
+
</span>
|
|
316
|
+
</Tip>
|
|
317
|
+
<Tip label="Move question connects when folder APIs are wired." side="bottom">
|
|
318
|
+
<span className="inline-flex">
|
|
319
|
+
<Button type="button" variant="outline" size="sm" className="gap-1.5" disabled>
|
|
320
|
+
<ArrowRightLeft className="size-3.5" aria-hidden />
|
|
321
|
+
Move
|
|
322
|
+
</Button>
|
|
323
|
+
</span>
|
|
324
|
+
</Tip>
|
|
325
|
+
</div>
|
|
326
|
+
</header>
|
|
327
|
+
|
|
328
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
|
329
|
+
<div className="space-y-5 px-4 py-4">
|
|
330
|
+
<dl className="space-y-2.5 text-sm">
|
|
331
|
+
<div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-3 gap-y-0.5">
|
|
332
|
+
<dt className="text-muted-foreground">Bloom's</dt>
|
|
333
|
+
<dd className="font-medium text-foreground">{bloom}</dd>
|
|
334
|
+
</div>
|
|
335
|
+
<div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-3 gap-y-0.5">
|
|
336
|
+
<dt className="text-muted-foreground">Difficulty</dt>
|
|
337
|
+
<dd className="font-medium text-foreground">{DIFFICULTY_LABEL[question.difficulty]}</dd>
|
|
338
|
+
</div>
|
|
339
|
+
<div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-3 gap-y-0.5">
|
|
340
|
+
<dt className="text-muted-foreground">Type</dt>
|
|
341
|
+
<dd className="font-medium text-foreground">{QUESTION_TYPE_ABBREV[question.type]}</dd>
|
|
342
|
+
</div>
|
|
343
|
+
<div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-3 gap-y-0.5">
|
|
344
|
+
<dt className="text-muted-foreground">Folder</dt>
|
|
345
|
+
<dd className="min-w-0 font-medium text-foreground">{folderLeafName}</dd>
|
|
346
|
+
</div>
|
|
347
|
+
<div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] items-center gap-x-3 gap-y-0.5">
|
|
348
|
+
<dt className="text-muted-foreground">Code</dt>
|
|
349
|
+
<dd>
|
|
350
|
+
<span className="inline-flex rounded-md border border-rose-200/90 bg-rose-50 px-2 py-0.5 font-mono text-xs font-medium leading-none text-rose-950 shadow-sm dark:border-rose-500/35 dark:bg-rose-950/45 dark:text-rose-50">
|
|
351
|
+
{itemCode}
|
|
352
|
+
</span>
|
|
353
|
+
</dd>
|
|
354
|
+
</div>
|
|
355
|
+
</dl>
|
|
356
|
+
|
|
357
|
+
{tags.length > 0 ? (
|
|
358
|
+
<div>
|
|
359
|
+
<InspectorSectionTitle>Tags</InspectorSectionTitle>
|
|
360
|
+
<div className="flex flex-wrap gap-2" role="list" aria-label="Question tags">
|
|
361
|
+
{tags.map(raw => {
|
|
362
|
+
const label = raw.replace(/^#/, "").trim()
|
|
363
|
+
return (
|
|
364
|
+
<Badge
|
|
365
|
+
key={label}
|
|
366
|
+
variant="outline"
|
|
367
|
+
role="listitem"
|
|
368
|
+
className={cn(
|
|
369
|
+
LIST_HUB_INSPECTOR_CHIP_SHELL,
|
|
370
|
+
"border-border/60 bg-muted/15 font-normal text-foreground",
|
|
371
|
+
)}
|
|
372
|
+
>
|
|
373
|
+
#{label}
|
|
374
|
+
</Badge>
|
|
375
|
+
)
|
|
376
|
+
})}
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
) : null}
|
|
380
|
+
|
|
381
|
+
<Separator className="bg-border/60" />
|
|
382
|
+
|
|
383
|
+
<section className="min-w-0">
|
|
384
|
+
<InspectorSectionTitle>Creator & history</InspectorSectionTitle>
|
|
385
|
+
<div className="space-y-3 text-sm">
|
|
386
|
+
<div className="flex gap-3">
|
|
387
|
+
<span
|
|
388
|
+
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-pink-500/15 text-xs font-semibold text-pink-950 dark:bg-pink-500/25 dark:text-pink-50"
|
|
389
|
+
aria-hidden
|
|
390
|
+
>
|
|
391
|
+
{creatorInitials}
|
|
392
|
+
</span>
|
|
393
|
+
<div className="min-w-0">
|
|
394
|
+
<p className="text-xs text-muted-foreground">Created by</p>
|
|
395
|
+
<p className="font-medium leading-snug text-foreground">{createdBy}</p>
|
|
396
|
+
<p className="mt-0.5 text-xs text-muted-foreground">{createdAtLabel}</p>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
<div>
|
|
400
|
+
<p className="text-xs text-muted-foreground">Last edited</p>
|
|
401
|
+
<p className="font-medium text-foreground">{lastEditedLine}</p>
|
|
402
|
+
</div>
|
|
403
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
404
|
+
<p className="text-sm">
|
|
405
|
+
<span className="text-muted-foreground">Version </span>
|
|
406
|
+
<span className="font-medium tabular-nums text-foreground">{versionLabel}</span>
|
|
407
|
+
</p>
|
|
408
|
+
<Tip label="Version history opens when connected to your CMS.">
|
|
409
|
+
<span className="inline-flex">
|
|
410
|
+
<Button
|
|
411
|
+
type="button"
|
|
412
|
+
variant="link"
|
|
413
|
+
className="h-auto gap-1 px-0 py-0 text-xs font-normal"
|
|
414
|
+
disabled
|
|
415
|
+
>
|
|
416
|
+
Version history
|
|
417
|
+
<ChevronRightIcon className="size-3.5 opacity-70" aria-hidden />
|
|
418
|
+
</Button>
|
|
419
|
+
</span>
|
|
420
|
+
</Tip>
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
</section>
|
|
424
|
+
|
|
425
|
+
<Separator className="bg-border/60" />
|
|
426
|
+
|
|
427
|
+
<section className="min-w-0">
|
|
428
|
+
<InspectorSectionTitle>Usage</InspectorSectionTitle>
|
|
429
|
+
<dl className="space-y-2 text-sm">
|
|
430
|
+
<div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-3">
|
|
431
|
+
<dt className="text-muted-foreground">Used in</dt>
|
|
432
|
+
<dd className="font-medium text-foreground">
|
|
433
|
+
{question.examUsageCount != null ? `${question.examUsageCount} exams` : "—"}
|
|
434
|
+
</dd>
|
|
435
|
+
</div>
|
|
436
|
+
<div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-3">
|
|
437
|
+
<dt className="text-muted-foreground">PBI</dt>
|
|
438
|
+
<dd className="font-semibold tabular-nums text-foreground">
|
|
439
|
+
{question.pbi != null ? question.pbi.toFixed(2) : "—"}
|
|
440
|
+
</dd>
|
|
441
|
+
</div>
|
|
442
|
+
<div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-3">
|
|
443
|
+
<dt className="text-muted-foreground">Avg score</dt>
|
|
444
|
+
<dd className="font-medium text-foreground">
|
|
445
|
+
{question.avgScoreCorrectPct != null ? `${question.avgScoreCorrectPct}% correct` : "—"}
|
|
446
|
+
</dd>
|
|
447
|
+
</div>
|
|
448
|
+
<div className="grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-3">
|
|
449
|
+
<dt className="text-muted-foreground">Last used</dt>
|
|
450
|
+
<dd className="font-medium text-foreground">{question.lastUsedLabel ?? "—"}</dd>
|
|
451
|
+
</div>
|
|
452
|
+
</dl>
|
|
453
|
+
</section>
|
|
454
|
+
|
|
455
|
+
{question.type === "multiple_choice" && question.options && question.options.length > 0 ? (
|
|
456
|
+
<>
|
|
457
|
+
<Separator className="bg-border/60" />
|
|
458
|
+
<DetailSection title="Answer choices">
|
|
459
|
+
<ul className="flex flex-col gap-2" aria-label="Multiple choice options">
|
|
460
|
+
{question.options.map((opt, idx) => {
|
|
461
|
+
const letter = String.fromCharCode(65 + idx)
|
|
462
|
+
const isCorrect = Boolean(opt.isCorrect)
|
|
463
|
+
return (
|
|
464
|
+
<li
|
|
465
|
+
key={`${question.id}-opt-${idx}`}
|
|
466
|
+
className={cn(
|
|
467
|
+
"flex items-start gap-2.5 rounded-lg border px-3 py-2.5 text-sm transition-colors",
|
|
468
|
+
isCorrect
|
|
469
|
+
? "border-emerald-500/40 bg-emerald-500/10 shadow-[inset_0_0_0_1px] shadow-emerald-500/15"
|
|
470
|
+
: "border-border/50 bg-muted/10",
|
|
471
|
+
)}
|
|
472
|
+
>
|
|
473
|
+
<span
|
|
474
|
+
className={cn(
|
|
475
|
+
"flex size-7 shrink-0 items-center justify-center rounded-md border text-xs font-semibold tabular-nums",
|
|
476
|
+
isCorrect
|
|
477
|
+
? "border-emerald-500/40 bg-emerald-500/15 text-emerald-900 dark:text-emerald-100"
|
|
478
|
+
: "border-border/60 bg-background text-muted-foreground",
|
|
479
|
+
)}
|
|
480
|
+
>
|
|
481
|
+
{letter}
|
|
482
|
+
</span>
|
|
483
|
+
<span
|
|
484
|
+
className={cn(
|
|
485
|
+
"min-w-0 flex-1 leading-snug",
|
|
486
|
+
isCorrect ? "font-medium text-foreground" : "text-foreground/90",
|
|
487
|
+
)}
|
|
488
|
+
>
|
|
489
|
+
{opt.text}
|
|
490
|
+
</span>
|
|
491
|
+
{isCorrect ? (
|
|
492
|
+
<span className="flex shrink-0 items-center gap-1 text-emerald-600 dark:text-emerald-400">
|
|
493
|
+
<CheckCircle2Icon className="size-4" aria-hidden />
|
|
494
|
+
<span className="sr-only">Correct answer</span>
|
|
495
|
+
</span>
|
|
496
|
+
) : null}
|
|
497
|
+
</li>
|
|
498
|
+
)
|
|
499
|
+
})}
|
|
500
|
+
</ul>
|
|
501
|
+
</DetailSection>
|
|
502
|
+
</>
|
|
503
|
+
) : null}
|
|
504
|
+
|
|
505
|
+
{question.type === "true_false" ? (
|
|
506
|
+
<>
|
|
507
|
+
<Separator className="bg-border/60" />
|
|
508
|
+
<DetailSection title="Response format">
|
|
509
|
+
<p className="rounded-lg border border-border/50 bg-muted/10 px-3 py-3 text-xs leading-relaxed text-muted-foreground">
|
|
510
|
+
Learners choose <span className="font-medium text-foreground">True</span> or{" "}
|
|
511
|
+
<span className="font-medium text-foreground">False</span>. No options list is shown in the bank
|
|
512
|
+
preview.
|
|
513
|
+
</p>
|
|
514
|
+
</DetailSection>
|
|
515
|
+
</>
|
|
516
|
+
) : null}
|
|
517
|
+
|
|
518
|
+
{question.type === "short_answer" ? (
|
|
519
|
+
<>
|
|
520
|
+
<Separator className="bg-border/60" />
|
|
521
|
+
<DetailSection title="Response format">
|
|
522
|
+
<p className="rounded-lg border border-border/50 bg-muted/10 px-3 py-3 text-xs leading-relaxed text-muted-foreground">
|
|
523
|
+
Free-text response; grading rules and sample answers are managed when the question is edited in the
|
|
524
|
+
full editor.
|
|
525
|
+
</p>
|
|
526
|
+
</DetailSection>
|
|
527
|
+
</>
|
|
528
|
+
) : null}
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
</div>
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return (
|
|
536
|
+
<div className="flex h-full min-h-0 flex-col items-center justify-center bg-gradient-to-b from-muted/25 to-card px-6 py-10 text-center text-muted-foreground">
|
|
537
|
+
<div className="mb-3 flex size-12 items-center justify-center rounded-xl border border-border/60 bg-muted/20">
|
|
538
|
+
<FileIcon className="size-6 opacity-50" aria-hidden />
|
|
539
|
+
</div>
|
|
540
|
+
<p className="text-sm font-medium text-foreground">Item not found</p>
|
|
541
|
+
<p className="mt-1 max-w-[14rem] text-xs leading-relaxed text-muted-foreground">
|
|
542
|
+
This selection is no longer in the tree. Choose another folder or question.
|
|
543
|
+
</p>
|
|
544
|
+
</div>
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ============================================================================
|
|
549
|
+
// HubTreePanelView — tree + details wiring (question bank demo; reusable shell above)
|
|
550
|
+
// ============================================================================
|
|
551
|
+
|
|
552
|
+
export interface HubTreePanelViewProps {
|
|
553
|
+
items: QuestionBankItem[]
|
|
554
|
+
folders: QuestionBankFolder[]
|
|
555
|
+
onItemsChange: (items: QuestionBankItem[]) => void
|
|
556
|
+
onFoldersChange: (folders: QuestionBankFolder[]) => void
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export function HubTreePanelView({
|
|
560
|
+
items,
|
|
561
|
+
folders,
|
|
562
|
+
onFoldersChange,
|
|
563
|
+
}: HubTreePanelViewProps) {
|
|
564
|
+
const [selectedItemId, setSelectedItemId] = React.useState<string | null>(null)
|
|
565
|
+
const [newFolderOpen, setNewFolderOpen] = React.useState(false)
|
|
566
|
+
const [newFolderParentId, setNewFolderParentId] = React.useState<string | null>(null)
|
|
567
|
+
const [customizingFolder, setCustomizingFolder] = React.useState<QuestionBankFolder | null>(null)
|
|
568
|
+
|
|
569
|
+
const rootFolders = React.useMemo(
|
|
570
|
+
() => folders.filter(f => f.parentId === null).sort((a, b) => a.name.localeCompare(b.name)),
|
|
571
|
+
[folders],
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
const handleNewFolderCreated = React.useCallback(
|
|
575
|
+
(newFolder: { name: string; icon: string; colorKey: QuestionBankFolderColorKey; parentId: string | null }) => {
|
|
576
|
+
if (customizingFolder) {
|
|
577
|
+
onFoldersChange(
|
|
578
|
+
folders.map(f =>
|
|
579
|
+
f.id === customizingFolder.id
|
|
580
|
+
? { ...f, name: newFolder.name, icon: newFolder.icon, colorKey: newFolder.colorKey }
|
|
581
|
+
: f,
|
|
582
|
+
),
|
|
583
|
+
)
|
|
584
|
+
setCustomizingFolder(null)
|
|
585
|
+
} else {
|
|
586
|
+
onFoldersChange([
|
|
587
|
+
...folders,
|
|
588
|
+
{
|
|
589
|
+
id: `fld-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
|
|
590
|
+
name: newFolder.name,
|
|
591
|
+
icon: newFolder.icon,
|
|
592
|
+
colorKey: newFolder.colorKey,
|
|
593
|
+
parentId: newFolder.parentId,
|
|
594
|
+
},
|
|
595
|
+
])
|
|
596
|
+
}
|
|
597
|
+
setNewFolderOpen(false)
|
|
598
|
+
},
|
|
599
|
+
[customizingFolder, folders, onFoldersChange],
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
return (
|
|
603
|
+
<>
|
|
604
|
+
<ListPageTreePanelShell
|
|
605
|
+
resizableGroupId="hub-tree-panel"
|
|
606
|
+
ariaLabel="Folder tree and details"
|
|
607
|
+
tree={
|
|
608
|
+
<>
|
|
609
|
+
<ListPageTreeColumnHeader
|
|
610
|
+
title="Questions"
|
|
611
|
+
trailing={
|
|
612
|
+
<Tooltip>
|
|
613
|
+
<TooltipTrigger asChild>
|
|
614
|
+
<Button
|
|
615
|
+
size="icon-sm"
|
|
616
|
+
variant="ghost"
|
|
617
|
+
onClick={() => {
|
|
618
|
+
setNewFolderParentId(null)
|
|
619
|
+
setCustomizingFolder(null)
|
|
620
|
+
setNewFolderOpen(true)
|
|
621
|
+
}}
|
|
622
|
+
aria-label="Add folder"
|
|
623
|
+
>
|
|
624
|
+
<i className="fa-light fa-folder-plus text-xs" aria-hidden="true" />
|
|
625
|
+
</Button>
|
|
626
|
+
</TooltipTrigger>
|
|
627
|
+
<TooltipContent side="top" sideOffset={4}>
|
|
628
|
+
Add folder
|
|
629
|
+
</TooltipContent>
|
|
630
|
+
</Tooltip>
|
|
631
|
+
}
|
|
632
|
+
/>
|
|
633
|
+
|
|
634
|
+
<div className="min-h-0 flex-1 overflow-y-auto py-1" role="listbox" aria-label="Folder tree">
|
|
635
|
+
{rootFolders.length === 0 ? (
|
|
636
|
+
<p className="px-3 py-4 text-sm text-muted-foreground">No folders</p>
|
|
637
|
+
) : (
|
|
638
|
+
rootFolders.map(folder => (
|
|
639
|
+
<TreeItem
|
|
640
|
+
key={folder.id}
|
|
641
|
+
folder={folder}
|
|
642
|
+
folders={folders}
|
|
643
|
+
questions={items}
|
|
644
|
+
selectedItemId={selectedItemId}
|
|
645
|
+
depth={0}
|
|
646
|
+
onSelectItem={setSelectedItemId}
|
|
647
|
+
/>
|
|
648
|
+
))
|
|
649
|
+
)}
|
|
650
|
+
</div>
|
|
651
|
+
</>
|
|
652
|
+
}
|
|
653
|
+
details={
|
|
654
|
+
<DetailsPanel
|
|
655
|
+
selectedItemId={selectedItemId}
|
|
656
|
+
folders={folders}
|
|
657
|
+
questions={items}
|
|
658
|
+
onClearSelection={() => setSelectedItemId(null)}
|
|
659
|
+
/>
|
|
660
|
+
}
|
|
661
|
+
/>
|
|
662
|
+
|
|
663
|
+
<QuestionBankNewFolderSheet
|
|
664
|
+
open={newFolderOpen}
|
|
665
|
+
onOpenChange={setNewFolderOpen}
|
|
666
|
+
parentFolderId={customizingFolder?.parentId ?? newFolderParentId}
|
|
667
|
+
customizingFolder={customizingFolder}
|
|
668
|
+
onCreated={handleNewFolderCreated}
|
|
669
|
+
/>
|
|
670
|
+
</>
|
|
671
|
+
)
|
|
672
|
+
}
|