@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
|
@@ -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} />
|
|
@@ -27,7 +27,7 @@ export interface QuestionBankPageHeaderProps {
|
|
|
27
27
|
showMetrics?: boolean
|
|
28
28
|
onToggleMetrics?: () => void
|
|
29
29
|
showTitleBlock?: boolean
|
|
30
|
-
/** `collaboration` adds access line + collaborator
|
|
30
|
+
/** `collaboration` adds access line + collaborator face row before CTAs. */
|
|
31
31
|
variant?: PageHeaderVariant
|
|
32
32
|
/** Optional role / access row when `variant="collaboration"` (badge + copy). */
|
|
33
33
|
accessInfo?: React.ReactNode
|
|
@@ -44,6 +44,11 @@ export interface QuestionBankPageHeaderProps {
|
|
|
44
44
|
subtitleOverride?: string
|
|
45
45
|
/** Omits the action column (e.g. search landing before first query). */
|
|
46
46
|
hideActions?: boolean
|
|
47
|
+
/**
|
|
48
|
+
* When provided, the **More** menu includes **Customize folder** (opens the hub folder sheet).
|
|
49
|
+
* Wire this when the library is scoped to a folder (`?scope=folder&folderId=…`).
|
|
50
|
+
*/
|
|
51
|
+
onCustomizeFolder?: () => void
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
export function QuestionBankPageHeader({
|
|
@@ -57,7 +62,7 @@ export function QuestionBankPageHeader({
|
|
|
57
62
|
variant = "default",
|
|
58
63
|
accessInfo,
|
|
59
64
|
collaborators = QUESTION_BANK_HEADER_COLLABORATORS,
|
|
60
|
-
collaboratorDisplayLimit =
|
|
65
|
+
collaboratorDisplayLimit = 3,
|
|
61
66
|
onAddCollaborator = () => {},
|
|
62
67
|
onCollaboratorsOpen,
|
|
63
68
|
collaborationAddLabel = COLLABORATION_HEADER_ADD_LABEL,
|
|
@@ -65,6 +70,7 @@ export function QuestionBankPageHeader({
|
|
|
65
70
|
hideNewQuestion = false,
|
|
66
71
|
subtitleOverride,
|
|
67
72
|
hideActions = false,
|
|
73
|
+
onCustomizeFolder,
|
|
68
74
|
}: QuestionBankPageHeaderProps) {
|
|
69
75
|
const [moreOpen, setMoreOpen] = React.useState(false)
|
|
70
76
|
const countLine =
|
|
@@ -119,6 +125,16 @@ export function QuestionBankPageHeader({
|
|
|
119
125
|
{addCollaboratorLabel}
|
|
120
126
|
</DropdownMenuItem>
|
|
121
127
|
) : null}
|
|
128
|
+
{onCustomizeFolder ? (
|
|
129
|
+
<DropdownMenuItem
|
|
130
|
+
onSelect={() => {
|
|
131
|
+
window.setTimeout(() => onCustomizeFolder(), 0)
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
<i className="fa-light fa-wand-magic-sparkles" aria-hidden="true" />
|
|
135
|
+
Customize folder
|
|
136
|
+
</DropdownMenuItem>
|
|
137
|
+
) : null}
|
|
122
138
|
<DropdownMenuItem
|
|
123
139
|
onSelect={() => {
|
|
124
140
|
window.setTimeout(() => onExport(), 0)
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
import * as React from "react"
|
|
9
9
|
import Link from "next/link"
|
|
10
10
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|
11
|
-
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
|
12
11
|
import { Button } from "@/components/ui/button"
|
|
13
12
|
import {
|
|
14
13
|
Dialog,
|
|
@@ -22,7 +21,6 @@ import {
|
|
|
22
21
|
DropdownMenu,
|
|
23
22
|
DropdownMenuContent,
|
|
24
23
|
DropdownMenuItem,
|
|
25
|
-
DropdownMenuSeparator,
|
|
26
24
|
DropdownMenuTrigger,
|
|
27
25
|
Shortcut,
|
|
28
26
|
} from "@/components/ui/dropdown-menu"
|
|
@@ -30,43 +28,17 @@ import { Kbd, KbdGroup } from "@/components/ui/kbd"
|
|
|
30
28
|
import { Tip } from "@/components/ui/tip"
|
|
31
29
|
import { cn } from "@/lib/utils"
|
|
32
30
|
import type { QuestionBankFolder } from "@/lib/mock/question-bank-folders"
|
|
33
|
-
import { QUESTION_BANK_FOLDER_ICON_COLORS } from "@/lib/mock/question-bank-folders"
|
|
34
31
|
import { DEFAULT_QUESTION_BANK_FOLDERS, newFolderId, collectFolderDescendantIds } from "@/lib/mock/question-bank-folders"
|
|
35
32
|
import { useSecondaryPanel } from "@/components/secondary-panel"
|
|
36
33
|
import { QuestionBankNewFolderSheet } from "@/components/question-bank-new-folder-sheet"
|
|
37
34
|
import {
|
|
38
|
-
|
|
35
|
+
isQuestionBankNavActive,
|
|
39
36
|
parseQuestionBankNav,
|
|
40
37
|
QUESTION_BANK_FAVORITES_FOLDER_ID,
|
|
41
|
-
QUESTION_BANK_LIBRARY_HUB_PATHS,
|
|
42
|
-
isQuestionBankSearchNavActive,
|
|
43
38
|
questionBankFavoritesFolderHref,
|
|
44
39
|
questionBankHubScopeHref,
|
|
45
|
-
questionBankSearchLandingNavHref,
|
|
46
|
-
type QuestionBankNavScope,
|
|
47
40
|
} from "@/lib/question-bank-nav"
|
|
48
|
-
|
|
49
|
-
function isNavActive(
|
|
50
|
-
pathname: string,
|
|
51
|
-
nav: ReturnType<typeof parseQuestionBankNav>,
|
|
52
|
-
scope: QuestionBankNavScope,
|
|
53
|
-
folderId?: string | null,
|
|
54
|
-
) {
|
|
55
|
-
if (!QUESTION_BANK_LIBRARY_HUB_PATHS.includes(pathname)) return false
|
|
56
|
-
// Dedicated search shells (list / hub-find) use the “Search” row for All/My — not these rows.
|
|
57
|
-
if (scope === "all") {
|
|
58
|
-
if (isQuestionBankDedicatedSearchPathname(pathname)) return false
|
|
59
|
-
return nav.scope === "all"
|
|
60
|
-
}
|
|
61
|
-
if (scope === "my") {
|
|
62
|
-
if (isQuestionBankDedicatedSearchPathname(pathname)) return false
|
|
63
|
-
return nav.scope === "my"
|
|
64
|
-
}
|
|
65
|
-
if (scope === "folder" && folderId) {
|
|
66
|
-
return nav.scope === "folder" && nav.folderId === folderId
|
|
67
|
-
}
|
|
68
|
-
return false
|
|
69
|
-
}
|
|
41
|
+
import { QuestionBankFolderTreeBranch } from "@/components/data-views/question-bank-folder-tree-branch"
|
|
70
42
|
|
|
71
43
|
function NavRow({
|
|
72
44
|
href,
|
|
@@ -147,179 +119,6 @@ function IconNavRow({
|
|
|
147
119
|
)
|
|
148
120
|
}
|
|
149
121
|
|
|
150
|
-
function PanelFolderBranch({
|
|
151
|
-
folder,
|
|
152
|
-
folders,
|
|
153
|
-
depth,
|
|
154
|
-
pathname,
|
|
155
|
-
hubSearchParams,
|
|
156
|
-
nav,
|
|
157
|
-
canManageFolders,
|
|
158
|
-
canManageAccess,
|
|
159
|
-
onAddSubfolder,
|
|
160
|
-
onCustomizeFolder,
|
|
161
|
-
onManageAccess,
|
|
162
|
-
onDeleteFolder,
|
|
163
|
-
}: {
|
|
164
|
-
folder: QuestionBankFolder
|
|
165
|
-
folders: QuestionBankFolder[]
|
|
166
|
-
depth: number
|
|
167
|
-
pathname: string
|
|
168
|
-
hubSearchParams: URLSearchParams
|
|
169
|
-
nav: ReturnType<typeof parseQuestionBankNav>
|
|
170
|
-
canManageFolders: boolean
|
|
171
|
-
canManageAccess: boolean
|
|
172
|
-
onAddSubfolder: (parentId: string) => void
|
|
173
|
-
onCustomizeFolder: (folder: QuestionBankFolder) => void
|
|
174
|
-
onManageAccess: () => void
|
|
175
|
-
onDeleteFolder: (folder: QuestionBankFolder) => void
|
|
176
|
-
}) {
|
|
177
|
-
const childFolders = React.useMemo(
|
|
178
|
-
() =>
|
|
179
|
-
folders
|
|
180
|
-
.filter(f => f.parentId === folder.id)
|
|
181
|
-
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
182
|
-
[folders, folder.id],
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
const hasSubfolders = childFolders.length > 0
|
|
186
|
-
const indent = depth * 10
|
|
187
|
-
|
|
188
|
-
const folderHref = questionBankHubScopeHref(pathname, hubSearchParams, {
|
|
189
|
-
scope: "folder",
|
|
190
|
-
folderId: folder.id,
|
|
191
|
-
})
|
|
192
|
-
const folderActive = isNavActive(pathname, nav, "folder", folder.id)
|
|
193
|
-
|
|
194
|
-
return (
|
|
195
|
-
<Collapsible defaultOpen={depth < 1} className="group">
|
|
196
|
-
<div
|
|
197
|
-
className={cn(
|
|
198
|
-
"group/row flex min-w-0 items-center rounded-md px-2 transition-colors",
|
|
199
|
-
folderActive
|
|
200
|
-
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
|
201
|
-
: "hover:bg-sidebar-accent/50",
|
|
202
|
-
)}
|
|
203
|
-
>
|
|
204
|
-
<div style={{ width: indent }} className="shrink-0" aria-hidden />
|
|
205
|
-
{hasSubfolders ? (
|
|
206
|
-
<CollapsibleTrigger asChild>
|
|
207
|
-
<button
|
|
208
|
-
type="button"
|
|
209
|
-
className="flex size-8 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
210
|
-
aria-label={`${folder.name} — expand or collapse`}
|
|
211
|
-
>
|
|
212
|
-
<i
|
|
213
|
-
className="fa-light fa-chevron-right text-xs transition-transform duration-150 group-data-[state=open]:rotate-90"
|
|
214
|
-
aria-hidden
|
|
215
|
-
/>
|
|
216
|
-
</button>
|
|
217
|
-
</CollapsibleTrigger>
|
|
218
|
-
) : (
|
|
219
|
-
<div className="size-8 shrink-0" aria-hidden />
|
|
220
|
-
)}
|
|
221
|
-
<Tip label={folder.name} side="right">
|
|
222
|
-
<Link
|
|
223
|
-
href={folderHref}
|
|
224
|
-
scroll={false}
|
|
225
|
-
aria-current={folderActive ? "page" : undefined}
|
|
226
|
-
className={cn(
|
|
227
|
-
"flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left text-sm transition-colors",
|
|
228
|
-
"rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
|
229
|
-
folderActive ? "font-medium" : "text-sidebar-foreground",
|
|
230
|
-
)}
|
|
231
|
-
>
|
|
232
|
-
<i
|
|
233
|
-
className={cn("fa-light shrink-0 text-sm", folder.icon, QUESTION_BANK_FOLDER_ICON_COLORS[folder.colorKey])}
|
|
234
|
-
aria-hidden
|
|
235
|
-
/>
|
|
236
|
-
<span className="min-w-0 flex-1 truncate leading-tight">{folder.name}</span>
|
|
237
|
-
{hasSubfolders ? (
|
|
238
|
-
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{childFolders.length}</span>
|
|
239
|
-
) : null}
|
|
240
|
-
</Link>
|
|
241
|
-
</Tip>
|
|
242
|
-
{canManageFolders ? (
|
|
243
|
-
<DropdownMenu>
|
|
244
|
-
<Tip label={`Folder actions for ${folder.name}`} side="right">
|
|
245
|
-
<DropdownMenuTrigger asChild>
|
|
246
|
-
<Button
|
|
247
|
-
type="button"
|
|
248
|
-
size="icon-xs"
|
|
249
|
-
variant="ghost"
|
|
250
|
-
aria-label={`Folder actions for ${folder.name}`}
|
|
251
|
-
className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/row:opacity-100 group-focus-within/row:opacity-100"
|
|
252
|
-
onClick={event => event.stopPropagation()}
|
|
253
|
-
>
|
|
254
|
-
<i className="fa-light fa-ellipsis text-xs" aria-hidden="true" />
|
|
255
|
-
</Button>
|
|
256
|
-
</DropdownMenuTrigger>
|
|
257
|
-
</Tip>
|
|
258
|
-
<DropdownMenuContent align="end">
|
|
259
|
-
<DropdownMenuItem
|
|
260
|
-
onSelect={() => {
|
|
261
|
-
window.setTimeout(() => onAddSubfolder(folder.id), 0)
|
|
262
|
-
}}
|
|
263
|
-
>
|
|
264
|
-
<i className="fa-light fa-plus text-xs" aria-hidden="true" />
|
|
265
|
-
Add folder
|
|
266
|
-
</DropdownMenuItem>
|
|
267
|
-
<DropdownMenuItem
|
|
268
|
-
onSelect={() => {
|
|
269
|
-
window.setTimeout(() => onCustomizeFolder(folder), 0)
|
|
270
|
-
}}
|
|
271
|
-
>
|
|
272
|
-
<i className="fa-light fa-wand-magic-sparkles text-xs" aria-hidden="true" />
|
|
273
|
-
Customize
|
|
274
|
-
</DropdownMenuItem>
|
|
275
|
-
<DropdownMenuItem
|
|
276
|
-
disabled={!canManageAccess}
|
|
277
|
-
onSelect={() => {
|
|
278
|
-
window.setTimeout(() => onManageAccess(), 0)
|
|
279
|
-
}}
|
|
280
|
-
>
|
|
281
|
-
<i className="fa-light fa-user-gear text-xs" aria-hidden="true" />
|
|
282
|
-
Manage access
|
|
283
|
-
</DropdownMenuItem>
|
|
284
|
-
<DropdownMenuSeparator />
|
|
285
|
-
<DropdownMenuItem
|
|
286
|
-
variant="destructive"
|
|
287
|
-
onSelect={() => {
|
|
288
|
-
window.setTimeout(() => onDeleteFolder(folder), 0)
|
|
289
|
-
}}
|
|
290
|
-
>
|
|
291
|
-
<i className="fa-light fa-trash text-xs" aria-hidden="true" />
|
|
292
|
-
Delete
|
|
293
|
-
</DropdownMenuItem>
|
|
294
|
-
</DropdownMenuContent>
|
|
295
|
-
</DropdownMenu>
|
|
296
|
-
) : null}
|
|
297
|
-
</div>
|
|
298
|
-
{hasSubfolders ? (
|
|
299
|
-
<CollapsibleContent>
|
|
300
|
-
{childFolders.map(child => (
|
|
301
|
-
<PanelFolderBranch
|
|
302
|
-
key={child.id}
|
|
303
|
-
folder={child}
|
|
304
|
-
folders={folders}
|
|
305
|
-
depth={depth + 1}
|
|
306
|
-
pathname={pathname}
|
|
307
|
-
hubSearchParams={hubSearchParams}
|
|
308
|
-
nav={nav}
|
|
309
|
-
canManageFolders={canManageFolders}
|
|
310
|
-
canManageAccess={canManageAccess}
|
|
311
|
-
onAddSubfolder={onAddSubfolder}
|
|
312
|
-
onCustomizeFolder={onCustomizeFolder}
|
|
313
|
-
onManageAccess={onManageAccess}
|
|
314
|
-
onDeleteFolder={onDeleteFolder}
|
|
315
|
-
/>
|
|
316
|
-
))}
|
|
317
|
-
</CollapsibleContent>
|
|
318
|
-
) : null}
|
|
319
|
-
</Collapsible>
|
|
320
|
-
)
|
|
321
|
-
}
|
|
322
|
-
|
|
323
122
|
export function QuestionBankSecondaryNav() {
|
|
324
123
|
const pathname = usePathname()
|
|
325
124
|
const router = useRouter()
|
|
@@ -340,7 +139,7 @@ export function QuestionBankSecondaryNav() {
|
|
|
340
139
|
const canManageFolders = questionBankFolderBridge != null
|
|
341
140
|
const canManageAccess = questionBankAccessBridge != null
|
|
342
141
|
|
|
343
|
-
/** Favorites is a primary nav row (with All / My
|
|
142
|
+
/** Favorites is a primary nav row (with All / My), not under “Folders”. */
|
|
344
143
|
const folderTreeRoots = React.useMemo(
|
|
345
144
|
() =>
|
|
346
145
|
folders
|
|
@@ -518,32 +317,25 @@ export function QuestionBankSecondaryNav() {
|
|
|
518
317
|
<ul className="flex flex-1 flex-col items-center gap-1 overflow-y-auto px-1 py-2" role="list">
|
|
519
318
|
<IconNavRow
|
|
520
319
|
href={questionBankHubScopeHref(pathname, searchParams, { scope: "all" })}
|
|
521
|
-
active={
|
|
320
|
+
active={isQuestionBankNavActive(pathname, nav, "all")}
|
|
522
321
|
iconClass="fa-table-list"
|
|
523
322
|
label="All questions"
|
|
524
323
|
onClick={() => openPanel("question-bank")}
|
|
525
324
|
/>
|
|
526
325
|
<IconNavRow
|
|
527
326
|
href={questionBankHubScopeHref(pathname, searchParams, { scope: "my" })}
|
|
528
|
-
active={
|
|
327
|
+
active={isQuestionBankNavActive(pathname, nav, "my")}
|
|
529
328
|
iconClass="fa-user"
|
|
530
329
|
label="My questions"
|
|
531
330
|
onClick={() => openPanel("question-bank")}
|
|
532
331
|
/>
|
|
533
332
|
<IconNavRow
|
|
534
333
|
href={questionBankFavoritesFolderHref(pathname, searchParams)}
|
|
535
|
-
active={
|
|
334
|
+
active={isQuestionBankNavActive(pathname, nav, "folder", QUESTION_BANK_FAVORITES_FOLDER_ID)}
|
|
536
335
|
iconClass="fa-star"
|
|
537
336
|
label="Favorites"
|
|
538
337
|
onClick={() => openPanel("question-bank")}
|
|
539
338
|
/>
|
|
540
|
-
<IconNavRow
|
|
541
|
-
href={questionBankSearchLandingNavHref(nav, searchParams)}
|
|
542
|
-
active={isQuestionBankSearchNavActive(pathname, nav)}
|
|
543
|
-
iconClass="fa-magnifying-glass"
|
|
544
|
-
label="Search"
|
|
545
|
-
onClick={() => openPanel("question-bank")}
|
|
546
|
-
/>
|
|
547
339
|
<li className="flex w-full justify-center pt-1" role="none">
|
|
548
340
|
<DropdownMenu>
|
|
549
341
|
<DropdownMenuTrigger asChild>
|
|
@@ -566,7 +358,7 @@ export function QuestionBankSecondaryNav() {
|
|
|
566
358
|
scope: "folder",
|
|
567
359
|
folderId: folder.id,
|
|
568
360
|
})
|
|
569
|
-
const active =
|
|
361
|
+
const active = isQuestionBankNavActive(pathname, nav, "folder", folder.id)
|
|
570
362
|
return (
|
|
571
363
|
<DropdownMenuItem key={folder.id} asChild>
|
|
572
364
|
<Link
|
|
@@ -613,31 +405,24 @@ export function QuestionBankSecondaryNav() {
|
|
|
613
405
|
<ul className="space-y-0.5" role="list">
|
|
614
406
|
<NavRow
|
|
615
407
|
href={questionBankHubScopeHref(pathname, searchParams, { scope: "all" })}
|
|
616
|
-
active={
|
|
408
|
+
active={isQuestionBankNavActive(pathname, nav, "all")}
|
|
617
409
|
iconClass="fa-table-list"
|
|
618
410
|
label="All questions"
|
|
619
411
|
onClick={() => openPanel("question-bank")}
|
|
620
412
|
/>
|
|
621
413
|
<NavRow
|
|
622
414
|
href={questionBankHubScopeHref(pathname, searchParams, { scope: "my" })}
|
|
623
|
-
active={
|
|
415
|
+
active={isQuestionBankNavActive(pathname, nav, "my")}
|
|
624
416
|
iconClass="fa-user"
|
|
625
417
|
label="My questions"
|
|
626
418
|
/>
|
|
627
419
|
<NavRow
|
|
628
420
|
href={questionBankFavoritesFolderHref(pathname, searchParams)}
|
|
629
|
-
active={
|
|
421
|
+
active={isQuestionBankNavActive(pathname, nav, "folder", QUESTION_BANK_FAVORITES_FOLDER_ID)}
|
|
630
422
|
iconClass="fa-star"
|
|
631
423
|
label="Favorites"
|
|
632
424
|
onClick={() => openPanel("question-bank")}
|
|
633
425
|
/>
|
|
634
|
-
<NavRow
|
|
635
|
-
href={questionBankSearchLandingNavHref(nav, searchParams)}
|
|
636
|
-
active={isQuestionBankSearchNavActive(pathname, nav)}
|
|
637
|
-
iconClass="fa-magnifying-glass"
|
|
638
|
-
label="Search"
|
|
639
|
-
onClick={() => openPanel("question-bank")}
|
|
640
|
-
/>
|
|
641
426
|
<li role="presentation" className="select-none">
|
|
642
427
|
<div className="flex items-center justify-between gap-2 px-2 pt-3 pb-1">
|
|
643
428
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">
|
|
@@ -659,11 +444,10 @@ export function QuestionBankSecondaryNav() {
|
|
|
659
444
|
</div>
|
|
660
445
|
</li>
|
|
661
446
|
{folderTreeRoots.map(folder => (
|
|
662
|
-
<li key={folder.id} className="min-w-0">
|
|
663
|
-
<
|
|
447
|
+
<li key={folder.id} className="min-w-0 w-full list-none">
|
|
448
|
+
<QuestionBankFolderTreeBranch
|
|
664
449
|
folder={folder}
|
|
665
450
|
folders={folders}
|
|
666
|
-
depth={0}
|
|
667
451
|
pathname={pathname}
|
|
668
452
|
hubSearchParams={searchParams}
|
|
669
453
|
nav={nav}
|
|
@@ -6,11 +6,13 @@
|
|
|
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"
|
|
12
13
|
import type { ColumnDef } from "@/components/data-table/types"
|
|
13
14
|
import { useTableState } from "@/components/data-table/use-table-state"
|
|
15
|
+
import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
|
|
14
16
|
import { TablePropertiesDrawerButton } from "@/components/table-properties"
|
|
15
17
|
import type { ConditionalRule, FilterFieldDef, FilterOperator } from "@/components/table-properties/types"
|
|
16
18
|
import { Button } from "@/components/ui/button"
|
|
@@ -281,7 +283,7 @@ function buildQuestionBankColumns(
|
|
|
281
283
|
<span className="truncate text-sm font-medium text-foreground">{row.author}</span>
|
|
282
284
|
{row.authorEmail ? (
|
|
283
285
|
<a
|
|
284
|
-
href={
|
|
286
|
+
href={mailtoHref(row.authorEmail)}
|
|
285
287
|
className="truncate text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
|
286
288
|
onClick={e => e.stopPropagation()}
|
|
287
289
|
>
|
|
@@ -382,25 +384,26 @@ function HubFolderColumnsPanel({
|
|
|
382
384
|
setSelectedPath(prev => [...prev.slice(0, depth), item])
|
|
383
385
|
}
|
|
384
386
|
|
|
385
|
-
// Auto-select first item at each level (only on first render)
|
|
387
|
+
// Auto-select first item at each level (only on first render). Intentional
|
|
388
|
+
// empty deps: we want this to run exactly once on mount; depending on the
|
|
389
|
+
// referenced values (folders / rows / selectedPath) would re-run on every
|
|
390
|
+
// edit and keep re-seeding the selection, undoing the user's choice.
|
|
386
391
|
React.useEffect(() => {
|
|
387
|
-
// Only auto-select if we're at a folder in the path and this is the first render
|
|
388
392
|
if (isFirstRenderRef.current && selectedPath.length > 0) {
|
|
389
393
|
const lastItem = selectedPath[selectedPath.length - 1]
|
|
390
394
|
if (isFolder(lastItem)) {
|
|
391
395
|
const folder = lastItem as QuestionBankFolder
|
|
392
|
-
// Get the items in this folder
|
|
393
396
|
const subfolders = folders.filter(f => f.parentId === folder.id).sort((a, b) => a.name.localeCompare(b.name))
|
|
394
397
|
const questionsInFolder = rows.filter(r => r.folderId === folder.id)
|
|
395
398
|
const items: HierarchyItem[] = [...subfolders, ...questionsInFolder]
|
|
396
399
|
|
|
397
|
-
// If there are items and nothing is selected at the next level, select the first item
|
|
398
400
|
if (items.length > 0 && !selectedPath[selectedPath.length + 1]) {
|
|
399
401
|
setSelectedPath(prev => [...prev, items[0]])
|
|
400
402
|
isFirstRenderRef.current = false
|
|
401
403
|
}
|
|
402
404
|
}
|
|
403
405
|
}
|
|
406
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
404
407
|
}, [])
|
|
405
408
|
|
|
406
409
|
// Build columns dynamically based on selected path
|
|
@@ -715,6 +718,28 @@ export const QuestionBankTable = React.forwardRef<
|
|
|
715
718
|
searchLanding ? undefined : urlListSearch,
|
|
716
719
|
)
|
|
717
720
|
|
|
721
|
+
// Persist this hub's table lifecycle (sort / search / filters / column
|
|
722
|
+
// visibility / etc.) to localStorage. See `lib/table-state-lifecycle`.
|
|
723
|
+
// NOTE: tabId is `"main"` here — the question-bank folder scope is
|
|
724
|
+
// already URL-driven (`?scope=`, `?folderId=`), so we only persist
|
|
725
|
+
// table chrome, not navigation.
|
|
726
|
+
const lifecycleColumnKeys = React.useMemo(
|
|
727
|
+
() => new Set(columns.map(c => c.key)),
|
|
728
|
+
[columns],
|
|
729
|
+
)
|
|
730
|
+
useTableStateLifecycle({
|
|
731
|
+
namespace: "question-bank",
|
|
732
|
+
tabId: "main",
|
|
733
|
+
tableState,
|
|
734
|
+
columnKeys: lifecycleColumnKeys,
|
|
735
|
+
extras: { conditionalRules },
|
|
736
|
+
onLoadExtras: e => {
|
|
737
|
+
if (e && Array.isArray(e.conditionalRules)) {
|
|
738
|
+
setConditionalRules(e.conditionalRules as ConditionalRule[])
|
|
739
|
+
}
|
|
740
|
+
},
|
|
741
|
+
})
|
|
742
|
+
|
|
718
743
|
const openNewFolderForColumn = React.useCallback((parentId: string | null) => {
|
|
719
744
|
setNewFolderParentId(parentId)
|
|
720
745
|
setCustomizingFolder(null)
|
|
@@ -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) => {
|
|
@@ -160,7 +180,7 @@ function QuestionBankPanel() {
|
|
|
160
180
|
className="text-xl font-semibold leading-tight text-sidebar-foreground"
|
|
161
181
|
style={{ fontFamily: "var(--font-heading)" }}
|
|
162
182
|
>
|
|
163
|
-
|
|
183
|
+
Library
|
|
164
184
|
</h2>
|
|
165
185
|
<Tip label="Collapse to icons" side="bottom">
|
|
166
186
|
<Button
|