@exxatdesignux/ui 0.2.16 → 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 +11 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +148 -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 +14 -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/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +18 -15
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/question-bank/layout.tsx +18 -5
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/globals.css +108 -1
- package/template/app/layout.tsx +41 -5
- package/template/components/app-sidebar.tsx +68 -34
- package/template/components/ask-leo-sidebar.tsx +0 -2
- 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/index.ts +7 -3
- package/template/components/data-views/os-folder-glyph.tsx +8 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +172 -317
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +74 -46
- 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 +2 -1
- 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 -8
- package/template/components/product-wordmark.tsx +285 -0
- package/template/components/question-bank-client.tsx +20 -2
- 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-secondary-nav.tsx +0 -3
- package/template/components/question-bank-table.tsx +30 -5
- package/template/components/rotations-empty-state.tsx +3 -0
- package/template/components/secondary-panel.tsx +23 -3
- package/template/components/settings-appearance-card.tsx +584 -141
- package/template/components/site-header.tsx +36 -31
- 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/nested-secondary-panel-shell.tsx +8 -2
- 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/eslint.config.mjs +18 -0
- 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/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 +44 -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 +3 -3
- package/template/stores/app-store.ts +46 -1
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import * as React from "react"
|
|
15
|
-
import
|
|
15
|
+
import {
|
|
16
|
+
PageBreadcrumbBack,
|
|
17
|
+
PageBreadcrumbTrail,
|
|
18
|
+
type PageBreadcrumbBackProps,
|
|
19
|
+
type PageBreadcrumbTrailItem,
|
|
20
|
+
} from "@/components/page-breadcrumb-trail"
|
|
16
21
|
import { Separator } from "@/components/ui/separator"
|
|
17
22
|
import { SidebarTrigger } from "@/components/ui/sidebar"
|
|
18
23
|
import { Kbd, KbdGroup } from "@/components/ui/kbd"
|
|
@@ -25,19 +30,26 @@ import { AskLeoToggle } from "@/components/ask-leo-sidebar"
|
|
|
25
30
|
import { useModKeyLabel } from "@/hooks/use-mod-key-label"
|
|
26
31
|
import { cn } from "@/lib/utils"
|
|
27
32
|
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
href?: string
|
|
31
|
-
}
|
|
33
|
+
export type BreadcrumbItem = PageBreadcrumbTrailItem
|
|
34
|
+
export type SiteHeaderBackLink = Pick<PageBreadcrumbBackProps, "label" | "href">
|
|
32
35
|
|
|
33
36
|
export interface SiteHeaderProps {
|
|
34
|
-
/** Current page title (last breadcrumb segment) */
|
|
37
|
+
/** Current page title (last breadcrumb segment in trail mode). */
|
|
35
38
|
title?: string
|
|
36
39
|
/** Full breadcrumb trail — each item can be a link or plain text. Title is appended automatically as the last segment. */
|
|
37
40
|
breadcrumbs?: BreadcrumbItem[]
|
|
41
|
+
/**
|
|
42
|
+
* Back-icon variant — parent link only (no `title` segment in the header).
|
|
43
|
+
* Prefer when the page `<h1>` carries the current title (e.g. New question composer).
|
|
44
|
+
*/
|
|
45
|
+
back?: SiteHeaderBackLink
|
|
38
46
|
}
|
|
39
47
|
|
|
40
|
-
export function SiteHeader({
|
|
48
|
+
export function SiteHeader({
|
|
49
|
+
title = "Dashboard",
|
|
50
|
+
breadcrumbs,
|
|
51
|
+
back,
|
|
52
|
+
}: SiteHeaderProps) {
|
|
41
53
|
const mod = useModKeyLabel()
|
|
42
54
|
const [isStuck, setIsStuck] = React.useState(false)
|
|
43
55
|
|
|
@@ -51,7 +63,13 @@ export function SiteHeader({ title = "Dashboard", breadcrumbs }: SiteHeaderProps
|
|
|
51
63
|
return (
|
|
52
64
|
<div
|
|
53
65
|
className={cn(
|
|
54
|
-
|
|
66
|
+
// Sticky page chrome sits BELOW every Radix overlay (DropdownMenu /
|
|
67
|
+
// Popover / Select / Dialog / Sheet / Tooltip / Drawer all render at
|
|
68
|
+
// z-50). Previously `z-60` here caused the school/product switcher
|
|
69
|
+
// dropdown to open behind the breadcrumb. `z-30` keeps the header
|
|
70
|
+
// above page content (charts, tables, scrolled rows) but below
|
|
71
|
+
// floating overlays.
|
|
72
|
+
"sticky top-0 z-30 transition-colors",
|
|
55
73
|
isStuck ? "bg-sidebar border-b border-border" : "bg-transparent",
|
|
56
74
|
)}
|
|
57
75
|
>
|
|
@@ -78,29 +96,16 @@ export function SiteHeader({ title = "Dashboard", breadcrumbs }: SiteHeaderProps
|
|
|
78
96
|
className="mx-2 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-auto"
|
|
79
97
|
/>
|
|
80
98
|
|
|
81
|
-
{
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
</Link>
|
|
92
|
-
) : (
|
|
93
|
-
<span className="font-sans text-sm text-muted-foreground tracking-normal">
|
|
94
|
-
{crumb.label}
|
|
95
|
-
</span>
|
|
96
|
-
)}
|
|
97
|
-
<i className="fa-light fa-chevron-right text-xs text-muted-foreground/50" aria-hidden="true" />
|
|
98
|
-
</span>
|
|
99
|
-
))}
|
|
100
|
-
<span className="font-sans text-sm font-medium text-foreground tracking-normal truncate">
|
|
101
|
-
{title}
|
|
102
|
-
</span>
|
|
103
|
-
</nav>
|
|
99
|
+
{back ? (
|
|
100
|
+
<PageBreadcrumbBack {...back} className="min-w-0 flex-1" />
|
|
101
|
+
) : (
|
|
102
|
+
<PageBreadcrumbTrail
|
|
103
|
+
variant="header"
|
|
104
|
+
items={breadcrumbs}
|
|
105
|
+
currentPage={title}
|
|
106
|
+
className="flex-1"
|
|
107
|
+
/>
|
|
108
|
+
)}
|
|
104
109
|
|
|
105
110
|
<div className="ml-auto shrink-0">
|
|
106
111
|
<AskLeoToggle />
|
|
@@ -3,45 +3,40 @@
|
|
|
3
3
|
import Link from "next/link"
|
|
4
4
|
import type { SiteDirectoryRow } from "@/lib/mock/sites-directory"
|
|
5
5
|
import { ListPageBoardCard } from "@/components/data-views/list-page-board-card"
|
|
6
|
+
import { DataRowList } from "@/components/data-views/data-row-list"
|
|
6
7
|
|
|
7
8
|
export function SitesListView({ rows }: { rows: SiteDirectoryRow[] }) {
|
|
8
|
-
if (rows.length === 0) {
|
|
9
|
-
return (
|
|
10
|
-
<div className="px-4 py-16 text-center lg:px-6">
|
|
11
|
-
<p className="text-sm text-muted-foreground">No sites match your search.</p>
|
|
12
|
-
</div>
|
|
13
|
-
)
|
|
14
|
-
}
|
|
15
|
-
|
|
16
9
|
return (
|
|
17
|
-
<
|
|
18
|
-
{rows
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
10
|
+
<DataRowList<SiteDirectoryRow>
|
|
11
|
+
rows={rows}
|
|
12
|
+
getRowId={site => site.id}
|
|
13
|
+
emptyState="No sites match your search."
|
|
14
|
+
ariaLabel="Sites"
|
|
15
|
+
renderRow={site => (
|
|
16
|
+
<Link
|
|
17
|
+
href={site.url}
|
|
18
|
+
className="block rounded-xl text-inherit no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
19
|
+
>
|
|
20
|
+
<ListPageBoardCard
|
|
21
|
+
layout="row"
|
|
22
|
+
interactive
|
|
23
|
+
rowContainerClassName="flex flex-row items-center gap-3"
|
|
24
|
+
leading={
|
|
25
|
+
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-md bg-brand/10 text-brand">
|
|
26
|
+
<i className="fa-light fa-hospital text-sm" aria-hidden="true" />
|
|
27
|
+
</span>
|
|
28
|
+
}
|
|
29
|
+
rowEnd={
|
|
30
|
+
<i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
|
|
31
|
+
}
|
|
23
32
|
>
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
rowEnd={
|
|
34
|
-
<i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
|
|
35
|
-
}
|
|
36
|
-
>
|
|
37
|
-
<div className="space-y-0.5">
|
|
38
|
-
<p className="truncate text-sm font-semibold text-foreground">{site.name}</p>
|
|
39
|
-
<p className="truncate text-xs text-muted-foreground">{site.id}</p>
|
|
40
|
-
</div>
|
|
41
|
-
</ListPageBoardCard>
|
|
42
|
-
</Link>
|
|
43
|
-
</li>
|
|
44
|
-
))}
|
|
45
|
-
</ul>
|
|
33
|
+
<div className="space-y-0.5">
|
|
34
|
+
<p className="truncate text-sm font-semibold text-foreground">{site.name}</p>
|
|
35
|
+
<p className="truncate text-xs text-muted-foreground">{site.id}</p>
|
|
36
|
+
</div>
|
|
37
|
+
</ListPageBoardCard>
|
|
38
|
+
</Link>
|
|
39
|
+
)}
|
|
40
|
+
/>
|
|
46
41
|
)
|
|
47
42
|
}
|
|
@@ -10,6 +10,7 @@ import Link from "next/link"
|
|
|
10
10
|
import type { SiteDirectoryRow } from "@/lib/mock/sites-directory"
|
|
11
11
|
import { DataTable, DataTableToolbar } from "@/components/data-table"
|
|
12
12
|
import { useTableState } from "@/components/data-table/use-table-state"
|
|
13
|
+
import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
|
|
13
14
|
import type { ColumnDef } from "@/components/data-table/types"
|
|
14
15
|
import type { DataListViewType } from "@/lib/data-list-view"
|
|
15
16
|
import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
|
|
@@ -193,6 +194,25 @@ export const SitesTable = React.forwardRef<
|
|
|
193
194
|
|
|
194
195
|
const tableState = useTableState<SiteDirectoryRow>(sites, columns, { key: "name", dir: "asc" })
|
|
195
196
|
|
|
197
|
+
// Persist this hub's table lifecycle (sort / search / filters / column
|
|
198
|
+
// visibility / etc.) to localStorage. See `lib/table-state-lifecycle`.
|
|
199
|
+
const lifecycleColumnKeys = React.useMemo(
|
|
200
|
+
() => new Set(columns.map(c => c.key)),
|
|
201
|
+
[columns],
|
|
202
|
+
)
|
|
203
|
+
useTableStateLifecycle({
|
|
204
|
+
namespace: "sites",
|
|
205
|
+
tabId: "main",
|
|
206
|
+
tableState,
|
|
207
|
+
columnKeys: lifecycleColumnKeys,
|
|
208
|
+
extras: { conditionalRules },
|
|
209
|
+
onLoadExtras: e => {
|
|
210
|
+
if (e && Array.isArray(e.conditionalRules)) {
|
|
211
|
+
setConditionalRules(e.conditionalRules as ConditionalRule[])
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
|
|
196
216
|
React.useImperativeHandle(
|
|
197
217
|
ref,
|
|
198
218
|
() => ({
|
|
@@ -200,6 +220,10 @@ export const SitesTable = React.forwardRef<
|
|
|
200
220
|
tableState.setSheetOpen(true)
|
|
201
221
|
},
|
|
202
222
|
}),
|
|
223
|
+
// `tableState` is freshly returned each render by useTableState; depending
|
|
224
|
+
// on it would re-create the imperative handle on every render. Only the
|
|
225
|
+
// React setter is needed (and is referentially stable).
|
|
226
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
203
227
|
[tableState.setSheetOpen],
|
|
204
228
|
)
|
|
205
229
|
|
|
@@ -250,7 +250,7 @@ export function TablePropertiesDrawer({
|
|
|
250
250
|
showOverlay={false}
|
|
251
251
|
// w-[min(20rem,calc(100vw-1rem))]: cap to viewport width - 1rem at narrow/zoomed viewports
|
|
252
252
|
// so the drawer never overflows horizontally. Use 100svh so height is correct on mobile.
|
|
253
|
-
className="w-[min(20rem,calc(100vw-1rem))] p-0 gap-0 flex flex-col border border-border shadow-xl rounded-xl overflow-hidden"
|
|
253
|
+
className="z-[80] w-[min(20rem,calc(100vw-1rem))] p-0 gap-0 flex flex-col border border-border shadow-xl rounded-xl overflow-hidden"
|
|
254
254
|
style={{ top: "0.5rem", bottom: "0.5rem", right: "0.5rem", height: "calc(100svh - 1rem)" }}
|
|
255
255
|
>
|
|
256
256
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Team page — primary list template: ListPageTemplate + KeyMetrics + TeamTable (same composition as
|
|
4
|
+
* Team page — primary list template: ListPageTemplate + KeyMetrics + TeamTable (same composition as PlacementsClient).
|
|
5
5
|
* Imports from `@/components/data-views` for shared list-page + view types.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* TeamListView — full-width rows for team roster (same data as DataTable / board).
|
|
5
|
+
* Shell from generic `DataRowList`; row body stays team-specific (avatar,
|
|
6
|
+
* name, role, email, status badge).
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
|
-
import * as React from "react"
|
|
8
9
|
import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
|
|
9
10
|
import { ListPageBoardCard, ListPageBoardCardAvatar } from "@/components/data-views/list-page-board-card"
|
|
11
|
+
import { DataRowList } from "@/components/data-views/data-row-list"
|
|
10
12
|
import {
|
|
11
13
|
TEAM_MEMBER_STATUS_BADGE_CLASS,
|
|
12
14
|
TEAM_MEMBER_STATUS_ICON,
|
|
@@ -14,42 +16,6 @@ import {
|
|
|
14
16
|
} from "@/lib/list-status-badges"
|
|
15
17
|
import type { TeamMember } from "@/lib/mock/team"
|
|
16
18
|
|
|
17
|
-
function TeamListRow({
|
|
18
|
-
member,
|
|
19
|
-
onRowActivate,
|
|
20
|
-
}: {
|
|
21
|
-
member: TeamMember
|
|
22
|
-
onRowActivate?: (member: TeamMember) => void
|
|
23
|
-
}) {
|
|
24
|
-
return (
|
|
25
|
-
<li>
|
|
26
|
-
<ListPageBoardCard
|
|
27
|
-
layout="row"
|
|
28
|
-
rowContainerClassName="flex flex-row items-center gap-3"
|
|
29
|
-
onClick={onRowActivate ? () => onRowActivate(member) : undefined}
|
|
30
|
-
leading={<ListPageBoardCardAvatar initials={member.initials} className="size-9" />}
|
|
31
|
-
rowEnd={
|
|
32
|
-
<div className="flex shrink-0 items-center gap-2">
|
|
33
|
-
<ListHubStatusBadge
|
|
34
|
-
surface="board"
|
|
35
|
-
label={TEAM_MEMBER_STATUS_LABEL[member.status]}
|
|
36
|
-
tintClassName={TEAM_MEMBER_STATUS_BADGE_CLASS[member.status]}
|
|
37
|
-
icon={TEAM_MEMBER_STATUS_ICON[member.status]}
|
|
38
|
-
/>
|
|
39
|
-
<i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
|
|
40
|
-
</div>
|
|
41
|
-
}
|
|
42
|
-
>
|
|
43
|
-
<div className="space-y-0.5">
|
|
44
|
-
<p className="truncate text-sm font-semibold text-foreground">{member.name}</p>
|
|
45
|
-
<p className="text-xs text-muted-foreground">{member.role}</p>
|
|
46
|
-
<p className="truncate text-xs text-muted-foreground">{member.email}</p>
|
|
47
|
-
</div>
|
|
48
|
-
</ListPageBoardCard>
|
|
49
|
-
</li>
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
19
|
export function TeamListView({
|
|
54
20
|
members,
|
|
55
21
|
onRowActivate,
|
|
@@ -57,19 +23,37 @@ export function TeamListView({
|
|
|
57
23
|
members: TeamMember[]
|
|
58
24
|
onRowActivate?: (member: TeamMember) => void
|
|
59
25
|
}) {
|
|
60
|
-
if (members.length === 0) {
|
|
61
|
-
return (
|
|
62
|
-
<div className="px-4 py-16 text-center lg:px-6">
|
|
63
|
-
<p className="text-sm text-muted-foreground">No team members match your filters.</p>
|
|
64
|
-
</div>
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
26
|
return (
|
|
69
|
-
<
|
|
70
|
-
{members
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
27
|
+
<DataRowList<TeamMember>
|
|
28
|
+
rows={members}
|
|
29
|
+
getRowId={m => m.id}
|
|
30
|
+
emptyState="No team members match your filters."
|
|
31
|
+
ariaLabel="Team members"
|
|
32
|
+
renderRow={member => (
|
|
33
|
+
<ListPageBoardCard
|
|
34
|
+
layout="row"
|
|
35
|
+
rowContainerClassName="flex flex-row items-center gap-3"
|
|
36
|
+
onClick={onRowActivate ? () => onRowActivate(member) : undefined}
|
|
37
|
+
leading={<ListPageBoardCardAvatar initials={member.initials} className="size-9" />}
|
|
38
|
+
rowEnd={
|
|
39
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
40
|
+
<ListHubStatusBadge
|
|
41
|
+
surface="board"
|
|
42
|
+
label={TEAM_MEMBER_STATUS_LABEL[member.status]}
|
|
43
|
+
tintClassName={TEAM_MEMBER_STATUS_BADGE_CLASS[member.status]}
|
|
44
|
+
icon={TEAM_MEMBER_STATUS_ICON[member.status]}
|
|
45
|
+
/>
|
|
46
|
+
<i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
|
|
47
|
+
</div>
|
|
48
|
+
}
|
|
49
|
+
>
|
|
50
|
+
<div className="space-y-0.5">
|
|
51
|
+
<p className="truncate text-sm font-semibold text-foreground">{member.name}</p>
|
|
52
|
+
<p className="text-xs text-muted-foreground">{member.role}</p>
|
|
53
|
+
<p className="truncate text-xs text-muted-foreground">{member.email}</p>
|
|
54
|
+
</div>
|
|
55
|
+
</ListPageBoardCard>
|
|
56
|
+
)}
|
|
57
|
+
/>
|
|
74
58
|
)
|
|
75
59
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
TEAM_MEMBER_STATUS_ICON,
|
|
14
14
|
TEAM_MEMBER_STATUS_LABEL,
|
|
15
15
|
} from "@/lib/list-status-badges"
|
|
16
|
+
import { mailtoHref } from "@/lib/mailto"
|
|
16
17
|
import type { TeamMember } from "@/lib/mock/team"
|
|
17
18
|
import { DataTable, DataTableToolbar } from "@/components/data-table"
|
|
18
19
|
import {
|
|
@@ -37,6 +38,7 @@ import type { DataListViewType } from "@/lib/data-list-view"
|
|
|
37
38
|
import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
|
|
38
39
|
import type { ColumnDef } from "@/components/data-table/types"
|
|
39
40
|
import { useTableState } from "@/components/data-table/use-table-state"
|
|
41
|
+
import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
|
|
40
42
|
import { TablePropertiesDrawerButton } from "@/components/table-properties"
|
|
41
43
|
import type { ConditionalRule, FilterFieldDef, FilterOperator } from "@/components/table-properties/types"
|
|
42
44
|
import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
|
|
@@ -154,7 +156,7 @@ function TeamFinderDetail({
|
|
|
154
156
|
<i className="fa-light fa-envelope text-[10px]" aria-hidden="true" /> Email
|
|
155
157
|
</dt>
|
|
156
158
|
<dd className="text-[13px]">
|
|
157
|
-
<a href={
|
|
159
|
+
<a href={mailtoHref(member.email)} className="text-interactive-foreground hover:underline">{member.email}</a>
|
|
158
160
|
</dd>
|
|
159
161
|
</div>
|
|
160
162
|
<div className="flex flex-col gap-0.5">
|
|
@@ -277,7 +279,7 @@ function buildTeamColumns(members: TeamMember[]): ColumnDef<TeamMember>[] {
|
|
|
277
279
|
operators: ["contains", "not_contains"],
|
|
278
280
|
},
|
|
279
281
|
cell: row => (
|
|
280
|
-
<a href={
|
|
282
|
+
<a href={mailtoHref(row.email)} className="text-sm text-primary hover:underline truncate block">
|
|
281
283
|
{row.email}
|
|
282
284
|
</a>
|
|
283
285
|
),
|
|
@@ -341,7 +343,7 @@ function buildTeamColumns(members: TeamMember[]): ColumnDef<TeamMember>[] {
|
|
|
341
343
|
</Button>
|
|
342
344
|
</DropdownMenuTrigger>
|
|
343
345
|
<DropdownMenuContent align="end">
|
|
344
|
-
<DropdownMenuItem onClick={() => window.open(
|
|
346
|
+
<DropdownMenuItem onClick={() => window.open(mailtoHref(row.email))}>
|
|
345
347
|
<i className="fa-light fa-envelope" aria-hidden="true" />
|
|
346
348
|
Email
|
|
347
349
|
</DropdownMenuItem>
|
|
@@ -400,6 +402,26 @@ export const TeamTable = React.forwardRef<
|
|
|
400
402
|
|
|
401
403
|
const tableState = useTableState(members, columns, { key: "name", dir: "asc" })
|
|
402
404
|
|
|
405
|
+
// Persist this hub's table lifecycle (sort / search / filters / column
|
|
406
|
+
// visibility / etc.) to localStorage. See `lib/table-state-lifecycle` for
|
|
407
|
+
// the centralised hook; pass `extras` for any non-table state.
|
|
408
|
+
const lifecycleColumnKeys = React.useMemo(
|
|
409
|
+
() => new Set(columns.map(c => c.key)),
|
|
410
|
+
[columns],
|
|
411
|
+
)
|
|
412
|
+
useTableStateLifecycle({
|
|
413
|
+
namespace: "team",
|
|
414
|
+
tabId: "main",
|
|
415
|
+
tableState,
|
|
416
|
+
columnKeys: lifecycleColumnKeys,
|
|
417
|
+
extras: { conditionalRules },
|
|
418
|
+
onLoadExtras: e => {
|
|
419
|
+
if (e && Array.isArray(e.conditionalRules)) {
|
|
420
|
+
setConditionalRules(e.conditionalRules as ConditionalRule[])
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
})
|
|
424
|
+
|
|
403
425
|
const dashboardKpi = React.useMemo(
|
|
404
426
|
() => ({
|
|
405
427
|
metrics: teamKpiMetrics(tableState.rows),
|
|
@@ -501,6 +523,10 @@ export const TeamTable = React.forwardRef<
|
|
|
501
523
|
openPropertiesDrawer: () => {
|
|
502
524
|
tableState.setSheetOpen(true)
|
|
503
525
|
},
|
|
526
|
+
// `tableState` is freshly returned each render by useTableState; depending on
|
|
527
|
+
// it would re-create the imperative handle on every render. Only the React
|
|
528
|
+
// setter is needed (and is referentially stable).
|
|
529
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
504
530
|
}), [tableState.setSheetOpen])
|
|
505
531
|
|
|
506
532
|
const teamPanelFinderGroups = React.useMemo(
|
|
@@ -34,10 +34,16 @@ export function NestedSecondaryPanelShell({
|
|
|
34
34
|
"transition-[width,margin,opacity] duration-200 ease-linear",
|
|
35
35
|
open
|
|
36
36
|
? cn(
|
|
37
|
+
// Match the primary sidebar: fill the full viewport height
|
|
38
|
+
// (minus our 0.5rem top + 0.5rem bottom margin from `m-2` →
|
|
39
|
+
// 1rem on desktop where the panel is `md:sticky md:top-2`;
|
|
40
|
+
// 2rem on mobile where the panel scrolls inline and we leave
|
|
41
|
+
// a little more breathing room). No upper cap so tall screens
|
|
42
|
+
// get a fully-extended rail.
|
|
37
43
|
"shrink-0 m-2 mx-2 rounded-xl ring-1 ring-border shadow-sm relative md:sticky md:top-2 bg-[var(--secondary-panel-bg)]",
|
|
38
44
|
compact
|
|
39
|
-
? "w-12 min-w-12 max-w-12 h-[
|
|
40
|
-
: "w-64 min-w-64 max-w-64 h-[
|
|
45
|
+
? "w-12 min-w-12 max-w-12 h-[calc(100svh-2rem)] md:h-[calc(100svh-1rem)]"
|
|
46
|
+
: "w-64 min-w-64 max-w-64 h-[calc(100svh-2rem)] md:h-[calc(100svh-1rem)]",
|
|
41
47
|
)
|
|
42
48
|
: "h-0 min-h-0 shrink overflow-hidden border-0 p-0 m-0 min-w-0 w-0 max-w-0 opacity-0 pointer-events-none",
|
|
43
49
|
className,
|
|
@@ -37,8 +37,32 @@ type Cloud = {
|
|
|
37
37
|
delay: number
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Tiny deterministic PRNG (mulberry32). We use a seeded RNG instead of
|
|
42
|
+
* `Math.random()` so the SVG attributes emitted on the server match the
|
|
43
|
+
* client's first paint — otherwise React reports a hydration mismatch and
|
|
44
|
+
* has to re-paint every drifting `<motion.circle>` on mount, which is both
|
|
45
|
+
* a perf cost and a visible jump.
|
|
46
|
+
*/
|
|
47
|
+
function mulberry32(seed: number): () => number {
|
|
48
|
+
let s = seed >>> 0
|
|
49
|
+
return () => {
|
|
50
|
+
s = (s + 0x6d2b79f5) >>> 0
|
|
51
|
+
let t = s
|
|
52
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
53
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
54
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hashString(str: string): number {
|
|
59
|
+
// Cheap FNV-1a-style hash. Stable across SSR + CSR for the same input.
|
|
60
|
+
let h = 2166136261
|
|
61
|
+
for (let i = 0; i < str.length; i++) {
|
|
62
|
+
h ^= str.charCodeAt(i)
|
|
63
|
+
h = Math.imul(h, 16777619)
|
|
64
|
+
}
|
|
65
|
+
return h >>> 0
|
|
42
66
|
}
|
|
43
67
|
|
|
44
68
|
export function DotPattern({
|
|
@@ -59,33 +83,33 @@ export function DotPattern({
|
|
|
59
83
|
const maskId = `${id}-mask`
|
|
60
84
|
const gradId = `${id}-grad`
|
|
61
85
|
|
|
62
|
-
const clouds = React.useMemo<Cloud[]>(
|
|
63
|
-
()
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
86
|
+
const clouds = React.useMemo<Cloud[]>(() => {
|
|
87
|
+
const rng = mulberry32(hashString(`${id}|${glowCount}`))
|
|
88
|
+
const rand = (min: number, max: number) => min + rng() * (max - min)
|
|
89
|
+
return Array.from({ length: glowCount }).map((_, i) => {
|
|
90
|
+
// Drift diagonally: bottom-right → top-left. Start/end partly off-canvas
|
|
91
|
+
// so the cloud enters and exits softly without a visible edge.
|
|
92
|
+
const startX = rand(85, 120)
|
|
93
|
+
const endX = rand(-20, 15)
|
|
94
|
+
const midX = (startX + endX) / 2 + rand(-6, 6)
|
|
70
95
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
96
|
+
const startY = rand(85, 115)
|
|
97
|
+
const endY = rand(-15, 10)
|
|
98
|
+
const midY = (startY + endY) / 2 + rand(-4, 4)
|
|
74
99
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
100
|
+
const duration = rand(8, 12)
|
|
101
|
+
// Offset clouds by half a cycle so one is arriving as the other leaves.
|
|
102
|
+
const delay = -(i / glowCount) * duration
|
|
78
103
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
)
|
|
104
|
+
return {
|
|
105
|
+
key: i,
|
|
106
|
+
xs: [`${startX}%`, `${midX}%`, `${endX}%`],
|
|
107
|
+
ys: [`${startY}%`, `${midY}%`, `${endY}%`],
|
|
108
|
+
duration,
|
|
109
|
+
delay,
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
}, [glowCount, id])
|
|
89
113
|
|
|
90
114
|
return (
|
|
91
115
|
<svg
|
|
@@ -618,14 +618,34 @@ function InteractiveIcon({ sz, reduced }: { sz: SZ; reduced: boolean }) {
|
|
|
618
618
|
const onDown = React.useCallback(() => setPressed(true), [])
|
|
619
619
|
const onUp = React.useCallback(() => setPressed(false), [])
|
|
620
620
|
|
|
621
|
+
// Track click-effect timers so unmounting (Ask Leo sidebar close) doesn't
|
|
622
|
+
// leave timers running that then call setState on an unmounted component.
|
|
623
|
+
const clickTimersRef = React.useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
|
|
624
|
+
React.useEffect(() => {
|
|
625
|
+
const set = clickTimersRef.current
|
|
626
|
+
return () => {
|
|
627
|
+
for (const t of set) clearTimeout(t)
|
|
628
|
+
set.clear()
|
|
629
|
+
}
|
|
630
|
+
}, [])
|
|
631
|
+
|
|
632
|
+
const ringIdRef = React.useRef(0)
|
|
621
633
|
const onClick = React.useCallback(() => {
|
|
622
634
|
if (reduced) return
|
|
623
635
|
setCast(true)
|
|
624
|
-
setTimeout(() =>
|
|
636
|
+
const tCast = setTimeout(() => {
|
|
637
|
+
clickTimersRef.current.delete(tCast)
|
|
638
|
+
setCast(false)
|
|
639
|
+
}, 720)
|
|
640
|
+
clickTimersRef.current.add(tCast)
|
|
625
641
|
|
|
626
|
-
const id =
|
|
642
|
+
const id = ++ringIdRef.current
|
|
627
643
|
setRings(prev => [...prev, id])
|
|
628
|
-
setTimeout(() =>
|
|
644
|
+
const tRing = setTimeout(() => {
|
|
645
|
+
clickTimersRef.current.delete(tRing)
|
|
646
|
+
setRings(prev => prev.filter(r => r !== id))
|
|
647
|
+
}, 800)
|
|
648
|
+
clickTimersRef.current.add(tRing)
|
|
629
649
|
|
|
630
650
|
spawnBurst(6)
|
|
631
651
|
}, [reduced, spawnBurst])
|