@crossworks/share-ui 0.232.16 → 0.232.18
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 +3 -3
- package/src/draw-presenter.tsx +17 -5
- package/src/event-presenter.tsx +117 -8
- package/src/table-presenter.tsx +11 -4
- package/src/task-presenter.tsx +4 -1
- package/src/view-payload.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossworks/share-ui",
|
|
3
|
-
"version": "0.232.
|
|
3
|
+
"version": "0.232.18",
|
|
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",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"./styles/app.css": "./styles/app.css"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@mantle/client-types": "npm:@crossworks/client-types@0.232.
|
|
36
|
-
"@mantle/content-core": "npm:@crossworks/content-core@0.232.
|
|
35
|
+
"@mantle/client-types": "npm:@crossworks/client-types@0.232.18",
|
|
36
|
+
"@mantle/content-core": "npm:@crossworks/content-core@0.232.18",
|
|
37
37
|
"@radix-ui/react-label": "^2.1.12",
|
|
38
38
|
"@radix-ui/react-slot": "^1.3.0",
|
|
39
39
|
"class-variance-authority": "^0.7.1",
|
package/src/draw-presenter.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
|
|
2
|
+
import { cn } from './lib/utils';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Public draw render — the committed SVG snapshot, static, no JS.
|
|
@@ -9,28 +10,39 @@ import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
|
|
|
9
10
|
* page, and external references don't load. acceptSceneSvg still validates at
|
|
10
11
|
* commit, but this surface no longer depends on it being exhaustive.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Theme: the snapshot is exported light-mode with its own background. On the
|
|
14
|
+
* STANDALONE page the white mat stays on either theme — a framed artwork on a
|
|
15
|
+
* page that leaves the brain. EMBEDDED (the /team reader is a themed app
|
|
16
|
+
* surface), dark mode applies Excalidraw's own theme filter
|
|
17
|
+
* (invert + hue-rotate, the exact transform the editor uses) over the whole
|
|
18
|
+
* mat, so strokes and canvas read dark natively — UNLESS the scene contains a
|
|
19
|
+
* raster image (`hasImage`), which one flat filter would render as a negative;
|
|
20
|
+
* those keep the light mat, same veto the owner previews apply.
|
|
15
21
|
*/
|
|
16
22
|
export function DrawPresenter({
|
|
17
23
|
view,
|
|
18
24
|
src,
|
|
19
25
|
chrome,
|
|
20
26
|
}: {
|
|
21
|
-
view: { title: string; hasSvg: boolean };
|
|
27
|
+
view: { title: string; hasSvg: boolean; hasImage?: boolean };
|
|
22
28
|
/** URL of the snapshot image (`/s/<token>/draw`). */
|
|
23
29
|
src: string;
|
|
24
30
|
chrome?: PresenterChrome;
|
|
25
31
|
}) {
|
|
26
32
|
const embedded = isEmbedded(chrome);
|
|
33
|
+
const invertible = embedded && view.hasImage !== true;
|
|
27
34
|
return (
|
|
28
35
|
<article className={embedded ? 'w-full px-6 py-6' : 'mx-auto max-w-5xl px-6 py-12 md:py-16'}>
|
|
29
36
|
{!embedded && (
|
|
30
37
|
<h1 className="mb-8 text-3xl font-bold tracking-tight text-balance">{view.title}</h1>
|
|
31
38
|
)}
|
|
32
39
|
{view.hasSvg ? (
|
|
33
|
-
<div
|
|
40
|
+
<div
|
|
41
|
+
className={cn(
|
|
42
|
+
'overflow-hidden rounded-lg border border-border bg-white p-3',
|
|
43
|
+
invertible && 'dark:[filter:invert(93%)_hue-rotate(180deg)]',
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
34
46
|
{/* eslint-disable-next-line @next/next/no-img-element -- a drawing
|
|
35
47
|
snapshot is not a next/image candidate: it's an owner-generated
|
|
36
48
|
SVG of unknown dimensions served from our own share route. */}
|
package/src/event-presenter.tsx
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
1
4
|
import ReactMarkdown from 'react-markdown';
|
|
2
5
|
import remarkGfm from 'remark-gfm';
|
|
3
|
-
import { CalendarDays, Clock, Download, MapPin } from 'lucide-react';
|
|
6
|
+
import { CalendarCheck, CalendarDays, Clock, Download, MapPin, Repeat, Tag } from 'lucide-react';
|
|
4
7
|
import { formatDateTime } from '@mantle/client-types/lib/format-datetime';
|
|
5
8
|
import { buildIcsHref } from '@mantle/client-types/lib/event-time';
|
|
6
9
|
import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
|
|
@@ -12,20 +15,96 @@ type EventView = {
|
|
|
12
15
|
startsAt: string | null;
|
|
13
16
|
endsAt: string | null;
|
|
14
17
|
location: string | null;
|
|
18
|
+
/** Optional — an older brain omits these and the rows simply don't render. */
|
|
19
|
+
recur?: string | null;
|
|
20
|
+
recurUntil?: string | null;
|
|
21
|
+
tags?: string[];
|
|
15
22
|
};
|
|
16
23
|
|
|
24
|
+
/** Live clock, ticking only after mount. On the static /s render (no
|
|
25
|
+
* hydration for the event kind) this stays null and the hero shows the
|
|
26
|
+
* date-anchored snapshot instead — same SSR-safe fallback the owner pane
|
|
27
|
+
* uses. */
|
|
28
|
+
function useNow(): number | null {
|
|
29
|
+
const [now, setNow] = useState<number | null>(null);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
setNow(Date.now());
|
|
32
|
+
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
33
|
+
return () => clearInterval(t);
|
|
34
|
+
}, []);
|
|
35
|
+
return now;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function remainingLabel(ms: number): string {
|
|
39
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
40
|
+
const days = Math.floor(s / 86_400);
|
|
41
|
+
if (days >= 2) return `${days} days`;
|
|
42
|
+
const hours = Math.floor(s / 3600);
|
|
43
|
+
if (hours >= 1) return `${hours} h ${Math.floor((s % 3600) / 60)} min`;
|
|
44
|
+
const min = Math.floor(s / 60);
|
|
45
|
+
return `${min}:${String(s % 60).padStart(2, '0')} min`;
|
|
46
|
+
}
|
|
47
|
+
|
|
17
48
|
/**
|
|
18
|
-
*
|
|
49
|
+
* The countdown hero — the owner pane's signature, ported for members: a
|
|
50
|
+
* coming event leads with HOW SOON, a running one says so live, a past one
|
|
51
|
+
* closes the loop. Left-aligned like every other row (the member asked "when
|
|
52
|
+
* is this", not for a poster). Degrades to plain dates with no JS.
|
|
53
|
+
*/
|
|
54
|
+
function CountdownHero({ startsAt, endsAt }: { startsAt: string; endsAt: string | null }) {
|
|
55
|
+
const now = useNow();
|
|
56
|
+
const start = Date.parse(startsAt);
|
|
57
|
+
const end = endsAt ? Date.parse(endsAt) : start + 60 * 60 * 1000;
|
|
58
|
+
if (now === null) {
|
|
59
|
+
return <p className="text-sm font-medium text-primary-ink">{formatDateTime(startsAt)}</p>;
|
|
60
|
+
}
|
|
61
|
+
if (now < start) {
|
|
62
|
+
return (
|
|
63
|
+
<div>
|
|
64
|
+
<p className="text-3xl font-bold tabular-nums tracking-tight">
|
|
65
|
+
{remainingLabel(start - now)}
|
|
66
|
+
</p>
|
|
67
|
+
<p className="mt-0.5 text-xs text-muted-foreground">until it starts</p>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (now <= end) {
|
|
72
|
+
return (
|
|
73
|
+
<div className="flex items-center gap-2">
|
|
74
|
+
<span className="relative flex size-2.5">
|
|
75
|
+
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-75" />
|
|
76
|
+
<span className="relative inline-flex size-2.5 rounded-full bg-success" />
|
|
77
|
+
</span>
|
|
78
|
+
<div>
|
|
79
|
+
<p className="text-lg font-semibold">Happening now</p>
|
|
80
|
+
{endsAt && <p className="text-xs text-muted-foreground">ends {formatDateTime(endsAt)}</p>}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return (
|
|
86
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
87
|
+
<CalendarCheck className="size-5 shrink-0" aria-hidden />
|
|
88
|
+
<p className="text-sm">Ended {formatDateTime(endsAt ?? startsAt)}</p>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Public event render — countdown, time, location, recurrence, tags, and an
|
|
95
|
+
* .ics link: the owner pane's details, member-safe (reminders stay private).
|
|
19
96
|
*
|
|
20
|
-
* On the standalone page this is a
|
|
21
|
-
* a reader where the event begins and ends on an otherwise empty
|
|
22
|
-
* in a pane that already has a header rule and a border of its
|
|
23
|
-
* card is the "floating box in the middle" — so embedded drops
|
|
24
|
-
* the title, and lays the detail out on the pane.
|
|
97
|
+
* On the standalone page this is a card in a measured column: the card border
|
|
98
|
+
* is what tells a reader where the event begins and ends on an otherwise empty
|
|
99
|
+
* page. Embedded in a pane that already has a header rule and a border of its
|
|
100
|
+
* own, that same card is the "floating box in the middle" — so embedded drops
|
|
101
|
+
* the frame and the title, and lays the detail out on the pane. Everything is
|
|
102
|
+
* left-aligned in both modes. See `PresenterChrome`.
|
|
25
103
|
*/
|
|
26
104
|
export function EventPresenter({ view, chrome }: { view: EventView; chrome?: PresenterChrome }) {
|
|
27
105
|
const ics = buildIcsHref(view);
|
|
28
106
|
const embedded = isEmbedded(chrome);
|
|
107
|
+
const recur = view.recur && view.recur !== 'none' ? view.recur : null;
|
|
29
108
|
return (
|
|
30
109
|
<div className={embedded ? 'w-full px-6 py-6' : 'mx-auto max-w-2xl px-6 py-12 md:py-16'}>
|
|
31
110
|
<div className={cn(!embedded && 'rounded-xl border border-border bg-card p-6')}>
|
|
@@ -36,7 +115,13 @@ export function EventPresenter({ view, chrome }: { view: EventView; chrome?: Pre
|
|
|
36
115
|
</div>
|
|
37
116
|
)}
|
|
38
117
|
|
|
39
|
-
|
|
118
|
+
{view.startsAt && (
|
|
119
|
+
<div className={cn(!embedded && 'mt-5')}>
|
|
120
|
+
<CountdownHero startsAt={view.startsAt} endsAt={view.endsAt} />
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
<dl className={cn('space-y-2 text-sm', view.startsAt ? 'mt-4' : !embedded && 'mt-5')}>
|
|
40
125
|
{view.startsAt && (
|
|
41
126
|
<div className="flex items-center gap-2 text-muted-foreground">
|
|
42
127
|
<Clock className="size-4 shrink-0" aria-hidden />
|
|
@@ -52,6 +137,30 @@ export function EventPresenter({ view, chrome }: { view: EventView; chrome?: Pre
|
|
|
52
137
|
<span>{view.location}</span>
|
|
53
138
|
</div>
|
|
54
139
|
)}
|
|
140
|
+
{recur && (
|
|
141
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
142
|
+
<Repeat className="size-4 shrink-0" aria-hidden />
|
|
143
|
+
<span className="capitalize">
|
|
144
|
+
{recur}
|
|
145
|
+
{view.recurUntil ? ` · until ${formatDateTime(view.recurUntil)}` : ''}
|
|
146
|
+
</span>
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
{(view.tags?.length ?? 0) > 0 && (
|
|
150
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
151
|
+
<Tag className="size-4 shrink-0" aria-hidden />
|
|
152
|
+
<span className="flex flex-wrap gap-1">
|
|
153
|
+
{view.tags!.map((t) => (
|
|
154
|
+
<span
|
|
155
|
+
key={t}
|
|
156
|
+
className="rounded-full border border-border bg-muted px-2 py-0.5 text-xs text-muted-foreground"
|
|
157
|
+
>
|
|
158
|
+
{t}
|
|
159
|
+
</span>
|
|
160
|
+
))}
|
|
161
|
+
</span>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
55
164
|
</dl>
|
|
56
165
|
|
|
57
166
|
{view.body && (
|
package/src/table-presenter.tsx
CHANGED
|
@@ -233,7 +233,10 @@ export function TablePresenter({
|
|
|
233
233
|
const anyTotal = columns.some((c) => kindFor(c.id) !== 'none');
|
|
234
234
|
|
|
235
235
|
const header = (
|
|
236
|
-
|
|
236
|
+
// Embedded: a thin meta strip flush with the pane — the grid below is
|
|
237
|
+
// full-bleed (owner-grid parity), so the padding lives here, not on a
|
|
238
|
+
// wrapper.
|
|
239
|
+
<header className={embedded ? 'shrink-0 px-3 pb-2 pt-2' : 'mb-6 shrink-0 text-center'}>
|
|
237
240
|
{!embedded && (
|
|
238
241
|
<h1 className="text-xl font-semibold tracking-tight">
|
|
239
242
|
{view.icon ? `${view.icon} ` : ''}
|
|
@@ -249,7 +252,7 @@ export function TablePresenter({
|
|
|
249
252
|
);
|
|
250
253
|
|
|
251
254
|
const tabStrip = tabs.length > 1 && (
|
|
252
|
-
<div className=
|
|
255
|
+
<div className={cn('flex shrink-0 flex-wrap gap-1', embedded ? 'px-3 pb-2' : 'mb-3')}>
|
|
253
256
|
{tabs.map((t) => (
|
|
254
257
|
<button
|
|
255
258
|
key={t.id}
|
|
@@ -403,10 +406,14 @@ export function TablePresenter({
|
|
|
403
406
|
// has nothing to do.
|
|
404
407
|
if (embedded) {
|
|
405
408
|
return (
|
|
406
|
-
|
|
409
|
+
// Full-bleed (owner-grid parity): no wrapper padding, no border box —
|
|
410
|
+
// the pane's edges are the grid's edges and the cells draw the rules.
|
|
411
|
+
// The scroller keeps the bounded height so the sticky header/footer
|
|
412
|
+
// still have something to stick to.
|
|
413
|
+
<div className="flex h-full min-h-0 w-full flex-col">
|
|
407
414
|
{header}
|
|
408
415
|
{tabStrip}
|
|
409
|
-
<div className="min-h-0 flex-1 overflow-auto
|
|
416
|
+
<div className="min-h-0 flex-1 overflow-auto border-t border-border scrollbar-thin">
|
|
410
417
|
{grid}
|
|
411
418
|
{foot}
|
|
412
419
|
</div>
|
package/src/task-presenter.tsx
CHANGED
|
@@ -44,7 +44,10 @@ export function TaskPresenter({
|
|
|
44
44
|
const chips = (
|
|
45
45
|
<div className={cn('flex flex-wrap items-center gap-2 text-xs', !embedded && 'mt-3')}>
|
|
46
46
|
<span className="rounded-full border border-border px-2 py-0.5 text-muted-foreground">
|
|
47
|
-
{
|
|
47
|
+
{/* Guarded like `todos` above: a payload from an older brain may omit
|
|
48
|
+
status, and an unguarded deref here took down the whole /team
|
|
49
|
+
workspace (no boundary caught it). */}
|
|
50
|
+
{STATUS_LABEL[view.status ?? 'open'] ?? (view.status ?? 'open').replace(/_/g, ' ')}
|
|
48
51
|
</span>
|
|
49
52
|
<span className="rounded-full border border-border px-2 py-0.5 capitalize text-muted-foreground">
|
|
50
53
|
{view.priority} priority
|
package/src/view-payload.ts
CHANGED
|
@@ -62,6 +62,11 @@ export type ShareViewPayload =
|
|
|
62
62
|
startsAt: string | null;
|
|
63
63
|
endsAt: string | null;
|
|
64
64
|
location: string | null;
|
|
65
|
+
/** Recurrence rule name ('daily'|'weekly'|…) + optional end — optional:
|
|
66
|
+
* an older brain omits them and the presenter simply shows nothing. */
|
|
67
|
+
recur?: string | null;
|
|
68
|
+
recurUntil?: string | null;
|
|
69
|
+
tags?: string[];
|
|
65
70
|
}
|
|
66
71
|
| { kind: 'file'; fileId: string; filename: string; mimeType: string; size: number }
|
|
67
72
|
| { kind: 'app'; appId: string; title: string }
|