@supersuit/artifacts 0.59.0 → 0.61.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/CHANGELOG.md +24 -0
- package/README.md +81 -2
- package/lib/artifacts/front-matter.d.ts +5 -0
- package/lib/artifacts/front-matter.js +13 -1
- package/lib/artifacts/goal.d.ts +47 -0
- package/lib/artifacts/goal.js +70 -0
- package/lib/artifacts/index.d.ts +2 -1
- package/lib/artifacts/index.js +1 -0
- package/lib/artifacts/recipients-store.d.ts +36 -1
- package/lib/artifacts/recipients-store.js +28 -4
- package/lib/artifacts/render.js +17 -5
- package/lib/artifacts/store.d.ts +3 -0
- package/lib/artifacts/store.js +2 -0
- package/lib/reader/index.d.ts +1 -0
- package/lib/reader/index.js +1 -0
- package/lib/reader/media-viewer.d.ts +1 -0
- package/lib/reader/media-viewer.js +391 -0
- package/lib/reader/media-zoom.d.ts +23 -0
- package/lib/reader/media-zoom.js +30 -0
- package/lib/reader/quiet-view.js +18 -0
- package/lib/routes/artifacts.d.ts +5 -0
- package/lib/routes/artifacts.js +5 -3
- package/lib/routes/recipients-routes.d.ts +4 -0
- package/lib/routes/recipients-routes.js +20 -2
- package/package.json +1 -1
- package/template/hub/package.json +1 -1
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// Tap any image or video on a page and it fills the screen: pinch to zoom, drag to look around,
|
|
4
|
+
// arrows and swipes to move through every picture and video on the page in order.
|
|
5
|
+
//
|
|
6
|
+
// A phone cannot read a screenshot at the width of a column, and until now the page itself was the
|
|
7
|
+
// only thing to pinch, which zooms the words and the bars with it. The viewer sits over the page in
|
|
8
|
+
// a portal on <body>, outside the comment layer's root, so the comment gestures never see it.
|
|
9
|
+
//
|
|
10
|
+
// It adds nothing to the server render: the page is a plain page with this component disabled.
|
|
11
|
+
// All styles are inline because a host's Tailwind build decides which classes exist.
|
|
12
|
+
//
|
|
13
|
+
// iOS Safari is the hard target. `touch-action: none` alone does not always hold there, so the
|
|
14
|
+
// viewer also cancels touchmove and Safari's gesture events; those are what keep a pinch on the
|
|
15
|
+
// picture instead of on the page behind it.
|
|
16
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
17
|
+
import { createPortal } from 'react-dom';
|
|
18
|
+
import { clampView, fitted, maxScale, ONE, zoomAt } from './media-zoom.js';
|
|
19
|
+
const ITEMS = 'img[data-artifact-image], [data-artifact-figure] img, video[data-artifact-media]';
|
|
20
|
+
const EXPAND = 'data-artifact-expand';
|
|
21
|
+
/** A press that travelled this far was a drag (a comment box, a scroll), never a tap. */
|
|
22
|
+
const TAP_PX = 10;
|
|
23
|
+
const DOUBLE_TAP_MS = 300;
|
|
24
|
+
const SWIPE_PX = 60;
|
|
25
|
+
const FLICK_PX = 24;
|
|
26
|
+
const FLICK_MS = 250;
|
|
27
|
+
const CLOSE_PX = 90;
|
|
28
|
+
const TAP_ZOOM = 2.5;
|
|
29
|
+
const KEY_ZOOM = 1.5;
|
|
30
|
+
const WHEEL_ZOOM = 0.01;
|
|
31
|
+
const isVideo = (el) => el.tagName === 'VIDEO';
|
|
32
|
+
const scrubbing = (t) => t instanceof Element && !!t.closest('video[controls]');
|
|
33
|
+
/** Every picture and video on the page, in page order. An image inside a link belongs to the link. */
|
|
34
|
+
function pageItems() {
|
|
35
|
+
return Array.from(document.querySelectorAll(ITEMS))
|
|
36
|
+
.filter((el) => !el.closest('a[href], button'))
|
|
37
|
+
.map((el) => ({
|
|
38
|
+
el,
|
|
39
|
+
video: isVideo(el),
|
|
40
|
+
src: el.currentSrc || el.getAttribute('src') || '',
|
|
41
|
+
alt: (isVideo(el) ? el.getAttribute('aria-label') : el.getAttribute('alt')) ?? '',
|
|
42
|
+
controls: isVideo(el) && el.controls,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
const round = {
|
|
46
|
+
width: 44, height: 44, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
|
|
47
|
+
border: 'none', borderRadius: 999, background: 'rgba(0,0,0,0.55)', color: '#fff', cursor: 'pointer',
|
|
48
|
+
};
|
|
49
|
+
function Icon({ d }) {
|
|
50
|
+
return (_jsx("svg", { width: "22", height: "22", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: d }) }));
|
|
51
|
+
}
|
|
52
|
+
export function MediaViewer() {
|
|
53
|
+
const [open, setOpen] = useState(null);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
let down = null;
|
|
56
|
+
const onDown = (e) => { const p = e; down = { x: p.clientX, y: p.clientY }; };
|
|
57
|
+
const show = (el) => {
|
|
58
|
+
const items = pageItems();
|
|
59
|
+
const at = items.findIndex((i) => i.el === el);
|
|
60
|
+
if (at >= 0)
|
|
61
|
+
setOpen({ items, at });
|
|
62
|
+
return at >= 0;
|
|
63
|
+
};
|
|
64
|
+
const target = (e) => {
|
|
65
|
+
const t = e.target;
|
|
66
|
+
if (!(t instanceof Element))
|
|
67
|
+
return null;
|
|
68
|
+
const expand = t.closest(`[${EXPAND}]`);
|
|
69
|
+
if (expand)
|
|
70
|
+
return expand.parentElement?.querySelector('video') ?? null;
|
|
71
|
+
if (scrubbing(t))
|
|
72
|
+
return null;
|
|
73
|
+
return t.closest(ITEMS);
|
|
74
|
+
};
|
|
75
|
+
const onClick = (e) => {
|
|
76
|
+
// A second copy of this component on the page has already taken the click.
|
|
77
|
+
if (e.defaultPrevented)
|
|
78
|
+
return;
|
|
79
|
+
const was = down;
|
|
80
|
+
down = null;
|
|
81
|
+
if (was && Math.hypot(e.clientX - was.x, e.clientY - was.y) > TAP_PX)
|
|
82
|
+
return;
|
|
83
|
+
const el = target(e);
|
|
84
|
+
if (el && show(el))
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
};
|
|
87
|
+
const onKey = (e) => {
|
|
88
|
+
if ((e.key !== 'Enter' && e.key !== ' ') || e.defaultPrevented)
|
|
89
|
+
return;
|
|
90
|
+
const el = target(e);
|
|
91
|
+
if (el && show(el))
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
};
|
|
94
|
+
const added = [];
|
|
95
|
+
const dressed = [];
|
|
96
|
+
for (const { el, controls } of pageItems()) {
|
|
97
|
+
if (controls) {
|
|
98
|
+
const box = el.parentElement;
|
|
99
|
+
if (!box || box.querySelector(`[${EXPAND}]`))
|
|
100
|
+
continue;
|
|
101
|
+
box.style.position = 'relative';
|
|
102
|
+
const b = document.createElement('button');
|
|
103
|
+
b.type = 'button';
|
|
104
|
+
b.setAttribute(EXPAND, 'true');
|
|
105
|
+
b.setAttribute('aria-label', 'Open this video full screen');
|
|
106
|
+
b.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H3v5M16 3h5v5M21 16v5h-5M3 16v5h5"/></svg>';
|
|
107
|
+
Object.assign(b.style, { position: 'absolute', top: '8px', right: '8px', width: '36px', height: '36px', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0', border: 'none', borderRadius: '999px', background: 'rgba(0,0,0,0.55)', color: '#fff', cursor: 'pointer' });
|
|
108
|
+
box.appendChild(b);
|
|
109
|
+
added.push(b);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
el.style.cursor = 'zoom-in';
|
|
113
|
+
el.tabIndex = 0;
|
|
114
|
+
el.setAttribute('role', 'button');
|
|
115
|
+
el.setAttribute('aria-haspopup', 'dialog');
|
|
116
|
+
dressed.push(el);
|
|
117
|
+
}
|
|
118
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
119
|
+
document.addEventListener('click', onClick);
|
|
120
|
+
document.addEventListener('keydown', onKey);
|
|
121
|
+
return () => {
|
|
122
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
123
|
+
document.removeEventListener('click', onClick);
|
|
124
|
+
document.removeEventListener('keydown', onKey);
|
|
125
|
+
for (const b of added)
|
|
126
|
+
b.remove();
|
|
127
|
+
for (const el of dressed) {
|
|
128
|
+
el.style.cursor = '';
|
|
129
|
+
el.removeAttribute('tabindex');
|
|
130
|
+
el.removeAttribute('role');
|
|
131
|
+
el.removeAttribute('aria-haspopup');
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}, []);
|
|
135
|
+
const close = useCallback(() => setOpen(null), []);
|
|
136
|
+
return open ? createPortal(_jsx(Viewer, { items: open.items, start: open.at, onClose: close }), document.body) : null;
|
|
137
|
+
}
|
|
138
|
+
function Viewer({ items, start, onClose }) {
|
|
139
|
+
const [at, setAt] = useState(start);
|
|
140
|
+
const dir = useRef(0);
|
|
141
|
+
const item = items[at];
|
|
142
|
+
const shell = useRef(null);
|
|
143
|
+
const stage = useRef(null);
|
|
144
|
+
const media = useRef(null);
|
|
145
|
+
const view = useRef(ONE);
|
|
146
|
+
const points = useRef(new Map());
|
|
147
|
+
const press = useRef(null);
|
|
148
|
+
const pinch = useRef(null);
|
|
149
|
+
const lastTap = useRef(null);
|
|
150
|
+
const multiple = items.length > 1;
|
|
151
|
+
const stageSize = () => ({ w: stage.current?.clientWidth || window.innerWidth, h: stage.current?.clientHeight || window.innerHeight });
|
|
152
|
+
const natural = () => {
|
|
153
|
+
const m = media.current;
|
|
154
|
+
if (!m)
|
|
155
|
+
return { w: 0, h: 0 };
|
|
156
|
+
return isVideo(m) ? { w: m.videoWidth, h: m.videoHeight } : { w: m.naturalWidth, h: m.naturalHeight };
|
|
157
|
+
};
|
|
158
|
+
const shownSize = () => fitted(natural(), stageSize());
|
|
159
|
+
/** A window point, measured from the screen's centre. */
|
|
160
|
+
const centred = (x, y) => {
|
|
161
|
+
const r = stage.current?.getBoundingClientRect();
|
|
162
|
+
const s = stageSize();
|
|
163
|
+
return { x: x - (r?.left ?? 0) - s.w / 2, y: y - (r?.top ?? 0) - s.h / 2 };
|
|
164
|
+
};
|
|
165
|
+
const paint = (v, animate = false) => {
|
|
166
|
+
if (!media.current)
|
|
167
|
+
return;
|
|
168
|
+
media.current.style.transition = animate ? 'transform 0.2s ease-out' : 'none';
|
|
169
|
+
media.current.style.transform = `translate(${v.x}px, ${v.y}px) scale(${v.s})`;
|
|
170
|
+
if (shell.current)
|
|
171
|
+
shell.current.style.background = '#000';
|
|
172
|
+
};
|
|
173
|
+
const set = (v, animate = false) => { view.current = v; paint(v, animate); };
|
|
174
|
+
const zoomBy = (factor, p, animate = false) => set(zoomAt(view.current, factor, p, shownSize(), stageSize(), maxScale(natural(), shownSize())), animate);
|
|
175
|
+
const go = useCallback((d) => {
|
|
176
|
+
setAt((n) => {
|
|
177
|
+
const to = Math.min(items.length - 1, Math.max(0, n + d));
|
|
178
|
+
if (to !== n)
|
|
179
|
+
dir.current = d;
|
|
180
|
+
return to;
|
|
181
|
+
});
|
|
182
|
+
}, [items.length]);
|
|
183
|
+
// Each item opens fitted, sliding in from the side the reader moved toward.
|
|
184
|
+
useLayoutEffect(() => {
|
|
185
|
+
view.current = ONE;
|
|
186
|
+
paint(ONE);
|
|
187
|
+
const m = media.current;
|
|
188
|
+
const still = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
189
|
+
if (dir.current && m && !still)
|
|
190
|
+
m.animate?.([{ opacity: 0, transform: `translateX(${dir.current * 48}px)` }, { opacity: 1, transform: 'none' }], { duration: 180, easing: 'ease-out' });
|
|
191
|
+
dir.current = 0;
|
|
192
|
+
// The next and previous pictures are ready before the reader asks for them.
|
|
193
|
+
for (const n of [items[at - 1], items[at + 1]])
|
|
194
|
+
if (n && !n.video)
|
|
195
|
+
new Image().src = n.src;
|
|
196
|
+
// A clip with sound moves to the viewer and comes back at the second it reached.
|
|
197
|
+
const src = items[at];
|
|
198
|
+
if (!src.controls)
|
|
199
|
+
return;
|
|
200
|
+
const inline = src.el;
|
|
201
|
+
inline.pause?.();
|
|
202
|
+
const shown = m;
|
|
203
|
+
return () => { if (shown && shown.currentTime)
|
|
204
|
+
inline.currentTime = shown.currentTime; };
|
|
205
|
+
// `paint` reads only refs.
|
|
206
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
207
|
+
}, [at, items]);
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
const root = document.documentElement;
|
|
210
|
+
const was = root.style.overflow;
|
|
211
|
+
root.style.overflow = 'hidden';
|
|
212
|
+
const before = document.activeElement;
|
|
213
|
+
shell.current?.focus();
|
|
214
|
+
return () => { root.style.overflow = was; before?.focus?.(); };
|
|
215
|
+
}, []);
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
const onKey = (e) => {
|
|
218
|
+
const act = {
|
|
219
|
+
Escape: onClose,
|
|
220
|
+
ArrowRight: () => go(1),
|
|
221
|
+
ArrowLeft: () => go(-1),
|
|
222
|
+
'+': () => zoomBy(KEY_ZOOM, { x: 0, y: 0 }, true),
|
|
223
|
+
'=': () => zoomBy(KEY_ZOOM, { x: 0, y: 0 }, true),
|
|
224
|
+
'-': () => zoomBy(1 / KEY_ZOOM, { x: 0, y: 0 }, true),
|
|
225
|
+
'0': () => set(ONE, true),
|
|
226
|
+
};
|
|
227
|
+
if (e.key === 'Tab') {
|
|
228
|
+
const stops = Array.from(shell.current?.querySelectorAll('button') ?? []);
|
|
229
|
+
const now = stops.indexOf(document.activeElement);
|
|
230
|
+
stops[(now + (e.shiftKey ? -1 : 1) + stops.length) % stops.length]?.focus();
|
|
231
|
+
e.preventDefault();
|
|
232
|
+
e.stopPropagation();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const run = act[e.key];
|
|
236
|
+
if (!run)
|
|
237
|
+
return;
|
|
238
|
+
// Capture on window, and stopped: Escape closes this before a comment card under it.
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
e.stopPropagation();
|
|
241
|
+
run();
|
|
242
|
+
};
|
|
243
|
+
window.addEventListener('keydown', onKey, true);
|
|
244
|
+
return () => window.removeEventListener('keydown', onKey, true);
|
|
245
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
246
|
+
}, [go, onClose]);
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
const el = shell.current;
|
|
249
|
+
const box = stage.current;
|
|
250
|
+
if (!el || !box)
|
|
251
|
+
return;
|
|
252
|
+
const noTouch = (e) => { if (!scrubbing(e.target))
|
|
253
|
+
e.preventDefault(); };
|
|
254
|
+
const noGesture = (e) => e.preventDefault();
|
|
255
|
+
const onWheel = (e) => {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
const p = centred(e.clientX, e.clientY);
|
|
258
|
+
// A trackpad pinch arrives as a wheel with ctrl held.
|
|
259
|
+
if (e.ctrlKey || view.current.s === 1)
|
|
260
|
+
return zoomBy(Math.exp(-e.deltaY * WHEEL_ZOOM), p);
|
|
261
|
+
set(clampView({ ...view.current, x: view.current.x - e.deltaX, y: view.current.y - e.deltaY }, shownSize(), stageSize()));
|
|
262
|
+
};
|
|
263
|
+
el.addEventListener('touchmove', noTouch, { passive: false });
|
|
264
|
+
for (const g of ['gesturestart', 'gesturechange', 'gestureend'])
|
|
265
|
+
el.addEventListener(g, noGesture);
|
|
266
|
+
box.addEventListener('wheel', onWheel, { passive: false });
|
|
267
|
+
return () => {
|
|
268
|
+
el.removeEventListener('touchmove', noTouch);
|
|
269
|
+
for (const g of ['gesturestart', 'gesturechange', 'gestureend'])
|
|
270
|
+
el.removeEventListener(g, noGesture);
|
|
271
|
+
box.removeEventListener('wheel', onWheel);
|
|
272
|
+
};
|
|
273
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
274
|
+
}, []);
|
|
275
|
+
const tap = (x, y, mouse) => {
|
|
276
|
+
const p = centred(x, y);
|
|
277
|
+
const v = view.current;
|
|
278
|
+
const shown = shownSize();
|
|
279
|
+
// Off the picture a mouse closes the viewer. A finger does not: the arrows sit in that black,
|
|
280
|
+
// and a near miss must not throw the reader back to the page (seen on an iPhone, 2026-11-06).
|
|
281
|
+
if (Math.abs(p.x - v.x) > (shown.w * v.s) / 2 || Math.abs(p.y - v.y) > (shown.h * v.s) / 2)
|
|
282
|
+
return mouse ? onClose() : undefined;
|
|
283
|
+
const t = Date.now();
|
|
284
|
+
const last = lastTap.current;
|
|
285
|
+
const twice = !!last && t - last.t < DOUBLE_TAP_MS && Math.hypot(x - last.x, y - last.y) < TAP_PX * 3;
|
|
286
|
+
lastTap.current = twice ? null : { t, x, y };
|
|
287
|
+
if (!mouse && !twice)
|
|
288
|
+
return;
|
|
289
|
+
if (v.s > 1)
|
|
290
|
+
set(ONE, true);
|
|
291
|
+
else
|
|
292
|
+
zoomBy(TAP_ZOOM, p, true);
|
|
293
|
+
};
|
|
294
|
+
const onDown = (e) => {
|
|
295
|
+
if (scrubbing(e.target) || (e.pointerType === 'mouse' && e.button !== 0))
|
|
296
|
+
return;
|
|
297
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
298
|
+
points.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
299
|
+
const held = Array.from(points.current.values());
|
|
300
|
+
if (held.length === 1)
|
|
301
|
+
press.current = { x: e.clientX, y: e.clientY, t: Date.now(), view: view.current, axis: null, pinched: false };
|
|
302
|
+
if (held.length === 2) {
|
|
303
|
+
if (press.current)
|
|
304
|
+
press.current.pinched = true;
|
|
305
|
+
pinch.current = { dist: Math.hypot(held[0].x - held[1].x, held[0].y - held[1].y), mid: { x: (held[0].x + held[1].x) / 2, y: (held[0].y + held[1].y) / 2 }, view: view.current };
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
const onMove = (e) => {
|
|
309
|
+
if (!points.current.has(e.pointerId))
|
|
310
|
+
return;
|
|
311
|
+
points.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
312
|
+
const held = Array.from(points.current.values());
|
|
313
|
+
const two = pinch.current;
|
|
314
|
+
if (held.length >= 2 && two) {
|
|
315
|
+
const mid = { x: (held[0].x + held[1].x) / 2, y: (held[0].y + held[1].y) / 2 };
|
|
316
|
+
const dist = Math.hypot(held[0].x - held[1].x, held[0].y - held[1].y);
|
|
317
|
+
const shown = shownSize();
|
|
318
|
+
const v = zoomAt(two.view, dist / (two.dist || 1), centred(two.mid.x, two.mid.y), shown, stageSize(), maxScale(natural(), shown));
|
|
319
|
+
// Two fingers moving together drag the picture while they zoom it.
|
|
320
|
+
set(clampView({ ...v, x: v.x + mid.x - two.mid.x, y: v.y + mid.y - two.mid.y }, shown, stageSize()));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const p = press.current;
|
|
324
|
+
if (!p)
|
|
325
|
+
return;
|
|
326
|
+
const dx = e.clientX - p.x;
|
|
327
|
+
const dy = e.clientY - p.y;
|
|
328
|
+
if (view.current.s > 1 || p.pinched) {
|
|
329
|
+
set(clampView({ ...p.view, x: p.view.x + dx, y: p.view.y + dy }, shownSize(), stageSize()));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (!p.axis && Math.hypot(dx, dy) > TAP_PX)
|
|
333
|
+
p.axis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y';
|
|
334
|
+
if (p.axis === 'x') {
|
|
335
|
+
// Past either end the picture drags heavily, and lets go back to where it was.
|
|
336
|
+
const free = (dx < 0 && at < items.length - 1) || (dx > 0 && at > 0);
|
|
337
|
+
paint({ ...ONE, x: free ? dx : dx * 0.3 });
|
|
338
|
+
}
|
|
339
|
+
else if (p.axis === 'y') {
|
|
340
|
+
paint({ ...ONE, y: dy });
|
|
341
|
+
if (shell.current && dy > 0)
|
|
342
|
+
shell.current.style.background = `rgba(0,0,0,${Math.max(0.4, 1 - dy / 400)})`;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
const onEnd = (e, cancelled = false) => {
|
|
346
|
+
if (!points.current.delete(e.pointerId))
|
|
347
|
+
return;
|
|
348
|
+
const left = Array.from(points.current.values());
|
|
349
|
+
if (left.length === 1) {
|
|
350
|
+
// One finger stays down after a pinch: it carries on panning from here.
|
|
351
|
+
pinch.current = null;
|
|
352
|
+
press.current = { x: left[0].x, y: left[0].y, t: Date.now(), view: view.current, axis: null, pinched: true };
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (left.length)
|
|
356
|
+
return;
|
|
357
|
+
const p = press.current;
|
|
358
|
+
press.current = null;
|
|
359
|
+
pinch.current = null;
|
|
360
|
+
if (!p || cancelled)
|
|
361
|
+
return set(view.current, true);
|
|
362
|
+
if (p.pinched)
|
|
363
|
+
return;
|
|
364
|
+
const dx = e.clientX - p.x;
|
|
365
|
+
const dy = e.clientY - p.y;
|
|
366
|
+
const ms = Date.now() - p.t;
|
|
367
|
+
if (view.current.s > 1 && p.axis === null && Math.hypot(dx, dy) > TAP_PX)
|
|
368
|
+
return;
|
|
369
|
+
if (p.axis === 'x') {
|
|
370
|
+
const far = Math.abs(dx) > SWIPE_PX || (Math.abs(dx) > FLICK_PX && ms < FLICK_MS);
|
|
371
|
+
const d = dx < 0 ? 1 : -1;
|
|
372
|
+
if (far && items[at + d])
|
|
373
|
+
return go(d);
|
|
374
|
+
return set(ONE, true);
|
|
375
|
+
}
|
|
376
|
+
if (p.axis === 'y')
|
|
377
|
+
return dy > CLOSE_PX ? onClose() : set(ONE, true);
|
|
378
|
+
tap(e.clientX, e.clientY, e.pointerType === 'mouse');
|
|
379
|
+
};
|
|
380
|
+
const mediaStyle = { width: '100%', height: '100%', objectFit: 'contain', display: 'block', transformOrigin: 'center', userSelect: 'none', WebkitUserSelect: 'none', willChange: 'transform' };
|
|
381
|
+
const edge = (side, px) => `calc(env(safe-area-inset-${side}, 0px) + ${px}px)`;
|
|
382
|
+
const arrow = (side) => ({ ...round, position: 'absolute', top: '50%', marginTop: -22, [side]: edge(side, 10) });
|
|
383
|
+
return (_jsxs("div", { ref: shell, "data-artifact-viewer": true, "data-nospeak": true, role: "dialog", "aria-modal": "true", "aria-label": "Full screen view", tabIndex: -1, style: { position: 'fixed', inset: 0, zIndex: 2147483000, background: '#000', color: '#fff', outline: 'none', overscrollBehavior: 'contain', fontFamily: 'system-ui, -apple-system, sans-serif' }, children: [_jsx("div", { ref: stage, "data-artifact-viewer-stage": true, onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onEnd, onPointerCancel: (e) => onEnd(e, true), style: { position: 'absolute', inset: 0, overflow: 'hidden', touchAction: 'none' }, children: item.video ? (_jsx("video", { ref: media, src: item.src, "aria-label": item.alt, autoPlay: true, playsInline: true, muted: !item.controls, loop: !item.controls, controls: item.controls, ...(item.controls ? { 'data-artifact-media': item.src.split(/[?#]/)[0].split('/').pop() } : {}), onLoadedMetadata: (e) => { if (item.controls)
|
|
384
|
+
e.currentTarget.currentTime = item.el.currentTime; }, style: mediaStyle }, at)) : (
|
|
385
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
386
|
+
_jsx("img", { ref: media, src: item.src, alt: item.alt, draggable: false, style: mediaStyle }, at)) }), multiple ? (
|
|
387
|
+
// Centred: a clip's own buttons hold the top left corner on iOS.
|
|
388
|
+
_jsx("div", { style: { position: 'absolute', top: edge('top', 22), left: 0, right: 0, textAlign: 'center', fontSize: 14, opacity: 0.85, pointerEvents: 'none' }, children: `${at + 1} of ${items.length}` })) : null, _jsx("button", { type: "button", "aria-label": "Close", onClick: onClose, style: { ...round, position: 'absolute', top: edge('top', 10), right: edge('right', 10) }, children: _jsx(Icon, { d: "M6 6l12 12M18 6L6 18" }) }), items[at - 1] ? _jsx("button", { type: "button", "aria-label": "Previous", onClick: () => go(-1), style: arrow('left'), children: _jsx(Icon, { d: "M15 5l-7 7 7 7" }) }) : null, items[at + 1] ? _jsx("button", { type: "button", "aria-label": "Next", onClick: () => go(1), style: arrow('right'), children: _jsx(Icon, { d: "M9 5l7 7-7 7" }) }) : null, item.alt ? (
|
|
389
|
+
// A clip's own controls hold the bottom of the screen, so its caption sits above them.
|
|
390
|
+
_jsx("div", { style: { position: 'absolute', left: 16, right: 16, bottom: edge('bottom', item.controls ? 76 : 16), textAlign: 'center', fontSize: 14, lineHeight: 1.4, pointerEvents: 'none', textShadow: '0 1px 3px #000' }, children: item.alt })) : null] }));
|
|
391
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type Size = {
|
|
2
|
+
w: number;
|
|
3
|
+
h: number;
|
|
4
|
+
};
|
|
5
|
+
export type Point = {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
};
|
|
9
|
+
export type View = {
|
|
10
|
+
s: number;
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
};
|
|
14
|
+
export declare const ONE: View;
|
|
15
|
+
/** The size a picture draws at when fitted to the screen. A small picture is blown up to fill it. */
|
|
16
|
+
export declare function fitted(natural: Size, stage: Size): Size;
|
|
17
|
+
/** How far a picture may zoom: far enough to read a large screenshot at twice its own pixels. */
|
|
18
|
+
export declare function maxScale(natural: Size, shown: Size): number;
|
|
19
|
+
/** `v` with its offset held so the zoomed picture never leaves a gap it could cover. */
|
|
20
|
+
export declare function clampView(v: View, shown: Size, stage: Size): View;
|
|
21
|
+
/** `v` zoomed by `factor` about `p`, a point measured from the screen's centre. The picture point
|
|
22
|
+
* under `p` stays under `p`, so a pinch zooms what the fingers are on. */
|
|
23
|
+
export declare function zoomAt(v: View, factor: number, p: Point, shown: Size, stage: Size, max: number): View;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const ONE = { s: 1, x: 0, y: 0 };
|
|
2
|
+
const MIN_MAX_SCALE = 4;
|
|
3
|
+
const MAX_MAX_SCALE = 12;
|
|
4
|
+
/** The size a picture draws at when fitted to the screen. A small picture is blown up to fill it. */
|
|
5
|
+
export function fitted(natural, stage) {
|
|
6
|
+
if (!natural.w || !natural.h)
|
|
7
|
+
return stage;
|
|
8
|
+
const k = Math.min(stage.w / natural.w, stage.h / natural.h);
|
|
9
|
+
return { w: natural.w * k, h: natural.h * k };
|
|
10
|
+
}
|
|
11
|
+
/** How far a picture may zoom: far enough to read a large screenshot at twice its own pixels. */
|
|
12
|
+
export function maxScale(natural, shown) {
|
|
13
|
+
const pixels = shown.w ? (2 * natural.w) / shown.w : 0;
|
|
14
|
+
return Math.min(MAX_MAX_SCALE, Math.max(MIN_MAX_SCALE, pixels));
|
|
15
|
+
}
|
|
16
|
+
/** `v` with its offset held so the zoomed picture never leaves a gap it could cover. */
|
|
17
|
+
export function clampView(v, shown, stage) {
|
|
18
|
+
const reach = (size, room) => Math.max(0, (size * v.s - room) / 2);
|
|
19
|
+
const hold = (n, r) => (r === 0 ? 0 : Math.min(r, Math.max(-r, n)));
|
|
20
|
+
return { s: v.s, x: hold(v.x, reach(shown.w, stage.w)), y: hold(v.y, reach(shown.h, stage.h)) };
|
|
21
|
+
}
|
|
22
|
+
/** `v` zoomed by `factor` about `p`, a point measured from the screen's centre. The picture point
|
|
23
|
+
* under `p` stays under `p`, so a pinch zooms what the fingers are on. */
|
|
24
|
+
export function zoomAt(v, factor, p, shown, stage, max) {
|
|
25
|
+
const s = Math.min(max, Math.max(1, v.s * factor));
|
|
26
|
+
if (s === 1)
|
|
27
|
+
return ONE;
|
|
28
|
+
const k = s / v.s;
|
|
29
|
+
return clampView({ s, x: p.x - (p.x - v.x) * k, y: p.y - (p.y - v.y) * k }, shown, stage);
|
|
30
|
+
}
|
package/lib/reader/quiet-view.js
CHANGED
|
@@ -31,6 +31,11 @@ export function QuietView({ id, rid, sig, token, endpoint }) {
|
|
|
31
31
|
let wait = null;
|
|
32
32
|
let tick = null;
|
|
33
33
|
let since = 0;
|
|
34
|
+
// CTAs pressed before the view earned its beat: sent once it lands (issue #12).
|
|
35
|
+
let pending = [];
|
|
36
|
+
const sendClicks = () => { if (!beat)
|
|
37
|
+
return; for (const cta of pending)
|
|
38
|
+
void post({ event: 'click', beat, cta }); pending = []; };
|
|
34
39
|
const post = async (body) => {
|
|
35
40
|
try {
|
|
36
41
|
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...base, ...body }), keepalive: true });
|
|
@@ -81,6 +86,7 @@ export function QuietView({ id, rid, sig, token, endpoint }) {
|
|
|
81
86
|
}
|
|
82
87
|
else if (r?.beat)
|
|
83
88
|
beat = r.beat;
|
|
89
|
+
sendClicks();
|
|
84
90
|
}, VIEW_AFTER_MS);
|
|
85
91
|
};
|
|
86
92
|
const disarm = () => {
|
|
@@ -101,6 +107,17 @@ export function QuietView({ id, rid, sig, token, endpoint }) {
|
|
|
101
107
|
if (media && beat)
|
|
102
108
|
void post({ event: 'play', beat, media });
|
|
103
109
|
};
|
|
110
|
+
// A press of anything marked data-artifact-cta (a link titled `cta:<id>`) is the reader taking
|
|
111
|
+
// that CTA. keepalive carries it through the new tab a link opens.
|
|
112
|
+
const onClick = (e) => {
|
|
113
|
+
const el = e.target?.closest?.('[data-artifact-cta]');
|
|
114
|
+
const cta = el?.getAttribute('data-artifact-cta');
|
|
115
|
+
if (!cta)
|
|
116
|
+
return;
|
|
117
|
+
pending.push(cta);
|
|
118
|
+
sendClicks();
|
|
119
|
+
};
|
|
120
|
+
document.addEventListener('click', onClick, true);
|
|
104
121
|
document.addEventListener('visibilitychange', onVisibility);
|
|
105
122
|
document.addEventListener('prerenderingchange', onVisibility);
|
|
106
123
|
document.addEventListener('play', onPlay, true);
|
|
@@ -110,6 +127,7 @@ export function QuietView({ id, rid, sig, token, endpoint }) {
|
|
|
110
127
|
document.removeEventListener('visibilitychange', onVisibility);
|
|
111
128
|
document.removeEventListener('prerenderingchange', onVisibility);
|
|
112
129
|
document.removeEventListener('play', onPlay, true);
|
|
130
|
+
document.removeEventListener('click', onClick, true);
|
|
113
131
|
window.removeEventListener('pagehide', disarm);
|
|
114
132
|
disarm();
|
|
115
133
|
};
|
|
@@ -11,6 +11,7 @@ import { type Access, type AllowEntry } from '../artifacts/reader.js';
|
|
|
11
11
|
import { type ReadersStore } from '../artifacts/readers-store.js';
|
|
12
12
|
import { type ReaderEvent } from '../artifacts/read-alerts.js';
|
|
13
13
|
import type { ResolveEvent } from '../artifacts/resolve-digest.js';
|
|
14
|
+
import type { OutcomeThresholds } from '../artifacts/goal.js';
|
|
14
15
|
import { type AnalyticsConfig } from '../reader/analytics-plan.js';
|
|
15
16
|
import type { PersonalStore } from '../artifacts/personal-store.js';
|
|
16
17
|
import type { Transcriber } from '../artifacts/transcribe.js';
|
|
@@ -123,6 +124,9 @@ export type ArtifactRoutesConfig = {
|
|
|
123
124
|
recipients?: RecipientsStore;
|
|
124
125
|
/** Signs per-reader links and render tokens. At least 32 characters. Read at request time. */
|
|
125
126
|
recipientSecret?: () => string | undefined;
|
|
127
|
+
/** The thresholds `GET /views` measures each send's outcome by (issue #12), over the package's
|
|
128
|
+
* OUTCOME_THRESHOLDS: `readSeconds` (default 30) and `returnGapMs` (default an hour). */
|
|
129
|
+
outcomeThresholds?: Partial<OutcomeThresholds>;
|
|
126
130
|
/** Product analytics for this host's pages (0.50.0): PostHog with session replay, heatmaps,
|
|
127
131
|
* autocapture, scroll depth on leave, and section_viewed / narration_* events. Without a key no
|
|
128
132
|
* page loads any analytics script. Never records the owner or an admin, never a link-preview
|
|
@@ -260,6 +264,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
|
260
264
|
markdown: string;
|
|
261
265
|
version: number;
|
|
262
266
|
updatedAt: string;
|
|
267
|
+
goal: import("../index.js").ArtifactGoal;
|
|
263
268
|
share: {
|
|
264
269
|
links: string;
|
|
265
270
|
forward: "signed-in" | "off" | "open";
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -140,7 +140,7 @@ export function createArtifactRoutes(config) {
|
|
|
140
140
|
});
|
|
141
141
|
const recipientsRoutes = createRecipientsRoutes({
|
|
142
142
|
store, recipients: config.recipients, recipientSecret: config.recipientSecret, publishKey: config.publishKey,
|
|
143
|
-
state: config.state, assets: config.assets, pageUrl, authed, readerSecret, signInOrigin, siteUrl,
|
|
143
|
+
state: config.state, assets: config.assets, pageUrl, authed, readerSecret, signInOrigin, siteUrl, outcomes: config.outcomeThresholds,
|
|
144
144
|
});
|
|
145
145
|
const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl, audioUrl });
|
|
146
146
|
const voiceRoutes = createVoiceRoutes({
|
|
@@ -501,7 +501,7 @@ export function createArtifactRoutes(config) {
|
|
|
501
501
|
const cover = !bot && a.cover && !/!\[[^\]]*\]\(/.test(a.markdown) ? a.cover : null;
|
|
502
502
|
return (_jsxs(_Fragment, { children: [admin ? (_jsx(PageMenu, { role: roleOf(reader), name: reader ? reader.name ?? reader.email : null, share: null, comments: mode !== 'off', signIn: null, signOut: signOutUrl(a.id), history: null, markdown: null, settings: null })) : null, _jsx("main", { "data-broadcast": true, className: "mx-auto max-w-lg px-4 pt-16 pb-20 sm:pt-24", children: _jsxs("article", { id: "broadcast-root", "data-broadcast-card": true, className: "rounded-2xl border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-5 py-6 sm:px-7 sm:py-8", children: [_jsx("p", { "data-nospeak": true, "data-broadcast-from": true, className: "text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: `From ${owner}` }), _jsx("h1", { className: "mt-3 text-2xl sm:text-3xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), cover ? (
|
|
503
503
|
// eslint-disable-next-line @next/next/no-img-element
|
|
504
|
-
_jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, embeds: embeds }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || (effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone' && !(perReader && signInToAct(a, perReader))), signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, await Analytics(a, { reader, perReader, untracked, bot })] }));
|
|
504
|
+
_jsx("img", { "data-artifact-image": true, src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, embeds: embeds }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || (effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone' && !(perReader && signInToAct(a, perReader))), signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, await Analytics(a, { reader, perReader, untracked, bot })] }));
|
|
505
505
|
}
|
|
506
506
|
/** The page itself, shared by open and gated pages. */
|
|
507
507
|
async function Body(a, { top, reader, perReader, untracked = false }) {
|
|
@@ -526,7 +526,7 @@ export function createArtifactRoutes(config) {
|
|
|
526
526
|
const n = a.version ?? (a.versions?.length ?? 0) + 1;
|
|
527
527
|
const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { n: n, at: a.updatedAt, numbered: seesHistory(a, reader) }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }));
|
|
528
528
|
const header = pack.Cover ? (_jsx(pack.Cover, { title: a.title, subtitle: a.subtitle, summary: a.summary, to: a.to, markdown: a.markdown, kicker: _jsx(Kicker, { to: a.to, brand: brand }), meta: meta, version: n, showVersion: seesHistory(a, reader), updatedAt: a.updatedAt, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), meta] }));
|
|
529
|
-
return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [perReader?.closesAt && !perReader.owner ? (_jsx(FeedbackWindow, { owner: owner, label: perReader.label, note: perReader.note, group: perReader.group, closesAt: perReader.closesAt })) : perReader?.note ? (_jsx("p", { "data-for-reader": true, className: "mx-auto mt-6 mb-0 max-w-2xl px-6 text-lg", children: `For ${perReader.label}: ${perReader.note}` })) : null, header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
|
|
529
|
+
return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [perReader?.closesAt && !perReader.owner ? (_jsx(FeedbackWindow, { owner: owner, label: perReader.label, note: perReader.note, group: perReader.group, closesAt: perReader.closesAt })) : perReader?.note ? (_jsx("p", { "data-for-reader": true, className: "mx-auto mt-6 mb-0 max-w-2xl px-6 text-lg", children: `For ${perReader.label}: ${perReader.note}` })) : null, header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { "data-artifact-image": true, src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
|
|
530
530
|
// Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
|
|
531
531
|
checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure, embeds: embeds }) }));
|
|
532
532
|
})() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || (effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone' && !(perReader && signInToAct(a, perReader)))), signedIn: reader !== null, isOwner: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, await Analytics(a, { reader, perReader, untracked }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
|
|
@@ -909,6 +909,8 @@ export function createArtifactRoutes(config) {
|
|
|
909
909
|
text: narrationText({ title: a.title, subtitle: a.subtitle, summary: a.summary, markdown: a.markdown }),
|
|
910
910
|
title: a.title, ...(a.subtitle ? { subtitle: a.subtitle } : {}), summary: a.summary, ...(a.to ? { to: a.to } : {}),
|
|
911
911
|
markdown: a.markdown, version: a.version ?? 1, updatedAt: a.updatedAt,
|
|
912
|
+
// What a send of the page is for (issue #12): the goal each reader's outcome is measured by.
|
|
913
|
+
goal: a.goal ?? 'read',
|
|
912
914
|
// What a link to this page lets its reader do (0.57.0), so a sender can say so before the
|
|
913
915
|
// reader's link exists: the send card is approved before anything is minted.
|
|
914
916
|
share: {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
2
|
import { type Provenance, type RecipientsStore } from '../artifacts/recipients-store.js';
|
|
3
|
+
import { type OutcomeThresholds } from '../artifacts/goal.js';
|
|
3
4
|
import type { ArtifactStore } from '../artifacts/store.js';
|
|
4
5
|
import type { StateStore } from '../artifacts/state-store.js';
|
|
5
6
|
import type { ArtifactAssets } from '../artifacts/assets.js';
|
|
@@ -24,6 +25,9 @@ export type RecipientsRoutesCtx = {
|
|
|
24
25
|
readerSecret?: () => string | undefined;
|
|
25
26
|
signInOrigin?: string;
|
|
26
27
|
siteUrl?: string;
|
|
28
|
+
/** The outcome thresholds `GET /views` measures by (issue #12), over OUTCOME_THRESHOLDS. A caller
|
|
29
|
+
* may move `readSeconds` per request with `?readSeconds=<n>`. */
|
|
30
|
+
outcomes?: Partial<OutcomeThresholds>;
|
|
27
31
|
};
|
|
28
32
|
/** How many forward links one reader may make on one page in a day. */
|
|
29
33
|
export declare const FORWARDS_PER_DAY = 20;
|
|
@@ -5,6 +5,7 @@ import { GRANT_COOKIE, signInUrl, verifyGrant } from '../artifacts/reader.js';
|
|
|
5
5
|
import { perReaderGate } from './per-reader.js';
|
|
6
6
|
import { summarizeViews } from '../artifacts/recipients-store.js';
|
|
7
7
|
import { COMMENTS_SLOT } from '../artifacts/comments.js';
|
|
8
|
+
import { CTA_ID, ctasIn, outcomeThresholds } from '../artifacts/goal.js';
|
|
8
9
|
import { ARTIFACT_ID_RE } from './ids.js';
|
|
9
10
|
/** How many forward links one reader may make on one page in a day. */
|
|
10
11
|
export const FORWARDS_PER_DAY = 20;
|
|
@@ -98,7 +99,21 @@ export function createRecipientsRoutes(given) {
|
|
|
98
99
|
await ctx.recipients.recordView({ artifactId: id, rid, session, device, at, addSeconds: seconds, ...(media ? { play: media } : {}) });
|
|
99
100
|
return json({});
|
|
100
101
|
}
|
|
101
|
-
|
|
102
|
+
// A CTA the reader pressed (issue #12): the same beat as any other signal after the view, and
|
|
103
|
+
// only an id the page itself carries, so a click is never recorded for a button nobody drew.
|
|
104
|
+
if (event === 'click') {
|
|
105
|
+
if (str(b?.beat) !== beatKey(secret, id, rid, session))
|
|
106
|
+
return json({ error: 'forbidden' }, 403);
|
|
107
|
+
const cta = str(b?.cta);
|
|
108
|
+
if (!CTA_ID.test(cta))
|
|
109
|
+
return json({ error: 'cta must be a CTA id' }, 400);
|
|
110
|
+
const page = await ctx.store.get(id);
|
|
111
|
+
if (!page || !ctasIn(page.markdown ?? '').includes(cta))
|
|
112
|
+
return json({ error: 'this page carries no such CTA' }, 400);
|
|
113
|
+
await ctx.recipients.recordView({ artifactId: id, rid, session, device, at, addSeconds: 0, click: cta });
|
|
114
|
+
return json({});
|
|
115
|
+
}
|
|
116
|
+
return json({ error: 'event must be view, beat, play or click' }, 400);
|
|
102
117
|
}
|
|
103
118
|
const unauth = () => json({ error: 'publish key required' }, 401);
|
|
104
119
|
async function listing(id) {
|
|
@@ -207,7 +222,10 @@ export function createRecipientsRoutes(given) {
|
|
|
207
222
|
const rec = (await ctx.recipients.get(id)) ?? { recipients: {}, expireAt: null, keep: false };
|
|
208
223
|
const entries = ctx.state ? await ctx.state.entries(id) : [];
|
|
209
224
|
const comments = entries.filter((e) => e.slot === COMMENTS_SLOT && e.readerKey.startsWith('r:')).map((e) => ({ readerKey: e.readerKey, id: e.id, parent: typeof e.value?.parent === 'string' ? e.value.parent : null, at: e.at, text: String(e.value?.body ?? '') }));
|
|
210
|
-
|
|
225
|
+
const q = req.nextUrl.searchParams.get('readSeconds');
|
|
226
|
+
const thresholds = outcomeThresholds({ ...ctx.outcomes, ...(q !== null && /^\d{1,6}$/.test(q) ? { readSeconds: Number(q) } : {}) });
|
|
227
|
+
const goal = a.goal ?? 'read';
|
|
228
|
+
return json({ id, expireAt: rec.expireAt, keep: rec.keep, goal, thresholds, recipients: summarizeViews(rec, await ctx.recipients.views(id), comments, { goal, thresholds }) });
|
|
211
229
|
}
|
|
212
230
|
/** Reader text goes with the page: every comment and answer on it, whoever wrote it. */
|
|
213
231
|
async function clearReaderText(id) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"description": "Publish markdown as branded, read-aloud pages from a Next.js app: the artifacts store, the route factory, the read-along reader, brand packs, password and confidential pages, and share cards.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|