@exxatdesignux/ui 0.2.14 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -1
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +3 -0
  4. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +3 -1
  5. package/consumer-extras/patterns/collaboration-access-pattern.md +2 -0
  6. package/package.json +1 -1
  7. package/src/components/ui/dropdown-menu.tsx +2 -0
  8. package/src/components/ui/popover.tsx +2 -2
  9. package/src/components/ui/select.tsx +1 -1
  10. package/src/components/ui/tooltip.tsx +7 -1
  11. package/src/globals.css +27 -2
  12. package/src/theme.css +4 -2
  13. package/template/AGENTS.md +6 -4
  14. package/template/app/(app)/question-bank/layout.tsx +11 -4
  15. package/template/app/globals.css +34 -2
  16. package/template/components/app-sidebar.tsx +89 -41
  17. package/template/components/ask-leo-sidebar.tsx +1 -2
  18. package/template/components/compliance-board-view.tsx +11 -3
  19. package/template/components/compliance-list-view.tsx +16 -3
  20. package/template/components/compliance-table.tsx +5 -1
  21. package/template/components/data-table/index.tsx +25 -11
  22. package/template/components/data-views/finder-panel-view.tsx +2 -2
  23. package/template/components/data-views/index.ts +19 -0
  24. package/template/components/data-views/list-page-split-details-placeholder.tsx +3 -3
  25. package/template/components/data-views/list-page-split-hub-tokens.ts +1 -1
  26. package/template/components/data-views/list-page-tree-column-header.tsx +1 -1
  27. package/template/components/data-views/outline-tree-menu.tsx +157 -0
  28. package/template/components/data-views/question-bank-folder-tree-branch.tsx +210 -0
  29. package/template/components/exxat-product-logo.tsx +11 -72
  30. package/template/components/folder-details-shell.tsx +1 -1
  31. package/template/components/hub-tree-panel-view.tsx +88 -80
  32. package/template/components/key-metrics.tsx +50 -13
  33. package/template/components/page-header.tsx +19 -10
  34. package/template/components/product-switcher.tsx +1 -4
  35. package/template/components/question-bank-board-view.tsx +11 -2
  36. package/template/components/question-bank-client.tsx +111 -69
  37. package/template/components/question-bank-list-view.tsx +12 -1
  38. package/template/components/question-bank-page-header.tsx +18 -2
  39. package/template/components/question-bank-secondary-nav.tsx +12 -225
  40. package/template/components/question-bank-table.tsx +6 -1
  41. package/template/components/secondary-panel.tsx +1 -1
  42. package/template/components/site-header.tsx +21 -2
  43. package/template/components/team-board-view.tsx +11 -3
  44. package/template/components/team-list-view.tsx +16 -3
  45. package/template/components/team-table.tsx +6 -2
  46. package/template/components/templates/dedicated-search-landing-template.tsx +86 -20
  47. package/template/components/templates/list-page.tsx +1 -3
  48. package/template/components/templates/nested-secondary-panel-shell.tsx +3 -4
  49. package/template/docs/collaboration-access-pattern.md +2 -0
  50. package/template/docs/question-bank-hub-header-pattern.md +25 -0
  51. package/template/hooks/use-secondary-panel-hub-nav.ts +17 -1
  52. package/template/lib/mock/navigation.tsx +30 -1
  53. package/template/lib/question-bank-nav.ts +26 -0
  54. package/template/package.json +3 -3
  55. package/template/components/command-menu-01.tsx +0 -133
  56. package/template/components/command-menu-02.tsx +0 -386
