@crossworks/share-ui 0.230.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE.md +135 -0
  2. package/package.json +68 -0
  3. package/src/app-bridge-protocol.ts +115 -0
  4. package/src/app-presenter.tsx +25 -0
  5. package/src/app-sandbox.tsx +552 -0
  6. package/src/appearance.ts +192 -0
  7. package/src/avatar.test.ts +229 -0
  8. package/src/avatar.ts +731 -0
  9. package/src/backgrounds.test.ts +119 -0
  10. package/src/backgrounds.ts +118 -0
  11. package/src/draw-presenter.tsx +39 -0
  12. package/src/event-presenter.tsx +62 -0
  13. package/src/file-presenter.tsx +76 -0
  14. package/src/formula-calculator.tsx +209 -0
  15. package/src/formula-presenter.test.ts +128 -0
  16. package/src/formula-presenter.tsx +301 -0
  17. package/src/help-topics.ts +104 -0
  18. package/src/lib/ink-audit.test.ts +314 -0
  19. package/src/lib/theme-css-blocks.ts +26 -0
  20. package/src/lib/theme-generator.test.ts +179 -0
  21. package/src/lib/theme-registry.gen.ts +352 -0
  22. package/src/lib/themes.test.ts +308 -0
  23. package/src/lib/themes.ts +75 -0
  24. package/src/lib/utils.ts +6 -0
  25. package/src/nav-items.ts +225 -0
  26. package/src/note-presenter.tsx +14 -0
  27. package/src/page-outline.tsx +127 -0
  28. package/src/table-presenter.tsx +226 -0
  29. package/src/task-presenter.tsx +60 -0
  30. package/src/ui/button.tsx +50 -0
  31. package/src/ui/input.tsx +18 -0
  32. package/src/ui/label.tsx +20 -0
  33. package/src/view-payload.ts +82 -0
  34. package/styles/app.css +1098 -0
  35. package/styles/themes.css +6198 -0
  36. package/themes/generate.d.mts +11 -0
  37. package/themes/generate.mjs +618 -0
  38. package/themes/model.d.mts +24 -0
  39. package/themes/model.mjs +213 -0
  40. package/themes/preview.html +145 -0
  41. package/themes/seeds.d.mts +16 -0
  42. package/themes/seeds.mjs +3694 -0
  43. package/tsconfig.json +15 -0
  44. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Color-theme registry. The theme LIST is generated — `themes/seeds.mjs` is
