@exxatdesignux/ui 0.2.6 → 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/package.json +2 -1
- 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/placements-page-header.tsx +14 -8
- package/template/components/placements-table-columns.tsx +8 -8
- package/template/components/question-bank-client.tsx +157 -40
- 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 +3 -3
- 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-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,31 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
export interface ListPageTreeColumnHeaderProps {
|
|
7
|
+
title: string
|
|
8
|
+
/** Right side (e.g. icon buttons) — keep touch targets ≥ 24px */
|
|
9
|
+
trailing?: React.ReactNode
|
|
10
|
+
className?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Shared left-column header for tree / outline surfaces — matches Question bank “Questions” bar.
|
|
15
|
+
*/
|
|
16
|
+
export function ListPageTreeColumnHeader({
|
|
17
|
+
title,
|
|
18
|
+
trailing,
|
|
19
|
+
className,
|
|
20
|
+
}: ListPageTreeColumnHeaderProps) {
|
|
21
|
+
return (
|
|
22
|
+
<div className={cn("shrink-0 border-b border-border/50 bg-muted/10 px-3 py-2", className)}>
|
|
23
|
+
<div className="flex h-9 items-center justify-between gap-2">
|
|
24
|
+
<h3 className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{title}</h3>
|
|
25
|
+
{trailing ? (
|
|
26
|
+
<div className="flex shrink-0 items-center gap-0.5">{trailing}</div>
|
|
27
|
+
) : null}
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generic two-pane layout: scrollable **tree / outline** column + **details** column,
|
|
5
|
+
* with persisted split sizes (`ResizablePanelGroup` `id`) and shared **split hub chrome**
|
|
6
|
+
* (`ListPageSplitHubChrome`) so tree views match finder / folder panels across the app.
|
|
7
|
+
*
|
|
8
|
+
* Domain hubs pass `tree` and `details` nodes; this module stays entity-agnostic.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as React from "react"
|
|
12
|
+
import {
|
|
13
|
+
ResizableHandle,
|
|
14
|
+
ResizablePanel,
|
|
15
|
+
ResizablePanelGroup,
|
|
16
|
+
} from "@/components/ui/resizable"
|
|
17
|
+
import { ListPageSplitHubChrome } from "@/components/data-views/list-page-split-hub-chrome"
|
|
18
|
+
import {
|
|
19
|
+
LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS,
|
|
20
|
+
LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS,
|
|
21
|
+
LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS,
|
|
22
|
+
} from "@/components/data-views/list-page-split-hub-tokens"
|
|
23
|
+
import {
|
|
24
|
+
LIST_PAGE_VIEW_FRAME_GUTTER,
|
|
25
|
+
LIST_PAGE_VIEW_FRAME_MAX_WIDE,
|
|
26
|
+
} from "@/components/data-views/list-page-view-frame"
|
|
27
|
+
|
|
28
|
+
export interface ListPageTreePanelShellProps {
|
|
29
|
+
/** Stable id for `react-resizable-panels` layout persistence (per hub / route). */
|
|
30
|
+
resizableGroupId: string
|
|
31
|
+
/** Left column (tree chrome + body). */
|
|
32
|
+
tree: React.ReactNode
|
|
33
|
+
/** Right column (detail / inspector). */
|
|
34
|
+
details: React.ReactNode
|
|
35
|
+
/** Accessible name for the split surface, e.g. “Curriculum tree and details”. */
|
|
36
|
+
ariaLabel: string
|
|
37
|
+
treePanelId?: string
|
|
38
|
+
detailsPanelId?: string
|
|
39
|
+
treeDefaultSize?: string
|
|
40
|
+
treeMinSize?: string
|
|
41
|
+
treeMaxSize?: string
|
|
42
|
+
detailsDefaultSize?: string
|
|
43
|
+
detailsMinSize?: string
|
|
44
|
+
gutterClassName?: string
|
|
45
|
+
maxWidthClassName?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ListPageTreePanelShell({
|
|
49
|
+
resizableGroupId,
|
|
50
|
+
tree,
|
|
51
|
+
details,
|
|
52
|
+
ariaLabel,
|
|
53
|
+
treePanelId = "tree",
|
|
54
|
+
detailsPanelId = "details",
|
|
55
|
+
treeDefaultSize = "40%",
|
|
56
|
+
treeMinSize = "20%",
|
|
57
|
+
treeMaxSize = "60%",
|
|
58
|
+
detailsDefaultSize = "60%",
|
|
59
|
+
detailsMinSize = "30%",
|
|
60
|
+
gutterClassName = LIST_PAGE_VIEW_FRAME_GUTTER,
|
|
61
|
+
maxWidthClassName = LIST_PAGE_VIEW_FRAME_MAX_WIDE,
|
|
62
|
+
}: ListPageTreePanelShellProps) {
|
|
63
|
+
return (
|
|
64
|
+
<ListPageSplitHubChrome
|
|
65
|
+
aria-label={ariaLabel}
|
|
66
|
+
gutterClassName={gutterClassName}
|
|
67
|
+
maxWidthClassName={maxWidthClassName}
|
|
68
|
+
>
|
|
69
|
+
<ResizablePanelGroup id={resizableGroupId} direction="horizontal" className="h-full min-h-0 w-full flex-1">
|
|
70
|
+
<ResizablePanel
|
|
71
|
+
id={treePanelId}
|
|
72
|
+
defaultSize={treeDefaultSize}
|
|
73
|
+
minSize={treeMinSize}
|
|
74
|
+
maxSize={treeMaxSize}
|
|
75
|
+
className={LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS}
|
|
76
|
+
>
|
|
77
|
+
{tree}
|
|
78
|
+
</ResizablePanel>
|
|
79
|
+
<ResizableHandle withHandle className={LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS} />
|
|
80
|
+
<ResizablePanel
|
|
81
|
+
id={detailsPanelId}
|
|
82
|
+
defaultSize={detailsDefaultSize}
|
|
83
|
+
minSize={detailsMinSize}
|
|
84
|
+
className={LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS}
|
|
85
|
+
>
|
|
86
|
+
{details}
|
|
87
|
+
</ResizablePanel>
|
|
88
|
+
</ResizablePanelGroup>
|
|
89
|
+
</ListPageSplitHubChrome>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ListPageViewFrame — shared horizontal gutter + optional centered max-width for list-hub **view bodies**
|
|
5
|
+
* (folder icon grid, finder panel chrome, OS-style folder explorer, dashboard slices, etc.).
|
|
6
|
+
*
|
|
7
|
+
* **MUST** be used instead of ad-hoc `mx-4 lg:mx-6` + `mx-auto max-w-*` pairs on each page — see
|
|
8
|
+
* `AGENTS.md` §4.5 and `.cursor/rules/exxat-list-page-view-shells.mdc`.
|
|
9
|
+
*
|
|
10
|
+
* **MUST NOT** wrap `DataTable` when its toolbar already applies the same inset (avoid double gutter);
|
|
11
|
+
* use this for **non-table** view branches or **sections below** the shared toolbar.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as React from "react"
|
|
15
|
+
import { cn } from "@/lib/utils"
|
|
16
|
+
|
|
17
|
+
/** Default horizontal rhythm for view bodies under `ListPageTemplate` (matches `FolderGridView`). */
|
|
18
|
+
export const LIST_PAGE_VIEW_FRAME_GUTTER = "mx-4 mb-6 lg:mx-6"
|
|
19
|
+
|
|
20
|
+
/** Typical max width for icon grids / dense tile views on ultra-wide monitors. */
|
|
21
|
+
export const LIST_PAGE_VIEW_FRAME_MAX_ICON_GRID = "max-w-6xl"
|
|
22
|
+
|
|
23
|
+
/** Slightly wider shell when a view includes toolbar + breadcrumbs + grid (e.g. OS folder explorer). */
|
|
24
|
+
export const LIST_PAGE_VIEW_FRAME_MAX_WIDE = "max-w-7xl"
|
|
25
|
+
|
|
26
|
+
export interface ListPageViewFrameProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
27
|
+
children: React.ReactNode
|
|
28
|
+
/**
|
|
29
|
+
* When set, children are wrapped in `mx-auto w-full min-w-0` + this max-width so the block stays
|
|
30
|
+
* centered inside the primary page column.
|
|
31
|
+
*/
|
|
32
|
+
maxWidthClassName?: string
|
|
33
|
+
/** Override outer gutter; default `LIST_PAGE_VIEW_FRAME_GUTTER`. */
|
|
34
|
+
gutterClassName?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ListPageViewFrame({
|
|
38
|
+
children,
|
|
39
|
+
className,
|
|
40
|
+
maxWidthClassName,
|
|
41
|
+
gutterClassName = LIST_PAGE_VIEW_FRAME_GUTTER,
|
|
42
|
+
...rest
|
|
43
|
+
}: ListPageViewFrameProps) {
|
|
44
|
+
return (
|
|
45
|
+
<div className={cn(gutterClassName, className)} {...rest}>
|
|
46
|
+
{maxWidthClassName ? (
|
|
47
|
+
<div className={cn("mx-auto w-full min-w-0", maxWidthClassName)}>{children}</div>
|
|
48
|
+
) : (
|
|
49
|
+
children
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Windows 11–style folder art (Icons8) + optional FA glyph on the pocket.
|
|
5
|
+
* Static asset: `public/folders/icons8-folder-windows-11.svg`
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as React from "react"
|
|
9
|
+
import { cn } from "@/lib/utils"
|
|
10
|
+
import type { QuestionBankFolderColorKey } from "@/lib/mock/question-bank-folders"
|
|
11
|
+
|
|
12
|
+
/** Served from `apps/web/public/folders/` (copied from Icons8 “folder windows 11 color”). */
|
|
13
|
+
export const OS_FOLDER_GLYPH_SRC = "/folders/icons8-folder-windows-11.svg"
|
|
14
|
+
|
|
15
|
+
/** Subtle hue tweak so “color” choice still reads on the shared yellow asset. */
|
|
16
|
+
const COLOR_TINT_FILTER: Record<QuestionBankFolderColorKey, string> = {
|
|
17
|
+
brand: "hue-rotate(-42deg) saturate(1.25) brightness(0.97)",
|
|
18
|
+
success: "hue-rotate(82deg) saturate(1.2) brightness(0.95)",
|
|
19
|
+
warning: "hue-rotate(-5deg) saturate(1.35) brightness(1.02)",
|
|
20
|
+
destructive: "hue-rotate(300deg) saturate(1.15) brightness(0.92)",
|
|
21
|
+
muted: "saturate(0.15) brightness(1.08)",
|
|
22
|
+
chart1: "hue-rotate(200deg) saturate(1.2) brightness(0.96)",
|
|
23
|
+
chart2: "hue-rotate(95deg) saturate(1.15) brightness(0.96)",
|
|
24
|
+
chart3: "hue-rotate(265deg) saturate(1.2) brightness(0.96)",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SIZE_MAP = {
|
|
28
|
+
/** Compact — folder inspector / column headers (matches ~36px row height). */
|
|
29
|
+
xs: "h-9 w-[2.6rem]",
|
|
30
|
+
sm: "h-[3.35rem] w-[3.85rem]",
|
|
31
|
+
md: "h-[4.6rem] w-[5.25rem]",
|
|
32
|
+
lg: "h-[6.5rem] w-[7.25rem]",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ICON_TEXT: Record<keyof typeof SIZE_MAP, string> = {
|
|
36
|
+
xs: "text-[13px] leading-none",
|
|
37
|
+
sm: "text-lg",
|
|
38
|
+
md: "text-2xl",
|
|
39
|
+
lg: "text-4xl",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Darker version of each folder color for punched icon appearance. */
|
|
43
|
+
const ICON_COLOR: Record<QuestionBankFolderColorKey, string> = {
|
|
44
|
+
brand: "text-orange-800 dark:text-orange-600",
|
|
45
|
+
success: "text-emerald-800 dark:text-emerald-600",
|
|
46
|
+
warning: "text-amber-800 dark:text-amber-600",
|
|
47
|
+
destructive: "text-red-800 dark:text-red-600",
|
|
48
|
+
muted: "text-slate-600 dark:text-slate-400",
|
|
49
|
+
chart1: "text-blue-800 dark:text-blue-600",
|
|
50
|
+
chart2: "text-lime-800 dark:text-lime-600",
|
|
51
|
+
chart3: "text-purple-800 dark:text-purple-600",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface OsFolderGlyphProps {
|
|
55
|
+
colorKey: QuestionBankFolderColorKey
|
|
56
|
+
/** Font Awesome icon classes without weight (e.g. `fa-stethoscope`). */
|
|
57
|
+
icon: string
|
|
58
|
+
size?: keyof typeof SIZE_MAP
|
|
59
|
+
className?: string
|
|
60
|
+
variant?: "solid" | "outline"
|
|
61
|
+
/**
|
|
62
|
+
* When false, exposes `role="img"` + `aria-label` (use with a short label, e.g. sheet preview).
|
|
63
|
+
* When true (default), hides the glyph from AT — parent control should name the action.
|
|
64
|
+
*/
|
|
65
|
+
decorative?: boolean
|
|
66
|
+
/** Required when `decorative={false}` */
|
|
67
|
+
label?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function OsFolderGlyph({
|
|
71
|
+
colorKey,
|
|
72
|
+
icon,
|
|
73
|
+
size = "md",
|
|
74
|
+
className,
|
|
75
|
+
variant = "solid",
|
|
76
|
+
decorative = true,
|
|
77
|
+
label,
|
|
78
|
+
}: OsFolderGlyphProps) {
|
|
79
|
+
const outline = variant === "outline"
|
|
80
|
+
const tint = COLOR_TINT_FILTER[colorKey]
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
className={cn(
|
|
85
|
+
"group relative shrink-0 select-none transition-[transform,box-shadow] duration-200 ease-out",
|
|
86
|
+
"hover:z-[1] hover:scale-105 motion-reduce:transform-none motion-reduce:hover:scale-100",
|
|
87
|
+
SIZE_MAP[size],
|
|
88
|
+
className,
|
|
89
|
+
)}
|
|
90
|
+
role={!decorative && label ? "img" : undefined}
|
|
91
|
+
aria-label={!decorative ? label : undefined}
|
|
92
|
+
aria-hidden={decorative ? true : undefined}
|
|
93
|
+
>
|
|
94
|
+
<img
|
|
95
|
+
src={OS_FOLDER_GLYPH_SRC}
|
|
96
|
+
alt=""
|
|
97
|
+
width={240}
|
|
98
|
+
height={240}
|
|
99
|
+
draggable={false}
|
|
100
|
+
className={cn(
|
|
101
|
+
"h-full w-full object-contain",
|
|
102
|
+
"transition-[filter] duration-200",
|
|
103
|
+
outline && "opacity-75 saturate-[0.65]",
|
|
104
|
+
)}
|
|
105
|
+
style={outline ? undefined : { filter: tint }}
|
|
106
|
+
/>
|
|
107
|
+
<span
|
|
108
|
+
className={cn(
|
|
109
|
+
"pointer-events-none absolute inset-0 flex items-center justify-center",
|
|
110
|
+
size === "xs" ? "translate-y-[0.18rem]" : "translate-y-[0.35rem]",
|
|
111
|
+
ICON_TEXT[size],
|
|
112
|
+
outline
|
|
113
|
+
? "text-muted-foreground"
|
|
114
|
+
: cn(ICON_COLOR[colorKey], "opacity-100"),
|
|
115
|
+
)}
|
|
116
|
+
>
|
|
117
|
+
<i className={cn("fa-solid", icon)} aria-hidden="true" />
|
|
118
|
+
</span>
|
|
119
|
+
</div>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Folder details panel — OsFolderGlyph header + aggregates (`question-bank-inspector` helpers today).
|
|
5
|
+
* Reusable across list hubs that share `QuestionBankFolder` / `QuestionBankItem` shapes or adapters.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as React from "react"
|
|
9
|
+
import { ChevronRightIcon, X } from "lucide-react"
|
|
10
|
+
import { OsFolderGlyph } from "@/components/data-views/os-folder-glyph"
|
|
11
|
+
import { Button } from "@/components/ui/button"
|
|
12
|
+
import { Separator } from "@/components/ui/separator"
|
|
13
|
+
import { Tip } from "@/components/ui/tip"
|
|
14
|
+
import { cn } from "@/lib/utils"
|
|
15
|
+
import type { QuestionBankItem } from "@/lib/mock/question-bank"
|
|
16
|
+
import type { QuestionBankFolder } from "@/lib/mock/question-bank-folders"
|
|
17
|
+
import {
|
|
18
|
+
aggregateFolderQuestions,
|
|
19
|
+
BLOOM_LEVEL_ORDER,
|
|
20
|
+
questionsInFolderSubtree,
|
|
21
|
+
} from "@/lib/mock/question-bank-inspector"
|
|
22
|
+
|
|
23
|
+
function DetailBreadcrumbNav({ segments }: { segments: { id: string; label: string }[] }) {
|
|
24
|
+
if (segments.length === 0) return null
|
|
25
|
+
return (
|
|
26
|
+
<nav aria-label="Path in folder tree" className="flex min-w-0 flex-wrap items-center gap-1 text-xs text-muted-foreground">
|
|
27
|
+
{segments.map((seg, idx) => (
|
|
28
|
+
<React.Fragment key={seg.id}>
|
|
29
|
+
{idx > 0 && <ChevronRightIcon className="size-3 shrink-0 opacity-40" aria-hidden />}
|
|
30
|
+
<span
|
|
31
|
+
className={cn(
|
|
32
|
+
"min-w-0 truncate",
|
|
33
|
+
idx === segments.length - 1 ? "font-medium text-foreground" : "text-muted-foreground",
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
{seg.label}
|
|
37
|
+
</span>
|
|
38
|
+
</React.Fragment>
|
|
39
|
+
))}
|
|
40
|
+
</nav>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function DetailSection({
|
|
45
|
+
title,
|
|
46
|
+
children,
|
|
47
|
+
className,
|
|
48
|
+
}: {
|
|
49
|
+
title: string
|
|
50
|
+
children: React.ReactNode
|
|
51
|
+
className?: string
|
|
52
|
+
}) {
|
|
53
|
+
return (
|
|
54
|
+
<section className={cn("min-w-0", className)}>
|
|
55
|
+
<h3 className="mb-2 text-xs font-medium text-muted-foreground">{title}</h3>
|
|
56
|
+
{children}
|
|
57
|
+
</section>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function InspectorSectionTitle({ children, id }: { children: React.ReactNode; id?: string }) {
|
|
62
|
+
return (
|
|
63
|
+
<p
|
|
64
|
+
id={id}
|
|
65
|
+
className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
</p>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface FolderDetailsShellProps {
|
|
73
|
+
folder: QuestionBankFolder
|
|
74
|
+
folders: QuestionBankFolder[]
|
|
75
|
+
questions: QuestionBankItem[]
|
|
76
|
+
/** Clears selection (tree inspector dismiss). */
|
|
77
|
+
onClearSelection?: () => void
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function FolderDetailsShell({
|
|
81
|
+
folder,
|
|
82
|
+
folders,
|
|
83
|
+
questions,
|
|
84
|
+
onClearSelection,
|
|
85
|
+
}: FolderDetailsShellProps) {
|
|
86
|
+
const subtreeQuestions = questionsInFolderSubtree(folders, questions, folder.id)
|
|
87
|
+
const agg = aggregateFolderQuestions(subtreeQuestions)
|
|
88
|
+
const { totalQuestions, difficulty: diffAgg, bloom, avgPbi, scoredCount } = agg
|
|
89
|
+
const diffSum = diffAgg.easy + diffAgg.medium + diffAgg.hard
|
|
90
|
+
const maxBloomCount = Math.max(1, ...BLOOM_LEVEL_ORDER.map(level => bloom[level] ?? 0))
|
|
91
|
+
|
|
92
|
+
const breadcrumbs: QuestionBankFolder[] = []
|
|
93
|
+
let cur: QuestionBankFolder | undefined = folder
|
|
94
|
+
while (cur) {
|
|
95
|
+
breadcrumbs.unshift(cur)
|
|
96
|
+
cur = folders.find(f => f.id === cur?.parentId)
|
|
97
|
+
}
|
|
98
|
+
const pathSegments = breadcrumbs.map(f => ({ id: f.id, label: f.name }))
|
|
99
|
+
|
|
100
|
+
const diffHeadingId = `folder-details-difficulty-${folder.id}`
|
|
101
|
+
const bloomHeadingId = `folder-details-bloom-${folder.id}`
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-card">
|
|
105
|
+
<header className="shrink-0 border-b border-border/60 bg-muted/10 px-4 pb-4 pt-4">
|
|
106
|
+
<div className="flex items-start justify-between gap-3">
|
|
107
|
+
<div className="flex min-w-0 flex-1 items-start gap-3">
|
|
108
|
+
<OsFolderGlyph
|
|
109
|
+
colorKey={folder.colorKey}
|
|
110
|
+
icon={folder.icon}
|
|
111
|
+
size="xs"
|
|
112
|
+
className="shrink-0"
|
|
113
|
+
/>
|
|
114
|
+
<div className="min-w-0 flex-1">
|
|
115
|
+
<h2 className="text-base font-semibold leading-tight tracking-tight text-foreground">
|
|
116
|
+
<span className="truncate">{folder.name}</span>
|
|
117
|
+
<span className="font-normal text-muted-foreground"> · Question Bank</span>
|
|
118
|
+
</h2>
|
|
119
|
+
<p className="mt-1 text-sm font-semibold tabular-nums text-foreground">
|
|
120
|
+
{totalQuestions} question{totalQuestions !== 1 ? "s" : ""}
|
|
121
|
+
</p>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
{onClearSelection ? (
|
|
125
|
+
<Tip label="Close details" side="bottom">
|
|
126
|
+
<Button
|
|
127
|
+
type="button"
|
|
128
|
+
variant="ghost"
|
|
129
|
+
size="icon-sm"
|
|
130
|
+
className="shrink-0 text-muted-foreground hover:text-foreground"
|
|
131
|
+
onClick={onClearSelection}
|
|
132
|
+
aria-label="Close details"
|
|
133
|
+
>
|
|
134
|
+
<X className="size-4" aria-hidden />
|
|
135
|
+
</Button>
|
|
136
|
+
</Tip>
|
|
137
|
+
) : null}
|
|
138
|
+
</div>
|
|
139
|
+
</header>
|
|
140
|
+
|
|
141
|
+
<div className="flex min-h-0 flex-1 flex-col gap-0 overflow-y-auto">
|
|
142
|
+
<div className="space-y-5 px-4 py-4">
|
|
143
|
+
<section aria-labelledby={diffHeadingId}>
|
|
144
|
+
<InspectorSectionTitle id={diffHeadingId}>Difficulty</InspectorSectionTitle>
|
|
145
|
+
<div
|
|
146
|
+
className="flex h-2 w-full overflow-hidden rounded-full bg-muted"
|
|
147
|
+
role="img"
|
|
148
|
+
aria-label={`Difficulty mix: ${diffAgg.easy} easy, ${diffAgg.medium} medium, ${diffAgg.hard} hard of ${diffSum || 0} questions`}
|
|
149
|
+
>
|
|
150
|
+
{diffSum === 0 ? (
|
|
151
|
+
<div className="h-full w-full bg-muted" />
|
|
152
|
+
) : (
|
|
153
|
+
<>
|
|
154
|
+
<div
|
|
155
|
+
className="h-full bg-emerald-400/85 dark:bg-emerald-500/70"
|
|
156
|
+
style={{ width: `${(diffAgg.easy / diffSum) * 100}%` }}
|
|
157
|
+
/>
|
|
158
|
+
<div
|
|
159
|
+
className="h-full bg-amber-400/90 dark:bg-amber-500/75"
|
|
160
|
+
style={{ width: `${(diffAgg.medium / diffSum) * 100}%` }}
|
|
161
|
+
/>
|
|
162
|
+
<div className="h-full bg-slate-500 dark:bg-slate-600" style={{ width: `${(diffAgg.hard / diffSum) * 100}%` }} />
|
|
163
|
+
</>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1.5 text-xs text-foreground">
|
|
167
|
+
<span className="inline-flex items-center gap-1.5">
|
|
168
|
+
<span className="size-2 shrink-0 rounded-sm bg-emerald-400/85 dark:bg-emerald-500/70" aria-hidden />
|
|
169
|
+
Easy: <span className="tabular-nums font-medium">{diffAgg.easy}</span>
|
|
170
|
+
</span>
|
|
171
|
+
<span className="inline-flex items-center gap-1.5">
|
|
172
|
+
<span className="size-2 shrink-0 rounded-sm bg-amber-400/90 dark:bg-amber-500/75" aria-hidden />
|
|
173
|
+
Medium: <span className="tabular-nums font-medium">{diffAgg.medium}</span>
|
|
174
|
+
</span>
|
|
175
|
+
<span className="inline-flex items-center gap-1.5">
|
|
176
|
+
<span className="size-2 shrink-0 rounded-sm bg-slate-500 dark:bg-slate-600" aria-hidden />
|
|
177
|
+
Hard: <span className="tabular-nums font-medium">{diffAgg.hard}</span>
|
|
178
|
+
</span>
|
|
179
|
+
</div>
|
|
180
|
+
</section>
|
|
181
|
+
|
|
182
|
+
<Separator className="bg-border/60" />
|
|
183
|
+
|
|
184
|
+
<section aria-labelledby={bloomHeadingId}>
|
|
185
|
+
<InspectorSectionTitle id={bloomHeadingId}>Bloom's</InspectorSectionTitle>
|
|
186
|
+
<ul className="flex flex-col gap-2.5" aria-label="Bloom taxonomy counts">
|
|
187
|
+
{BLOOM_LEVEL_ORDER.map(level => {
|
|
188
|
+
const count = bloom[level] ?? 0
|
|
189
|
+
const barPct = maxBloomCount ? (count / maxBloomCount) * 100 : 0
|
|
190
|
+
return (
|
|
191
|
+
<li key={level} className="grid grid-cols-[minmax(5.5rem,auto)_1fr_auto] items-center gap-2 text-sm">
|
|
192
|
+
<span className="text-muted-foreground">{level}</span>
|
|
193
|
+
<div className="min-w-0">
|
|
194
|
+
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
|
195
|
+
<div
|
|
196
|
+
className="h-full rounded-full bg-pink-500/75 dark:bg-pink-500/65"
|
|
197
|
+
style={{ width: `${barPct}%` }}
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
<span className="w-7 shrink-0 text-right tabular-nums font-medium text-foreground">{count}</span>
|
|
202
|
+
</li>
|
|
203
|
+
)
|
|
204
|
+
})}
|
|
205
|
+
</ul>
|
|
206
|
+
</section>
|
|
207
|
+
|
|
208
|
+
<Separator className="bg-border/60" />
|
|
209
|
+
|
|
210
|
+
<p className="text-xs text-muted-foreground">
|
|
211
|
+
Avg. pBIS:{" "}
|
|
212
|
+
<span className="font-semibold text-foreground">{avgPbi != null ? avgPbi.toFixed(2) : "—"}</span>{" "}
|
|
213
|
+
<span className="tabular-nums">
|
|
214
|
+
({scoredCount} of {totalQuestions} scored)
|
|
215
|
+
</span>
|
|
216
|
+
</p>
|
|
217
|
+
|
|
218
|
+
{pathSegments.length > 0 ? (
|
|
219
|
+
<>
|
|
220
|
+
<Separator className="bg-border/60" />
|
|
221
|
+
<DetailSection title="Location">
|
|
222
|
+
<DetailBreadcrumbNav segments={pathSegments} />
|
|
223
|
+
</DetailSection>
|
|
224
|
+
</>
|
|
225
|
+
) : null}
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
)
|
|
230
|
+
}
|