@crossworks/share-ui 0.230.70 → 0.232.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/draw-presenter.tsx +9 -2
- package/src/event-presenter.tsx +22 -9
- package/src/file-presenter.tsx +41 -8
- package/src/lib/presenter-chrome.ts +37 -0
- package/src/note-presenter.tsx +20 -4
- package/src/table-presenter.tsx +376 -92
- package/src/task-presenter.tsx +56 -34
- package/src/view-payload.ts +33 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossworks/share-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.232.0",
|
|
4
4
|
"description": "The server-rendered share surface — the /s/<token> presenters, view-payload contract, mini-app sandbox, and the few primitives they need. Lives in MANTLE (the server renders these), published for jackdaw to consume; may depend only on @mantle/{client-types,content-core} (the jackdaw-repo-split boundary).",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./app-presenter": "./src/app-presenter.tsx",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"./ui/button": "./src/ui/button.tsx",
|
|
20
20
|
"./ui/input": "./src/ui/input.tsx",
|
|
21
21
|
"./ui/label": "./src/ui/label.tsx",
|
|
22
|
+
"./lib/presenter-chrome": "./src/lib/presenter-chrome.ts",
|
|
22
23
|
"./lib/utils": "./src/lib/utils.ts",
|
|
23
24
|
"./page-outline": "./src/page-outline.tsx",
|
|
24
25
|
"./nav-items": "./src/nav-items.ts",
|
|
@@ -31,8 +32,8 @@
|
|
|
31
32
|
"./styles/app.css": "./styles/app.css"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@mantle/client-types": "npm:@crossworks/client-types@0.
|
|
35
|
-
"@mantle/content-core": "npm:@crossworks/content-core@0.
|
|
35
|
+
"@mantle/client-types": "npm:@crossworks/client-types@0.232.0",
|
|
36
|
+
"@mantle/content-core": "npm:@crossworks/content-core@0.232.0",
|
|
36
37
|
"@radix-ui/react-label": "^2.1.12",
|
|
37
38
|
"@radix-ui/react-slot": "^1.3.0",
|
|
38
39
|
"class-variance-authority": "^0.7.1",
|
package/src/draw-presenter.tsx
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Public draw render — the committed SVG snapshot, static, no JS.
|
|
3
5
|
*
|
|
@@ -14,14 +16,19 @@
|
|
|
14
16
|
export function DrawPresenter({
|
|
15
17
|
view,
|
|
16
18
|
src,
|
|
19
|
+
chrome,
|
|
17
20
|
}: {
|
|
18
21
|
view: { title: string; hasSvg: boolean };
|
|
19
22
|
/** URL of the snapshot image (`/s/<token>/draw`). */
|
|
20
23
|
src: string;
|
|
24
|
+
chrome?: PresenterChrome;
|
|
21
25
|
}) {
|
|
26
|
+
const embedded = isEmbedded(chrome);
|
|
22
27
|
return (
|
|
23
|
-
<article className=
|
|
24
|
-
|
|
28
|
+
<article className={embedded ? 'w-full px-6 py-6' : 'mx-auto max-w-5xl px-6 py-12 md:py-16'}>
|
|
29
|
+
{!embedded && (
|
|
30
|
+
<h1 className="mb-8 text-3xl font-bold tracking-tight text-balance">{view.title}</h1>
|
|
31
|
+
)}
|
|
25
32
|
{view.hasSvg ? (
|
|
26
33
|
<div className="overflow-hidden rounded-lg border border-border bg-white p-3">
|
|
27
34
|
{/* eslint-disable-next-line @next/next/no-img-element -- a drawing
|
package/src/event-presenter.tsx
CHANGED
|
@@ -3,6 +3,8 @@ import remarkGfm from 'remark-gfm';
|
|
|
3
3
|
import { CalendarDays, Clock, Download, MapPin } from 'lucide-react';
|
|
4
4
|
import { formatDateTime } from '@mantle/client-types/lib/format-datetime';
|
|
5
5
|
import { buildIcsHref } from '@mantle/client-types/lib/event-time';
|
|
6
|
+
import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
|
|
7
|
+
import { cn } from './lib/utils';
|
|
6
8
|
|
|
7
9
|
type EventView = {
|
|
8
10
|
title: string;
|
|
@@ -12,18 +14,29 @@ type EventView = {
|
|
|
12
14
|
location: string | null;
|
|
13
15
|
};
|
|
14
16
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Public event render — time, location, and an .ics link.
|
|
19
|
+
*
|
|
20
|
+
* On the standalone page this is a centred card: the card border is what tells
|
|
21
|
+
* a reader where the event begins and ends on an otherwise empty page. Embedded
|
|
22
|
+
* in a pane that already has a header rule and a border of its own, that same
|
|
23
|
+
* card is the "floating box in the middle" — so embedded drops the frame and
|
|
24
|
+
* the title, and lays the detail out on the pane. See `PresenterChrome`.
|
|
25
|
+
*/
|
|
26
|
+
export function EventPresenter({ view, chrome }: { view: EventView; chrome?: PresenterChrome }) {
|
|
17
27
|
const ics = buildIcsHref(view);
|
|
28
|
+
const embedded = isEmbedded(chrome);
|
|
18
29
|
return (
|
|
19
|
-
<div className=
|
|
20
|
-
<div className=
|
|
21
|
-
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
<div className={embedded ? 'w-full px-6 py-6' : 'mx-auto max-w-2xl px-6 py-12 md:py-16'}>
|
|
31
|
+
<div className={cn(!embedded && 'rounded-xl border border-border bg-card p-6')}>
|
|
32
|
+
{!embedded && (
|
|
33
|
+
<div className="flex items-start gap-3">
|
|
34
|
+
<CalendarDays className="mt-1 size-6 shrink-0 text-primary-ink" aria-hidden />
|
|
35
|
+
<h1 className="text-2xl font-bold tracking-tight text-balance">{view.title}</h1>
|
|
36
|
+
</div>
|
|
37
|
+
)}
|
|
25
38
|
|
|
26
|
-
<dl className=
|
|
39
|
+
<dl className={cn('space-y-2 text-sm', !embedded && 'mt-5')}>
|
|
27
40
|
{view.startsAt && (
|
|
28
41
|
<div className="flex items-center gap-2 text-muted-foreground">
|
|
29
42
|
<Clock className="size-4 shrink-0" aria-hidden />
|
package/src/file-presenter.tsx
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
import { Download, FileText } from 'lucide-react';
|
|
2
2
|
import { formatBytes } from '@mantle/client-types/lib/format-bytes';
|
|
3
|
+
import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Public file render with a media-appropriate viewer: images, PDFs, video, and
|
|
6
7
|
* audio play inline (served from the scoped asset route); everything else gets
|
|
7
|
-
* a
|
|
8
|
+
* a download card.
|
|
9
|
+
*
|
|
10
|
+
* Embedded spans the pane. This is the presenter Jason was describing — a
|
|
11
|
+
* non-previewable file drew a `max-w-md` card, which is a phone-width box
|
|
12
|
+
* marooned in the middle of a 2000px pane. Every branch here gets better with
|
|
13
|
+
* width: a PDF, an image and a video all want the space, and the download row
|
|
14
|
+
* wants to look like a row rather than an island. See `PresenterChrome`.
|
|
8
15
|
*/
|
|
9
16
|
export function FilePresenter({
|
|
10
17
|
view,
|
|
11
18
|
assetUrl,
|
|
19
|
+
chrome,
|
|
12
20
|
}: {
|
|
13
21
|
view: { fileId: string; filename: string; mimeType: string; size: number };
|
|
14
22
|
assetUrl: (fileId: string) => string;
|
|
23
|
+
chrome?: PresenterChrome;
|
|
15
24
|
}) {
|
|
16
25
|
const src = assetUrl(view.fileId);
|
|
17
26
|
const mime = view.mimeType || '';
|
|
@@ -19,12 +28,20 @@ export function FilePresenter({
|
|
|
19
28
|
const isPdf = mime === 'application/pdf';
|
|
20
29
|
const isVideo = mime.startsWith('video/');
|
|
21
30
|
const isAudio = mime.startsWith('audio/');
|
|
31
|
+
const embedded = isEmbedded(chrome);
|
|
22
32
|
|
|
23
33
|
return (
|
|
24
|
-
<div className=
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
<div className={embedded ? 'w-full px-6 py-6' : 'mx-auto max-w-5xl px-6 py-12'}>
|
|
35
|
+
{/* Embedded keeps the type/size line — the pane header carries only the
|
|
36
|
+
filename, so this is the one place a member learns what they are
|
|
37
|
+
looking at — but drops the hero title above it. */}
|
|
38
|
+
<header className={embedded ? 'mb-4' : 'mb-6 text-center'}>
|
|
39
|
+
{!embedded && <h1 className="text-xl font-semibold tracking-tight">{view.filename}</h1>}
|
|
40
|
+
<p
|
|
41
|
+
className={
|
|
42
|
+
embedded ? 'text-xs text-muted-foreground' : 'mt-1 text-xs text-muted-foreground'
|
|
43
|
+
}
|
|
44
|
+
>
|
|
28
45
|
{mime || 'file'} · {formatBytes(view.size)}
|
|
29
46
|
</p>
|
|
30
47
|
</header>
|
|
@@ -43,7 +60,11 @@ export function FilePresenter({
|
|
|
43
60
|
<iframe
|
|
44
61
|
src={src}
|
|
45
62
|
title={view.filename}
|
|
46
|
-
className=
|
|
63
|
+
className={
|
|
64
|
+
embedded
|
|
65
|
+
? 'h-[calc(100dvh-14rem)] min-h-96 w-full rounded-lg border border-border'
|
|
66
|
+
: 'h-[82vh] w-full rounded-lg border border-border'
|
|
67
|
+
}
|
|
47
68
|
/>
|
|
48
69
|
) : isVideo ? (
|
|
49
70
|
<video
|
|
@@ -52,11 +73,23 @@ export function FilePresenter({
|
|
|
52
73
|
className="mx-auto max-h-[80vh] w-full rounded-lg border border-border bg-black"
|
|
53
74
|
/>
|
|
54
75
|
) : isAudio ? (
|
|
55
|
-
<div
|
|
76
|
+
<div
|
|
77
|
+
className={
|
|
78
|
+
embedded
|
|
79
|
+
? 'rounded-lg border border-border bg-card p-4'
|
|
80
|
+
: 'mx-auto max-w-xl rounded-lg border border-border bg-card p-4'
|
|
81
|
+
}
|
|
82
|
+
>
|
|
56
83
|
<audio src={src} controls className="w-full" />
|
|
57
84
|
</div>
|
|
58
85
|
) : (
|
|
59
|
-
<div
|
|
86
|
+
<div
|
|
87
|
+
className={
|
|
88
|
+
embedded
|
|
89
|
+
? 'flex items-center gap-3 rounded-xl border border-border bg-card p-4'
|
|
90
|
+
: 'mx-auto flex max-w-md items-center gap-3 rounded-xl border border-border bg-card p-4'
|
|
91
|
+
}
|
|
92
|
+
>
|
|
60
93
|
<FileText className="size-8 shrink-0 text-muted-foreground" aria-hidden />
|
|
61
94
|
<div className="min-w-0 flex-1">
|
|
62
95
|
<p className="truncate text-sm font-medium">{view.filename}</p>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which shell a presenter is drawing inside.
|
|
3
|
+
*
|
|
4
|
+
* Every presenter here was written for ONE surface: the anonymous public `/s`
|
|
5
|
+
* page, where the presenter *is* the page. A centred column with a hero title
|
|
6
|
+
* and generous top padding is exactly right there — nothing else is on screen.
|
|
7
|
+
*
|
|
8
|
+
* `/team` then reused the same components inside a master-detail pane, and both
|
|
9
|
+
* of those choices became wrong at once. The pane already draws the title in
|
|
10
|
+
* its own header, so the hero title was the second of three on screen; and the
|
|
11
|
+
* centred cap meant dragging the divider only grew the empty margins while the
|
|
12
|
+
* content stayed a fixed narrow column. Members read that as "the drag is
|
|
13
|
+
* broken". The handle was fine — the content was ignoring it.
|
|
14
|
+
*
|
|
15
|
+
* So a presenter now says which shell it is in:
|
|
16
|
+
*
|
|
17
|
+
* - `'share'` (the default) — the standalone page. Unchanged, and it stays the
|
|
18
|
+
* default deliberately: the public page must not change because an embedder
|
|
19
|
+
* forgot a prop.
|
|
20
|
+
* - `'embedded'` — inside a shell that already owns the title and the padding.
|
|
21
|
+
* Drop the hero title, tighten the vertical rhythm, and let the content take
|
|
22
|
+
* the width it is given.
|
|
23
|
+
*
|
|
24
|
+
* ⚠ `'embedded'` is NOT a synonym for full-bleed. It means *the shell owns the
|
|
25
|
+
* chrome*; what to do with the width is still the content's call. A table, a
|
|
26
|
+
* file listing and a media viewer all get better as they get wider, so they
|
|
27
|
+
* span the pane. Prose does not — a 2000px line is unreadable no matter whose
|
|
28
|
+
* pane it is in — so a note keeps its measure and simply stops being centred
|
|
29
|
+
* under a title it no longer draws. Anything that reads as "a floating box in
|
|
30
|
+
* the middle of a big screen" is the bug; a comfortable reading measure is not.
|
|
31
|
+
*/
|
|
32
|
+
export type PresenterChrome = 'share' | 'embedded';
|
|
33
|
+
|
|
34
|
+
/** Reads better at the call sites than comparing the string in each presenter. */
|
|
35
|
+
export function isEmbedded(chrome?: PresenterChrome): boolean {
|
|
36
|
+
return chrome === 'embedded';
|
|
37
|
+
}
|
package/src/note-presenter.tsx
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
import ReactMarkdown from 'react-markdown';
|
|
2
2
|
import remarkGfm from 'remark-gfm';
|
|
3
|
+
import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
|
|
3
4
|
|
|
4
|
-
/**
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Public note render — markdown (GFM) in a prose column.
|
|
7
|
+
*
|
|
8
|
+
* Embedded keeps the measure. A note is prose, and prose wants a line length
|
|
9
|
+
* whoever is reading it; what it drops is the hero title the shell already
|
|
10
|
+
* draws, and the standalone page's tall top padding. See `PresenterChrome`.
|
|
11
|
+
*/
|
|
12
|
+
export function NotePresenter({
|
|
13
|
+
view,
|
|
14
|
+
chrome,
|
|
15
|
+
}: {
|
|
16
|
+
view: { title: string; content: string };
|
|
17
|
+
chrome?: PresenterChrome;
|
|
18
|
+
}) {
|
|
19
|
+
const embedded = isEmbedded(chrome);
|
|
6
20
|
return (
|
|
7
|
-
<article className=
|
|
8
|
-
|
|
21
|
+
<article className={embedded ? 'max-w-3xl px-6 py-6' : 'mx-auto max-w-3xl px-6 py-12 md:py-16'}>
|
|
22
|
+
{!embedded && (
|
|
23
|
+
<h1 className="mb-8 text-3xl font-bold tracking-tight text-balance">{view.title}</h1>
|
|
24
|
+
)}
|
|
9
25
|
<div className="prose dark:prose-invert max-w-none prose-accent prose-document">
|
|
10
26
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{view.content}</ReactMarkdown>
|
|
11
27
|
</div>
|
package/src/table-presenter.tsx
CHANGED
|
@@ -1,36 +1,120 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
ArrowUpRight,
|
|
6
|
+
Calendar,
|
|
7
|
+
CalendarClock,
|
|
8
|
+
DollarSign,
|
|
9
|
+
Hash,
|
|
10
|
+
Link as LinkIcon,
|
|
11
|
+
List,
|
|
12
|
+
Loader2,
|
|
13
|
+
Percent,
|
|
14
|
+
SquareCheck,
|
|
15
|
+
Tags,
|
|
16
|
+
Type,
|
|
17
|
+
Variable,
|
|
18
|
+
type LucideIcon,
|
|
19
|
+
} from 'lucide-react';
|
|
20
|
+
import { AGGREGATE_KINDS, type AggregateKind } from '@mantle/content-core/table-model';
|
|
6
21
|
import { cn } from './lib/utils';
|
|
22
|
+
import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
|
|
7
23
|
|
|
8
24
|
type CellValue = string | number | boolean | string[] | null;
|
|
9
25
|
type PublicRow = { id: string; cells: Record<string, CellValue> };
|
|
10
26
|
type PublicColumn = { id: string; name: string; type: string };
|
|
11
|
-
type PublicTab = {
|
|
27
|
+
type PublicTab = {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
rowCount: number;
|
|
31
|
+
columns: PublicColumn[];
|
|
32
|
+
aggregates?: Record<string, AggregateKind>;
|
|
33
|
+
aggregateValues?: Record<string, number | null>;
|
|
34
|
+
};
|
|
12
35
|
|
|
13
36
|
const PAGE = 200;
|
|
14
37
|
|
|
38
|
+
/** Header glyphs, the owner grid's map (table-grid.tsx) — the two surfaces
|
|
39
|
+
* should not disagree about what a currency column looks like. */
|
|
40
|
+
const TYPE_ICON: Record<string, LucideIcon> = {
|
|
41
|
+
text: Type,
|
|
42
|
+
number: Hash,
|
|
43
|
+
currency: DollarSign,
|
|
44
|
+
percent: Percent,
|
|
45
|
+
date: Calendar,
|
|
46
|
+
datetime: CalendarClock,
|
|
47
|
+
checkbox: SquareCheck,
|
|
48
|
+
select: List,
|
|
49
|
+
multiselect: Tags,
|
|
50
|
+
url: LinkIcon,
|
|
51
|
+
formula: Variable,
|
|
52
|
+
reference: ArrowUpRight,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const AGG_LABEL: Record<AggregateKind, string> = {
|
|
56
|
+
none: 'None',
|
|
57
|
+
sum: 'Sum',
|
|
58
|
+
avg: 'Average',
|
|
59
|
+
count: 'Count',
|
|
60
|
+
min: 'Min',
|
|
61
|
+
max: 'Max',
|
|
62
|
+
empty: 'Empty',
|
|
63
|
+
filled: 'Filled',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const NUMERIC_TYPES = new Set(['number', 'currency', 'percent', 'formula']);
|
|
67
|
+
|
|
68
|
+
/** Aggregate values are plain numbers by the time they reach here. An average
|
|
69
|
+
* keeps two decimals; everything else renders as a grouped integer-ish number,
|
|
70
|
+
* which is what a total of a currency or a count wants. */
|
|
71
|
+
function formatAggregate(value: number, kind: AggregateKind): string {
|
|
72
|
+
if (kind === 'avg') return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
73
|
+
return value.toLocaleString(undefined, { maximumFractionDigits: 4 });
|
|
74
|
+
}
|
|
75
|
+
|
|
15
76
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
77
|
+
* Read-only grid for a shared table.
|
|
78
|
+
*
|
|
79
|
+
* File-backed workbooks page their PUBLISHED rows through GET /s/[token]/rows;
|
|
80
|
+
* legacy JSONB tables arrive whole in the share view and render inline. Raw
|
|
81
|
+
* fetch on purpose: apiFetch is the app shell's authenticated wrapper. No edit
|
|
82
|
+
* affordances of any kind — the server surface is read-only anyway; this
|
|
83
|
+
* component simply has nothing to strip.
|
|
84
|
+
*
|
|
85
|
+
* ── Totals, and the one thing worth being careful about ────────────────────
|
|
86
|
+
*
|
|
87
|
+
* A footer total must never be computed from the rows this component happens to
|
|
88
|
+
* be holding. It pages 200 at a time, so a client-side sum over a partial
|
|
89
|
+
* window is not a smaller number — it is a WRONG one that looks exactly as
|
|
90
|
+
* authoritative as a right one. So for a file-backed tab every total comes from
|
|
91
|
+
* the server: the owner's presets ride in the share view already computed, and
|
|
92
|
+
* a total the READER picks is fetched from /s/[token]/aggregate, which runs it
|
|
93
|
+
* in SQL over every row.
|
|
94
|
+
*
|
|
95
|
+
* A legacy table is the exception, and only because it genuinely arrives whole:
|
|
96
|
+
* there, `computeAggregate` over the doc in hand is the complete answer.
|
|
97
|
+
*
|
|
98
|
+
* A reader's own choice is view-local and never persisted. Nothing here writes;
|
|
99
|
+
* reloading restores whatever the owner set.
|
|
22
100
|
*/
|
|
23
101
|
export function TablePresenter({
|
|
24
102
|
view,
|
|
25
103
|
token,
|
|
104
|
+
chrome,
|
|
26
105
|
}: {
|
|
27
106
|
view: {
|
|
28
107
|
title: string;
|
|
29
108
|
icon: string | null;
|
|
30
109
|
tabs: PublicTab[] | null;
|
|
31
|
-
legacyDoc: {
|
|
110
|
+
legacyDoc: {
|
|
111
|
+
columns: PublicColumn[];
|
|
112
|
+
rows: PublicRow[];
|
|
113
|
+
aggregates?: Record<string, AggregateKind>;
|
|
114
|
+
} | null;
|
|
32
115
|
};
|
|
33
116
|
token: string;
|
|
117
|
+
chrome?: PresenterChrome;
|
|
34
118
|
}) {
|
|
35
119
|
const tabs = view.tabs ?? [];
|
|
36
120
|
const [tabId, setTabId] = useState<string | null>(tabs[0]?.id ?? null);
|
|
@@ -46,6 +130,12 @@ export function TablePresenter({
|
|
|
46
130
|
// Tab-switch race guard: a stale page for the previous tab must not land.
|
|
47
131
|
const reqSeq = useRef(0);
|
|
48
132
|
|
|
133
|
+
/** The reader's own picks, per column — view-local, never sent anywhere that
|
|
134
|
+
* writes. Keyed per tab so switching tabs does not carry a choice across to
|
|
135
|
+
* a column that merely shares an id position. */
|
|
136
|
+
const [readerAggs, setReaderAggs] = useState<Record<string, AggregateKind>>({});
|
|
137
|
+
const [readerVals, setReaderVals] = useState<Record<string, number | null>>({});
|
|
138
|
+
|
|
49
139
|
const fetchPage = useCallback(
|
|
50
140
|
async (offset: number, forTab: string) => {
|
|
51
141
|
const seq = ++reqSeq.current;
|
|
@@ -74,6 +164,8 @@ export function TablePresenter({
|
|
|
74
164
|
if (tab) {
|
|
75
165
|
setRows([]);
|
|
76
166
|
setTotal(tab.rowCount);
|
|
167
|
+
setReaderAggs({});
|
|
168
|
+
setReaderVals({});
|
|
77
169
|
void fetchPage(0, tab.id);
|
|
78
170
|
}
|
|
79
171
|
// Deliberately narrower than exhaustive-deps: refetch when the SELECTED TAB
|
|
@@ -95,110 +187,302 @@ export function TablePresenter({
|
|
|
95
187
|
const hasMore = !view.legacyDoc && shownRows.length < totalRows;
|
|
96
188
|
|
|
97
189
|
const numericCols = useMemo(
|
|
98
|
-
() =>
|
|
99
|
-
new Set(columns.filter((c) => c.type === 'number' || c.type === 'currency').map((c) => c.id)),
|
|
190
|
+
() => new Set(columns.filter((c) => NUMERIC_TYPES.has(c.type)).map((c) => c.id)),
|
|
100
191
|
[columns],
|
|
101
192
|
);
|
|
102
193
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
194
|
+
/** The owner's presets for whatever is on screen. */
|
|
195
|
+
const ownerAggs = view.legacyDoc ? (view.legacyDoc.aggregates ?? {}) : (tab?.aggregates ?? {});
|
|
196
|
+
|
|
197
|
+
const kindFor = (colId: string): AggregateKind => readerAggs[colId] ?? ownerAggs[colId] ?? 'none';
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The total to draw under a column, or null for "nothing to show".
|
|
201
|
+
*
|
|
202
|
+
* Three sources, and which one applies is entirely about whether the rows in
|
|
203
|
+
* hand are the whole story:
|
|
204
|
+
* - legacy → compute locally; the doc IS complete.
|
|
205
|
+
* - a reader's pick on a file-backed tab → the value fetched for it.
|
|
206
|
+
* - otherwise → the owner's preset, computed server-side in the share view.
|
|
207
|
+
*/
|
|
208
|
+
const valueFor = (colId: string): number | null => {
|
|
209
|
+
const kind = kindFor(colId);
|
|
210
|
+
if (kind === 'none') return null;
|
|
211
|
+
if (view.legacyDoc) return legacyAggregate(view.legacyDoc.rows, colId, kind);
|
|
212
|
+
if (colId in readerAggs) return readerVals[colId] ?? null;
|
|
213
|
+
return tab?.aggregateValues?.[colId] ?? null;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const pickAggregate = async (colId: string, kind: AggregateKind) => {
|
|
217
|
+
setReaderAggs((prev) => ({ ...prev, [colId]: kind }));
|
|
218
|
+
if (kind === 'none' || view.legacyDoc || !tab) return;
|
|
219
|
+
setReaderVals((prev) => ({ ...prev, [colId]: null }));
|
|
220
|
+
try {
|
|
221
|
+
const qs = new URLSearchParams({ tab: tab.id, col: colId, kind });
|
|
222
|
+
const r = await fetch(`/s/${token}/aggregate?${qs.toString()}`, { cache: 'no-store' });
|
|
223
|
+
if (!r.ok) return;
|
|
224
|
+
const d = (await r.json()) as { value: number | null };
|
|
225
|
+
setReaderVals((prev) => ({ ...prev, [colId]: d.value }));
|
|
226
|
+
} catch {
|
|
227
|
+
// A failed total stays blank. There is no honest fallback — the rows in
|
|
228
|
+
// hand are a window, and a number from them would be wrong.
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const embedded = isEmbedded(chrome);
|
|
233
|
+
const anyTotal = columns.some((c) => kindFor(c.id) !== 'none');
|
|
234
|
+
|
|
235
|
+
const header = (
|
|
236
|
+
<header className={embedded ? 'mb-3 shrink-0' : 'mb-6 shrink-0 text-center'}>
|
|
237
|
+
{!embedded && (
|
|
106
238
|
<h1 className="text-xl font-semibold tracking-tight">
|
|
107
239
|
{view.icon ? `${view.icon} ` : ''}
|
|
108
240
|
{view.title}
|
|
109
241
|
</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
242
|
)}
|
|
243
|
+
<p className={cn('text-xs text-muted-foreground', !embedded && 'mt-1')}>
|
|
244
|
+
{view.legacyDoc || !hasMore
|
|
245
|
+
? `${totalRows} row${totalRows === 1 ? '' : 's'}`
|
|
246
|
+
: `${shownRows.length} of ${totalRows} rows`}
|
|
247
|
+
</p>
|
|
248
|
+
</header>
|
|
249
|
+
);
|
|
134
250
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
251
|
+
const tabStrip = tabs.length > 1 && (
|
|
252
|
+
<div className="mb-3 flex shrink-0 flex-wrap gap-1">
|
|
253
|
+
{tabs.map((t) => (
|
|
254
|
+
<button
|
|
255
|
+
key={t.id}
|
|
256
|
+
type="button"
|
|
257
|
+
onClick={() => setTabId(t.id)}
|
|
258
|
+
className={cn(
|
|
259
|
+
'rounded-md px-3 py-1.5 text-sm transition-colors',
|
|
260
|
+
t.id === (tab?.id ?? null)
|
|
261
|
+
? 'bg-primary text-primary-foreground'
|
|
262
|
+
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
263
|
+
)}
|
|
264
|
+
>
|
|
265
|
+
{t.name}
|
|
266
|
+
</button>
|
|
267
|
+
))}
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const grid = (
|
|
272
|
+
<table className="w-full border-collapse text-sm">
|
|
273
|
+
{/* Sticky against the scroll box below, not the page: a grid whose
|
|
274
|
+
headers leave the screen at row 40 is a grid of unlabelled numbers. */}
|
|
275
|
+
<thead className="sticky top-0 z-10">
|
|
276
|
+
<tr className="border-b border-border bg-muted">
|
|
277
|
+
{columns.map((c) => {
|
|
278
|
+
const Icon = TYPE_ICON[c.type] ?? Type;
|
|
279
|
+
return (
|
|
280
|
+
<th
|
|
281
|
+
key={c.id}
|
|
282
|
+
className={cn(
|
|
283
|
+
'whitespace-nowrap px-3 py-2 text-left font-medium text-muted-foreground',
|
|
284
|
+
numericCols.has(c.id) && 'text-right',
|
|
285
|
+
)}
|
|
286
|
+
>
|
|
287
|
+
<span
|
|
142
288
|
className={cn(
|
|
143
|
-
'
|
|
144
|
-
numericCols.has(c.id) && '
|
|
289
|
+
'inline-flex items-center gap-1.5',
|
|
290
|
+
numericCols.has(c.id) && 'flex-row-reverse',
|
|
145
291
|
)}
|
|
146
292
|
>
|
|
293
|
+
<Icon className="size-3.5 shrink-0 opacity-70" aria-hidden />
|
|
147
294
|
{c.name}
|
|
148
|
-
</
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
295
|
+
</span>
|
|
296
|
+
</th>
|
|
297
|
+
);
|
|
298
|
+
})}
|
|
299
|
+
</tr>
|
|
300
|
+
</thead>
|
|
301
|
+
<tbody>
|
|
302
|
+
{shownRows.map((r) => (
|
|
303
|
+
<tr key={r.id} className="border-b border-border/60 last:border-0 hover:bg-muted/30">
|
|
304
|
+
{columns.map((c) => (
|
|
305
|
+
<td
|
|
306
|
+
key={c.id}
|
|
307
|
+
className={cn(
|
|
308
|
+
'max-w-96 truncate px-3 py-1.5',
|
|
309
|
+
numericCols.has(c.id) && 'text-right tabular-nums',
|
|
310
|
+
)}
|
|
311
|
+
>
|
|
312
|
+
<Cell value={r.cells[c.id] ?? null} type={c.type} />
|
|
313
|
+
</td>
|
|
314
|
+
))}
|
|
315
|
+
</tr>
|
|
316
|
+
))}
|
|
317
|
+
{shownRows.length === 0 && !loading && (
|
|
318
|
+
<tr>
|
|
319
|
+
<td
|
|
320
|
+
colSpan={Math.max(columns.length, 1)}
|
|
321
|
+
className="px-3 py-8 text-center text-muted-foreground"
|
|
322
|
+
>
|
|
323
|
+
{failed ? 'Could not load rows.' : 'No rows.'}
|
|
324
|
+
</td>
|
|
325
|
+
</tr>
|
|
326
|
+
)}
|
|
327
|
+
</tbody>
|
|
328
|
+
{/* Always rendered, even with nothing set: the row IS the affordance.
|
|
329
|
+
A member who wants a total needs somewhere to ask for one, and a
|
|
330
|
+
footer that appears only once the owner has already chosen leaves
|
|
331
|
+
them no door. `bg-background` because a transparent sticky row shows
|
|
332
|
+
the scrolling cells through itself. */}
|
|
333
|
+
<tfoot className="sticky bottom-0 z-10">
|
|
334
|
+
<tr className={cn('border-t-2 border-border bg-muted', !anyTotal && 'text-xs')}>
|
|
335
|
+
{columns.map((c) => {
|
|
336
|
+
const kind = kindFor(c.id);
|
|
337
|
+
const value = valueFor(c.id);
|
|
338
|
+
return (
|
|
339
|
+
<td
|
|
340
|
+
key={c.id}
|
|
341
|
+
className={cn(
|
|
342
|
+
'border-l border-border/60 px-1.5 py-1 text-xs first:border-l-0',
|
|
343
|
+
numericCols.has(c.id) && 'text-right',
|
|
344
|
+
)}
|
|
345
|
+
>
|
|
346
|
+
<label className="flex items-center justify-end gap-1.5">
|
|
347
|
+
<span className="sr-only">Total for {c.name}</span>
|
|
348
|
+
{kind !== 'none' && (
|
|
349
|
+
<span className="min-w-0 truncate font-medium tabular-nums">
|
|
350
|
+
{value === null ? (
|
|
351
|
+
// null is a real answer — "this column cannot be
|
|
352
|
+
// totalled that way". A 0 here would be a lie.
|
|
353
|
+
<span className="text-muted-foreground/50">—</span>
|
|
354
|
+
) : (
|
|
355
|
+
formatAggregate(value, kind)
|
|
356
|
+
)}
|
|
357
|
+
</span>
|
|
358
|
+
)}
|
|
359
|
+
<select
|
|
360
|
+
value={kind}
|
|
361
|
+
onChange={(e) => void pickAggregate(c.id, e.target.value as AggregateKind)}
|
|
158
362
|
className={cn(
|
|
159
|
-
'
|
|
160
|
-
|
|
363
|
+
'cursor-pointer rounded-sm border-0 bg-transparent py-0 text-xs outline-none',
|
|
364
|
+
kind === 'none'
|
|
365
|
+
? 'text-muted-foreground/60 hover:text-foreground'
|
|
366
|
+
: 'text-muted-foreground',
|
|
161
367
|
)}
|
|
162
368
|
>
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
)}
|
|
178
|
-
</tbody>
|
|
179
|
-
</table>
|
|
180
|
-
</div>
|
|
369
|
+
{AGGREGATE_KINDS.map((k) => (
|
|
370
|
+
<option key={k} value={k}>
|
|
371
|
+
{k === 'none' ? 'Σ' : AGG_LABEL[k]}
|
|
372
|
+
</option>
|
|
373
|
+
))}
|
|
374
|
+
</select>
|
|
375
|
+
</label>
|
|
376
|
+
</td>
|
|
377
|
+
);
|
|
378
|
+
})}
|
|
379
|
+
</tr>
|
|
380
|
+
</tfoot>
|
|
381
|
+
</table>
|
|
382
|
+
);
|
|
181
383
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
384
|
+
const foot = (
|
|
385
|
+
<>
|
|
386
|
+
{loading && (
|
|
387
|
+
<div className="flex shrink-0 items-center justify-center gap-1.5 py-2 text-xs text-muted-foreground">
|
|
388
|
+
<Loader2 className="size-3 animate-spin" aria-hidden /> Loading…
|
|
389
|
+
</div>
|
|
390
|
+
)}
|
|
391
|
+
{/* Auto-fetch rather than a "Load more" button: scrolling toward the end
|
|
392
|
+
is already the gesture that means "show me the rest". */}
|
|
393
|
+
{!loading && hasMore && tab && (
|
|
394
|
+
<MoreSentinel onReveal={() => void fetchPage(shownRows.length, tab.id)} />
|
|
395
|
+
)}
|
|
396
|
+
</>
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
// Embedded, the grid OWNS the height it was given: the header and tab strip
|
|
400
|
+
// are fixed, and the table scrolls inside a bounded box so its sticky header
|
|
401
|
+
// and footer have something to stick to. On the standalone /s page there is
|
|
402
|
+
// no height to own — the page scrolls, the table simply grows, and sticky
|
|
403
|
+
// has nothing to do.
|
|
404
|
+
if (embedded) {
|
|
405
|
+
return (
|
|
406
|
+
<div className="flex h-full min-h-0 w-full flex-col px-6 py-6">
|
|
407
|
+
{header}
|
|
408
|
+
{tabStrip}
|
|
409
|
+
<div className="min-h-0 flex-1 overflow-auto rounded-lg border border-border scrollbar-thin">
|
|
410
|
+
{grid}
|
|
411
|
+
{foot}
|
|
412
|
+
</div>
|
|
197
413
|
</div>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
<div className="mx-auto max-w-6xl px-6 py-12">
|
|
419
|
+
{header}
|
|
420
|
+
{tabStrip}
|
|
421
|
+
<div className="overflow-x-auto rounded-lg border border-border">{grid}</div>
|
|
422
|
+
{foot}
|
|
198
423
|
</div>
|
|
199
424
|
);
|
|
200
425
|
}
|
|
201
426
|
|
|
427
|
+
/**
|
|
428
|
+
* The legacy path's total. Safe ONLY here: a legacy table's rows arrive whole
|
|
429
|
+
* in the share view, so this is the complete set, not a window. Mirrors
|
|
430
|
+
* computeAggregate's numeric semantics — non-numeric cells are ignored by the
|
|
431
|
+
* numeric kinds rather than counted as zero.
|
|
432
|
+
*/
|
|
433
|
+
function legacyAggregate(rows: PublicRow[], colId: string, kind: AggregateKind): number | null {
|
|
434
|
+
const isEmptyCell = (v: CellValue) =>
|
|
435
|
+
v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0);
|
|
436
|
+
if (kind === 'count') return rows.length;
|
|
437
|
+
if (kind === 'filled') return rows.filter((r) => !isEmptyCell(r.cells[colId] ?? null)).length;
|
|
438
|
+
if (kind === 'empty') return rows.filter((r) => isEmptyCell(r.cells[colId] ?? null)).length;
|
|
439
|
+
const nums = rows
|
|
440
|
+
.map((r) => {
|
|
441
|
+
const v = r.cells[colId];
|
|
442
|
+
if (typeof v === 'number') return v;
|
|
443
|
+
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v);
|
|
444
|
+
if (typeof v === 'boolean') return v ? 1 : 0;
|
|
445
|
+
return null;
|
|
446
|
+
})
|
|
447
|
+
.filter((n): n is number => n !== null);
|
|
448
|
+
if (nums.length === 0) return null;
|
|
449
|
+
switch (kind) {
|
|
450
|
+
case 'sum':
|
|
451
|
+
return nums.reduce((a, b) => a + b, 0);
|
|
452
|
+
case 'avg':
|
|
453
|
+
return nums.reduce((a, b) => a + b, 0) / nums.length;
|
|
454
|
+
case 'min':
|
|
455
|
+
return Math.min(...nums);
|
|
456
|
+
case 'max':
|
|
457
|
+
return Math.max(...nums);
|
|
458
|
+
default:
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Fetches the next page when it scrolls into view. */
|
|
464
|
+
function MoreSentinel({ onReveal }: { onReveal: () => void }) {
|
|
465
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
466
|
+
// The callback changes identity every render, so the observer reads it from a
|
|
467
|
+
// ref — re-creating the observer each render would disconnect it mid-scroll
|
|
468
|
+
// and the grid would stop growing until the reader scrolled again.
|
|
469
|
+
const reveal = useRef(onReveal);
|
|
470
|
+
reveal.current = onReveal;
|
|
471
|
+
|
|
472
|
+
useEffect(() => {
|
|
473
|
+
const el = ref.current;
|
|
474
|
+
if (!el) return;
|
|
475
|
+
const io = new IntersectionObserver(
|
|
476
|
+
(entries) => entries[0]?.isIntersecting && reveal.current(),
|
|
477
|
+
{ rootMargin: '400px' },
|
|
478
|
+
);
|
|
479
|
+
io.observe(el);
|
|
480
|
+
return () => io.disconnect();
|
|
481
|
+
}, []);
|
|
482
|
+
|
|
483
|
+
return <div ref={ref} className="h-8 shrink-0" aria-hidden />;
|
|
484
|
+
}
|
|
485
|
+
|
|
202
486
|
function Cell({ value, type }: { value: CellValue; type: string }) {
|
|
203
487
|
if (value === null || value === undefined || value === '') {
|
|
204
488
|
return <span className="text-muted-foreground/50">—</span>;
|
package/src/task-presenter.tsx
CHANGED
|
@@ -2,6 +2,8 @@ import ReactMarkdown from 'react-markdown';
|
|
|
2
2
|
import remarkGfm from 'remark-gfm';
|
|
3
3
|
import { Check, Circle } from 'lucide-react';
|
|
4
4
|
import { formatDateTime } from '@mantle/client-types/lib/format-datetime';
|
|
5
|
+
import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
|
|
6
|
+
import { cn } from './lib/utils';
|
|
5
7
|
|
|
6
8
|
/** Human label per lifecycle status (raw value falls through for forward
|
|
7
9
|
* compatibility — never render a snake_case token to a reader). */
|
|
@@ -12,9 +14,19 @@ const STATUS_LABEL: Record<string, string> = {
|
|
|
12
14
|
done: 'Done',
|
|
13
15
|
};
|
|
14
16
|
|
|
15
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Public task render — a status card standalone, laid out on the pane when
|
|
19
|
+
* embedded.
|
|
20
|
+
*
|
|
21
|
+
* The status/priority/due chips survive both shells: they are the task's
|
|
22
|
+
* metadata, not decoration, and the pane header carries only the title. What
|
|
23
|
+
* embedded drops is the card frame, the hero title, and the done-tick beside it
|
|
24
|
+
* (the "Done" chip already says the same thing without a 24px glyph claiming
|
|
25
|
+
* the leftmost column). See `PresenterChrome`.
|
|
26
|
+
*/
|
|
16
27
|
export function TaskPresenter({
|
|
17
28
|
view,
|
|
29
|
+
chrome,
|
|
18
30
|
}: {
|
|
19
31
|
view: {
|
|
20
32
|
title: string;
|
|
@@ -24,48 +36,58 @@ export function TaskPresenter({
|
|
|
24
36
|
dueAt: string | null;
|
|
25
37
|
todos?: { text: string; done: boolean }[];
|
|
26
38
|
};
|
|
39
|
+
chrome?: PresenterChrome;
|
|
27
40
|
}) {
|
|
28
41
|
const done = view.status === 'done';
|
|
29
42
|
const todos = view.todos ?? [];
|
|
43
|
+
const embedded = isEmbedded(chrome);
|
|
44
|
+
const chips = (
|
|
45
|
+
<div className={cn('flex flex-wrap items-center gap-2 text-xs', !embedded && 'mt-3')}>
|
|
46
|
+
<span className="rounded-full border border-border px-2 py-0.5 text-muted-foreground">
|
|
47
|
+
{STATUS_LABEL[view.status] ?? view.status.replace(/_/g, ' ')}
|
|
48
|
+
</span>
|
|
49
|
+
<span className="rounded-full border border-border px-2 py-0.5 capitalize text-muted-foreground">
|
|
50
|
+
{view.priority} priority
|
|
51
|
+
</span>
|
|
52
|
+
{view.dueAt && (
|
|
53
|
+
<span className="rounded-full border border-border px-2 py-0.5 text-muted-foreground">
|
|
54
|
+
Due {formatDateTime(view.dueAt)}
|
|
55
|
+
</span>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
|
|
30
60
|
return (
|
|
31
|
-
<div className=
|
|
32
|
-
<div className=
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
? 'border-primary bg-primary text-primary-foreground'
|
|
39
|
-
: 'border-border text-transparent')
|
|
40
|
-
}
|
|
41
|
-
aria-hidden
|
|
42
|
-
>
|
|
43
|
-
{done ? <Check className="size-4" /> : <Circle className="size-3" />}
|
|
44
|
-
</span>
|
|
45
|
-
<div className="min-w-0 flex-1">
|
|
46
|
-
<h1
|
|
61
|
+
<div className={embedded ? 'w-full px-6 py-6' : 'mx-auto max-w-2xl px-6 py-12 md:py-16'}>
|
|
62
|
+
<div className={cn(!embedded && 'rounded-xl border border-border bg-card p-6')}>
|
|
63
|
+
{embedded ? (
|
|
64
|
+
chips
|
|
65
|
+
) : (
|
|
66
|
+
<div className="flex items-start gap-3">
|
|
67
|
+
<span
|
|
47
68
|
className={
|
|
48
|
-
'
|
|
49
|
-
(done
|
|
69
|
+
'mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border ' +
|
|
70
|
+
(done
|
|
71
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
72
|
+
: 'border-border text-transparent')
|
|
50
73
|
}
|
|
74
|
+
aria-hidden
|
|
51
75
|
>
|
|
52
|
-
{
|
|
53
|
-
</
|
|
54
|
-
<div className="
|
|
55
|
-
<
|
|
56
|
-
{
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
</span>
|
|
65
|
-
)}
|
|
76
|
+
{done ? <Check className="size-4" /> : <Circle className="size-3" />}
|
|
77
|
+
</span>
|
|
78
|
+
<div className="min-w-0 flex-1">
|
|
79
|
+
<h1
|
|
80
|
+
className={
|
|
81
|
+
'text-2xl font-bold tracking-tight ' +
|
|
82
|
+
(done ? 'text-muted-foreground line-through' : '')
|
|
83
|
+
}
|
|
84
|
+
>
|
|
85
|
+
{view.title}
|
|
86
|
+
</h1>
|
|
87
|
+
{chips}
|
|
66
88
|
</div>
|
|
67
89
|
</div>
|
|
68
|
-
|
|
90
|
+
)}
|
|
69
91
|
{view.body && (
|
|
70
92
|
<div className="prose prose-sm dark:prose-invert mt-5 max-w-none border-t border-border pt-5">
|
|
71
93
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{view.body}</ReactMarkdown>
|
package/src/view-payload.ts
CHANGED
|
@@ -16,13 +16,22 @@ import type { TocEntry } from '@mantle/content-core/page-toc';
|
|
|
16
16
|
import type { CoverageGap, FormulaSpec } from '@mantle/content-core/formula-spec';
|
|
17
17
|
import type { TargetSignature } from '@mantle/content-core/formula-signature';
|
|
18
18
|
import type { DimensionIssue } from '@mantle/content-core/formula-dimensions';
|
|
19
|
-
import type { Column, Row } from '@mantle/content-core/table-model';
|
|
19
|
+
import type { AggregateKind, Column, Row } from '@mantle/content-core/table-model';
|
|
20
20
|
|
|
21
21
|
export type ShareFolderListing = {
|
|
22
22
|
/** ltree path currently being listed (the shared root or a descendant). */
|
|
23
23
|
currentPath: string;
|
|
24
24
|
folders: Array<{ id: string; path: string; slug: string; fileCount: number }>;
|
|
25
|
-
files: Array<{
|
|
25
|
+
files: Array<{
|
|
26
|
+
id: string;
|
|
27
|
+
filename: string;
|
|
28
|
+
mimeType: string;
|
|
29
|
+
sizeBytes: number;
|
|
30
|
+
/** ISO instant of the last write, so an embedded listing can carry a
|
|
31
|
+
* Modified column. Optional because a client pinned to an older server
|
|
32
|
+
* still gets a payload without it. */
|
|
33
|
+
updatedAt?: string;
|
|
34
|
+
}>;
|
|
26
35
|
};
|
|
27
36
|
|
|
28
37
|
export type ShareViewPayload =
|
|
@@ -66,8 +75,29 @@ export type ShareViewPayload =
|
|
|
66
75
|
name: string;
|
|
67
76
|
rowCount: number;
|
|
68
77
|
columns: Array<{ id: string; name: string; type: string }>;
|
|
78
|
+
/**
|
|
79
|
+
* The owner's footer totals for this tab: colId → kind, and the VALUE
|
|
80
|
+
* computed server-side over the whole tab.
|
|
81
|
+
*
|
|
82
|
+
* The value has to come from the server and this is the whole reason
|
|
83
|
+
* the field exists. A reader holds one 200-row window at a time, so a
|
|
84
|
+
* sum taken over what it happens to have loaded is not a smaller
|
|
85
|
+
* number — it is a WRONG one, and wrong quietly, which is worse than
|
|
86
|
+
* absent. `aggregateWindow` runs it in SQL over every row.
|
|
87
|
+
*
|
|
88
|
+
* Optional: an older server sends neither, and the footer simply does
|
|
89
|
+
* not render.
|
|
90
|
+
*/
|
|
91
|
+
aggregates?: Record<string, AggregateKind>;
|
|
92
|
+
aggregateValues?: Record<string, number | null>;
|
|
69
93
|
}> | null;
|
|
70
|
-
legacyDoc: {
|
|
94
|
+
legacyDoc: {
|
|
95
|
+
columns: Column[];
|
|
96
|
+
rows: Row[];
|
|
97
|
+
/** Legacy tables arrive WHOLE, so the reader can compute these itself
|
|
98
|
+
* with `computeAggregate` and no endpoint is involved. Settings only. */
|
|
99
|
+
aggregates?: Record<string, AggregateKind>;
|
|
100
|
+
} | null;
|
|
71
101
|
}
|
|
72
102
|
| {
|
|
73
103
|
kind: 'formula';
|