@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
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generic, opt-in lifecycle persistence for `useTableState`.
|
|
5
|
+
*
|
|
6
|
+
* - Any hub can persist its `DataTable` lifecycle (sort / search / filters /
|
|
7
|
+
* column order / pin / width / hidden / row height / gridlines / etc.) to
|
|
8
|
+
* `localStorage` by calling `useTableStateLifecycle({ namespace, tabId,
|
|
9
|
+
* tableState, columnKeys })`. Don't call it → no persistence (the table
|
|
10
|
+
* still works fine in memory).
|
|
11
|
+
* - Storage keys are namespaced (`exxat-ds:<namespace>:lifecycle:v1:<tabId>`)
|
|
12
|
+
* so each hub owns its own keyspace and can't clobber another hub.
|
|
13
|
+
* - Hubs that need to persist EXTRA state alongside the table (e.g. the
|
|
14
|
+
* placements table also persists `conditionalRules` + pagination) pass an
|
|
15
|
+
* `extras` object and an `onLoadExtras` callback.
|
|
16
|
+
* - Saves are debounced (~400ms) and SSR-safe (no-op on the server).
|
|
17
|
+
*
|
|
18
|
+
* Replaces the older `lib/data-list-persistence.ts`, which was hard-coded to
|
|
19
|
+
* the placements / "data-list" route. That file now re-exports from here for
|
|
20
|
+
* back-compat so existing imports keep working during the migration window.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as React from "react"
|
|
24
|
+
import type { Dispatch, SetStateAction } from "react"
|
|
25
|
+
import type { RowHeight } from "@/lib/row-height"
|
|
26
|
+
import type { DataListDisplayOptions } from "@/lib/data-list-display-options"
|
|
27
|
+
import type { ActiveFilter, ConditionalRule, SortRule } from "@/components/table-properties/types"
|
|
28
|
+
import type { ViewTab } from "@/components/templates/list-page"
|
|
29
|
+
import type { DataListViewType } from "@/lib/data-list-view"
|
|
30
|
+
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
// Storage key + debounce config
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const LIFECYCLE_SAVE_DEBOUNCE_MS = 400
|
|
36
|
+
const PAGE_SAVE_DEBOUNCE_MS = 400
|
|
37
|
+
|
|
38
|
+
/** Public so hubs that want to clear or namespace-scan storage can. */
|
|
39
|
+
export function lifecycleStorageKey(namespace: string, tabId: string): string {
|
|
40
|
+
return `exxat-ds:${namespace}:lifecycle:v1:${tabId}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function pageStorageKey(namespace: string): string {
|
|
44
|
+
return `exxat-ds:${namespace}:page:v1`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Module-level timer maps — one per namespace+tabId combo and one per page key.
|
|
48
|
+
const lifecycleTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
49
|
+
const pageTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
50
|
+
|
|
51
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
52
|
+
// Persisted shapes
|
|
53
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Versioned snapshot of a single table's lifecycle state. The fields under
|
|
57
|
+
* `extras` are entity-specific (e.g. placements stuff `conditionalRules` and
|
|
58
|
+
* pagination there) and opaque to this module. Older v1 records (pre-extras
|
|
59
|
+
* rollout) may have those entity-specific fields at the top level — the
|
|
60
|
+
* parser accepts both shapes for back-compat.
|
|
61
|
+
*/
|
|
62
|
+
export interface PersistedLifecycleV1 {
|
|
63
|
+
v: 1
|
|
64
|
+
sortRules: SortRule[]
|
|
65
|
+
search: string
|
|
66
|
+
activeFilters: ActiveFilter[]
|
|
67
|
+
filterConnectors: Record<string, "and" | "or">
|
|
68
|
+
groupBy: string | null
|
|
69
|
+
colOrder: string[]
|
|
70
|
+
hiddenCols: string[]
|
|
71
|
+
colWidths: Record<string, number>
|
|
72
|
+
colPins: Record<string, "left" | "right">
|
|
73
|
+
colWrap: Record<string, boolean>
|
|
74
|
+
colMenuSearch: Record<string, string>
|
|
75
|
+
rowHeight: RowHeight
|
|
76
|
+
showGridlines: boolean
|
|
77
|
+
filterBarVisible: boolean
|
|
78
|
+
searchOpen: boolean
|
|
79
|
+
/** Generic hub-defined extras. Persisted as JSON. */
|
|
80
|
+
extras?: Record<string, unknown>
|
|
81
|
+
/**
|
|
82
|
+
* @deprecated Legacy top-level fields (used by placements pre-extras
|
|
83
|
+
* rollout). New code SHOULD live under `extras`. Kept here so existing
|
|
84
|
+
* placements `localStorage` payloads still parse.
|
|
85
|
+
*/
|
|
86
|
+
conditionalRules?: ConditionalRule[]
|
|
87
|
+
pagination?: boolean
|
|
88
|
+
paginationPage?: number
|
|
89
|
+
paginationPageSize?: number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface PersistedPageV1 {
|
|
93
|
+
v: 1
|
|
94
|
+
displayOptions: DataListDisplayOptions
|
|
95
|
+
showMetrics: boolean
|
|
96
|
+
tabs: ViewTab[]
|
|
97
|
+
activeTabId: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Narrow surface the lifecycle hook needs from `useTableState` — getters +
|
|
102
|
+
* setters for every persisted slice. Defined as a structural type so
|
|
103
|
+
* callers can pass `tableState` directly (it satisfies this shape).
|
|
104
|
+
*/
|
|
105
|
+
export interface TableStatePersistSlice {
|
|
106
|
+
sortRules: SortRule[]
|
|
107
|
+
search: string
|
|
108
|
+
activeFilters: ActiveFilter[]
|
|
109
|
+
filterConnectors: Record<string, "and" | "or">
|
|
110
|
+
groupBy: string | null
|
|
111
|
+
colOrder: string[]
|
|
112
|
+
hiddenCols: Set<string>
|
|
113
|
+
colWidths: Record<string, number>
|
|
114
|
+
colPins: Record<string, "left" | "right">
|
|
115
|
+
colWrap: Record<string, boolean>
|
|
116
|
+
colMenuSearch: Record<string, string>
|
|
117
|
+
rowHeight: RowHeight
|
|
118
|
+
showGridlines: boolean
|
|
119
|
+
filterBarVisible: boolean
|
|
120
|
+
searchOpen: boolean
|
|
121
|
+
setSortRules: Dispatch<SetStateAction<SortRule[]>>
|
|
122
|
+
setSearch: Dispatch<SetStateAction<string>>
|
|
123
|
+
setActiveFilters: Dispatch<SetStateAction<ActiveFilter[]>>
|
|
124
|
+
setFilterConnectors: Dispatch<SetStateAction<Record<string, "and" | "or">>>
|
|
125
|
+
setGroupBy: Dispatch<SetStateAction<string | null>>
|
|
126
|
+
setColOrder: Dispatch<SetStateAction<string[]>>
|
|
127
|
+
setHiddenCols: Dispatch<SetStateAction<Set<string>>>
|
|
128
|
+
setColWidths: Dispatch<SetStateAction<Record<string, number>>>
|
|
129
|
+
setColPins: Dispatch<SetStateAction<Record<string, "left" | "right">>>
|
|
130
|
+
setColWrap: Dispatch<SetStateAction<Record<string, boolean>>>
|
|
131
|
+
setColMenuSearch: Dispatch<SetStateAction<Record<string, string>>>
|
|
132
|
+
setRowHeight: Dispatch<SetStateAction<RowHeight>>
|
|
133
|
+
setShowGridlines: Dispatch<SetStateAction<boolean>>
|
|
134
|
+
setFilterBarVisible: Dispatch<SetStateAction<boolean>>
|
|
135
|
+
setSearchOpen: Dispatch<SetStateAction<boolean>>
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
// Parsers + validators
|
|
140
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
const VIEW_TYPES: DataListViewType[] = ["table", "list", "board", "dashboard"]
|
|
143
|
+
|
|
144
|
+
function isViewType(v: unknown): v is DataListViewType {
|
|
145
|
+
return typeof v === "string" && (VIEW_TYPES as string[]).includes(v)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseViewTab(raw: unknown): ViewTab | null {
|
|
149
|
+
if (!raw || typeof raw !== "object") return null
|
|
150
|
+
const o = raw as Record<string, unknown>
|
|
151
|
+
if (typeof o.id !== "string" || typeof o.label !== "string") return null
|
|
152
|
+
if (!isViewType(o.viewType)) return null
|
|
153
|
+
if (typeof o.icon !== "string" || typeof o.filterId !== "string") return null
|
|
154
|
+
return { id: o.id, label: o.label, viewType: o.viewType, icon: o.icon, filterId: o.filterId }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function parsePersistedPage(raw: string | null): PersistedPageV1 | null {
|
|
158
|
+
if (!raw) return null
|
|
159
|
+
try {
|
|
160
|
+
const j = JSON.parse(raw) as unknown
|
|
161
|
+
if (!j || typeof j !== "object") return null
|
|
162
|
+
const o = j as Record<string, unknown>
|
|
163
|
+
if (o.v !== 1) return null
|
|
164
|
+
if (!o.displayOptions || typeof o.displayOptions !== "object") return null
|
|
165
|
+
if (typeof o.showMetrics !== "boolean") return null
|
|
166
|
+
if (!Array.isArray(o.tabs) || typeof o.activeTabId !== "string") return null
|
|
167
|
+
const tabs = o.tabs.map(parseViewTab).filter((t): t is ViewTab => t !== null)
|
|
168
|
+
if (tabs.length === 0) return null
|
|
169
|
+
return {
|
|
170
|
+
v: 1,
|
|
171
|
+
displayOptions: o.displayOptions as DataListDisplayOptions,
|
|
172
|
+
showMetrics: o.showMetrics,
|
|
173
|
+
tabs,
|
|
174
|
+
activeTabId: o.activeTabId,
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function parsePersistedLifecycle(raw: string | null): PersistedLifecycleV1 | null {
|
|
182
|
+
if (!raw) return null
|
|
183
|
+
try {
|
|
184
|
+
const j = JSON.parse(raw) as unknown
|
|
185
|
+
if (!j || typeof j !== "object") return null
|
|
186
|
+
const o = j as Record<string, unknown>
|
|
187
|
+
if (o.v !== 1) return null
|
|
188
|
+
if (!Array.isArray(o.sortRules)) return null
|
|
189
|
+
if (typeof o.search !== "string") return null
|
|
190
|
+
if (!Array.isArray(o.activeFilters)) return null
|
|
191
|
+
if (!o.filterConnectors || typeof o.filterConnectors !== "object") return null
|
|
192
|
+
if (o.groupBy !== null && typeof o.groupBy !== "string") return null
|
|
193
|
+
if (!Array.isArray(o.colOrder)) return null
|
|
194
|
+
if (!Array.isArray(o.hiddenCols)) return null
|
|
195
|
+
if (!o.colWidths || typeof o.colWidths !== "object") return null
|
|
196
|
+
if (!o.colPins || typeof o.colPins !== "object") return null
|
|
197
|
+
if (!o.colWrap || typeof o.colWrap !== "object") return null
|
|
198
|
+
if (!o.colMenuSearch || typeof o.colMenuSearch !== "object") return null
|
|
199
|
+
if (typeof o.rowHeight !== "string") return null
|
|
200
|
+
if (typeof o.showGridlines !== "boolean") return null
|
|
201
|
+
if (typeof o.filterBarVisible !== "boolean") return null
|
|
202
|
+
if (typeof o.searchOpen !== "boolean") return null
|
|
203
|
+
// `extras` is optional; legacy placements payloads kept these at the top
|
|
204
|
+
// level instead and we accept those for back-compat.
|
|
205
|
+
if (o.extras !== undefined && (typeof o.extras !== "object" || o.extras === null)) return null
|
|
206
|
+
return o as unknown as PersistedLifecycleV1
|
|
207
|
+
} catch {
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
213
|
+
// Apply / serialize
|
|
214
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
function mergeColOrder(saved: string[], columnKeys: Set<string>): string[] {
|
|
217
|
+
const ordered = saved.filter(k => columnKeys.has(k))
|
|
218
|
+
for (const k of columnKeys) {
|
|
219
|
+
if (!ordered.includes(k)) ordered.push(k)
|
|
220
|
+
}
|
|
221
|
+
return ordered
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function filterRecordKeys<T extends Record<string, unknown>>(obj: T, keys: Set<string>): T {
|
|
225
|
+
const out = { ...obj }
|
|
226
|
+
for (const k of Object.keys(out)) {
|
|
227
|
+
if (!keys.has(k)) delete out[k]
|
|
228
|
+
}
|
|
229
|
+
return out
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function applyLifecyclePersisted(
|
|
233
|
+
ts: TableStatePersistSlice,
|
|
234
|
+
p: PersistedLifecycleV1,
|
|
235
|
+
columnKeys: Set<string>,
|
|
236
|
+
): void {
|
|
237
|
+
const colOrder = mergeColOrder(p.colOrder, columnKeys)
|
|
238
|
+
const hidden = new Set(p.hiddenCols.filter(k => columnKeys.has(k)))
|
|
239
|
+
const colWidths = filterRecordKeys(p.colWidths, columnKeys) as Record<string, number>
|
|
240
|
+
const colPins = filterRecordKeys(p.colPins, columnKeys) as Record<string, "left" | "right">
|
|
241
|
+
const colWrap = filterRecordKeys(p.colWrap, columnKeys) as Record<string, boolean>
|
|
242
|
+
const colMenuSearch = filterRecordKeys(p.colMenuSearch, columnKeys) as Record<string, string>
|
|
243
|
+
|
|
244
|
+
ts.setSortRules(p.sortRules)
|
|
245
|
+
ts.setSearch(p.search)
|
|
246
|
+
ts.setActiveFilters(p.activeFilters)
|
|
247
|
+
ts.setFilterConnectors(p.filterConnectors)
|
|
248
|
+
ts.setGroupBy(p.groupBy)
|
|
249
|
+
ts.setColOrder(colOrder)
|
|
250
|
+
ts.setHiddenCols(hidden)
|
|
251
|
+
ts.setColWidths(colWidths)
|
|
252
|
+
ts.setColPins(colPins)
|
|
253
|
+
ts.setColWrap(colWrap)
|
|
254
|
+
ts.setColMenuSearch(colMenuSearch)
|
|
255
|
+
ts.setRowHeight(p.rowHeight)
|
|
256
|
+
ts.setShowGridlines(p.showGridlines)
|
|
257
|
+
ts.setFilterBarVisible(p.filterBarVisible)
|
|
258
|
+
ts.setSearchOpen(p.searchOpen)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function serializeLifecycle(
|
|
262
|
+
ts: TableStatePersistSlice,
|
|
263
|
+
extras?: Record<string, unknown>,
|
|
264
|
+
): PersistedLifecycleV1 {
|
|
265
|
+
return {
|
|
266
|
+
v: 1,
|
|
267
|
+
sortRules: ts.sortRules,
|
|
268
|
+
search: ts.search,
|
|
269
|
+
activeFilters: ts.activeFilters,
|
|
270
|
+
filterConnectors: ts.filterConnectors,
|
|
271
|
+
groupBy: ts.groupBy,
|
|
272
|
+
colOrder: ts.colOrder,
|
|
273
|
+
hiddenCols: [...ts.hiddenCols],
|
|
274
|
+
colWidths: { ...ts.colWidths },
|
|
275
|
+
colPins: { ...ts.colPins },
|
|
276
|
+
colWrap: { ...ts.colWrap },
|
|
277
|
+
colMenuSearch: { ...ts.colMenuSearch },
|
|
278
|
+
rowHeight: ts.rowHeight,
|
|
279
|
+
showGridlines: ts.showGridlines,
|
|
280
|
+
filterBarVisible: ts.filterBarVisible,
|
|
281
|
+
searchOpen: ts.searchOpen,
|
|
282
|
+
extras,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Read merged extras from a payload, falling back to the legacy top-level
|
|
288
|
+
* placements fields when `extras` isn't set yet. Generic so hubs can cast to
|
|
289
|
+
* their own extras shape.
|
|
290
|
+
*/
|
|
291
|
+
export function readLifecycleExtras<TExtras extends Record<string, unknown>>(
|
|
292
|
+
p: PersistedLifecycleV1,
|
|
293
|
+
): TExtras | undefined {
|
|
294
|
+
if (p.extras) return p.extras as TExtras
|
|
295
|
+
// Legacy placements payload — synthesise extras from known top-level keys.
|
|
296
|
+
if (
|
|
297
|
+
p.conditionalRules !== undefined ||
|
|
298
|
+
p.pagination !== undefined ||
|
|
299
|
+
p.paginationPage !== undefined ||
|
|
300
|
+
p.paginationPageSize !== undefined
|
|
301
|
+
) {
|
|
302
|
+
const legacy: Record<string, unknown> = {}
|
|
303
|
+
if (p.conditionalRules !== undefined) legacy.conditionalRules = p.conditionalRules
|
|
304
|
+
if (p.pagination !== undefined) legacy.pagination = p.pagination
|
|
305
|
+
if (p.paginationPage !== undefined) legacy.paginationPage = p.paginationPage
|
|
306
|
+
if (p.paginationPageSize !== undefined) legacy.paginationPageSize = p.paginationPageSize
|
|
307
|
+
return legacy as TExtras
|
|
308
|
+
}
|
|
309
|
+
return undefined
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
313
|
+
// Direct storage IO (rarely needed — prefer the hook)
|
|
314
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
export function loadLifecycleFromStorage(
|
|
317
|
+
namespace: string,
|
|
318
|
+
tabId: string,
|
|
319
|
+
): PersistedLifecycleV1 | null {
|
|
320
|
+
if (typeof window === "undefined") return null
|
|
321
|
+
return parsePersistedLifecycle(localStorage.getItem(lifecycleStorageKey(namespace, tabId)))
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function scheduleLifecycleSave(
|
|
325
|
+
namespace: string,
|
|
326
|
+
tabId: string,
|
|
327
|
+
payload: PersistedLifecycleV1,
|
|
328
|
+
): void {
|
|
329
|
+
if (typeof window === "undefined") return
|
|
330
|
+
const key = lifecycleStorageKey(namespace, tabId)
|
|
331
|
+
const prev = lifecycleTimers.get(key)
|
|
332
|
+
if (prev) clearTimeout(prev)
|
|
333
|
+
const t = setTimeout(() => {
|
|
334
|
+
lifecycleTimers.delete(key)
|
|
335
|
+
try {
|
|
336
|
+
localStorage.setItem(key, JSON.stringify(payload))
|
|
337
|
+
} catch {
|
|
338
|
+
/* quota / private mode */
|
|
339
|
+
}
|
|
340
|
+
}, LIFECYCLE_SAVE_DEBOUNCE_MS)
|
|
341
|
+
lifecycleTimers.set(key, t)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function loadPageFromStorage(namespace: string): PersistedPageV1 | null {
|
|
345
|
+
if (typeof window === "undefined") return null
|
|
346
|
+
return parsePersistedPage(localStorage.getItem(pageStorageKey(namespace)))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function schedulePageSave(namespace: string, payload: PersistedPageV1): void {
|
|
350
|
+
if (typeof window === "undefined") return
|
|
351
|
+
const key = pageStorageKey(namespace)
|
|
352
|
+
const prev = pageTimers.get(key)
|
|
353
|
+
if (prev) clearTimeout(prev)
|
|
354
|
+
const t = setTimeout(() => {
|
|
355
|
+
pageTimers.delete(key)
|
|
356
|
+
try {
|
|
357
|
+
localStorage.setItem(key, JSON.stringify(payload))
|
|
358
|
+
} catch {
|
|
359
|
+
/* quota */
|
|
360
|
+
}
|
|
361
|
+
}, PAGE_SAVE_DEBOUNCE_MS)
|
|
362
|
+
pageTimers.set(key, t)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
366
|
+
// React hook (the recommended entry point)
|
|
367
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
export interface UseTableStateLifecycleOptions<TExtras extends Record<string, unknown> | void = void> {
|
|
370
|
+
/** Storage namespace, e.g. `"placements"`, `"team"`, `"question-bank"`. */
|
|
371
|
+
namespace: string
|
|
372
|
+
/**
|
|
373
|
+
* Sub-key per lifecycle tab. A hub with only one lifecycle should pass a
|
|
374
|
+
* stable constant like `"main"`. A hub with multiple lifecycle scopes
|
|
375
|
+
* (e.g. placements' "all / mine / shared" tabs) passes the active scope id.
|
|
376
|
+
*/
|
|
377
|
+
tabId: string
|
|
378
|
+
/** `useTableState(...)` return value. Satisfies `TableStatePersistSlice`. */
|
|
379
|
+
tableState: TableStatePersistSlice
|
|
380
|
+
/**
|
|
381
|
+
* Valid column keys for the active table. Persisted column references that
|
|
382
|
+
* are no longer present (e.g. column was renamed / removed) are dropped on
|
|
383
|
+
* load.
|
|
384
|
+
*/
|
|
385
|
+
columnKeys: Set<string>
|
|
386
|
+
/**
|
|
387
|
+
* Current value of extra state to persist alongside the table (optional).
|
|
388
|
+
* Pass `undefined` / omit entirely if the hub only persists the table.
|
|
389
|
+
*/
|
|
390
|
+
extras?: TExtras
|
|
391
|
+
/**
|
|
392
|
+
* Called once when the persisted record is loaded, with whatever `extras`
|
|
393
|
+
* it contained (or legacy top-level fields). Use this to rehydrate the
|
|
394
|
+
* matching React state in the consumer.
|
|
395
|
+
*/
|
|
396
|
+
onLoadExtras?: (extras: TExtras | Record<string, unknown> | undefined) => void
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Opt-in lifecycle persistence for a `DataTable`. Wires up:
|
|
401
|
+
*
|
|
402
|
+
* 1. **Load** (`useLayoutEffect`, once per `tabId` / `columnKeys` change) —
|
|
403
|
+
* reads from `localStorage` and pushes the persisted state back into
|
|
404
|
+
* `tableState` setters; then calls `onLoadExtras` so the consumer can
|
|
405
|
+
* restore hub-specific state too.
|
|
406
|
+
* 2. **Save** (`useEffect`, debounced ~400ms) — re-serializes whenever any
|
|
407
|
+
* persisted slice changes and writes to `localStorage`.
|
|
408
|
+
*
|
|
409
|
+
* Behaviour:
|
|
410
|
+
*
|
|
411
|
+
* - SSR-safe (`localStorage` reads are guarded; the layout effect only runs
|
|
412
|
+
* on the client).
|
|
413
|
+
* - No render hits: setters are called inside the effect, not during render.
|
|
414
|
+
* - Doesn't depend on the full `tableState` object — it depends on each
|
|
415
|
+
* persisted slice individually so the table object identity (which is fresh
|
|
416
|
+
* every render) doesn't force re-saves.
|
|
417
|
+
*/
|
|
418
|
+
export function useTableStateLifecycle<TExtras extends Record<string, unknown> | void = void>(
|
|
419
|
+
opts: UseTableStateLifecycleOptions<TExtras>,
|
|
420
|
+
): void {
|
|
421
|
+
const { namespace, tabId, tableState, columnKeys, extras, onLoadExtras } = opts
|
|
422
|
+
|
|
423
|
+
// Keep `onLoadExtras` in a ref so the load effect doesn't refire when the
|
|
424
|
+
// consumer passes a new function reference each render.
|
|
425
|
+
const onLoadExtrasRef = React.useRef(onLoadExtras)
|
|
426
|
+
React.useEffect(() => {
|
|
427
|
+
onLoadExtrasRef.current = onLoadExtras
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
// ── Load ────────────────────────────────────────────────────────────────
|
|
431
|
+
// useLayoutEffect so the rehydrated state paints in the first frame after
|
|
432
|
+
// mount instead of flashing the unhydrated defaults first.
|
|
433
|
+
React.useLayoutEffect(() => {
|
|
434
|
+
const raw = loadLifecycleFromStorage(namespace, tabId)
|
|
435
|
+
if (!raw) return
|
|
436
|
+
applyLifecyclePersisted(tableState, raw, columnKeys)
|
|
437
|
+
const e = readLifecycleExtras<Record<string, unknown>>(raw)
|
|
438
|
+
onLoadExtrasRef.current?.(e as TExtras | Record<string, unknown> | undefined)
|
|
439
|
+
// `tableState` is freshly returned each render; depending on it would
|
|
440
|
+
// re-apply persisted state on every keystroke and undo edits. Depend only
|
|
441
|
+
// on the load scope (namespace + tabId + column set).
|
|
442
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
443
|
+
}, [namespace, tabId, columnKeys])
|
|
444
|
+
|
|
445
|
+
// ── Save ────────────────────────────────────────────────────────────────
|
|
446
|
+
// Serialise + debounce on every persisted slice change. Don't depend on
|
|
447
|
+
// the full `tableState` (fresh per render); depend on each slice instead so
|
|
448
|
+
// a no-op render doesn't trigger a no-op save.
|
|
449
|
+
const extrasJson = React.useMemo(() => (extras ? JSON.stringify(extras) : ""), [extras])
|
|
450
|
+
React.useEffect(() => {
|
|
451
|
+
const payload = serializeLifecycle(tableState, extras as Record<string, unknown> | undefined)
|
|
452
|
+
scheduleLifecycleSave(namespace, tabId, payload)
|
|
453
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
454
|
+
}, [
|
|
455
|
+
namespace,
|
|
456
|
+
tabId,
|
|
457
|
+
extrasJson,
|
|
458
|
+
tableState.sortRules,
|
|
459
|
+
tableState.search,
|
|
460
|
+
tableState.activeFilters,
|
|
461
|
+
tableState.filterConnectors,
|
|
462
|
+
tableState.groupBy,
|
|
463
|
+
tableState.colOrder,
|
|
464
|
+
tableState.hiddenCols,
|
|
465
|
+
tableState.colWidths,
|
|
466
|
+
tableState.colPins,
|
|
467
|
+
tableState.colWrap,
|
|
468
|
+
tableState.colMenuSearch,
|
|
469
|
+
tableState.rowHeight,
|
|
470
|
+
tableState.showGridlines,
|
|
471
|
+
tableState.filterBarVisible,
|
|
472
|
+
tableState.searchOpen,
|
|
473
|
+
])
|
|
474
|
+
}
|
package/template/next.config.mjs
CHANGED
|
@@ -4,12 +4,168 @@ const withBundleAnalyzer = bundleAnalyzer({
|
|
|
4
4
|
enabled: process.env.ANALYZE === "true",
|
|
5
5
|
})
|
|
6
6
|
|
|
7
|
+
const isDev = process.env.NODE_ENV !== "production"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Content Security Policy
|
|
11
|
+
*
|
|
12
|
+
* Allowlist matches the runtime third-party origins this app actually contacts
|
|
13
|
+
* (see `app/layout.tsx` and `lib/{logo-dev,stock-portrait}.ts`):
|
|
14
|
+
*
|
|
15
|
+
* • Font Awesome Pro Kit — *.fontawesome.com (loader at kit.*, Pro CSS/font/telemetry
|
|
16
|
+
* at ka-p.*, free fallback at ka-f.*). Wildcard is used
|
|
17
|
+
* because FA rotates resource subdomains per kit tier and
|
|
18
|
+
* every subdomain is FA-controlled.
|
|
19
|
+
* • Adobe Typekit — use.typekit.net (CSS) + p.typekit.net (font files)
|
|
20
|
+
* • Avatars (mock) — img.logo.dev (school logos), randomuser.me (mock portraits)
|
|
21
|
+
*
|
|
22
|
+
* `'unsafe-inline'` is kept for `style-src` because Tailwind injects inline styles
|
|
23
|
+
* and the chart primitive uses one `<style dangerouslySetInnerHTML>` block for
|
|
24
|
+
* per-instance CSS variables. `'unsafe-inline'` is kept for `script-src` because
|
|
25
|
+
* Next.js inlines small bootstrap scripts (page data, RSC payload) on every
|
|
26
|
+
* route; migrating to nonces is a separate refactor. `'unsafe-eval'` is only
|
|
27
|
+
* permitted in development for HMR.
|
|
28
|
+
*
|
|
29
|
+
* If you add a new third-party origin (fonts, images, telemetry), update the
|
|
30
|
+
* matching directive below before shipping.
|
|
31
|
+
*/
|
|
32
|
+
function buildContentSecurityPolicy() {
|
|
33
|
+
// `https://*.fontawesome.com` covers every FA Kit subdomain (loader at
|
|
34
|
+
// `kit.fontawesome.com`, Pro resources at `ka-p.fontawesome.com`, free
|
|
35
|
+
// fallback at `ka-f.fontawesome.com`, telemetry, etc.) without us having to
|
|
36
|
+
// chase per-tier subdomain changes. Every subdomain is FA-controlled.
|
|
37
|
+
const FA_HOSTS = "https://*.fontawesome.com"
|
|
38
|
+
|
|
39
|
+
const scriptSrc = [
|
|
40
|
+
"'self'",
|
|
41
|
+
"'unsafe-inline'",
|
|
42
|
+
isDev ? "'unsafe-eval'" : "",
|
|
43
|
+
FA_HOSTS,
|
|
44
|
+
]
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join(" ")
|
|
47
|
+
|
|
48
|
+
const styleSrc = [
|
|
49
|
+
"'self'",
|
|
50
|
+
"'unsafe-inline'",
|
|
51
|
+
"https://use.typekit.net",
|
|
52
|
+
"https://p.typekit.net",
|
|
53
|
+
FA_HOSTS,
|
|
54
|
+
].join(" ")
|
|
55
|
+
|
|
56
|
+
const fontSrc = [
|
|
57
|
+
"'self'",
|
|
58
|
+
"data:",
|
|
59
|
+
"https://use.typekit.net",
|
|
60
|
+
"https://p.typekit.net",
|
|
61
|
+
FA_HOSTS,
|
|
62
|
+
].join(" ")
|
|
63
|
+
|
|
64
|
+
const imgSrc = [
|
|
65
|
+
"'self'",
|
|
66
|
+
"data:",
|
|
67
|
+
"blob:",
|
|
68
|
+
"https://img.logo.dev",
|
|
69
|
+
"https://randomuser.me",
|
|
70
|
+
FA_HOSTS,
|
|
71
|
+
].join(" ")
|
|
72
|
+
|
|
73
|
+
const connectSrc = [
|
|
74
|
+
"'self'",
|
|
75
|
+
"https://use.typekit.net",
|
|
76
|
+
FA_HOSTS,
|
|
77
|
+
// Next.js dev server uses websockets for HMR.
|
|
78
|
+
isDev ? "ws:" : "",
|
|
79
|
+
isDev ? "wss:" : "",
|
|
80
|
+
]
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.join(" ")
|
|
83
|
+
|
|
84
|
+
const directives = [
|
|
85
|
+
`default-src 'self'`,
|
|
86
|
+
`script-src ${scriptSrc}`,
|
|
87
|
+
`style-src ${styleSrc}`,
|
|
88
|
+
`font-src ${fontSrc}`,
|
|
89
|
+
`img-src ${imgSrc}`,
|
|
90
|
+
`connect-src ${connectSrc}`,
|
|
91
|
+
`frame-ancestors 'none'`,
|
|
92
|
+
`frame-src 'none'`,
|
|
93
|
+
`object-src 'none'`,
|
|
94
|
+
`base-uri 'self'`,
|
|
95
|
+
`form-action 'self'`,
|
|
96
|
+
`manifest-src 'self'`,
|
|
97
|
+
`worker-src 'self' blob:`,
|
|
98
|
+
// `upgrade-insecure-requests` is only meaningful on HTTPS responses.
|
|
99
|
+
isDev ? "" : "upgrade-insecure-requests",
|
|
100
|
+
]
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.join("; ")
|
|
103
|
+
|
|
104
|
+
return directives
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const SECURITY_HEADERS = [
|
|
108
|
+
{
|
|
109
|
+
key: "Content-Security-Policy",
|
|
110
|
+
value: buildContentSecurityPolicy(),
|
|
111
|
+
},
|
|
112
|
+
// Defence-in-depth — `frame-ancestors 'none'` already blocks framing in
|
|
113
|
+
// modern browsers, but X-Frame-Options is still honoured by older clients
|
|
114
|
+
// and is required by some scanners.
|
|
115
|
+
{ key: "X-Frame-Options", value: "DENY" },
|
|
116
|
+
{ key: "X-Content-Type-Options", value: "nosniff" },
|
|
117
|
+
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
|
118
|
+
{
|
|
119
|
+
key: "Permissions-Policy",
|
|
120
|
+
value: [
|
|
121
|
+
"accelerometer=()",
|
|
122
|
+
"autoplay=()",
|
|
123
|
+
"camera=()",
|
|
124
|
+
"display-capture=()",
|
|
125
|
+
"encrypted-media=()",
|
|
126
|
+
"fullscreen=(self)",
|
|
127
|
+
"geolocation=()",
|
|
128
|
+
"gyroscope=()",
|
|
129
|
+
"magnetometer=()",
|
|
130
|
+
"microphone=()",
|
|
131
|
+
"midi=()",
|
|
132
|
+
"payment=()",
|
|
133
|
+
"picture-in-picture=()",
|
|
134
|
+
"publickey-credentials-get=()",
|
|
135
|
+
"screen-wake-lock=()",
|
|
136
|
+
"sync-xhr=()",
|
|
137
|
+
"usb=()",
|
|
138
|
+
"xr-spatial-tracking=()",
|
|
139
|
+
"interest-cohort=()",
|
|
140
|
+
].join(", "),
|
|
141
|
+
},
|
|
142
|
+
// HSTS only takes effect over HTTPS; harmless on http://localhost during dev.
|
|
143
|
+
// Subdomain + preload opt-in — drop `; preload` if the apex domain is not
|
|
144
|
+
// ready for the HSTS preload list.
|
|
145
|
+
{
|
|
146
|
+
key: "Strict-Transport-Security",
|
|
147
|
+
value: "max-age=63072000; includeSubDomains; preload",
|
|
148
|
+
},
|
|
149
|
+
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
|
150
|
+
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
|
|
151
|
+
]
|
|
152
|
+
|
|
7
153
|
/** @type {import('next').NextConfig} */
|
|
8
154
|
const nextConfig = {
|
|
9
155
|
transpilePackages: ["@exxatdesignux/ui"],
|
|
10
156
|
experimental: {
|
|
11
157
|
optimizePackageImports: ["lucide-react", "recharts", "@exxatdesignux/ui"],
|
|
12
158
|
},
|
|
159
|
+
async headers() {
|
|
160
|
+
return [
|
|
161
|
+
{
|
|
162
|
+
// Apply to every route. Static assets under `/_next/static/*` get the
|
|
163
|
+
// same headers — that's fine since they are first-party.
|
|
164
|
+
source: "/:path*",
|
|
165
|
+
headers: SECURITY_HEADERS,
|
|
166
|
+
},
|
|
167
|
+
]
|
|
168
|
+
},
|
|
13
169
|
async redirects() {
|
|
14
170
|
return [
|
|
15
171
|
{ source: "/rotations", destination: "/examples", permanent: false },
|
package/template/package.json
CHANGED
|
@@ -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.
|
|
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",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"react-hook-form": "^7.72.0",
|
|
51
51
|
"react-resizable-panels": "^4.10.0",
|
|
52
52
|
"recharts": "^2.15.4",
|
|
53
|
-
"shadcn": "^4.
|
|
53
|
+
"shadcn": "^4.7.0",
|
|
54
54
|
"sonner": "^2.0.7",
|
|
55
55
|
"tailwind-merge": "^3.5.0",
|
|
56
56
|
"tw-animate-css": "^1.4.0",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@eslint/eslintrc": "^3",
|
|
63
|
-
"@next/bundle-analyzer": "
|
|
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,10 +69,10 @@
|
|
|
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.
|
|
72
|
+
"eslint-config-next": "16.2.6",
|
|
73
73
|
"jsdom": "^26.1.0",
|
|
74
|
-
"pm2": "^
|
|
75
|
-
"postcss": "^8",
|
|
74
|
+
"pm2": "^7.0.1",
|
|
75
|
+
"postcss": "^8.5.14",
|
|
76
76
|
"prettier": "^3.8.1",
|
|
77
77
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
78
78
|
"tailwindcss": "^4.2.1",
|