3
+ * the authored source, `pnpm themes:build` regenerates both the CSS and
4
+ * `theme-registry.gen.ts`, and the drift test keeps them in lockstep. This
5
+ * module owns the TYPE, the default, and the random-theme behaviour toggles.
6
+ *
7
+ * Swatches ([primary, accent, background], per mode) drive the picker preview
8
+ * only. Both modes ship because the picker sits inside the app it is theming:
9
+ * showing light-mode dots to someone browsing in dark mode advertises colours
10
+ * they would not get.
11
+ */
12
+ export type ThemeSwatches = readonly [string, string, string];
13
+
14
+ export type ColorTheme = {
15
+ id: string;
16
+ label: string;
17
+ swatches: { light: ThemeSwatches; dark: ThemeSwatches };
18
+ };
19
+
20
+ import { GENERATED_COLOR_THEMES } from './theme-registry.gen';
21
+
22
+ export const COLOR_THEMES: ColorTheme[] = GENERATED_COLOR_THEMES;
23
+
24
+ export const DEFAULT_COLOR_THEME = 'clean-slate';
25
+
26
+ /** The human label for a theme id — one spelling of a theme's name across the
27
+ * whole product. Falls back to the id so an unknown/retired id still reads. */
28
+ export function themeLabel(id: string): string {
29
+ return COLOR_THEMES.find((t) => t.id === id)?.label ?? id;
30
+ }
31
+
32
+ // (No storage key for the theme itself: the choice lives on the anchor
33
+ // owner's profile row and is server-rendered into <html data-color-theme> —
34
+ // see @mantle/web-ui/appearance. The RANDOM_* keys below are genuinely
35
+ // visitor-local behavior toggles, so localStorage remains right for them.)
36
+ /** Whether "random theme" mode (reshuffle on a timer) is on. */
37
+ export const RANDOM_THEME_STORAGE_KEY = 'mantle-random-theme';
38
+ /** Epoch-ms of the last random reshuffle, so the timer survives reloads. */
39
+ export const RANDOM_THEME_AT_STORAGE_KEY = 'mantle-random-theme-at';
40
+ /** Chosen reshuffle cadence, in ms (one of RANDOM_THEME_INTERVALS). */
41
+ export const RANDOM_THEME_INTERVAL_STORAGE_KEY = 'mantle-random-theme-interval';
42
+
43
+ const HOUR_MS = 60 * 60 * 1000;
44
+
45
+ /** Selectable reshuffle cadences for random-theme mode (the dice menu). */
46
+ export const RANDOM_THEME_INTERVALS: ReadonlyArray<{ ms: number; label: string }> = [
47
+ { ms: HOUR_MS, label: 'Every hour' },
48
+ { ms: 6 * HOUR_MS, label: 'Every 6 hours' },
49
+ { ms: 12 * HOUR_MS, label: 'Every 12 hours' },
50
+ { ms: 24 * HOUR_MS, label: 'Every day' },
51
+ { ms: 7 * 24 * HOUR_MS, label: 'Every week' },
52
+ ];
53
+
54
+ /** Default cadence (12 hours) — used when nothing valid is stored. */
55
+ export const RANDOM_THEME_INTERVAL_MS = 12 * HOUR_MS;
56
+
57
+ /** Coerce a stored interval string to a known cadence, else the default. */
58
+ export function coerceRandomInterval(raw: string | null): number {
59
+ const n = raw ? Number(raw) : NaN;
60
+ return Number.isFinite(n) && RANDOM_THEME_INTERVALS.some((i) => i.ms === n)
61
+ ? n
62
+ : RANDOM_THEME_INTERVAL_MS;
63
+ }
64
+
65
+ /**
66
+ * Pick a random color-theme id, avoiding `exclude` (the current one) so a
67
+ * reshuffle always visibly changes something. Falls back to the full list if
68
+ * excluding leaves nothing.
69
+ */
70
+ export function pickRandomColorTheme(exclude?: string): string {
71
+ const pool = COLOR_THEMES.filter((t) => t.id !== exclude);
72
+ const list = pool.length > 0 ? pool : COLOR_THEMES;
73
+ const picked = list[Math.floor(Math.random() * list.length)] ?? list[0]!;
74
+ return picked.id;
75
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,225 @@
1
+ import {
2
+ Activity,
3
+ AppWindow,
4
+ ArrowUpCircle,
5
+ BookOpen,
6
+ BookText,
7
+ Bot,
8
+ Boxes,
9
+ CalendarDays,
10
+ CheckSquare,
11
+ Container,
12
+ DatabaseBackup,
13
+ ClipboardCheck,
14
+ DoorOpen,
15
+ Contact,
16
+ Cpu,
17
+ Combine,
18
+ FileText,
19
+ FolderTree,
20
+ GitCompare,
21
+ GitMerge,
22
+ Cloud,
23
+ Hammer,
24
+ Layers,
25
+ HeartPulse,
26
+ Inbox,
27
+ LayoutDashboard,
28
+ ListTree,
29
+ Key,
30
+ Plug,
31
+ Lock,
32
+ Network,
33
+ NotebookPen,
34
+ Palette,
35
+ PenTool,
36
+ Radio,
37
+ ScrollText,
38
+ MessagesSquare,
39
+ ServerCog,
40
+ Settings,
41
+ Sparkles,
42
+ Table2,
43
+ Sigma,
44
+ TerminalSquare,
45
+ User,
46
+ UserCheck,
47
+ Users,
48
+ UsersRound,
49
+ Waypoints,
50
+ Workflow,
51
+ type LucideIcon,
52
+ } from 'lucide-react';
53
+
54
+ /**
55
+ * Single source of truth for the primary navigation. Consumed by the sidebar
56
+ * (grouped, with a live pending-approvals badge injected at render) and the
57
+ * footer quick-menu (flat, ranked by usage). Keeping one list here stops the two
58
+ * surfaces from drifting apart.
59
+ */
60
+ export type NavItem = {
61
+ name: string;
62
+ href: string;
63
+ icon: LucideIcon;
64
+ /** Exact-match only (used for "/" so it doesn't match every route). */
65
+ exact?: boolean;
66
+ };
67
+
68
+ export type NavGroup = {
69
+ label: string;
70
+ items: NavItem[];
71
+ /**
72
+ * Collapsible groups fold to a usage-ranked *head* — the few destinations
73
+ * this owner actually returns to — with the rest one disclosure away.
74
+ *
75
+ * Which groups get this is a statement about how they're used, not how big
76
+ * they are. Workspace and Review are the daily surfaces and stay whole even
77
+ * though Workspace is the larger of the two; Settings and System are a short
78
+ * head and a long tail, so folding them costs nothing and buys back most of
79
+ * the sidebar's height.
80
+ *
81
+ * Folded, never removed: the sidebar is how someone learns what this product
82
+ * can do, so every destination stays reachable from it without knowing its
83
+ * name.
84
+ */
85
+ collapsible?: boolean;
86
+ /** Items shown when collapsed. Ignored unless `collapsible`. */
87
+ headSize?: number;
88
+ /**
89
+ * Cold-start head, by href, in order — what a brain with no usage history
90
+ * shows. It doubles as the tie-break for equal counts, so a fresh install
91
+ * renders exactly this list and then drifts toward real behaviour without a
92
+ * second code path.
93
+ */
94
+ defaultHead?: string[];
95
+ };
96
+
97
+ export const NAV_GROUPS: NavGroup[] = [
98
+ {
99
+ label: 'Workspace',
100
+ items: [
101
+ { name: 'Dashboard', href: '/', icon: LayoutDashboard, exact: true },
102
+ { name: 'Journal', href: '/journal', icon: NotebookPen },
103
+ { name: 'Email', href: '/inbox', icon: Inbox },
104
+ { name: 'Files', href: '/files', icon: FolderTree },
105
+ { name: 'Notes', href: '/notes', icon: FileText },
106
+ { name: 'Pages', href: '/pages', icon: BookText },
107
+ { name: 'Draw', href: '/draw', icon: PenTool },
108
+ { name: 'Tables', href: '/tables', icon: Table2 },
109
+ { name: 'Formulas', href: '/formulas', icon: Sigma },
110
+ { name: 'Apps', href: '/apps', icon: AppWindow },
111
+ { name: 'Tasks', href: '/tasks', icon: CheckSquare },
112
+ { name: 'Events', href: '/events', icon: CalendarDays },
113
+ { name: 'Contacts', href: '/contacts', icon: Contact },
114
+ { name: 'Secrets', href: '/secrets', icon: Lock },
115
+ { name: 'Docs', href: '/docs', icon: BookOpen },
116
+ ],
117
+ },
118
+ {
119
+ label: 'Review',
120
+ items: [
121
+ { name: 'Models', href: '/models', icon: Boxes },
122
+ { name: 'Discover', href: '/settings/discover', icon: UserCheck },
123
+ { name: 'Team', href: '/team-admin', icon: MessagesSquare },
124
+ { name: 'Pending', href: '/pending', icon: ClipboardCheck },
125
+ // Was its own 'External' group for this one item — a header earning a row
126
+ // for a single link. It belongs here on the merits anyway: like Team and
127
+ // Discover it's owner-space pointed at people who are not the owner, and
128
+ // it points at the in-shell /team-portal rather than /team (outside the
129
+ // shell, member credential, no way back).
130
+ { name: 'Team Portal', href: '/team-portal', icon: DoorOpen },
131
+ ],
132
+ },
133
+ {
134
+ label: 'Settings',
135
+ // Twenty-four screens, and the owner returns to a handful. Folded to a head
136
+ // of five; the cold-start list is the setup path a new brain walks.
137
+ collapsible: true,
138
+ headSize: 5,
139
+ defaultHead: [
140
+ '/settings/accounts',
141
+ '/settings/profile',
142
+ '/settings/appearance',
143
+ '/settings/agents',
144
+ '/settings/keys',
145
+ ],
146
+ items: [
147
+ { name: 'Appearance', href: '/settings/appearance', icon: Palette },
148
+ { name: 'Accounts', href: '/settings/accounts', icon: Settings },
149
+ { name: 'Microsoft', href: '/settings/microsoft', icon: Cloud },
150
+ { name: 'Calendars', href: '/settings/calendar', icon: CalendarDays },
151
+ { name: 'Profile', href: '/settings/profile', icon: User },
152
+ { name: 'API keys', href: '/settings/keys', icon: Key },
153
+ { name: 'MCP', href: '/settings/mcp', icon: Plug },
154
+ { name: 'Agents', href: '/settings/agents', icon: Bot },
155
+ { name: 'AI workers', href: '/settings/ai-workers', icon: Cpu },
156
+ { name: 'Worker groups', href: '/settings/worker-groups', icon: UsersRound },
157
+ { name: 'Embedding', href: '/settings/embedding', icon: Combine },
158
+ { name: 'Local network', href: '/settings/network', icon: Radio },
159
+ { name: 'Tools', href: '/settings/tools', icon: Hammer },
160
+ { name: 'Tool groups', href: '/settings/tool-groups', icon: Layers },
161
+ { name: 'Skills', href: '/settings/skills', icon: Sparkles },
162
+ { name: 'Config', href: '/settings/config', icon: GitCompare },
163
+ { name: 'Heartbeats', href: '/settings/heartbeats', icon: HeartPulse },
164
+ { name: 'Entities', href: '/settings/entities', icon: GitMerge },
165
+ { name: 'Peers', href: '/settings/peers', icon: Network },
166
+ { name: 'PDF passwords', href: '/settings/pdf-passwords', icon: Lock },
167
+ { name: 'Backups', href: '/settings/backups', icon: DatabaseBackup },
168
+ { name: 'Updates', href: '/settings/updates', icon: ArrowUpCircle },
169
+ // Security was folded into Logins: its password change duplicated the
170
+ // per-login reset, and its device list now reads per login on that screen.
171
+ { name: 'Logins', href: '/settings/users', icon: Users },
172
+ { name: 'Audit log', href: '/settings/audit', icon: ScrollText },
173
+ ],
174
+ },
175
+ {
176
+ label: 'System',
177
+ // Diagnostics and machinery: reached deliberately, never browsed.
178
+ collapsible: true,
179
+ headSize: 3,
180
+ defaultHead: ['/studio', '/traces', '/debug'],
181
+ items: [
182
+ { name: 'Studio', href: '/studio', icon: Waypoints },
183
+ { name: 'API Console', href: '/dev-tools', icon: TerminalSquare },
184
+ { name: 'Runners', href: '/runners', icon: ServerCog },
185
+ // Durable runs surface (docs/runs.md). Nav is a static list with no
186
+ // per-item conditional-visibility mechanism (only the pending badge is
187
+ // dynamic), so this ships always-visible — the System-group siblings
188
+ // (Studio/Runners/Traces/Debug) do the same. It sits next to Debug where
189
+ // the run view used to live as a tab.
190
+ { name: 'Runs', href: '/runs', icon: ListTree },
191
+ // CLI sandboxes (opt-in per box via the `sandboxes` compose profile).
192
+ // Ships always-visible like its gated sibling Runs — the nav is a static
193
+ // list with no per-item conditional-visibility mechanism; the page itself
194
+ // explains how to enable the feature when sandboxd is absent.
195
+ { name: 'Sandboxes', href: '/sandboxes', icon: Container },
196
+ { name: 'Traces', href: '/traces', icon: Workflow },
197
+ { name: 'Debug', href: '/debug', icon: Activity },
198
+ ],
199
+ },
200
+ ];
201
+
202
+ /** Flat list of every nav item, in sidebar order. */
203
+ export const ALL_NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
204
+
205
+ /** Does `pathname` fall under `item`? Exact items match only their own href. */
206
+ export function navItemMatches(item: NavItem, pathname: string): boolean {
207
+ return item.exact
208
+ ? pathname === item.href
209
+ : pathname === item.href || pathname.startsWith(item.href + '/');
210
+ }
211
+
212
+ /**
213
+ * The canonical nav item for a pathname (most specific href wins, so
214
+ * /settings/agents beats a hypothetical /settings). Used to attribute a visit to
215
+ * exactly one menu for usage ranking.
216
+ */
217
+ export function matchNavItem(pathname: string): NavItem | undefined {
218
+ let best: NavItem | undefined;
219
+ for (const item of ALL_NAV_ITEMS) {
220
+ if (navItemMatches(item, pathname) && (!best || item.href.length > best.href.length)) {
221
+ best = item;
222
+ }
223
+ }
224
+ return best;
225
+ }
@@ -0,0 +1,14 @@
1
+ import ReactMarkdown from 'react-markdown';
2
+ import remarkGfm from 'remark-gfm';
3
+
4
+ /** Public note render — markdown (GFM) in a centered prose column. */
5
+ export function NotePresenter({ view }: { view: { title: string; content: string } }) {
6
+ return (
7
+ <article className="mx-auto max-w-3xl px-6 py-12 md:py-16">
8
+ <h1 className="mb-8 text-3xl font-bold tracking-tight text-balance">{view.title}</h1>
9
+ <div className="prose dark:prose-invert max-w-none prose-accent prose-document">
10
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{view.content}</ReactMarkdown>
11
+ </div>
12
+ </article>
13
+ );
14
+ }
@@ -0,0 +1,127 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { ChevronLeft, FileText, ListTree } from 'lucide-react';
5
+ import { cn } from './lib/utils';
6
+ import type { TocEntry } from '@mantle/content-core/page-toc';
7
+
8
+ const COLLAPSE_KEY = 'mantle:page-outline-collapsed';
9
+
10
+ /**
11
+ * Floating outline / table-of-contents for a page: headings (h1–h3, indented by
12
+ * level) and sub-page cards (indented under their section). Used on all three
13
+ * surfaces — editor, in-app read-only view, and the public share page.
14
+ *
15
+ * Collapsible: a header toggle hides the list down to a small handle; the
16
+ * choice persists in localStorage so it sticks across pages + reloads.
17
+ *
18
+ * Jumping: pass `onJump` to scroll via the editor API (the editor surface);
19
+ * omit it and the outline scrolls the DOM element whose `id` matches the block
20
+ * id (the rendered/public surfaces, where headings carry `id={blockId}`). The
21
+ * parent owns the floating placement (a sticky left rail); this is just the
22
+ * list.
23
+ */
24
+ export function PageOutline({
25
+ entries,
26
+ onJump,
27
+ className,
28
+ }: {
29
+ entries: TocEntry[];
30
+ onJump?: (id: string) => void;
31
+ className?: string;
32
+ }) {
33
+ const [collapsed, setCollapsed] = useState(false);
34
+
35
+ // Restore the collapse preference on mount (avoids an SSR/first-paint flip).
36
+ useEffect(() => {
37
+ try {
38
+ setCollapsed(localStorage.getItem(COLLAPSE_KEY) === '1');
39
+ } catch {
40
+ // ignore unavailable storage
41
+ }
42
+ }, []);
43
+
44
+ const toggle = () =>
45
+ setCollapsed((c) => {
46
+ const next = !c;
47
+ try {
48
+ localStorage.setItem(COLLAPSE_KEY, next ? '1' : '0');
49
+ } catch {
50
+ // ignore
51
+ }
52
+ return next;
53
+ });
54
+
55
+ if (entries.length === 0) return null;
56
+
57
+ if (collapsed) {
58
+ return (
59
+ <button
60
+ type="button"
61
+ onClick={toggle}
62
+ aria-label="Show outline"
63
+ title="Show outline"
64
+ className={cn(
65
+ 'inline-flex size-8 items-center justify-center rounded-md border border-border bg-card text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground',
66
+ className,
67
+ )}
68
+ >
69
+ <ListTree className="size-4" aria-hidden />
70
+ </button>
71
+ );
72
+ }
73
+
74
+ const jump = (id: string) => {
75
+ if (onJump) {
76
+ onJump(id);
77
+ return;
78
+ }
79
+ if (typeof document !== 'undefined') {
80
+ document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
81
+ }
82
+ };
83
+
84
+ return (
85
+ <nav className={cn('text-sm', className)} aria-label="Page outline">
86
+ <div className="mb-2 flex items-center justify-between gap-2 px-2">
87
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
88
+ On this page
89
+ </span>
90
+ <button
91
+ type="button"
92
+ onClick={toggle}
93
+ aria-label="Hide outline"
94
+ title="Hide outline"
95
+ className="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
96
+ >
97
+ <ChevronLeft className="size-4" aria-hidden />
98
+ </button>
99
+ </div>
100
+ <ul className="space-y-0.5">
101
+ {entries.map((e) => (
102
+ <li key={e.id}>
103
+ <button
104
+ type="button"
105
+ onClick={() => jump(e.id)}
106
+ style={{ paddingLeft: 8 + e.depth * 12 }}
107
+ title={e.label}
108
+ className={cn(
109
+ 'flex w-full items-center gap-1.5 rounded-sm py-1 pr-2 text-left leading-snug transition-colors hover:bg-accent hover:text-accent-foreground',
110
+ e.kind === 'page'
111
+ ? 'text-muted-foreground'
112
+ : e.level === 1
113
+ ? 'font-medium text-foreground'
114
+ : 'text-muted-foreground',
115
+ )}
116
+ >
117
+ {e.kind === 'page' && (
118
+ <FileText className="size-3.5 shrink-0 opacity-70" aria-hidden />
119
+ )}
120
+ <span className="truncate">{e.label}</span>
121
+ </button>
122
+ </li>
123
+ ))}
124
+ </ul>
125
+ </nav>
126
+ );
127
+ }
@@ -0,0 +1,226 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { Loader2, Table2 } from 'lucide-react';
5
+ import { Button } from './ui/button';
6
+ import { cn } from './lib/utils';
7
+
8
+ type CellValue = string | number | boolean | string[] | null;
9
+ type PublicRow = { id: string; cells: Record<string, CellValue> };
10
+ type PublicColumn = { id: string; name: string; type: string };
11
+ type PublicTab = { id: string; name: string; rowCount: number; columns: PublicColumn[] };
12
+
13
+ const PAGE = 200;
14
+
15
+ /**
16
+ * Public read-only grid for a shared table. File-backed workbooks page their
17
+ * PUBLISHED rows through GET /s/[token]/rows (offset windows, "Load more");
18
+ * legacy JSONB tables arrive whole in the share view and render inline.
19
+ * Raw fetch on purpose: apiFetch is the app shell's authenticated wrapper.
20
+ * No edit affordances of any kind — the server surface is read-only anyway;
21
+ * this component simply has nothing to strip.
22
+ */
23
+ export function TablePresenter({
24
+ view,
25
+ token,
26
+ }: {
27
+ view: {
28
+ title: string;
29
+ icon: string | null;
30
+ tabs: PublicTab[] | null;
31
+ legacyDoc: { columns: PublicColumn[]; rows: PublicRow[] } | null;
32
+ };
33
+ token: string;
34
+ }) {
35
+ const tabs = view.tabs ?? [];
36
+ const [tabId, setTabId] = useState<string | null>(tabs[0]?.id ?? null);
37
+ const tab = tabs.find((t) => t.id === tabId) ?? tabs[0] ?? null;
38
+
39
+ const [rows, setRows] = useState<PublicRow[]>([]);
40
+ // Seeded from the tab's registry count so the header is accurate before the
41
+ // first row page lands (and after a failed fetch); the server total from
42
+ // each successful page overrides it.
43
+ const [total, setTotal] = useState(tabs[0]?.rowCount ?? 0);
44
+ const [loading, setLoading] = useState(!!tab);
45
+ const [failed, setFailed] = useState(false);
46
+ // Tab-switch race guard: a stale page for the previous tab must not land.
47
+ const reqSeq = useRef(0);
48
+
49
+ const fetchPage = useCallback(
50
+ async (offset: number, forTab: string) => {
51
+ const seq = ++reqSeq.current;
52
+ setLoading(true);
53
+ setFailed(false);
54
+ try {
55
+ const r = await fetch(
56
+ `/s/${token}/rows?tab=${encodeURIComponent(forTab)}&offset=${offset}&limit=${PAGE}`,
57
+ { cache: 'no-store' },
58
+ );
59
+ if (!r.ok) throw new Error(String(r.status));
60
+ const d = (await r.json()) as { rows: PublicRow[]; total: number; offset: number };
61
+ if (seq !== reqSeq.current) return;
62
+ setRows((prev) => (offset === 0 ? d.rows : [...prev, ...d.rows]));
63
+ setTotal(d.total);
64
+ } catch {
65
+ if (seq === reqSeq.current) setFailed(true);
66
+ } finally {
67
+ if (seq === reqSeq.current) setLoading(false);
68
+ }
69
+ },
70
+ [token],
71
+ );
72
+
73
+ useEffect(() => {
74
+ if (tab) {
75
+ setRows([]);
76
+ setTotal(tab.rowCount);
77
+ void fetchPage(0, tab.id);
78
+ }
79
+ // Deliberately narrower than exhaustive-deps: refetch when the SELECTED TAB
80
+ // changes, not when the tab object or `fetchPage` is re-created. `fetchPage`
81
+ // is useCallback([token]) and a share page's token never changes, and `tab`
82
+ // comes from a static view — so the wider deps would only ever cause a
83
+ // redundant refetch that resets the reader's scroll.
84
+ // eslint-disable-next-line react-hooks/exhaustive-deps
85
+ }, [tab?.id]);
86
+
87
+ // Memoized so the `?? []` fallback doesn't mint a fresh array each render and
88
+ // re-run the numericCols useMemo below (react-hooks/exhaustive-deps).
89
+ const columns = useMemo(
90
+ () => (view.legacyDoc ? view.legacyDoc.columns : (tab?.columns ?? [])),
91
+ [view.legacyDoc, tab?.columns],
92
+ );
93
+ const shownRows = view.legacyDoc ? view.legacyDoc.rows : rows;
94
+ const totalRows = view.legacyDoc ? view.legacyDoc.rows.length : total;
95
+ const hasMore = !view.legacyDoc && shownRows.length < totalRows;
96
+
97
+ const numericCols = useMemo(
98
+ () =>
99
+ new Set(columns.filter((c) => c.type === 'number' || c.type === 'currency').map((c) => c.id)),
100
+ [columns],
101
+ );
102
+
103
+ return (
104
+ <div className="mx-auto max-w-6xl px-6 py-12">
105
+ <header className="mb-6 text-center">
106
+ <h1 className="text-xl font-semibold tracking-tight">
107
+ {view.icon ? `${view.icon} ` : ''}
108
+ {view.title}
109
+ </h1>
110
+ <p className="mt-1 text-xs text-muted-foreground">
111
+ {totalRows} row{totalRows === 1 ? '' : 's'}
112
+ </p>
113
+ </header>
114
+
115
+ {tabs.length > 1 && (
116
+ <div className="mb-3 flex flex-wrap gap-1">
117
+ {tabs.map((t) => (
118
+ <button
119
+ key={t.id}
120
+ type="button"
121
+ onClick={() => setTabId(t.id)}
122
+ className={cn(
123
+ 'rounded-md px-3 py-1.5 text-sm transition-colors',
124
+ t.id === (tab?.id ?? null)
125
+ ? 'bg-primary text-primary-foreground'
126
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
127
+ )}
128
+ >
129
+ {t.name}
130
+ </button>
131
+ ))}
132
+ </div>
133
+ )}
134
+
135
+ <div className="overflow-x-auto rounded-lg border border-border">
136
+ <table className="w-full border-collapse text-sm">
137
+ <thead>
138
+ <tr className="border-b border-border bg-muted/50">
139
+ {columns.map((c) => (
140
+ <th
141
+ key={c.id}
142
+ className={cn(
143
+ 'whitespace-nowrap px-3 py-2 text-left font-medium text-muted-foreground',
144
+ numericCols.has(c.id) && 'text-right',
145
+ )}
146
+ >
147
+ {c.name}
148
+ </th>
149
+ ))}
150
+ </tr>
151
+ </thead>
152
+ <tbody>
153
+ {shownRows.map((r) => (
154
+ <tr key={r.id} className="border-b border-border/60 last:border-0 hover:bg-muted/30">
155
+ {columns.map((c) => (
156
+ <td
157
+ key={c.id}
158
+ className={cn(
159
+ 'max-w-96 truncate px-3 py-1.5',
160
+ numericCols.has(c.id) && 'text-right tabular-nums',
161
+ )}
162
+ >
163
+ <Cell value={r.cells[c.id] ?? null} type={c.type} />
164
+ </td>
165
+ ))}
166
+ </tr>
167
+ ))}
168
+ {shownRows.length === 0 && !loading && (
169
+ <tr>
170
+ <td
171
+ colSpan={Math.max(columns.length, 1)}
172
+ className="px-3 py-8 text-center text-muted-foreground"
173
+ >
174
+ {failed ? 'Could not load rows.' : 'No rows.'}
175
+ </td>
176
+ </tr>
177
+ )}
178
+ </tbody>
179
+ </table>
180
+ </div>
181
+
182
+ <div className="mt-3 flex items-center justify-center gap-3">
183
+ {loading && (
184
+ <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
185
+ <Loader2 className="size-3 animate-spin" aria-hidden /> Loading…
186
+ </span>
187
+ )}
188
+ {!loading && hasMore && tab && (
189
+ <Button
190
+ variant="outline"
191
+ size="sm"
192
+ onClick={() => void fetchPage(shownRows.length, tab.id)}
193
+ >
194
+ <Table2 /> Load more ({shownRows.length} of {totalRows})
195
+ </Button>
196
+ )}
197
+ </div>
198
+ </div>
199
+ );
200
+ }
201
+
202
+ function Cell({ value, type }: { value: CellValue; type: string }) {
203
+ if (value === null || value === undefined || value === '') {
204
+ return <span className="text-muted-foreground/50">—</span>;
205
+ }
206
+ if (type === 'checkbox' || typeof value === 'boolean') {
207
+ return <span>{value ? '✓' : '—'}</span>;
208
+ }
209
+ if (Array.isArray(value)) return <>{value.join(', ')}</>;
210
+ if (type === 'url' && typeof value === 'string') {
211
+ const href = /^https?:\/\//i.test(value) ? value : null;
212
+ return href ? (
213
+ <a
214
+ href={href}
215
+ target="_blank"
216
+ rel="noreferrer"
217
+ className="text-primary-ink underline underline-offset-2"
218
+ >
219
+ {value}
220
+ </a>
221
+ ) : (
222
+ <>{value}</>
223
+ );
224
+ }
225
+ return <>{String(value)}</>;
226
+ }