@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.
- package/LICENSE.md +135 -0
- package/package.json +68 -0
- package/src/app-bridge-protocol.ts +115 -0
- package/src/app-presenter.tsx +25 -0
- package/src/app-sandbox.tsx +552 -0
- package/src/appearance.ts +192 -0
- package/src/avatar.test.ts +229 -0
- package/src/avatar.ts +731 -0
- package/src/backgrounds.test.ts +119 -0
- package/src/backgrounds.ts +118 -0
- package/src/draw-presenter.tsx +39 -0
- package/src/event-presenter.tsx +62 -0
- package/src/file-presenter.tsx +76 -0
- package/src/formula-calculator.tsx +209 -0
- package/src/formula-presenter.test.ts +128 -0
- package/src/formula-presenter.tsx +301 -0
- package/src/help-topics.ts +104 -0
- package/src/lib/ink-audit.test.ts +314 -0
- package/src/lib/theme-css-blocks.ts +26 -0
- package/src/lib/theme-generator.test.ts +179 -0
- package/src/lib/theme-registry.gen.ts +352 -0
- package/src/lib/themes.test.ts +308 -0
- package/src/lib/themes.ts +75 -0
- package/src/lib/utils.ts +6 -0
- package/src/nav-items.ts +225 -0
- package/src/note-presenter.tsx +14 -0
- package/src/page-outline.tsx +127 -0
- package/src/table-presenter.tsx +226 -0
- package/src/task-presenter.tsx +60 -0
- package/src/ui/button.tsx +50 -0
- package/src/ui/input.tsx +18 -0
- package/src/ui/label.tsx +20 -0
- package/src/view-payload.ts +82 -0
- package/styles/app.css +1098 -0
- package/styles/themes.css +6198 -0
- package/themes/generate.d.mts +11 -0
- package/themes/generate.mjs +618 -0
- package/themes/model.d.mts +24 -0
- package/themes/model.mjs +213 -0
- package/themes/preview.html +145 -0
- package/themes/seeds.d.mts +16 -0
- package/themes/seeds.mjs +3694 -0
- package/tsconfig.json +15 -0
- 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
|
+
}
|
package/src/lib/utils.ts
ADDED
package/src/nav-items.ts
ADDED
|
@@ -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
|
+
}
|