@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
|
@@ -17,14 +17,21 @@ export const LIST_HUB_STATUS_BADGE_TABLE_SHELL =
|
|
|
17
17
|
export const LIST_HUB_STATUS_BADGE_BOARD_SHELL =
|
|
18
18
|
"inline-flex h-6 items-center gap-1 border-0 px-2 py-1 text-xs font-medium leading-none shadow-none"
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Inspector / split-pane detail headers — uniform 24px chip height next to status badges.
|
|
22
|
+
* Reuse on sibling `Badge`s so rows align with `ListHubStatusBadge` `surface="detail"`.
|
|
23
|
+
*/
|
|
24
|
+
export const LIST_HUB_INSPECTOR_CHIP_SHELL =
|
|
25
|
+
"inline-flex h-6 min-h-6 shrink-0 items-center gap-1.5 px-2 py-0 text-xs font-medium leading-none"
|
|
26
|
+
|
|
20
27
|
export interface ListHubStatusBadgeProps {
|
|
21
28
|
label: string
|
|
22
29
|
/** Tails from `*_STATUS_BADGE_CLASS` in `@/lib/list-status-badges` */
|
|
23
30
|
tintClassName: string
|
|
24
31
|
/** Font Awesome icon class suffix, e.g. `fa-circle-check` (paired with `fa-light` here). */
|
|
25
32
|
icon: string
|
|
26
|
-
/** `table` —
|
|
27
|
-
surface?: "table" | "board"
|
|
33
|
+
/** `table` — grid cells; `board` — kanban cards; `detail` — hub inspector / tree detail column. */
|
|
34
|
+
surface?: "table" | "board" | "detail"
|
|
28
35
|
className?: string
|
|
29
36
|
}
|
|
30
37
|
|
|
@@ -35,11 +42,18 @@ export function ListHubStatusBadge({
|
|
|
35
42
|
surface = "table",
|
|
36
43
|
className,
|
|
37
44
|
}: ListHubStatusBadgeProps) {
|
|
45
|
+
const shell =
|
|
46
|
+
surface === "board"
|
|
47
|
+
? LIST_HUB_STATUS_BADGE_BOARD_SHELL
|
|
48
|
+
: surface === "detail"
|
|
49
|
+
? LIST_HUB_INSPECTOR_CHIP_SHELL
|
|
50
|
+
: LIST_HUB_STATUS_BADGE_TABLE_SHELL
|
|
51
|
+
|
|
38
52
|
return (
|
|
39
53
|
<Badge
|
|
40
54
|
variant="outline"
|
|
41
55
|
className={cn(
|
|
42
|
-
|
|
56
|
+
shell,
|
|
43
57
|
tintClassName,
|
|
44
58
|
className,
|
|
45
59
|
)}
|
|
@@ -17,23 +17,29 @@ import { useAltKeyLabel, useModKeyLabel } from "@/hooks/use-mod-key-label"
|
|
|
17
17
|
import { isEditableTarget } from "@/lib/editable-target"
|
|
18
18
|
|
|
19
19
|
export interface PlacementsPageHeaderProps {
|
|
20
|
+
/** Main heading in the page header */
|
|
21
|
+
title?: string
|
|
22
|
+
/** Primary button label */
|
|
23
|
+
primaryCtaLabel?: string
|
|
20
24
|
/** Shown under the page title */
|
|
21
25
|
subtitle?: string
|
|
22
26
|
onNewPlacement: () => void
|
|
23
27
|
onExport: () => void
|
|
24
28
|
showMetrics: boolean
|
|
25
29
|
onToggleMetrics: () => void
|
|
26
|
-
/** When false,
|
|
30
|
+
/** When false, title + subtitle are hidden visually (Display options). */
|
|
27
31
|
showTitleBlock?: boolean
|
|
28
32
|
className?: string
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
|
-
*
|
|
33
|
-
* Reusable for any
|
|
36
|
+
* List hub shell header — title, primary CTA, overflow menu (export, metrics).
|
|
37
|
+
* Reusable for any route that needs the same chrome.
|
|
34
38
|
*/
|
|
35
39
|
export function PlacementsPageHeader({
|
|
36
|
-
|
|
40
|
+
title = "Sample records",
|
|
41
|
+
primaryCtaLabel = "New row",
|
|
42
|
+
subtitle = "24 demo rows · Last updated now",
|
|
37
43
|
onNewPlacement,
|
|
38
44
|
onExport,
|
|
39
45
|
showMetrics,
|
|
@@ -76,17 +82,17 @@ export function PlacementsPageHeader({
|
|
|
76
82
|
<Shortcut keys="⌘⇧E" onInvoke={onExport} />
|
|
77
83
|
<Shortcut keys="⌘⌥H" onInvoke={onToggleMetrics} />
|
|
78
84
|
<PageHeader
|
|
79
|
-
title=
|
|
85
|
+
title={title}
|
|
80
86
|
subtitle={subtitle}
|
|
81
87
|
className={className}
|
|
82
88
|
showTitleBlock={showTitleBlock}
|
|
83
89
|
actions={
|
|
84
|
-
<div className="flex items-center gap-2" role="group" aria-label="
|
|
90
|
+
<div className="flex items-center gap-2" role="group" aria-label="Primary list actions">
|
|
85
91
|
<Tip
|
|
86
92
|
side="bottom"
|
|
87
93
|
label={
|
|
88
94
|
<>
|
|
89
|
-
<span>
|
|
95
|
+
<span>{primaryCtaLabel}</span>
|
|
90
96
|
<KbdGroup>
|
|
91
97
|
<Kbd>{mod}</Kbd>
|
|
92
98
|
<Kbd>{alt}</Kbd>
|
|
@@ -97,7 +103,7 @@ export function PlacementsPageHeader({
|
|
|
97
103
|
>
|
|
98
104
|
<Button size="lg" onClick={onNewPlacement}>
|
|
99
105
|
<i className="fa-light fa-plus" aria-hidden="true" />
|
|
100
|
-
|
|
106
|
+
{primaryCtaLabel}
|
|
101
107
|
</Button>
|
|
102
108
|
</Tip>
|
|
103
109
|
<DropdownMenu open={moreOpen} onOpenChange={setMoreOpen}>
|
|
@@ -621,19 +621,19 @@ export function getPlacementColumnsForLifecycle(tab: PlacementLifecycleTabId): C
|
|
|
621
621
|
export function emptyCopyForPlacementLifecycleTab(tab: PlacementLifecycleTabId): string {
|
|
622
622
|
switch (tab) {
|
|
623
623
|
case "upcoming":
|
|
624
|
-
return "No
|
|
624
|
+
return "No rows in this segment match your filters."
|
|
625
625
|
case "ongoing":
|
|
626
|
-
return "No
|
|
626
|
+
return "No rows in this segment match your filters."
|
|
627
627
|
case "completed":
|
|
628
|
-
return "No
|
|
628
|
+
return "No rows in this segment match your filters."
|
|
629
629
|
default:
|
|
630
|
-
return "No
|
|
630
|
+
return "No rows match your filters."
|
|
631
631
|
}
|
|
632
632
|
}
|
|
633
633
|
|
|
634
634
|
export const placementLifecycleDrawerLabels: Record<PlacementLifecycleTabId, string> = {
|
|
635
|
-
all: "
|
|
636
|
-
upcoming: "
|
|
637
|
-
ongoing: "
|
|
638
|
-
completed: "
|
|
635
|
+
all: "Segment: All rows",
|
|
636
|
+
upcoming: "Segment: Due soon",
|
|
637
|
+
ongoing: "Segment: In progress",
|
|
638
|
+
completed: "Segment: Done",
|
|
639
639
|
}
|
|
@@ -2,20 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Question bank hub — ListPageTemplate + KeyMetrics + QuestionBankTable (Team / Compliance pattern).
|
|
5
|
+
* URL hash syncs the active view tab; `?scope=` + `folderId=` sync with the secondary nav (`lib/question-bank-nav.ts`).
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import * as React from "react"
|
|
9
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|
8
10
|
import {
|
|
9
11
|
ListPageTemplate,
|
|
10
12
|
type ViewTab,
|
|
11
13
|
dataListViewIcon,
|
|
12
14
|
type DataListViewType,
|
|
13
15
|
} from "@/components/data-views"
|
|
16
|
+
import { QuestionBankPanelActivator } from "@/components/question-bank-panel-activator"
|
|
14
17
|
import { QuestionBankPageHeader } from "@/components/question-bank-page-header"
|
|
15
18
|
import { QuestionBankTable, type QuestionBankTableHandle } from "@/components/question-bank-table"
|
|
19
|
+
import { PrimaryPageTemplate } from "@/components/templates/primary-page-template"
|
|
20
|
+
import { useSecondaryPanel } from "@/components/secondary-panel"
|
|
16
21
|
import { KeyMetrics } from "@/components/key-metrics"
|
|
17
22
|
import { QUESTION_BANK_ITEMS } from "@/lib/mock/question-bank"
|
|
23
|
+
import { DEFAULT_QUESTION_BANK_FOLDERS } from "@/lib/mock/question-bank-folders"
|
|
18
24
|
import { questionBankKpiInsight, questionBankKpiMetrics } from "@/lib/mock/question-bank-kpi"
|
|
25
|
+
import {
|
|
26
|
+
filterQuestionBankItemsByNav,
|
|
27
|
+
parseQuestionBankNav,
|
|
28
|
+
questionBankHubHeaderModel,
|
|
29
|
+
} from "@/lib/question-bank-nav"
|
|
19
30
|
|
|
20
31
|
const DEFAULT_TABS: ViewTab[] = [
|
|
21
32
|
{
|
|
@@ -25,54 +36,160 @@ const DEFAULT_TABS: ViewTab[] = [
|
|
|
25
36
|
icon: "fa-table",
|
|
26
37
|
filterId: "all",
|
|
27
38
|
},
|
|
39
|
+
{
|
|
40
|
+
id: "panel-view",
|
|
41
|
+
label: "Panel",
|
|
42
|
+
viewType: "panel",
|
|
43
|
+
icon: "fa-columns",
|
|
44
|
+
filterId: "all",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "tree-panel",
|
|
48
|
+
label: "Tree",
|
|
49
|
+
viewType: "tree-panel",
|
|
50
|
+
icon: "fa-sitemap",
|
|
51
|
+
filterId: "all",
|
|
52
|
+
},
|
|
28
53
|
]
|
|
29
54
|
|
|
55
|
+
function questionBankQueryPrefixFromSearchString(qs: string) {
|
|
56
|
+
return qs ? `?${qs}` : ""
|
|
57
|
+
}
|
|
58
|
+
|
|
30
59
|
export function QuestionBankClient() {
|
|
60
|
+
const pathname = usePathname()
|
|
61
|
+
const router = useRouter()
|
|
62
|
+
const searchParams = useSearchParams()
|
|
63
|
+
const { openPanel } = useSecondaryPanel()
|
|
64
|
+
const [tabs, setTabs] = React.useState<ViewTab[]>(DEFAULT_TABS)
|
|
65
|
+
const [activeTabId, setActiveTabId] = React.useState(DEFAULT_TABS[0].id)
|
|
66
|
+
|
|
67
|
+
const tabIds = React.useMemo(() => new Set(tabs.map(t => t.id)), [tabs])
|
|
68
|
+
|
|
69
|
+
/** String key — `useSearchParams()` identity can stay stable when only the query changes. */
|
|
70
|
+
const searchParamsKey = searchParams.toString()
|
|
71
|
+
const navState = React.useMemo(
|
|
72
|
+
() => parseQuestionBankNav(new URLSearchParams(searchParamsKey)),
|
|
73
|
+
[searchParamsKey],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
/** “All questions” hub — keep secondary nav open when scope clears (breadcrumb, All questions link). */
|
|
77
|
+
React.useEffect(() => {
|
|
78
|
+
if (pathname !== "/question-bank") return
|
|
79
|
+
if (navState.scope !== "all") return
|
|
80
|
+
openPanel("question-bank")
|
|
81
|
+
}, [pathname, navState.scope, openPanel])
|
|
82
|
+
|
|
83
|
+
React.useEffect(() => {
|
|
84
|
+
if (pathname !== "/question-bank") return
|
|
85
|
+
const prefix = questionBankQueryPrefixFromSearchString(searchParamsKey)
|
|
86
|
+
const apply = () => {
|
|
87
|
+
const raw = typeof window !== "undefined" ? window.location.hash.slice(1) : ""
|
|
88
|
+
let nextId = "questions"
|
|
89
|
+
if (raw === "panel-view" || raw === "tree-panel") {
|
|
90
|
+
nextId = raw
|
|
91
|
+
} else if (raw && tabIds.has(raw)) {
|
|
92
|
+
nextId = raw
|
|
93
|
+
}
|
|
94
|
+
setActiveTabId(nextId)
|
|
95
|
+
if (nextId === "questions" && raw && raw !== "questions") {
|
|
96
|
+
router.replace(`/question-bank${prefix}`, { scroll: false })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
apply()
|
|
100
|
+
window.addEventListener("hashchange", apply)
|
|
101
|
+
return () => window.removeEventListener("hashchange", apply)
|
|
102
|
+
}, [pathname, router, tabIds, searchParamsKey])
|
|
103
|
+
|
|
104
|
+
const onActiveTabChange = React.useCallback(
|
|
105
|
+
(id: string) => {
|
|
106
|
+
setActiveTabId(id)
|
|
107
|
+
if (pathname !== "/question-bank") return
|
|
108
|
+
const prefix = questionBankQueryPrefixFromSearchString(searchParamsKey)
|
|
109
|
+
if (id === "questions") {
|
|
110
|
+
router.replace(`/question-bank${prefix}`, { scroll: false })
|
|
111
|
+
} else {
|
|
112
|
+
router.replace(`/question-bank${prefix}#${id}`, { scroll: false })
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
[pathname, router, searchParamsKey],
|
|
116
|
+
)
|
|
117
|
+
|
|
31
118
|
const [exportOpen, setExportOpen] = React.useState(false)
|
|
32
119
|
const [showMetrics, setShowMetrics] = React.useState(true)
|
|
33
120
|
const tableRef = React.useRef<QuestionBankTableHandle>(null)
|
|
34
|
-
const
|
|
121
|
+
const [items, setItems] = React.useState(() => QUESTION_BANK_ITEMS.map(q => ({ ...q })))
|
|
122
|
+
const [folders, setFolders] = React.useState(() => DEFAULT_QUESTION_BANK_FOLDERS.map(f => ({ ...f })))
|
|
35
123
|
|
|
36
|
-
const
|
|
37
|
-
|
|
124
|
+
const filteredItems = React.useMemo(
|
|
125
|
+
() => filterQuestionBankItemsByNav(items, folders, navState),
|
|
126
|
+
[items, folders, navState],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const count = filteredItems.length
|
|
130
|
+
|
|
131
|
+
const metrics = React.useMemo(() => questionBankKpiMetrics(filteredItems), [filteredItems])
|
|
132
|
+
const insight = React.useMemo(() => questionBankKpiInsight(filteredItems), [filteredItems])
|
|
133
|
+
|
|
134
|
+
const hubHeader = React.useMemo(
|
|
135
|
+
() => questionBankHubHeaderModel(folders, navState),
|
|
136
|
+
[folders, navState],
|
|
137
|
+
)
|
|
38
138
|
|
|
39
139
|
return (
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
140
|
+
<PrimaryPageTemplate
|
|
141
|
+
beforeSiteHeader={<QuestionBankPanelActivator />}
|
|
142
|
+
siteHeader={{
|
|
143
|
+
title: hubHeader.title,
|
|
144
|
+
breadcrumbs: hubHeader.breadcrumbs,
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
<ListPageTemplate
|
|
148
|
+
defaultTabs={DEFAULT_TABS}
|
|
149
|
+
tabs={tabs}
|
|
150
|
+
onTabsChange={setTabs}
|
|
151
|
+
activeTabId={activeTabId}
|
|
152
|
+
onActiveTabChange={onActiveTabChange}
|
|
153
|
+
getTabCount={() => count}
|
|
154
|
+
tablePropertiesRef={tableRef}
|
|
155
|
+
header={(
|
|
156
|
+
<QuestionBankPageHeader
|
|
157
|
+
variant="collaboration"
|
|
158
|
+
title={hubHeader.title}
|
|
159
|
+
questionCount={count}
|
|
160
|
+
onNewQuestion={() => {}}
|
|
161
|
+
onExport={() => setExportOpen(true)}
|
|
162
|
+
showMetrics={showMetrics}
|
|
163
|
+
onToggleMetrics={() => setShowMetrics(v => !v)}
|
|
164
|
+
/>
|
|
165
|
+
)}
|
|
166
|
+
metrics={(
|
|
167
|
+
<KeyMetrics
|
|
168
|
+
variant="flat"
|
|
169
|
+
metrics={metrics}
|
|
170
|
+
insight={insight}
|
|
171
|
+
showHeader={false}
|
|
172
|
+
metricsSingleRow
|
|
173
|
+
/>
|
|
174
|
+
)}
|
|
175
|
+
showMetrics={showMetrics}
|
|
176
|
+
exportOpen={exportOpen}
|
|
177
|
+
onExportOpenChange={setExportOpen}
|
|
178
|
+
exportTotalRows={count}
|
|
179
|
+
renderContent={(tab, updateTab) => (
|
|
180
|
+
<QuestionBankTable
|
|
181
|
+
key={tab.id}
|
|
182
|
+
ref={tableRef}
|
|
183
|
+
items={items}
|
|
184
|
+
navState={navState}
|
|
185
|
+
folders={folders}
|
|
186
|
+
onFoldersChange={setFolders}
|
|
187
|
+
onItemsChange={setItems}
|
|
188
|
+
view={tab.viewType}
|
|
189
|
+
onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
|
|
190
|
+
/>
|
|
191
|
+
)}
|
|
192
|
+
/>
|
|
193
|
+
</PrimaryPageTemplate>
|
|
77
194
|
)
|
|
78
195
|
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared “new folder” floating sheet (same shell as Export) — used by OS folder grid and column panel.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as React from "react"
|
|
8
|
+
import { Button } from "@/components/ui/button"
|
|
9
|
+
import { Shortcut } from "@/components/ui/dropdown-menu"
|
|
10
|
+
import { Input } from "@/components/ui/input"
|
|
11
|
+
import { Label } from "@/components/ui/label"
|
|
12
|
+
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
|
13
|
+
import { Tip } from "@/components/ui/tip"
|
|
14
|
+
import { Kbd, KbdGroup } from "@/components/ui/kbd"
|
|
15
|
+
import { cn } from "@/lib/utils"
|
|
16
|
+
import {
|
|
17
|
+
QUESTION_BANK_FOLDER_COLOR_STYLES,
|
|
18
|
+
QUESTION_BANK_FOLDER_ICON_OPTIONS,
|
|
19
|
+
type QuestionBankFolderColorKey,
|
|
20
|
+
} from "@/lib/mock/question-bank-folders"
|
|
21
|
+
import { OsFolderGlyph } from "@/components/data-views/os-folder-glyph"
|
|
22
|
+
|
|
23
|
+
const COLOR_OPTIONS: QuestionBankFolderColorKey[] = [
|
|
24
|
+
"brand",
|
|
25
|
+
"success",
|
|
26
|
+
"warning",
|
|
27
|
+
"destructive",
|
|
28
|
+
"muted",
|
|
29
|
+
"chart1",
|
|
30
|
+
"chart2",
|
|
31
|
+
"chart3",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
function FolderTilePreview({
|
|
35
|
+
name,
|
|
36
|
+
colorKey,
|
|
37
|
+
icon,
|
|
38
|
+
className,
|
|
39
|
+
}: {
|
|
40
|
+
name: string
|
|
41
|
+
colorKey: QuestionBankFolderColorKey
|
|
42
|
+
icon: string
|
|
43
|
+
className?: string
|
|
44
|
+
}) {
|
|
45
|
+
const display = name.trim() || "Untitled"
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
className={cn(
|
|
49
|
+
"flex flex-col items-center gap-4 rounded-2xl border border-border/80 bg-muted/20 p-6 shadow-sm",
|
|
50
|
+
className,
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
<OsFolderGlyph
|
|
54
|
+
colorKey={colorKey}
|
|
55
|
+
icon={icon}
|
|
56
|
+
size="lg"
|
|
57
|
+
decorative={false}
|
|
58
|
+
label={`Folder preview: ${display}`}
|
|
59
|
+
/>
|
|
60
|
+
<p className="line-clamp-2 min-h-[2.5rem] w-full text-center text-sm font-medium text-foreground">
|
|
61
|
+
{display}
|
|
62
|
+
</p>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface QuestionBankNewFolderSheetProps {
|
|
68
|
+
open: boolean
|
|
69
|
+
onOpenChange: (open: boolean) => void
|
|
70
|
+
/** Parent folder id for the new folder (`null` = top level). */
|
|
71
|
+
parentFolderId: string | null
|
|
72
|
+
/** Replaces default helper copy under the title. */
|
|
73
|
+
descriptionText?: string
|
|
74
|
+
/** When provided, the sheet is in "customize" mode with these initial values. */
|
|
75
|
+
customizingFolder?: {
|
|
76
|
+
name: string
|
|
77
|
+
icon: string
|
|
78
|
+
colorKey: QuestionBankFolderColorKey
|
|
79
|
+
parentId: string | null
|
|
80
|
+
} | null
|
|
81
|
+
onCreated: (folder: {
|
|
82
|
+
name: string
|
|
83
|
+
icon: string
|
|
84
|
+
colorKey: QuestionBankFolderColorKey
|
|
85
|
+
parentId: string | null
|
|
86
|
+
}) => void
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function QuestionBankNewFolderSheet({
|
|
90
|
+
open,
|
|
91
|
+
onOpenChange,
|
|
92
|
+
parentFolderId,
|
|
93
|
+
customizingFolder,
|
|
94
|
+
descriptionText = "Name, color, and icon update the preview. The folder is created in the location shown in the breadcrumb above the grid.",
|
|
95
|
+
onCreated,
|
|
96
|
+
}: QuestionBankNewFolderSheetProps) {
|
|
97
|
+
const [draft, setDraft] = React.useState<{
|
|
98
|
+
name: string
|
|
99
|
+
colorKey: QuestionBankFolderColorKey
|
|
100
|
+
icon: string
|
|
101
|
+
}>({ name: "Untitled", colorKey: "brand", icon: "fa-folder" })
|
|
102
|
+
|
|
103
|
+
React.useEffect(() => {
|
|
104
|
+
if (open) {
|
|
105
|
+
if (customizingFolder) {
|
|
106
|
+
setDraft({ name: customizingFolder.name, colorKey: customizingFolder.colorKey, icon: customizingFolder.icon })
|
|
107
|
+
} else {
|
|
108
|
+
setDraft({ name: "Untitled", colorKey: "brand", icon: "fa-folder" })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}, [open, customizingFolder])
|
|
112
|
+
|
|
113
|
+
const createDisabled = !draft.name.trim()
|
|
114
|
+
|
|
115
|
+
function commit() {
|
|
116
|
+
const v = draft.name.trim()
|
|
117
|
+
if (!v) return
|
|
118
|
+
onCreated({
|
|
119
|
+
name: v,
|
|
120
|
+
icon: draft.icon,
|
|
121
|
+
colorKey: draft.colorKey,
|
|
122
|
+
parentId: parentFolderId,
|
|
123
|
+
})
|
|
124
|
+
onOpenChange(false)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
129
|
+
<SheetContent
|
|
130
|
+
data-slot="new-folder-drawer"
|
|
131
|
+
side="right"
|
|
132
|
+
showCloseButton={false}
|
|
133
|
+
showOverlay={false}
|
|
134
|
+
className="z-[60] flex w-full max-w-md flex-col gap-0 overflow-hidden rounded-xl border border-border p-0 shadow-xl sm:max-w-md"
|
|
135
|
+
style={{ top: "0.5rem", bottom: "0.5rem", right: "0.5rem", height: "calc(100vh - 1rem)" }}
|
|
136
|
+
>
|
|
137
|
+
{open ? (
|
|
138
|
+
<>
|
|
139
|
+
<Shortcut keys="Enter" onInvoke={() => !createDisabled && commit()} />
|
|
140
|
+
<Shortcut keys="Esc" onInvoke={() => onOpenChange(false)} />
|
|
141
|
+
|
|
142
|
+
<div className="flex items-center justify-between gap-3 px-4 pt-5 pb-6">
|
|
143
|
+
<SheetTitle className="text-base font-semibold leading-tight">
|
|
144
|
+
{customizingFolder ? "Customize Folder" : "New folder"}
|
|
145
|
+
</SheetTitle>
|
|
146
|
+
<Tip label="Close" side="bottom">
|
|
147
|
+
<Button
|
|
148
|
+
type="button"
|
|
149
|
+
variant="ghost"
|
|
150
|
+
size="icon-sm"
|
|
151
|
+
aria-label="Close"
|
|
152
|
+
onClick={() => onOpenChange(false)}
|
|
153
|
+
>
|
|
154
|
+
<i className="fa-light fa-xmark text-[13px]" aria-hidden="true" />
|
|
155
|
+
</Button>
|
|
156
|
+
</Tip>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 pb-4 pt-2">
|
|
160
|
+
<div className="flex flex-col items-center gap-4">
|
|
161
|
+
<FolderTilePreview
|
|
162
|
+
name={draft.name}
|
|
163
|
+
colorKey={draft.colorKey}
|
|
164
|
+
icon={draft.icon}
|
|
165
|
+
className="w-full max-w-[280px]"
|
|
166
|
+
/>
|
|
167
|
+
<div className="w-full max-w-[280px] space-y-2">
|
|
168
|
+
<Label htmlFor="new-folder-name-shared">Folder name</Label>
|
|
169
|
+
<Input
|
|
170
|
+
id="new-folder-name-shared"
|
|
171
|
+
value={draft.name}
|
|
172
|
+
onChange={e => setDraft(d => ({ ...d, name: e.target.value }))}
|
|
173
|
+
autoComplete="off"
|
|
174
|
+
aria-describedby="new-folder-panel-desc new-folder-name-hint-shared"
|
|
175
|
+
aria-invalid={createDisabled}
|
|
176
|
+
/>
|
|
177
|
+
<p id="new-folder-name-hint-shared" className="text-sm text-muted-foreground">
|
|
178
|
+
Shown under the folder icon in the grid or column.
|
|
179
|
+
</p>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div>
|
|
184
|
+
<p className="mb-2 text-xs font-medium text-muted-foreground">Color</p>
|
|
185
|
+
<div className="flex flex-wrap gap-2">
|
|
186
|
+
{COLOR_OPTIONS.map(c => (
|
|
187
|
+
<button
|
|
188
|
+
key={c}
|
|
189
|
+
type="button"
|
|
190
|
+
aria-label={`Color ${c}`}
|
|
191
|
+
aria-pressed={draft.colorKey === c}
|
|
192
|
+
className={cn(
|
|
193
|
+
"size-10 rounded-xl border-2 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
194
|
+
QUESTION_BANK_FOLDER_COLOR_STYLES[c].tile,
|
|
195
|
+
draft.colorKey === c
|
|
196
|
+
? "ring-2 ring-ring"
|
|
197
|
+
: "border-transparent opacity-85 hover:opacity-100",
|
|
198
|
+
)}
|
|
199
|
+
onClick={() => setDraft(d => ({ ...d, colorKey: c }))}
|
|
200
|
+
/>
|
|
201
|
+
))}
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div>
|
|
206
|
+
<p className="mb-2 text-xs font-medium text-muted-foreground">Icon</p>
|
|
207
|
+
<div className="grid max-h-48 grid-cols-5 gap-2 overflow-y-auto rounded-xl border border-border p-3">
|
|
208
|
+
{QUESTION_BANK_FOLDER_ICON_OPTIONS.map(ic => (
|
|
209
|
+
<button
|
|
210
|
+
key={ic}
|
|
211
|
+
type="button"
|
|
212
|
+
aria-label={`Icon ${ic.replace(/^fa-/, "").replace(/-/g, " ")}`}
|
|
213
|
+
aria-pressed={draft.icon === ic}
|
|
214
|
+
className={cn(
|
|
215
|
+
"flex size-10 items-center justify-center rounded-lg border text-sm transition-colors",
|
|
216
|
+
draft.icon === ic
|
|
217
|
+
? "border-brand bg-brand/10 text-brand"
|
|
218
|
+
: "border-transparent hover:bg-muted",
|
|
219
|
+
)}
|
|
220
|
+
onClick={() => setDraft(d => ({ ...d, icon: ic }))}
|
|
221
|
+
>
|
|
222
|
+
<i className={cn("fa-light", ic)} aria-hidden="true" />
|
|
223
|
+
</button>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<div className="mt-auto flex flex-row flex-wrap justify-end gap-2 border-t border-border px-4 py-4">
|
|
230
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
231
|
+
Cancel
|
|
232
|
+
<KbdGroup className="ms-1.5">
|
|
233
|
+
<Kbd variant="bare">Esc</Kbd>
|
|
234
|
+
</KbdGroup>
|
|
235
|
+
</Button>
|
|
236
|
+
<Button type="button" disabled={createDisabled} onClick={commit}>
|
|
237
|
+
{customizingFolder ? "Update folder" : "Create folder"}
|
|
238
|
+
<KbdGroup className="ms-1.5">
|
|
239
|
+
<Kbd variant="bare">⏎</Kbd>
|
|
240
|
+
</KbdGroup>
|
|
241
|
+
</Button>
|
|
242
|
+
</div>
|
|
243
|
+
</>
|
|
244
|
+
) : null}
|
|
245
|
+
</SheetContent>
|
|
246
|
+
</Sheet>
|
|
247
|
+
)
|
|
248
|
+
}
|