@miadi/stateloom-web 0.3.3 → 0.3.4
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 +1 -1
- package/server/.next/BUILD_ID +1 -1
- package/server/.next/build-manifest.json +5 -5
- package/server/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/server/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/server/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_global-error.html +2 -2
- package/server/.next/server/app/_global-error.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/server/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/server/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_not-found.html +1 -1
- package/server/.next/server/app/_not-found.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/api/docs/route.js.nft.json +1 -1
- package/server/.next/server/app/api/file/route.js.nft.json +1 -1
- package/server/.next/server/app/api/marks/route.js.nft.json +1 -1
- package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
- package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
- package/server/.next/server/app/index.html +1 -1
- package/server/.next/server/app/index.rsc +3 -3
- package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/page/build-manifest.json +3 -3
- package/server/.next/server/app/page.js.nft.json +1 -1
- package/server/.next/server/app/page_client-reference-manifest.js +1 -1
- package/server/.next/server/chunks/ssr/{2374f_next_dist_0c464f98._.js → 2374f_next_dist_491bc9ca._.js} +2 -2
- package/server/.next/server/chunks/ssr/{[root-of-the-server]__385c85a7._.js → [root-of-the-server]__0d719a8a._.js} +2 -2
- package/server/.next/server/chunks/ssr/[root-of-the-server]__199d50b6._.js +3 -0
- package/server/.next/server/chunks/ssr/[root-of-the-server]__5c28bab6._.js +3 -0
- package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +6 -6
- package/server/.next/server/middleware-build-manifest.js +3 -3
- package/server/.next/server/pages/404.html +1 -1
- package/server/.next/server/pages/500.html +2 -2
- package/server/.next/static/chunks/33df24ad2d3a04e7.js +1 -0
- package/server/.next/static/chunks/6052c4bfdd66ef5c.css +2 -0
- package/server/.next/static/chunks/b0af2862e9bb3e18.js +14 -0
- package/server/.next/static/chunks/b71bf89a3bbb72e1.js +5 -0
- package/server/.next/static/chunks/{turbopack-b3cae9131ae5086f.js → turbopack-3a774f5a5a8a9023.js} +1 -1
- package/server/package-lock.json +2 -2
- package/server/package.json +1 -1
- package/server/src/app/globals.css +40 -2
- package/server/src/app/page.tsx +8 -6
- package/server/src/components/ChromeIcon.tsx +106 -0
- package/server/src/components/PropertiesPanel.tsx +3 -2
- package/server/src/components/Toolbar.tsx +37 -20
- package/server/src/components/erd/ErdWorkspace.tsx +2 -1
- package/server/src/components/sequence/SequenceWorkspace.tsx +10 -1
- package/server/src/components/stage/CompanionLayer.tsx +41 -6
- package/server/src/components/stage/StageLayer.tsx +206 -23
- package/server/src/components/stage/StageProvider.tsx +146 -25
- package/server/src/components/system/SystemWorkspace.tsx +26 -12
- package/server/src/lib/markRequest.ts +24 -0
- package/server/src/lib/useNarrow.ts +30 -0
- package/server/stateloom-web.json +2 -2
- package/server/tsconfig.tsbuildinfo +1 -1
- package/server/.next/server/chunks/ssr/[root-of-the-server]__a593e8b6._.js +0 -3
- package/server/.next/server/chunks/ssr/[root-of-the-server]__f25b51be._.js +0 -3
- package/server/.next/static/chunks/59a1ec89a1dc7e34.js +0 -14
- package/server/.next/static/chunks/662d1b0ce457c16b.js +0 -1
- package/server/.next/static/chunks/8f45e56f40c207bf.css +0 -2
- package/server/.next/static/chunks/96818f5620f039a1.js +0 -5
- /package/server/.next/static/{CZ4wRF1efsHnxjkcKHITP → ZoijkpoWD861NG-cIGJrO}/_buildManifest.js +0 -0
- /package/server/.next/static/{CZ4wRF1efsHnxjkcKHITP → ZoijkpoWD861NG-cIGJrO}/_clientMiddlewareManifest.json +0 -0
- /package/server/.next/static/{CZ4wRF1efsHnxjkcKHITP → ZoijkpoWD861NG-cIGJrO}/_ssgManifest.js +0 -0
|
@@ -23,20 +23,43 @@
|
|
|
23
23
|
* the answer shows one line by the controls for a few seconds ("Mia answered
|
|
24
24
|
* mark 3"), which opens the thread when tapped. Nothing takes focus and no
|
|
25
25
|
* card opens by itself.
|
|
26
|
+
* - Pins: on opening a drawing with open marks and nothing lit, the view frames
|
|
27
|
+
* the marks (never below a legible scale); pins stay inside the board, fan out
|
|
28
|
+
* when they share a point, and never sit under the board's buttons or under the
|
|
29
|
+
* caption (the phone's sheet), where they could not be tapped.
|
|
30
|
+
* - On a phone the cards open in the sheet at the bottom of the board, in the
|
|
31
|
+
* caption's place (StageProvider); the Talk conversation and a card take turns.
|
|
32
|
+
* - Elements are named as the person sees them; a board that knows words only it
|
|
33
|
+
* can give (a sequence's messages) passes `labels`.
|
|
34
|
+
* - The Talk dock's list of marks opens a mark here (`requestMark`): the thread
|
|
35
|
+
* opens and the view frames its mark.
|
|
26
36
|
*
|
|
27
37
|
* The sidecar is written through /api/marks; the hub is told after.
|
|
28
38
|
*/
|
|
29
39
|
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
|
30
|
-
import {
|
|
40
|
+
import { createPortal } from "react-dom";
|
|
41
|
+
import {
|
|
42
|
+
fitLegibly,
|
|
43
|
+
markAnchor,
|
|
44
|
+
MarkComposer,
|
|
45
|
+
MarkIcon,
|
|
46
|
+
MarkNotice,
|
|
47
|
+
MarksIcon,
|
|
48
|
+
MarkThread,
|
|
49
|
+
MarksPanel,
|
|
50
|
+
StageOverlay,
|
|
51
|
+
useMarksRead,
|
|
52
|
+
type MarkDraft,
|
|
53
|
+
} from "@miadi/stateloom-canvas";
|
|
31
54
|
import {
|
|
32
55
|
agentWordIn,
|
|
33
56
|
agentWordText,
|
|
34
57
|
boxesOf,
|
|
35
58
|
markCounts,
|
|
36
59
|
unreadMarks,
|
|
37
|
-
fitToBoxes,
|
|
38
60
|
formatReview,
|
|
39
61
|
screenToWorld,
|
|
62
|
+
type Box,
|
|
40
63
|
type DocKind,
|
|
41
64
|
type ElementBox,
|
|
42
65
|
type Mark,
|
|
@@ -47,6 +70,7 @@ import { useStageContext } from "./StageProvider";
|
|
|
47
70
|
import { episodeOf } from "./CompanionLayer";
|
|
48
71
|
import { presenceLine, useEpisodePresence } from "./useEpisodePresence";
|
|
49
72
|
import { urlParam } from "@/lib/systemParam";
|
|
73
|
+
import { MARK_REQUEST_EVENT, clearMarkRequest, markRequested } from "@/lib/markRequest";
|
|
50
74
|
|
|
51
75
|
export interface StageLayerProps {
|
|
52
76
|
/** The drawing this board shows, absolute. Null until the workspace has read it. */
|
|
@@ -65,13 +89,25 @@ export interface StageLayerProps {
|
|
|
65
89
|
* level, a sequence shown as a list. The workspace brings it on.
|
|
66
90
|
*/
|
|
67
91
|
onReveal?: (focuses: readonly string[]) => void;
|
|
92
|
+
/** Words for elements only this board can name (a sequence's messages), by focus key. */
|
|
93
|
+
labels?: Readonly<Record<string, string>>;
|
|
68
94
|
}
|
|
69
95
|
|
|
70
96
|
/** How long the line that says an agent answered stays by the controls. */
|
|
71
97
|
const NOTICE_MS = 7000;
|
|
72
98
|
|
|
73
99
|
const quietButton =
|
|
74
|
-
"flex h-9 min-w-9 items-center justify-center gap-1 rounded-lg border border-gray-700 bg-gray-900/90 px-2 text-sm text-gray-200 shadow-lg backdrop-blur hover:bg-gray-800";
|
|
100
|
+
"stage-quiet-btn flex h-9 min-w-9 items-center justify-center gap-1 rounded-lg border border-gray-700 bg-gray-900/90 px-2 text-sm text-gray-200 shadow-lg backdrop-blur hover:bg-gray-800";
|
|
101
|
+
|
|
102
|
+
/** How much of the board's bottom the caption, or the phone's sheet, covers. */
|
|
103
|
+
function coveredBottom(rect: DOMRect): number {
|
|
104
|
+
let top = Infinity;
|
|
105
|
+
for (const sel of [".stage-sheet", ".stage-caption-host .slc-stage-caption"]) {
|
|
106
|
+
const r = document.querySelector(sel)?.getBoundingClientRect();
|
|
107
|
+
if (r && r.height > 0 && r.top < rect.bottom) top = Math.min(top, r.top);
|
|
108
|
+
}
|
|
109
|
+
return top === Infinity ? 0 : Math.max(0, rect.bottom - top + 8);
|
|
110
|
+
}
|
|
75
111
|
|
|
76
112
|
async function postMarks(docPath: string, body: unknown): Promise<{ marks: Mark[]; change: { action: "add" | "reply" | "resolve" | "reopen" | "remove"; id: string } }> {
|
|
77
113
|
const r = await fetch(`/api/marks?doc=${encodeURIComponent(docPath)}`, {
|
|
@@ -84,7 +120,7 @@ async function postMarks(docPath: string, body: unknown): Promise<{ marks: Mark[
|
|
|
84
120
|
return data;
|
|
85
121
|
}
|
|
86
122
|
|
|
87
|
-
export default function StageLayer({ docPath, kind, viewport, setViewport, boxes, boardRef, focus = null, level, onReveal }: StageLayerProps) {
|
|
123
|
+
export default function StageLayer({ docPath, kind, viewport, setViewport, boxes, boardRef, focus = null, level, onReveal, labels }: StageLayerProps) {
|
|
88
124
|
const stage = useStageContext();
|
|
89
125
|
const [marks, setMarks] = useState<Mark[]>([]);
|
|
90
126
|
const [marking, setMarking] = useState(false);
|
|
@@ -100,6 +136,26 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
100
136
|
selectedRef.current = selected;
|
|
101
137
|
const { read, markRead } = useMarksRead(docPath);
|
|
102
138
|
|
|
139
|
+
// ── The drawing on this board, for the caption ──────────────────────
|
|
140
|
+
const setBoard = stage?.setBoard;
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!setBoard || !docPath) return;
|
|
143
|
+
setBoard(docPath);
|
|
144
|
+
return () => setBoard(null);
|
|
145
|
+
}, [setBoard, docPath]);
|
|
146
|
+
|
|
147
|
+
/** Frame `targets` in what the caption (or the phone's sheet) leaves of the board, legibly. */
|
|
148
|
+
const frame = useCallback(
|
|
149
|
+
(targets: readonly Box[], opts: { floor?: number } = {}) => {
|
|
150
|
+
const rect = boardRef.current?.getBoundingClientRect();
|
|
151
|
+
if (!rect?.width || !targets.length) return;
|
|
152
|
+
const covered = coveredBottom(rect);
|
|
153
|
+
const height = Math.max(rect.height * 0.4, rect.height - covered);
|
|
154
|
+
setViewport(fitLegibly(targets, rect.width, height, { padding: 48, maxScale: 1, floor: opts.floor ?? 0.2 }));
|
|
155
|
+
},
|
|
156
|
+
[boardRef, setViewport],
|
|
157
|
+
);
|
|
158
|
+
|
|
103
159
|
// ── This drawing's marks ─────────────────────────────────────────────
|
|
104
160
|
const load = useCallback(() => {
|
|
105
161
|
if (!docPath) return;
|
|
@@ -175,26 +231,62 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
175
231
|
return;
|
|
176
232
|
}
|
|
177
233
|
fitted.current = litHere.nonce;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (!rect?.width) return;
|
|
181
|
-
// The caption covers the bottom of the board: fit into what it leaves.
|
|
182
|
-
const caption = document.querySelector(".stage-caption-host .slc-stage-caption")?.getBoundingClientRect();
|
|
183
|
-
const covered = caption && caption.top < rect.bottom ? Math.max(0, rect.bottom - caption.top + 8) : 0;
|
|
184
|
-
const height = Math.max(rect.height * 0.4, rect.height - covered);
|
|
185
|
-
setViewport(fitToBoxes(targets, rect.width, height, { padding: 48, maxScale: 1 }));
|
|
186
|
-
};
|
|
234
|
+
// The caption covers the bottom of the board: fit into what it leaves.
|
|
235
|
+
const fit = (): void => frame(targets);
|
|
187
236
|
fit();
|
|
188
237
|
// Again after the workspace's own fit or centring has landed. Not cleared
|
|
189
238
|
// when the board's boxes move under it (a live update arrives just after a
|
|
190
239
|
// drawing opens) — only when the layer goes.
|
|
191
240
|
if (refit.current) clearTimeout(refit.current);
|
|
192
241
|
refit.current = setTimeout(fit, 520);
|
|
193
|
-
}, [litHere, litBoxes, region, spotlight,
|
|
242
|
+
}, [litHere, litBoxes, region, spotlight, frame, onReveal]);
|
|
194
243
|
useEffect(() => () => {
|
|
195
244
|
if (refit.current) clearTimeout(refit.current);
|
|
196
245
|
}, []);
|
|
197
246
|
|
|
247
|
+
// ── Opening a drawing with marks: the view frames them ──────────────
|
|
248
|
+
// Once per drawing, when nothing is lit here (a lit step decides the view): what the marks
|
|
249
|
+
// point at on this level, and the element in hand. Looked at again after the workspace's
|
|
250
|
+
// own first view and this browser's remembered view have landed, and framed again only
|
|
251
|
+
// if that view hides some of them.
|
|
252
|
+
const marksFramed = useRef<string | null>(null);
|
|
253
|
+
const marksRefit = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
254
|
+
const viewportRef = useRef(viewport);
|
|
255
|
+
viewportRef.current = viewport;
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
if (!docPath || marksFramed.current === docPath) return;
|
|
258
|
+
if (litHere) {
|
|
259
|
+
marksFramed.current = docPath;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const open = marks.filter((m) => m.status !== "resolved" && (!m.level || !level || m.level === level));
|
|
263
|
+
if (!open.length || !boxes.length) return;
|
|
264
|
+
// Marks that name elements wait for the board to draw them (a board still showing the
|
|
265
|
+
// layout it had before this drawing arrived draws none of them); an area stands alone.
|
|
266
|
+
const named = open.filter((m) => m.elements?.length);
|
|
267
|
+
const drawn = named.filter((m) => boxesOf(boxes, m.elements!).length > 0);
|
|
268
|
+
if (named.length && !drawn.length) return;
|
|
269
|
+
const anchors = [...drawn, ...open.filter((m) => !m.elements?.length)].map((m) => markAnchor(m, boxes)).filter((b): b is Box => !!b);
|
|
270
|
+
marksFramed.current = docPath;
|
|
271
|
+
if (!anchors.length) return;
|
|
272
|
+
const targets = [...anchors, ...(focus ? boxesOf(boxes, [focus]) : [])];
|
|
273
|
+
const fitIfHidden = (): void => {
|
|
274
|
+
const rect = boardRef.current?.getBoundingClientRect();
|
|
275
|
+
if (!rect?.width) return;
|
|
276
|
+
const vp = viewportRef.current;
|
|
277
|
+
const a = screenToWorld(vp, { x: 0, y: 0 });
|
|
278
|
+
const b = screenToWorld(vp, { x: rect.width, y: rect.height - coveredBottom(rect) });
|
|
279
|
+
const seen = targets.every((t) => t.x >= a.x && t.y >= a.y && t.x + t.width <= b.x && t.y + t.height <= b.y);
|
|
280
|
+
if (!seen) frame(targets, { floor: 0.55 });
|
|
281
|
+
};
|
|
282
|
+
fitIfHidden();
|
|
283
|
+
for (const t of marksRefit.current) clearTimeout(t);
|
|
284
|
+
marksRefit.current = [setTimeout(fitIfHidden, 520), setTimeout(fitIfHidden, 1400)];
|
|
285
|
+
}, [docPath, marks, boxes, litHere, level, focus, frame, boardRef]);
|
|
286
|
+
useEffect(() => () => {
|
|
287
|
+
for (const t of marksRefit.current) clearTimeout(t);
|
|
288
|
+
}, []);
|
|
289
|
+
|
|
198
290
|
// Answer on the caption: a composer on what is lit (or the element in hand).
|
|
199
291
|
const answered = useRef(stage?.answerNonce ?? 0);
|
|
200
292
|
useEffect(() => {
|
|
@@ -246,13 +338,101 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
246
338
|
if (selectedMark) markRead(selectedMark);
|
|
247
339
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
248
340
|
}, [selectedMark?.id, selectedReplies, markRead]);
|
|
249
|
-
const openThread = (id: string): void => {
|
|
341
|
+
const openThread = useCallback((id: string): void => {
|
|
250
342
|
setDraft(null);
|
|
251
343
|
setPanel(false);
|
|
252
344
|
setMarking(false);
|
|
253
345
|
setSelected(id);
|
|
254
346
|
setNotice(null);
|
|
255
|
-
};
|
|
347
|
+
}, []);
|
|
348
|
+
|
|
349
|
+
// ── A mark asked for from outside the board (the Talk dock's list) ──
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
const take = (): void => {
|
|
352
|
+
const id = markRequested(docPath);
|
|
353
|
+
const mark = id ? marks.find((m) => m.id === id) : null;
|
|
354
|
+
if (!mark) return;
|
|
355
|
+
clearMarkRequest();
|
|
356
|
+
openThread(mark.id);
|
|
357
|
+
const at = markAnchor(mark, boxes);
|
|
358
|
+
if (at) setTimeout(() => frame([at], { floor: 0.55 }), 60);
|
|
359
|
+
};
|
|
360
|
+
take();
|
|
361
|
+
window.addEventListener(MARK_REQUEST_EVENT, take);
|
|
362
|
+
return () => window.removeEventListener(MARK_REQUEST_EVENT, take);
|
|
363
|
+
}, [docPath, marks, boxes, openThread, frame]);
|
|
364
|
+
|
|
365
|
+
// ── The phone's sheet: a card holds it in the caption's place ───────
|
|
366
|
+
const cardOpen = !!(draft || selectedMark || panel);
|
|
367
|
+
const claimSheet = stage?.claimSheet;
|
|
368
|
+
const releaseSheet = stage?.releaseSheet;
|
|
369
|
+
useEffect(() => {
|
|
370
|
+
if (cardOpen) claimSheet?.("card");
|
|
371
|
+
else releaseSheet?.("card");
|
|
372
|
+
}, [cardOpen, claimSheet, releaseSheet]);
|
|
373
|
+
useEffect(() => () => releaseSheet?.("card"), [releaseSheet]);
|
|
374
|
+
// The conversation took the sheet: the card gives way to it.
|
|
375
|
+
const narrow = !!stage?.narrow;
|
|
376
|
+
const sheetOwner = stage?.sheet ?? null;
|
|
377
|
+
const [seenOwner, setSeenOwner] = useState(sheetOwner);
|
|
378
|
+
if (seenOwner !== sheetOwner) {
|
|
379
|
+
setSeenOwner(sheetOwner);
|
|
380
|
+
if (narrow && sheetOwner === "talk") {
|
|
381
|
+
setDraft(null);
|
|
382
|
+
setSelected(null);
|
|
383
|
+
setPanel(false);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── The board's buttons: no pin sits under them ─────────────────────
|
|
388
|
+
const controlsRef = useRef<HTMLDivElement | null>(null);
|
|
389
|
+
const [controlsBox, setControlsBox] = useState<Box | null>(null);
|
|
390
|
+
useEffect(() => {
|
|
391
|
+
const el = controlsRef.current;
|
|
392
|
+
if (!el) return;
|
|
393
|
+
const measure = (): void => {
|
|
394
|
+
const next = { x: el.offsetLeft, y: el.offsetTop, width: el.offsetWidth, height: el.offsetHeight };
|
|
395
|
+
setControlsBox((b) => (b && b.x === next.x && b.y === next.y && b.width === next.width && b.height === next.height ? b : next));
|
|
396
|
+
};
|
|
397
|
+
measure();
|
|
398
|
+
window.addEventListener("resize", measure);
|
|
399
|
+
if (typeof ResizeObserver === "undefined") return () => window.removeEventListener("resize", measure);
|
|
400
|
+
const watch = new ResizeObserver(measure);
|
|
401
|
+
watch.observe(el);
|
|
402
|
+
return () => {
|
|
403
|
+
watch.disconnect();
|
|
404
|
+
window.removeEventListener("resize", measure);
|
|
405
|
+
};
|
|
406
|
+
}, [docPath]);
|
|
407
|
+
// ── And none under the caption, or the phone's sheet: what it covers of the board ──
|
|
408
|
+
const [coverBox, setCoverBox] = useState<Box | null>(null);
|
|
409
|
+
const shownNow = stage?.shown ?? null;
|
|
410
|
+
useEffect(() => {
|
|
411
|
+
const board = boardRef.current;
|
|
412
|
+
if (!board) return;
|
|
413
|
+
const find = () => document.querySelector(".stage-sheet") ?? document.querySelector(".stage-caption-host .slc-stage-caption");
|
|
414
|
+
let frameId = 0;
|
|
415
|
+
const measure = (): void => {
|
|
416
|
+
cancelAnimationFrame(frameId);
|
|
417
|
+
frameId = requestAnimationFrame(() => {
|
|
418
|
+
const b = board.getBoundingClientRect();
|
|
419
|
+
const r = find()?.getBoundingClientRect();
|
|
420
|
+
const next = r && r.height > 0 && r.top < b.bottom ? { x: r.left - b.left, y: r.top - b.top, width: r.width, height: Math.min(r.bottom, b.bottom) - r.top } : null;
|
|
421
|
+
setCoverBox((c) => (c && next && c.x === next.x && c.y === next.y && c.width === next.width && c.height === next.height ? c : next));
|
|
422
|
+
});
|
|
423
|
+
};
|
|
424
|
+
measure();
|
|
425
|
+
const el = find();
|
|
426
|
+
const watch = el && typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null;
|
|
427
|
+
if (el) watch?.observe(el);
|
|
428
|
+
window.addEventListener("resize", measure);
|
|
429
|
+
return () => {
|
|
430
|
+
cancelAnimationFrame(frameId);
|
|
431
|
+
watch?.disconnect();
|
|
432
|
+
window.removeEventListener("resize", measure);
|
|
433
|
+
};
|
|
434
|
+
}, [boardRef, shownNow, cardOpen, sheetOwner, narrow, docPath]);
|
|
435
|
+
const avoid = useMemo(() => [controlsBox, coverBox].filter((b): b is Box => !!b), [controlsBox, coverBox]);
|
|
256
436
|
|
|
257
437
|
if (!docPath) return null;
|
|
258
438
|
|
|
@@ -280,9 +460,10 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
280
460
|
setDraft(d);
|
|
281
461
|
}}
|
|
282
462
|
draft={draft}
|
|
463
|
+
avoid={avoid}
|
|
283
464
|
/>
|
|
284
465
|
|
|
285
|
-
<div className="stage-board-controls">
|
|
466
|
+
<div className="stage-board-controls" ref={controlsRef}>
|
|
286
467
|
{notice && (
|
|
287
468
|
<MarkNotice className="stage-board-notice" onOpen={() => openThread(notice.id)} onDismiss={() => setNotice(null)}>
|
|
288
469
|
{notice.text}
|
|
@@ -330,9 +511,8 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
330
511
|
</div>
|
|
331
512
|
)}
|
|
332
513
|
|
|
333
|
-
{
|
|
334
|
-
|
|
335
|
-
{draft ? (
|
|
514
|
+
{cardOpen && (() => {
|
|
515
|
+
const card = draft ? (
|
|
336
516
|
<MarkComposer
|
|
337
517
|
key={`${draft.elements.join(",")}|${draft.region ? JSON.stringify(draft.region) : ""}`}
|
|
338
518
|
elements={draft.elements}
|
|
@@ -344,6 +524,7 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
344
524
|
busy={busy}
|
|
345
525
|
error={error}
|
|
346
526
|
note={note}
|
|
527
|
+
labels={labels}
|
|
347
528
|
onCancel={() => {
|
|
348
529
|
setDraft(null);
|
|
349
530
|
setError(null);
|
|
@@ -365,6 +546,7 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
365
546
|
busy={busy}
|
|
366
547
|
error={error}
|
|
367
548
|
note={note}
|
|
549
|
+
labels={labels}
|
|
368
550
|
onClose={() => setSelected(null)}
|
|
369
551
|
onReply={(text) => void write({ action: "reply", id: selectedMark.id, text, author: { name: stage?.author || "someone" } })}
|
|
370
552
|
onResolve={() => void write({ action: "resolve", id: selectedMark.id })}
|
|
@@ -385,11 +567,12 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
385
567
|
onCopy={copy}
|
|
386
568
|
copied={copied}
|
|
387
569
|
note={note}
|
|
570
|
+
labels={labels}
|
|
388
571
|
onClose={() => setPanel(false)}
|
|
389
572
|
/>
|
|
390
|
-
)
|
|
391
|
-
</div
|
|
392
|
-
)}
|
|
573
|
+
);
|
|
574
|
+
return narrow && stage?.sheetBody ? createPortal(card, stage.sheetBody) : <div className="stage-board-card">{card}</div>;
|
|
575
|
+
})()}
|
|
393
576
|
</>
|
|
394
577
|
);
|
|
395
578
|
}
|
|
@@ -15,14 +15,29 @@
|
|
|
15
15
|
* voice the person may turn on, the ears a mark may be spoken into, the name
|
|
16
16
|
* marks are signed with, and the line back to the agents — what the person is
|
|
17
17
|
* looking at, and that a drawing's marks changed.
|
|
18
|
+
*
|
|
19
|
+
* The caption's heading is the step's own title. When the step is about a
|
|
20
|
+
* drawing that is not the one on the board (a page opened on another drawing
|
|
21
|
+
* caught up with a tour the stage holds), the caption names that drawing,
|
|
22
|
+
* offers "Open and play", and has no Answer: a mark would land on the wrong
|
|
23
|
+
* drawing.
|
|
24
|
+
*
|
|
25
|
+
* On a phone (below 600 px) the bottom of the board holds one sheet: the Talk
|
|
26
|
+
* dock's bar as its handle, then one of the caption, a mark's card (composer,
|
|
27
|
+
* thread, list) or the Talk conversation. A card or the conversation takes the
|
|
28
|
+
* caption's place while it is open, and the caption comes back when it closes.
|
|
29
|
+
* Layers claim the sheet (`claimSheet`) and render into its slots
|
|
30
|
+
* (`sheetHandle`, `sheetBody`); on a wider screen there is no sheet and each
|
|
31
|
+
* keeps its own corner.
|
|
18
32
|
*/
|
|
19
33
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
20
34
|
import { browserDictation, browserVoice, useStage, type StageShown, type UseStage } from "@miadi/stateloom-react";
|
|
21
35
|
import { StageCaption, type ListenerLike } from "@miadi/stateloom-canvas";
|
|
22
|
-
import { DEFAULT_STAGE, isStageName, type Box, type MarksEnvelope, type StageContext } from "@miadi/stateloom-protocol";
|
|
36
|
+
import { DEFAULT_STAGE, isStageName, isSysdfPath, type Box, type MarksEnvelope, type StageContext } from "@miadi/stateloom-protocol";
|
|
23
37
|
import { loadRuntimeConfig } from "@/lib/runtimeConfig";
|
|
24
38
|
import { requestedDoc } from "@/lib/docParam";
|
|
25
39
|
import { FOCUS_REQUEST_EVENT, openInSystem, urlParam } from "@/lib/systemParam";
|
|
40
|
+
import { useNarrow } from "@/lib/useNarrow";
|
|
26
41
|
import CompanionLayer from "./CompanionLayer";
|
|
27
42
|
|
|
28
43
|
/** What the step on stage lights inside its drawing. `doc` absent: whatever drawing is open. */
|
|
@@ -37,6 +52,9 @@ export interface Lit {
|
|
|
37
52
|
/** What a layer reports about the board it sits on. */
|
|
38
53
|
export type BoardReport = Omit<StageContext, "stage" | "at" | "tour" | "who">;
|
|
39
54
|
|
|
55
|
+
/** Who holds the phone's sheet in place of the caption: a mark's card, or the Talk conversation. */
|
|
56
|
+
export type SheetOwner = "card" | "talk";
|
|
57
|
+
|
|
40
58
|
export interface StageValue {
|
|
41
59
|
stage: string;
|
|
42
60
|
connected: boolean;
|
|
@@ -58,6 +76,18 @@ export interface StageValue {
|
|
|
58
76
|
companion: boolean;
|
|
59
77
|
pause: () => void;
|
|
60
78
|
stop: () => void;
|
|
79
|
+
/** The drawing on the board now (absolute), as its layer says; null on the system map. */
|
|
80
|
+
board: string | null;
|
|
81
|
+
setBoard: (doc: string | null) => void;
|
|
82
|
+
/** Below 600 px: the caption, the cards and the conversation share one sheet. */
|
|
83
|
+
narrow: boolean;
|
|
84
|
+
/** Who holds the sheet instead of the caption. */
|
|
85
|
+
sheet: SheetOwner | null;
|
|
86
|
+
claimSheet: (owner: SheetOwner) => void;
|
|
87
|
+
releaseSheet: (owner: SheetOwner) => void;
|
|
88
|
+
/** The sheet's slots on a phone: the handle (the Talk bar), and the body (a card, the conversation). */
|
|
89
|
+
sheetHandle: HTMLElement | null;
|
|
90
|
+
sheetBody: HTMLElement | null;
|
|
61
91
|
}
|
|
62
92
|
|
|
63
93
|
const StageCtx = createContext<StageValue | null>(null);
|
|
@@ -85,6 +115,24 @@ function writeLocal(key: string, value: string): void {
|
|
|
85
115
|
}
|
|
86
116
|
}
|
|
87
117
|
|
|
118
|
+
/** The drawing's own name (settings.name), read once per drawing; its file name meanwhile. */
|
|
119
|
+
const drawingTitles = new Map<string, Promise<string | null>>();
|
|
120
|
+
function drawingTitle(doc: string): Promise<string | null> {
|
|
121
|
+
let found = drawingTitles.get(doc);
|
|
122
|
+
if (!found) {
|
|
123
|
+
found = fetch(`/api/file?doc=${encodeURIComponent(doc)}`, { cache: "no-store" })
|
|
124
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
125
|
+
.then((data: { content?: string } | null) => {
|
|
126
|
+
const def = data?.content ? JSON.parse(data.content) : null;
|
|
127
|
+
const name = (def?.settings ?? def?.stateMachine?.settings)?.name;
|
|
128
|
+
return typeof name === "string" && name ? name : null;
|
|
129
|
+
})
|
|
130
|
+
.catch(() => null);
|
|
131
|
+
drawingTitles.set(doc, found);
|
|
132
|
+
}
|
|
133
|
+
return found;
|
|
134
|
+
}
|
|
135
|
+
|
|
88
136
|
/** Ask the open workspace to select and centre `focus` on the drawing already open. */
|
|
89
137
|
function requestFocus(focus: string): void {
|
|
90
138
|
const url = new URL(window.location.href);
|
|
@@ -122,6 +170,16 @@ export default function StageProvider({ children }: { children: ReactNode }) {
|
|
|
122
170
|
|
|
123
171
|
const [lit, setLit] = useState<Lit | null>(null);
|
|
124
172
|
const marksListeners = useRef(new Set<(e: MarksEnvelope) => void>());
|
|
173
|
+
// The drawing on the board, and the one the stage just asked the page to open — asked
|
|
174
|
+
// while `from` was on the board, so it counts only until the board changes.
|
|
175
|
+
const [board, setBoardState] = useState<string | null>(null);
|
|
176
|
+
const [opening, setOpeningState] = useState<{ doc: string; from: string | null } | null>(null);
|
|
177
|
+
const boardNow = useRef<string | null>(null);
|
|
178
|
+
const setOpening = useCallback((doc: string) => setOpeningState({ doc, from: boardNow.current }), []);
|
|
179
|
+
const setBoard = useCallback((doc: string | null) => {
|
|
180
|
+
boardNow.current = doc;
|
|
181
|
+
setBoardState(doc);
|
|
182
|
+
}, []);
|
|
125
183
|
|
|
126
184
|
const show = useCallback((shown: StageShown | null) => {
|
|
127
185
|
if (!shown) {
|
|
@@ -137,13 +195,14 @@ export default function StageProvider({ children }: { children: ReactNode }) {
|
|
|
137
195
|
return;
|
|
138
196
|
}
|
|
139
197
|
if (step.doc && step.doc !== open) {
|
|
198
|
+
setOpening(step.doc);
|
|
140
199
|
openInSystem({ doc: step.doc, system: step.system ?? undefined, focus: step.focus ?? null });
|
|
141
200
|
} else if (step.focus) {
|
|
142
201
|
if (step.system && urlParam("system") !== step.system && open) openInSystem({ doc: open, system: step.system, focus: step.focus });
|
|
143
202
|
else requestFocus(step.focus);
|
|
144
203
|
}
|
|
145
204
|
setLit({ doc: step.doc, focus: step.focus, spotlight: step.spotlight, region: step.region, nonce: shown.nonce });
|
|
146
|
-
}, []);
|
|
205
|
+
}, [setOpening]);
|
|
147
206
|
|
|
148
207
|
const stage = useStage({
|
|
149
208
|
url: config.bridgeUrl ?? "",
|
|
@@ -180,8 +239,49 @@ export default function StageProvider({ children }: { children: ReactNode }) {
|
|
|
180
239
|
|
|
181
240
|
const [answerNonce, setAnswerNonce] = useState(0);
|
|
182
241
|
|
|
183
|
-
//
|
|
184
|
-
const
|
|
242
|
+
// ── The phone's sheet ────────────────────────────────────────────────
|
|
243
|
+
const narrow = useNarrow();
|
|
244
|
+
const [sheet, setSheet] = useState<SheetOwner | null>(null);
|
|
245
|
+
const claimSheet = useCallback((owner: SheetOwner) => setSheet(owner), []);
|
|
246
|
+
const releaseSheet = useCallback((owner: SheetOwner) => setSheet((s) => (s === owner ? null : s)), []);
|
|
247
|
+
const [sheetHandle, setSheetHandle] = useState<HTMLElement | null>(null);
|
|
248
|
+
const [sheetBody, setSheetBody] = useState<HTMLElement | null>(null);
|
|
249
|
+
|
|
250
|
+
// ── A step about a drawing that is not on the board ─────────────────
|
|
251
|
+
const shownStep = stage.shown?.step ?? null;
|
|
252
|
+
// Until the board's layer has said which drawing it shows, the page's `?doc=` stands for it.
|
|
253
|
+
const asked = urlParam("doc");
|
|
254
|
+
const onBoard = board ?? (asked && !isSysdfPath(asked) ? asked : null);
|
|
255
|
+
const openingDoc = opening && opening.from === board ? opening.doc : null;
|
|
256
|
+
const heldDoc = shownStep?.doc && shownStep.doc !== onBoard && shownStep.doc !== openingDoc ? shownStep.doc : null;
|
|
257
|
+
const [heldTitle, setHeldTitle] = useState<{ doc: string; title: string | null } | null>(null);
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
if (!heldDoc) return;
|
|
260
|
+
let live = true;
|
|
261
|
+
void drawingTitle(heldDoc).then((title) => live && setHeldTitle({ doc: heldDoc, title }));
|
|
262
|
+
return () => {
|
|
263
|
+
live = false;
|
|
264
|
+
};
|
|
265
|
+
}, [heldDoc]);
|
|
266
|
+
const play = stage.play;
|
|
267
|
+
const held = useMemo(() => {
|
|
268
|
+
if (!heldDoc || !shownStep) return null;
|
|
269
|
+
const title = heldTitle?.doc === heldDoc ? heldTitle.title : null;
|
|
270
|
+
const drawing = title ?? (heldDoc.split("/").pop() ?? heldDoc).replace(/\.(smdf|erdf|sqdf|sysdf)\.json$/i, "").replace(/\.json$/i, "");
|
|
271
|
+
const inTour = stage.shown?.index !== null && stage.shown?.index !== undefined;
|
|
272
|
+
return {
|
|
273
|
+
drawing,
|
|
274
|
+
label: inTour ? "Open and play" : "Open the drawing",
|
|
275
|
+
onOpen: () => {
|
|
276
|
+
setOpening(heldDoc);
|
|
277
|
+
openInSystem({ doc: heldDoc, system: shownStep.system ?? undefined, focus: shownStep.focus ?? null });
|
|
278
|
+
if (inTour) play();
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}, [heldDoc, heldTitle, shownStep, stage.shown?.index, play, setOpening]);
|
|
282
|
+
|
|
283
|
+
// How tall the caption stands, for what must sit above it beside the board (the Talk dock).
|
|
284
|
+
const captionUp = !!stage.shown && !narrow;
|
|
185
285
|
useEffect(() => {
|
|
186
286
|
const root = document.documentElement;
|
|
187
287
|
const caption = captionUp ? document.querySelector<HTMLElement>(".stage-caption-host .slc-stage-caption") : null;
|
|
@@ -225,34 +325,55 @@ export default function StageProvider({ children }: { children: ReactNode }) {
|
|
|
225
325
|
companion: config.companion,
|
|
226
326
|
pause: stage.pause,
|
|
227
327
|
stop: stage.stop,
|
|
328
|
+
board,
|
|
329
|
+
setBoard,
|
|
330
|
+
narrow,
|
|
331
|
+
sheet,
|
|
332
|
+
claimSheet,
|
|
333
|
+
releaseSheet,
|
|
334
|
+
sheetHandle,
|
|
335
|
+
sheetBody,
|
|
228
336
|
}),
|
|
229
|
-
[
|
|
337
|
+
[
|
|
338
|
+
config.stage, config.companion, stage.status, stage.shown, stage.sendMarks, stage.present, stage.pause, stage.stop, lit, author, listener, answerNonce, report,
|
|
339
|
+
board, setBoard, narrow, sheet, claimSheet, releaseSheet, sheetHandle, sheetBody,
|
|
340
|
+
],
|
|
230
341
|
);
|
|
231
342
|
|
|
343
|
+
const caption = stage.shown ? (
|
|
344
|
+
<StageCaption
|
|
345
|
+
shown={stage.shown}
|
|
346
|
+
playing={stage.playing}
|
|
347
|
+
speaking={stage.speaking}
|
|
348
|
+
voice={stage.ownVoice ? undefined : voiceOn}
|
|
349
|
+
onToggleVoice={() => {
|
|
350
|
+
const next = !voiceOn;
|
|
351
|
+
setVoiceOn(next);
|
|
352
|
+
writeLocal(VOICE_KEY, next ? "1" : "0");
|
|
353
|
+
}}
|
|
354
|
+
onPlay={stage.play}
|
|
355
|
+
onPause={stage.pause}
|
|
356
|
+
onNext={stage.next}
|
|
357
|
+
onPrev={stage.prev}
|
|
358
|
+
onGoto={stage.goto}
|
|
359
|
+
onClose={stage.dismiss}
|
|
360
|
+
onAnswer={() => setAnswerNonce((n) => n + 1)}
|
|
361
|
+
held={held}
|
|
362
|
+
/>
|
|
363
|
+
) : null;
|
|
364
|
+
|
|
232
365
|
return (
|
|
233
366
|
<StageCtx.Provider value={value}>
|
|
234
367
|
{children}
|
|
235
|
-
{
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
voice={stage.ownVoice ? undefined : voiceOn}
|
|
242
|
-
onToggleVoice={() => {
|
|
243
|
-
const next = !voiceOn;
|
|
244
|
-
setVoiceOn(next);
|
|
245
|
-
writeLocal(VOICE_KEY, next ? "1" : "0");
|
|
246
|
-
}}
|
|
247
|
-
onPlay={stage.play}
|
|
248
|
-
onPause={stage.pause}
|
|
249
|
-
onNext={stage.next}
|
|
250
|
-
onPrev={stage.prev}
|
|
251
|
-
onGoto={stage.goto}
|
|
252
|
-
onClose={stage.dismiss}
|
|
253
|
-
onAnswer={() => setAnswerNonce((n) => n + 1)}
|
|
254
|
-
/>
|
|
368
|
+
{narrow ? (
|
|
369
|
+
// One sheet at the bottom of the board: the handle, then the caption or what took its place.
|
|
370
|
+
<div className="stage-sheet">
|
|
371
|
+
<div className="stage-sheet-handle" ref={setSheetHandle} />
|
|
372
|
+
<div className="stage-sheet-body" ref={setSheetBody} />
|
|
373
|
+
{!sheet && caption && <div className="stage-sheet-caption">{caption}</div>}
|
|
255
374
|
</div>
|
|
375
|
+
) : (
|
|
376
|
+
caption && <div className="stage-caption-host">{caption}</div>
|
|
256
377
|
)}
|
|
257
378
|
{config.companion && <CompanionLayer stage={value} defaultEpisode={config.companionEpisode} />}
|
|
258
379
|
</StageCtx.Provider>
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* A card opens its member inside the system (`?doc=<member>&system=<this>`),
|
|
10
10
|
* where the strip keeps every drawing one tap away.
|
|
11
11
|
*
|
|
12
|
-
* The panel: Members (what each is, and the links between each pair
|
|
12
|
+
* The panel: Members (what each is, and the links between each pair; a member's
|
|
13
|
+
* note shows when it has words, and an empty one waits behind "Add a note" so the
|
|
14
|
+
* panel opens on what the system holds, not on empty boxes), Checks
|
|
13
15
|
* (`checkSystem` grouped by rule, errors first, then the replay of every path
|
|
14
16
|
* of every scenario, and the reconcile mode with the changes the scenarios
|
|
15
17
|
* imply), and Notes. Same shell as the other workspaces: one header row, the
|
|
@@ -109,6 +111,8 @@ export default function SystemWorkspace() {
|
|
|
109
111
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
110
112
|
const [panelHidden, setPanelHidden] = useState(false);
|
|
111
113
|
const [pair, setPair] = useState<string | null>(null);
|
|
114
|
+
// Members whose empty note the person asked to write.
|
|
115
|
+
const [noting, setNoting] = useState<ReadonlySet<string>>(() => new Set());
|
|
112
116
|
const [status, setStatusState] = useState<{ text: string; tone: "ok" | "warn" }>({ text: "loading…", tone: "ok" });
|
|
113
117
|
const setStatus = useCallback((text: string, tone: "ok" | "warn" = "ok") => setStatusState({ text, tone }), []);
|
|
114
118
|
const [busy, setBusy] = useState(false);
|
|
@@ -530,16 +534,26 @@ export default function SystemWorkspace() {
|
|
|
530
534
|
</span>
|
|
531
535
|
<span className="shrink-0 text-[11px] text-gray-500">open ›</span>
|
|
532
536
|
</button>
|
|
533
|
-
{/* The system's note about this member — kept in the .sysdf.json, not in the member.
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
537
|
+
{/* The system's note about this member — kept in the .sysdf.json, not in the member.
|
|
538
|
+
Shown when it has words; an empty one opens on request. */}
|
|
539
|
+
{note || noting.has(m) ? (
|
|
540
|
+
<div className="px-2 pb-2 [&_label]:mt-0">
|
|
541
|
+
<NotesField
|
|
542
|
+
key={`member:${m}`}
|
|
543
|
+
label="Note on this member, in the system"
|
|
544
|
+
value={note ?? ""}
|
|
545
|
+
placeholder={`Why ${memberTitle(system, m)} is part of this system, what is open…`}
|
|
546
|
+
onSave={(notes) => void writeSystem(updateMember(def, m, { notes }), "saved")}
|
|
547
|
+
/>
|
|
548
|
+
</div>
|
|
549
|
+
) : (
|
|
550
|
+
<button
|
|
551
|
+
className="mx-2 mb-1.5 text-[11px] text-gray-500 hover:text-gray-300"
|
|
552
|
+
onClick={() => setNoting((n) => new Set(n).add(m))}
|
|
553
|
+
>
|
|
554
|
+
Add a note on this member
|
|
555
|
+
</button>
|
|
556
|
+
)}
|
|
543
557
|
</div>
|
|
544
558
|
);
|
|
545
559
|
})}
|
|
@@ -553,7 +567,7 @@ export default function SystemWorkspace() {
|
|
|
553
567
|
onClick={() => setPair(pair === key ? null : key)}
|
|
554
568
|
>
|
|
555
569
|
<span className="truncate">
|
|
556
|
-
{memberTitle(system, p.a)}
|
|
570
|
+
{memberTitle(system, p.a)} {"\u2194\uFE0E"} {memberTitle(system, p.b)}
|
|
557
571
|
</span>
|
|
558
572
|
<span className="shrink-0 text-gray-500">{p.links.length}</span>
|
|
559
573
|
</button>
|