@@ -160,7 +160,7 @@ function QuestionBankPanel() {
160
160
  className="text-xl font-semibold leading-tight text-sidebar-foreground"
161
161
  style={{ fontFamily: "var(--font-heading)" }}
162
162
  >
163
- Question bank
163
+ Library
164
164
  </h2>
165
165
  <Tip label="Collapse to icons" side="bottom">
166
166
  <Button
@@ -5,10 +5,13 @@
5
5
  *
6
6
  * ✓ SidebarTrigger wrapped in Tooltip — icon-only button (WCAG 4.1.2, 1.1.1)
7
7
  * ✓ <header role="banner"> landmark for AT navigation (WCAG 1.3.6)
8
- * ✓ No bottom border (per design spec)
8
+ * ✓ Sticky at top when stuck, the rounded breadcrumb sits on the app bg and a
9
+ * bottom separator appears to anchor it; transparent at rest so the rounded
10
+ * corners blend into the inset card.
9
11
  * ✓ Uses Inter (font-sans) — Ivy Presto is reserved for PageHeader <h1> only
10
12
  */
11
13
 
14
+ import * as React from "react"
12
15
  import Link from "next/link"
13
16
  import { Separator } from "@/components/ui/separator"
14
17
  import { SidebarTrigger } from "@/components/ui/sidebar"
@@ -20,6 +23,7 @@ import {
20
23
  } from "@/components/ui/tooltip"
21
24
  import { AskLeoToggle } from "@/components/ask-leo-sidebar"
22
25
  import { useModKeyLabel } from "@/hooks/use-mod-key-label"
26
+ import { cn } from "@/lib/utils"
23
27
 
24
28
  export interface BreadcrumbItem {
25
29
  label: string
@@ -35,11 +39,25 @@ export interface SiteHeaderProps {
35
39
 
36
40
  export function SiteHeader({ title = "Dashboard", breadcrumbs }: SiteHeaderProps) {
37
41
  const mod = useModKeyLabel()
42
+ const [isStuck, setIsStuck] = React.useState(false)
43
+
44
+ React.useEffect(() => {
45
+ const onScroll = () => setIsStuck(window.scrollY > 0)
46
+ onScroll()
47
+ window.addEventListener("scroll", onScroll, { passive: true })
48
+ return () => window.removeEventListener("scroll", onScroll)
49
+ }, [])
38
50
 
39
51
  return (
52
+ <div
53
+ className={cn(
54
+ "sticky top-0 z-60 transition-colors",
55
+ isStuck ? "bg-sidebar border-b border-border" : "bg-transparent",
56
+ )}
57
+ >
40
58
  <header
41
59
  role="banner"
42
- className="flex h-(--header-height) shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)"
60
+ className="flex h-(--header-height) shrink-0 items-center gap-2 bg-background rounded-t-xl transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)"
43
61
  >
44
62
  <div className="flex w-full items-center gap-1 ps-4 pe-2 lg:gap-2 lg:ps-6 lg:pe-2">
45
63
  <Tooltip>
@@ -89,5 +107,6 @@ export function SiteHeader({ title = "Dashboard", breadcrumbs }: SiteHeaderProps
89
107
  </div>
90
108
  </div>
91
109
  </header>
110
+ </div>
92
111
  )
93
112
  }
@@ -61,9 +61,15 @@ function useTeamBoardModel(members: TeamMember[], groupByColumnKey: string) {
61
61
  }, [members, groupByColumnKey])
62
62
  }
63
63
 
64
- function TeamBoardCard({ member }: { member: TeamMember }) {
64
+ function TeamBoardCard({
65
+ member,
66
+ onRowActivate,
67
+ }: {
68
+ member: TeamMember
69
+ onRowActivate?: (member: TeamMember) => void
70
+ }) {
65
71
  return (
66
- <ListPageBoardCard className="w-full">
72
+ <ListPageBoardCard className="w-full" onClick={onRowActivate ? () => onRowActivate(member) : undefined}>
67
73
  <ListPageBoardCardHeader>
68
74
  <ListPageBoardCardTitleRow
69
75
  title={member.name}
@@ -94,9 +100,11 @@ export const TEAM_BOARD_GROUP_OPTIONS = [
94
100
  export function TeamBoardView({
95
101
  members,
96
102
  groupByColumnKey,
103
+ onRowActivate,
97
104
  }: {
98
105
  members: TeamMember[]
99
106
  groupByColumnKey: string
107
+ onRowActivate?: (member: TeamMember) => void
100
108
  }) {
101
109
  const key = groupByColumnKey === "role" ? "role" : "status"
102
110
  const { columns, badgeMap } = useTeamBoardModel(members, key)
@@ -108,7 +116,7 @@ export function TeamBoardView({
108
116
  getRowKey={m => m.id}
109
117
  columnCountBadgeClassName={badgeMap}
110
118
  emptyColumnLabel="No members"
111
- renderCard={member => <TeamBoardCard member={member} />}
119
+ renderCard={member => <TeamBoardCard member={member} onRowActivate={onRowActivate} />}
112
120
  />
113
121
  )
114
122
  }
@@ -14,12 +14,19 @@ import {
14
14
  } from "@/lib/list-status-badges"
15
15
  import type { TeamMember } from "@/lib/mock/team"
16
16
 
17
- function TeamListRow({ member }: { member: TeamMember }) {
17
+ function TeamListRow({
18
+ member,
19
+ onRowActivate,
20
+ }: {
21
+ member: TeamMember
22
+ onRowActivate?: (member: TeamMember) => void
23
+ }) {
18
24
  return (
19
25
  <li>
20
26
  <ListPageBoardCard
21
27
  layout="row"
22
28
  rowContainerClassName="flex flex-row items-center gap-3"
29
+ onClick={onRowActivate ? () => onRowActivate(member) : undefined}
23
30
  leading={<ListPageBoardCardAvatar initials={member.initials} className="size-9" />}
24
31
  rowEnd={
25
32
  <div className="flex shrink-0 items-center gap-2">
@@ -43,7 +50,13 @@ function TeamListRow({ member }: { member: TeamMember }) {
43
50
  )
44
51
  }
45
52
 
46
- export function TeamListView({ members }: { members: TeamMember[] }) {
53
+ export function TeamListView({
54
+ members,
55
+ onRowActivate,
56
+ }: {
57
+ members: TeamMember[]
58
+ onRowActivate?: (member: TeamMember) => void
59
+ }) {
47
60
  if (members.length === 0) {
48
61
  return (
49
62
  <div className="px-4 py-16 text-center lg:px-6">
@@ -55,7 +68,7 @@ export function TeamListView({ members }: { members: TeamMember[] }) {
55
68
  return (
56
69
  <ul className="flex list-none flex-col gap-2 px-4 pb-8 pt-2 lg:px-6">
57
70
  {members.map(m => (
58
- <TeamListRow key={m.id} member={m} />
71
+ <TeamListRow key={m.id} member={m} onRowActivate={onRowActivate} />
59
72
  ))}
60
73
  </ul>
61
74
  )
@@ -586,7 +586,7 @@ export const TeamTable = React.forwardRef<
586
586
  return (
587
587
  <div className="flex min-h-0 flex-1 flex-col">
588
588
  {sharedToolbar}
589
- <TeamListView members={tableState.rows} />
589
+ <TeamListView members={tableState.rows} onRowActivate={m => tableState.toggleRow(m.id)} />
590
590
  </div>
591
591
  )
592
592
  }
@@ -595,7 +595,11 @@ export const TeamTable = React.forwardRef<
595
595
  return (
596
596
  <div className="flex min-h-0 flex-1 flex-col">
597
597
  {sharedToolbar}
598
- <TeamBoardView members={tableState.rows} groupByColumnKey={teamBoardGroupKey} />
598
+ <TeamBoardView
599
+ members={tableState.rows}
600
+ groupByColumnKey={teamBoardGroupKey}
601
+ onRowActivate={m => tableState.toggleRow(m.id)}
602
+ />
599
603
  </div>
600
604
  )
601
605
  }
@@ -3,6 +3,8 @@
3
3
  import * as React from "react"
4
4
 
5
5
  import { ListPageViewFrame } from "@/components/data-views"
6
+ import { DotPattern } from "@/components/ui/dot-pattern"
7
+ import { cn } from "@/lib/utils"
6
8
 
7
9
  export interface DedicatedSearchLandingTemplateProps {
8
10
  /** Page title — string or rich node (e.g. styled heading). */
@@ -20,6 +22,67 @@ export interface DedicatedSearchLandingTemplateProps {
20
22
  const DEFAULT_GUTTER =
21
23
  "mx-auto flex min-h-[min(72vh,36rem)] w-full min-w-0 flex-col justify-center gap-0 px-6 py-8 sm:px-8 sm:py-10 md:px-12 md:py-12 lg:px-16"
22
24
 
25
+ /** Feather into page white / header so the hero never reads as a hard horizontal slab. */
26
+ const HERO_BACKDROP_MASK =
27
+ "[mask-image:linear-gradient(to_bottom,transparent_0%,black_5%,black_95%,transparent_100%)] [-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,black_5%,black_95%,transparent_100%)]"
28
+
29
+ /**
30
+ * Soft blurred blobs using only Ask Leo surface tints (`--leo-surface-tint-a|b` in `globals.css`).
31
+ */
32
+ function DedicatedSearchLandingBackdrop() {
33
+ return (
34
+ <div
35
+ aria-hidden
36
+ className={cn(
37
+ "pointer-events-none absolute inset-0 -z-10 select-none overflow-hidden",
38
+ HERO_BACKDROP_MASK,
39
+ )}
40
+ >
41
+ <div
42
+ className="absolute -left-[20%] -top-[30%] h-[min(54vmin,27rem)] w-[min(54vmin,27rem)] rounded-full blur-[76px]"
43
+ style={{
44
+ background: "radial-gradient(circle at 42% 36%, var(--leo-surface-tint-b) 0%, transparent 68%)",
45
+ }}
46
+ />
47
+ <div
48
+ className="absolute -right-[12%] top-[2%] h-[min(46vmin,23rem)] w-[min(46vmin,23rem)] rounded-full blur-[68px]"
49
+ style={{
50
+ background: "radial-gradient(circle at 48% 48%, var(--leo-surface-tint-a) 0%, transparent 66%)",
51
+ }}
52
+ />
53
+ <div
54
+ className="absolute bottom-[-16%] left-[14%] h-[min(50vmin,25rem)] w-[min(50vmin,25rem)] rounded-full blur-[84px]"
55
+ style={{
56
+ background: "radial-gradient(circle at 44% 40%, var(--leo-surface-tint-b) 0%, transparent 70%)",
57
+ }}
58
+ />
59
+ <div
60
+ className="absolute bottom-[4%] right-[6%] h-[min(40vmin,20rem)] w-[min(40vmin,20rem)] rounded-full blur-[60px]"
61
+ style={{
62
+ background: "radial-gradient(circle at 52% 44%, var(--leo-surface-tint-a) 0%, transparent 72%)",
63
+ }}
64
+ />
65
+ <div
66
+ className="absolute left-[36%] top-[32%] h-[min(38vmin,19rem)] w-[min(38vmin,19rem)] -translate-x-1/2 rounded-full blur-[74px]"
67
+ style={{
68
+ background: "radial-gradient(circle at 50% 50%, var(--leo-surface-tint-b) 0%, transparent 68%)",
69
+ }}
70
+ />
71
+
72
+ {/* Static dot field — same primitive as `AiThinkingOverlay` (no motion here). */}
73
+ <DotPattern
74
+ width={15}
75
+ height={15}
76
+ cr={0.65}
77
+ className={cn(
78
+ "absolute inset-0 opacity-[0.34] mix-blend-multiply dark:opacity-[0.22] dark:mix-blend-soft-light",
79
+ "fill-[color-mix(in_oklch,var(--brand-color)_14%,var(--background))]",
80
+ )}
81
+ />
82
+ </div>
83
+ )
84
+ }
85
+
23
86
  /**
24
87
  * Centered dedicated-search landing — empty `?q=` shell (hero title + composer + optional trailing).
25
88
  */
@@ -32,27 +95,30 @@ export function DedicatedSearchLandingTemplate({
32
95
  gutterClassName = DEFAULT_GUTTER,
33
96
  }: DedicatedSearchLandingTemplateProps) {
34
97
  return (
35
- <ListPageViewFrame
36
- maxWidthClassName={maxWidthClassName}
37
- className={frameClassName}
38
- gutterClassName={gutterClassName}
39
- >
40
- <header className="min-w-0">
41
- {typeof title === "string" ? (
42
- <h1
43
- className="text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-4xl"
44
- style={{ fontFamily: "var(--font-heading)" }}
45
- >
46
- {title}
47
- </h1>
48
- ) : (
49
- title
50
- )}
51
- </header>
98
+ <div className="relative isolate min-w-0 w-full overflow-hidden">
99
+ <DedicatedSearchLandingBackdrop />
100
+ <ListPageViewFrame
101
+ maxWidthClassName={maxWidthClassName}
102
+ className={cn("relative z-10", frameClassName)}
103
+ gutterClassName={gutterClassName}
104
+ >
105
+ <header className="min-w-0">
106
+ {typeof title === "string" ? (
107
+ <h1
108
+ className="text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-4xl"
109
+ style={{ fontFamily: "var(--font-heading)" }}
110
+ >
111
+ {title}
112
+ </h1>
113
+ ) : (
114
+ title
115
+ )}
116
+ </header>
52
117
 
53
- <div className="min-w-0 mt-6 sm:mt-8">{composer}</div>
118
+ <div className="min-w-0 mt-6 sm:mt-8">{composer}</div>
54
119
 
55
- {trailing ? <div className="min-w-0 mt-10 sm:mt-12 md:mt-14 lg:mt-16">{trailing}</div> : null}
56
- </ListPageViewFrame>
120
+ {trailing ? <div className="min-w-0 mt-10 sm:mt-12 md:mt-14 lg:mt-16">{trailing}</div> : null}
121
+ </ListPageViewFrame>
122
+ </div>
57
123
  )
58
124
  }
@@ -320,9 +320,7 @@ export function ListPageTemplate({
320
320
  const count = getTabCount?.(tab.filterId)
321
321
  const tabInner = (
322
322
  <>
323
- {isActive ? (
324
- <i className={`fa-light ${tab.icon} text-xs`} aria-hidden="true" />
325
- ) : null}
323
+ <i className={cn("fa-light shrink-0 text-xs", tab.icon)} aria-hidden="true" />
326
324
  {tab.label}
327
325
  {count !== undefined && (
328
326
  <span
@@ -14,8 +14,8 @@ export interface NestedSecondaryPanelShellProps {
14
14
  }
15
15
 
16
16
  /**
17
- * Shared chrome for a nested hub rail — full width vs icon rail, aligned with primary sidebar tokens.
18
- * Domain panels render their header + nav inside `children`.
17
+ * Shared chrome for a nested hub rail — full width vs icon rail.
18
+ * Fill uses `--secondary-panel-bg` (soft brand wash on `--background`).
19
19
  */
20
20
  export function NestedSecondaryPanelShell({
21
21
  open,
@@ -34,7 +34,7 @@ export function NestedSecondaryPanelShell({
34
34
  "transition-[width,margin,opacity] duration-200 ease-linear",
35
35
  open
36
36
  ? cn(
37
- "shrink-0 m-2 mx-2 rounded-xl ring-1 ring-sidebar-border shadow-sm relative md:sticky md:top-2",
37
+ "shrink-0 m-2 mx-2 rounded-xl ring-1 ring-border shadow-sm relative md:sticky md:top-2 bg-[var(--secondary-panel-bg)]",
38
38
  compact
39
39
  ? "w-12 min-w-12 max-w-12 h-[min(calc(100svh-2rem),800px)] md:h-[min(calc(100svh-1rem),800px)]"
40
40
  : "w-64 min-w-64 max-w-64 h-[min(calc(100svh-2rem),800px)] md:h-[min(calc(100svh-1rem),800px)]",
@@ -42,7 +42,6 @@ export function NestedSecondaryPanelShell({
42
42
  : "h-0 min-h-0 shrink overflow-hidden border-0 p-0 m-0 min-w-0 w-0 max-w-0 opacity-0 pointer-events-none",
43
43
  className,
44
44
  )}
45
- style={open ? { backgroundColor: "var(--secondary-panel-bg, #FAFBFF)" } : undefined}
46
45
  >
47
46
  <div
48
47
  className={cn(
@@ -2,6 +2,8 @@
2
2
 
3
3
  Shared UI for **who can access a hub** (face stack in the header) and **inviting people** (floating sheet). **Reference:** Question bank — `QuestionBankPageHeader`, `QuestionBankClient`, `InviteCollaboratorsDrawer`.
4
4
 
5
+ **Folder-scoped question bank:** When the library URL selects a folder (`?scope=folder&folderId=`), the same header **⋯ More** menu also exposes **Customize folder** (name / color / icon) via **`QuestionBankNewFolderSheet`** mounted on **`QuestionBankClient`** so it works on every view tab. See **`docs/question-bank-hub-header-pattern.md`** and **`.cursor/rules/exxat-question-bank-hub-header.mdc`**.
6
+
5
7
  ## When to use
6
8
 
7
9
  - A list hub or library is **shared** across people (not a private directory).
@@ -0,0 +1,25 @@
1
+ # Question bank hub header — folder scope + Customize folder
2
+
3
+ **Audience:** Engineers extending the question bank library hub (`QuestionBankClient`, `QuestionBankPageHeader`, URL scope).
4
+
5
+ ## Problem
6
+
7
+ The library uses **`ListPageTemplate`** with multiple **view tabs** (table, panel, tree, …). **`QuestionBankNewFolderSheet`** (customize mode) is also used inside **`QuestionBankTable`** for some views (e.g. panel columns). If **Customize folder** exists only there, users on **table** or other tabs **cannot** open the sheet from a consistent chrome entry point when the URL is scoped to a folder (`?scope=folder&folderId=…`).
8
+
9
+ ## Pattern
10
+
11
+ 1. **`QuestionBankPageHeader`** exposes optional **`onCustomizeFolder?: () => void`**. When **`navState.scope === "folder"`** and **`navState.folderId`** is set, the hub client passes a callback that opens customize mode for the matching **`QuestionBankFolder`**.
12
+ 2. **`QuestionBankClient`** (or equivalent hub client) mounts **`QuestionBankNewFolderSheet`** **once** beside **`SecondaryPanelHubTemplate` / `ListPageTemplate`**, with local state for **`open`** and **`customizingFolder`**. Saving updates **`folders`** the same way as table-embedded customize flows.
13
+ 3. The header **⋯ More** menu order stays aligned with **§4.7**: **Invite people** (when collaboration variant) → **Customize folder** (when folder-scoped) → **Export** → **Show / hide metric section** (when applicable).
14
+
15
+ ## References
16
+
17
+ | Piece | Location |
18
+ |-------|-----------|
19
+ | Header prop + menu item | `components/question-bank-page-header.tsx` |
20
+ | Client wiring + sheet | `components/question-bank-client.tsx` |
21
+ | URL scope | `lib/question-bank-nav.ts` (`parseQuestionBankNav`, `QuestionBankNavState`) |
22
+ | Sheet UI | `components/question-bank-new-folder-sheet.tsx` |
23
+
24
+ **Cursor rule:** `.cursor/rules/exxat-question-bank-hub-header.mdc`
25
+ **Handbook:** `AGENTS.md` §4.6 (folder-scoped hub chrome).
@@ -36,6 +36,11 @@ export interface UseSecondaryPanelHubNavOptions<TNav> {
36
36
  canonicalHref?: (searchParams: URLSearchParams) => string | null
37
37
  /** Re-open the secondary panel when the user returns to the default scope (e.g. All questions). */
38
38
  shouldReopenPanel?: (nav: TNav) => boolean
39
+ /**
40
+ * When set, auto-reopen only runs on these pathnames (e.g. library hub, not dedicated search landings).
41
+ * Omit to keep legacy behavior: any {@link hubPathnames} match may reopen the panel.
42
+ */
43
+ reopenPanelOnPathnames?: readonly string[]
39
44
  }
40
45
 
41
46
  /**
@@ -48,6 +53,7 @@ export function useSecondaryPanelHubNav<TNav>({
48
53
  parseNav,
49
54
  canonicalHref,
50
55
  shouldReopenPanel,
56
+ reopenPanelOnPathnames,
51
57
  }: UseSecondaryPanelHubNavOptions<TNav>) {
52
58
  const pathname = usePathname()
53
59
  const router = useRouter()
@@ -90,9 +96,19 @@ export function useSecondaryPanelHubNav<TNav>({
90
96
 
91
97
  React.useEffect(() => {
92
98
  if (!isHubPath || !shouldReopenPanel?.(navState)) return
99
+ if (reopenPanelOnPathnames?.length && !reopenPanelOnPathnames.includes(pathname)) return
93
100
  if (activePanel === panelId) return
94
101
  openPanel(panelId)
95
- }, [activePanel, isHubPath, navState, openPanel, panelId, shouldReopenPanel])
102
+ }, [
103
+ activePanel,
104
+ isHubPath,
105
+ navState,
106
+ openPanel,
107
+ panelId,
108
+ pathname,
109
+ reopenPanelOnPathnames,
110
+ shouldReopenPanel,
111
+ ])
96
112
 
97
113
  return { navState, searchParamsKey, hubPathname, hubBasePath, pathname, isHubPath }
98
114
  }
@@ -7,6 +7,11 @@ import type * as React from "react"
7
7
 
8
8
  import { logoDevUrl } from "@/lib/logo-dev"
9
9
  import { stockPortraitUrl } from "@/lib/stock-portrait"
10
+ import {
11
+ QUESTION_BANK_ENTRY_PATH,
12
+ QUESTION_BANK_HUB_FIND_PATH,
13
+ QUESTION_BANK_LIBRARY_PATH,
14
+ } from "@/lib/question-bank-nav"
10
15
 
11
16
  // ── Types ─────────────────────────────────────────────────────────────────────
12
17
 
@@ -97,10 +102,34 @@ export const NAV_PRIMARY: NavLinkItem[] = [
97
102
  {
98
103
  key: "question-bank",
99
104
  title: "Question bank",
100
- url: "/question-bank",
105
+ url: QUESTION_BANK_ENTRY_PATH,
101
106
  icon: <i className="fa-light fa-books" aria-hidden="true" />,
102
107
  iconActive: <i className="fa-solid fa-books" aria-hidden="true" />,
103
108
  secondaryPanel: "question-bank",
109
+ primaryHubChildKey: "question-bank-hub",
110
+ children: [
111
+ {
112
+ key: "question-bank-hub",
113
+ title: "Question hub",
114
+ url: QUESTION_BANK_ENTRY_PATH,
115
+ icon: <i className="fa-light fa-sparkles" aria-hidden="true" />,
116
+ iconActive: <i className="fa-solid fa-sparkles" aria-hidden="true" />,
117
+ },
118
+ {
119
+ key: "question-bank-search",
120
+ title: "Search",
121
+ url: QUESTION_BANK_HUB_FIND_PATH,
122
+ icon: <i className="fa-light fa-magnifying-glass" aria-hidden="true" />,
123
+ iconActive: <i className="fa-solid fa-magnifying-glass" aria-hidden="true" />,
124
+ },
125
+ {
126
+ key: "question-bank-library",
127
+ title: "Library",
128
+ url: QUESTION_BANK_LIBRARY_PATH,
129
+ icon: <i className="fa-light fa-table-list" aria-hidden="true" />,
130
+ iconActive: <i className="fa-solid fa-table-list" aria-hidden="true" />,
131
+ },
132
+ ],
104
133
  },
105
134
  {
106
135
  key: "data-list",
@@ -54,6 +54,32 @@ export function isQuestionBankDedicatedSearchPathname(pathname: string): boolean
54
54
  return pathname === QUESTION_BANK_LIST_PATH || pathname === QUESTION_BANK_HUB_FIND_PATH
55
55
  }
56
56
 
57
+ /**
58
+ * Whether a secondary-nav row (All / My / folder / Search) matches the current URL + parsed nav.
59
+ * Used by `QuestionBankSecondaryNav` and the folder tree branch.
60
+ */
61
+ export function isQuestionBankNavActive(
62
+ pathname: string,
63
+ nav: QuestionBankNavState,
64
+ scope: QuestionBankNavScope,
65
+ folderId?: string | null,
66
+ ): boolean {
67
+ const p = pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname
68
+ if (!QUESTION_BANK_LIBRARY_HUB_PATHS.includes(p)) return false
69
+ if (scope === "all") {
70
+ if (isQuestionBankDedicatedSearchPathname(pathname)) return false
71
+ return nav.scope === "all"
72
+ }
73
+ if (scope === "my") {
74
+ if (isQuestionBankDedicatedSearchPathname(pathname)) return false
75
+ return nav.scope === "my"
76
+ }
77
+ if (scope === "folder" && folderId) {
78
+ return nav.scope === "folder" && nav.folderId === folderId
79
+ }
80
+ return false
81
+ }
82
+
57
83
  /** Default secondary-nav selection — All questions (no `scope` query). */
58
84
  export const QUESTION_BANK_DEFAULT_NAV: QuestionBankNavState = {
59
85
  scope: "all",
@@ -42,7 +42,7 @@
42
42
  "cmdk": "^1.1.1",
43
43
  "lucide-react": "^0.577.0",
44
44
  "motion": "^12.38.0",
45
- "next": "16.2.4",
45
+ "next": "16.2.6",
46
46
  "next-themes": "^0.4.6",
47
47
  "react": "^19.2.4",
48
48
  "react-day-picker": "^9.14.0",
@@ -60,7 +60,7 @@
60
60
  },
61
61
  "devDependencies": {
62
62
  "@eslint/eslintrc": "^3",
63
- "@next/bundle-analyzer": "^16.2.1",
63
+ "@next/bundle-analyzer": "16.2.6",
64
64
  "@tailwindcss/postcss": "^4.2.1",
65
65
  "@testing-library/jest-dom": "^6.9.1",
66
66
  "@testing-library/react": "^16.3.0",
@@ -69,7 +69,7 @@
69
69
  "@types/react-dom": "^19.2.3",
70
70
  "@vitejs/plugin-react": "^4.7.0",
71
71
  "eslint": "^9.39.4",
72
- "eslint-config-next": "16.1.7",
72
+ "eslint-config-next": "16.2.6",
73
73
  "jsdom": "^26.1.0",
74
74
  "pm2": "^6.0.14",
75
75
  "postcss": "^8",
@@ -1,133 +0,0 @@
1
- "use client";
2
-
3
- import {
4
- IconBell,
5
- IconBolt,
6
- IconCalendar,
7
- IconChartBar,
8
- IconChartPie,
9
- IconClock,
10
- IconFileText,
11
- IconHelp,
12
- IconKeyboard,
13
- IconLayoutDashboard,
14
- IconLayoutKanban,
15
- IconLogout,
16
- IconMessage,
17
- IconPalette,
18
- IconSettings,
19
- IconSquareCheck,
20
- IconTarget,
21
- IconTrendingUp,
22
- IconUsers,
23
- } from "@tabler/icons-react";
24
- import { useEffect, useState } from "react";
25
- import { Button } from "@/components/ui/button";
26
- import {
27
- Command,
28
- CommandDialog,
29
- CommandEmpty,
30
- CommandGroup,
31
- CommandInput,
32
- CommandItem,
33
- CommandList,
34
- } from "@/components/ui/command";
35
- import { Kbd } from "@/components/ui/kbd";
36
-
37
- const workspaceItems = [
38
- { icon: IconLayoutDashboard, label: "Dashboard" },
39
- { icon: IconLayoutKanban, label: "Projects" },
40
- { icon: IconSquareCheck, label: "Tasks" },
41
- { icon: IconCalendar, label: "Calendar" },
42
- { icon: IconUsers, label: "Team members" },
43
- { icon: IconMessage, label: "Messages" },
44
- { icon: IconFileText, label: "Documents" },
45
- { icon: IconBell, label: "Notifications" },
46
- { icon: IconClock, label: "Time tracking" },
47
- { icon: IconTarget, label: "Goals" },
48
- ];
49
-
50
- const analyticsItems = [
51
- { icon: IconChartBar, label: "Overview" },
52
- { icon: IconTrendingUp, label: "Performance" },
53
- { icon: IconChartPie, label: "Reports" },
54
- { icon: IconBolt, label: "Insights" },
55
- ];
56
-
57
- const settingsItems = [
58
- { icon: IconSettings, label: "Preferences" },
59
- { icon: IconPalette, label: "Appearance" },
60
- { icon: IconKeyboard, label: "Keyboard shortcuts" },
61
- { icon: IconHelp, label: "Help & support" },
62
- { icon: IconLogout, label: "Sign out" },
63
- ];
64
-
65
- export function CommandMenu01() {
66
- const [open, setOpen] = useState(true);
67
-
68
- useEffect(() => {
69
- const down = (e: KeyboardEvent) => {
70
- if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
71
- e.preventDefault();
72
- setOpen((prev) => !prev);
73
- }
74
- };
75
-
76
- document.addEventListener("keydown", down);
77
- return () => document.removeEventListener("keydown", down);
78
- }, []);
79
-
80
- return (
81
- <>
82
- <Button onClick={() => setOpen(true)} variant="outline">
83
- Open Command Menu
84
- </Button>
85
-
86
- <CommandDialog onOpenChange={setOpen} open={open} showCloseButton={false}>
87
- <Command className="rounded-none border-0 bg-transparent shadow-none">
88
- <CommandInput
89
- className="h-12"
90
- placeholder="Type a command or search..."
91
- />
92
- <CommandList className="min-h-[min(420px,50vh)] max-h-[min(560px,65vh)]">
93
- <CommandEmpty>No results found.</CommandEmpty>
94
- <CommandGroup heading="Workspace">
95
- {workspaceItems.map((item) => (
96
- <CommandItem key={item.label}>
97
- <item.icon className="me-2 h-5 w-5" />
98
- <span>{item.label}</span>
99
- </CommandItem>
100
- ))}
101
- </CommandGroup>
102
- <CommandGroup heading="Analytics">
103
- {analyticsItems.map((item) => (
104
- <CommandItem key={item.label}>
105
- <item.icon className="me-2 h-5 w-5" />
106
- <span>{item.label}</span>
107
- </CommandItem>
108
- ))}
109
- </CommandGroup>
110
- <CommandGroup heading="Settings">
111
- {settingsItems.map((item) => (
112
- <CommandItem key={item.label}>
113
- <item.icon className="me-2 h-5 w-5" />
114
- <span>{item.label}</span>
115
- </CommandItem>
116
- ))}
117
- </CommandGroup>
118
- </CommandList>
119
- </Command>
120
- <div className="flex h-12 items-center justify-end border-t px-3">
121
- <button
122
- className="flex items-center gap-1 text-muted-foreground text-sm hover:text-foreground"
123
- onClick={() => setOpen(false)}
124
- type="button"
125
- >
126
- <span>Close</span>
127
- <Kbd className="ms-1">Esc</Kbd>
128
- </button>
129
- </div>
130
- </CommandDialog>
131
- </>
132
- );
133
- }