@supersuit/artifacts 0.11.0 → 0.12.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 +41 -0
- package/README.md +40 -0
- package/lib/artifacts/assets.d.ts +11 -0
- package/lib/artifacts/assets.js +35 -0
- package/lib/artifacts/audio.d.ts +21 -0
- package/lib/artifacts/audio.js +43 -0
- package/lib/artifacts/index.d.ts +3 -1
- package/lib/artifacts/index.js +3 -1
- package/lib/artifacts/personal-store.d.ts +4 -0
- package/lib/artifacts/personal-store.js +5 -1
- package/lib/artifacts/transcribe.d.ts +8 -0
- package/lib/artifacts/transcribe.js +56 -0
- package/lib/reader/comment-card.d.ts +16 -3
- package/lib/reader/comment-card.js +41 -6
- package/lib/reader/comment-layer.d.ts +7 -1
- package/lib/reader/comment-layer.js +94 -12
- package/lib/reader/device-audio.d.ts +7 -0
- package/lib/reader/device-audio.js +43 -0
- package/lib/reader/index.d.ts +3 -0
- package/lib/reader/index.js +2 -0
- package/lib/reader/theme-vars.d.ts +3 -0
- package/lib/reader/theme-vars.js +33 -0
- package/lib/reader/voice-recorder.d.ts +52 -0
- package/lib/reader/voice-recorder.js +213 -0
- package/lib/routes/artifacts.d.ts +12 -7
- package/lib/routes/artifacts.js +26 -4
- package/lib/routes/personal-routes.d.ts +2 -0
- package/lib/routes/personal-routes.js +15 -2
- package/lib/routes/state-routes.d.ts +3 -0
- package/lib/routes/state-routes.js +24 -0
- package/lib/routes/voice-routes.d.ts +35 -0
- package/lib/routes/voice-routes.js +176 -0
- package/package.json +1 -1
|
@@ -23,6 +23,10 @@ import { createPortal } from 'react-dom';
|
|
|
23
23
|
import { MAX_QUOTE_CHARS, textAnchorFrom } from '../artifacts/anchor.js';
|
|
24
24
|
import { COMMENTS_SLOT } from '../artifacts/comments.js';
|
|
25
25
|
import { CommentCard } from './comment-card.js';
|
|
26
|
+
import { AudioPlay, hostVoice, newMemoId } from './voice-recorder.js';
|
|
27
|
+
import { deviceAudio as defaultDeviceAudio, isDeviceAudio } from './device-audio.js';
|
|
28
|
+
import { audioExtFor } from '../artifacts/audio.js';
|
|
29
|
+
import { themeVarsAround } from './theme-vars.js';
|
|
26
30
|
import { addDeviceNote, deviceNotes, removeDeviceNote, replaceDeviceNote } from './device-notes.js';
|
|
27
31
|
import { boxOf, headingAt, indexText, offsetAt, placeAnchor, regionFrom } from './comment-place.js';
|
|
28
32
|
const isValue = (v) => !!v && typeof v === 'object' && typeof v.body === 'string' && !!v.anchor && typeof v.anchor === 'object';
|
|
@@ -51,12 +55,20 @@ function sharedFrom(body) {
|
|
|
51
55
|
for (const r of rows.values()) {
|
|
52
56
|
if (!isValue(r.value))
|
|
53
57
|
continue;
|
|
54
|
-
out.push({
|
|
58
|
+
out.push({
|
|
59
|
+
id: String(r.id), kind: 'shared', where: 'server', value: r.value, name: r.mine === true ? 'You' : typeof r.name === 'string' ? r.name : 'a reader', mine: r.mine === true, at: String(r.at ?? ''),
|
|
60
|
+
...(typeof r.audioUrl === 'string' ? { audioUrl: r.audioUrl } : {}),
|
|
61
|
+
});
|
|
55
62
|
}
|
|
56
63
|
return out.sort((a, b) => a.at.localeCompare(b.at));
|
|
57
64
|
}
|
|
58
|
-
const personalFrom = (notes, where) => notes.filter((n) => isValue(n.value)).map((n) => ({
|
|
59
|
-
|
|
65
|
+
const personalFrom = (notes, where) => notes.filter((n) => isValue(n.value)).map((n) => ({
|
|
66
|
+
id: n.id, kind: 'personal', where, value: n.value, name: 'You', mine: true, at: n.at,
|
|
67
|
+
...(typeof n.audioUrl === 'string' ? { audioUrl: n.audioUrl } : {}),
|
|
68
|
+
}));
|
|
69
|
+
export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, voice, audioStore, }) {
|
|
70
|
+
const host = useMemo(() => voice ?? hostVoice(artifactId), [voice, artifactId]);
|
|
71
|
+
const audioDb = useMemo(() => audioStore ?? defaultDeviceAudio, [audioStore]);
|
|
60
72
|
const stateUrl = `/api/artifacts/${artifactId}/state`;
|
|
61
73
|
const personalUrl = `/api/artifacts/${artifactId}/personal`;
|
|
62
74
|
const [mounted, setMounted] = useState(false);
|
|
@@ -71,6 +83,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
71
83
|
const [thread, setThread] = useState(null);
|
|
72
84
|
const [drag, setDrag] = useState(null);
|
|
73
85
|
const [tick, setTick] = useState(0);
|
|
86
|
+
const [theme, setTheme] = useState({});
|
|
74
87
|
const call = useCallback(async (url, init) => {
|
|
75
88
|
try {
|
|
76
89
|
const r = await fetch(url, { credentials: 'same-origin', cache: 'no-store', ...init });
|
|
@@ -81,6 +94,14 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
81
94
|
}
|
|
82
95
|
}, []);
|
|
83
96
|
const refreshDevice = useCallback(() => setDevice(personalFrom(deviceNotes(artifactId), 'device')), [artifactId]);
|
|
97
|
+
// The theme the overlay carries, re-read when a `system` page flips between light and dark.
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const read = () => setTheme(themeVarsAround(document.getElementById(rootId)));
|
|
100
|
+
read();
|
|
101
|
+
const mq = typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-color-scheme: dark)') : null;
|
|
102
|
+
mq?.addEventListener?.('change', read);
|
|
103
|
+
return () => mq?.removeEventListener?.('change', read);
|
|
104
|
+
}, [rootId]);
|
|
84
105
|
useEffect(() => {
|
|
85
106
|
setMounted(true);
|
|
86
107
|
refreshDevice();
|
|
@@ -100,12 +121,34 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
100
121
|
}
|
|
101
122
|
let notes = r.body.notes ?? [];
|
|
102
123
|
// Notes kept on this device before signing in move to the account, then leave the device.
|
|
124
|
+
// A recording kept on the device goes up to the account first, then the note names it there.
|
|
103
125
|
for (const n of deviceNotes(artifactId)) {
|
|
104
|
-
const
|
|
126
|
+
const value = { ...n.value };
|
|
127
|
+
const key = isDeviceAudio(value.audio) ? value.audio : null;
|
|
128
|
+
if (key) {
|
|
129
|
+
const blob = await audioDb.get(key);
|
|
130
|
+
const path = blob ? await host.upload({ blob, mime: blob.type }, 'personal') : null;
|
|
131
|
+
if (blob && !path)
|
|
132
|
+
break;
|
|
133
|
+
if (path)
|
|
134
|
+
value.audio = path;
|
|
135
|
+
else {
|
|
136
|
+
// The recording is gone from this device: keep the words, or nothing is left to keep.
|
|
137
|
+
delete value.audio;
|
|
138
|
+
delete value.transcript;
|
|
139
|
+
if (!value.body.trim()) {
|
|
140
|
+
removeDeviceNote(artifactId, n.id);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const m = await call(personalUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
|
|
105
146
|
if (m.status !== 200)
|
|
106
147
|
break;
|
|
107
148
|
notes = m.body.notes ?? notes;
|
|
108
149
|
removeDeviceNote(artifactId, n.id);
|
|
150
|
+
if (key)
|
|
151
|
+
await audioDb.remove(key);
|
|
109
152
|
}
|
|
110
153
|
if (!live)
|
|
111
154
|
return;
|
|
@@ -120,7 +163,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
120
163
|
if (ro && root)
|
|
121
164
|
ro.observe(root);
|
|
122
165
|
return () => { live = false; window.removeEventListener('resize', relayout); ro?.disconnect(); };
|
|
123
|
-
}, [artifactId, comments, signedIn, stateUrl, personalUrl, rootId, call, refreshDevice]);
|
|
166
|
+
}, [artifactId, comments, signedIn, stateUrl, personalUrl, rootId, call, refreshDevice, host, audioDb]);
|
|
124
167
|
// Text selection inside the page shows the Comment chip.
|
|
125
168
|
useEffect(() => {
|
|
126
169
|
const root = document.getElementById(rootId);
|
|
@@ -274,8 +317,26 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
274
317
|
return { placed, earlier };
|
|
275
318
|
// `tick` re-measures after a resize.
|
|
276
319
|
}, [mounted, rootId, tops, tick]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
277
|
-
|
|
278
|
-
|
|
320
|
+
/** The recording's path for where it is being saved: reused when the card already uploaded it
|
|
321
|
+
* there, uploaded now when the reader switched between Share and Just for me after recording. */
|
|
322
|
+
const memoPath = async (m, scope) => m.uploaded?.scope === scope ? m.uploaded.path : host.upload(m.memo, scope);
|
|
323
|
+
const LOST = 'The recording did not upload. Try again, or discard it and save the text.';
|
|
324
|
+
const saveNew = async (body, share, d, memo) => {
|
|
325
|
+
const value = { anchor: d.anchor, body, version, ...(memo?.transcript ? { transcript: memo.transcript } : {}) };
|
|
326
|
+
const toDevice = !share && !(signedIn && onServer);
|
|
327
|
+
if (memo && !toDevice) {
|
|
328
|
+
const path = await memoPath(memo, share ? 'comment' : 'personal');
|
|
329
|
+
if (!path)
|
|
330
|
+
return LOST;
|
|
331
|
+
value.audio = path;
|
|
332
|
+
}
|
|
333
|
+
if (memo && toDevice) {
|
|
334
|
+
// Not signed in: the recording stays in this browser with the note, and never leaves it.
|
|
335
|
+
const key = `device/${newMemoId()}.${audioExtFor(memo.memo.mime) ?? 'webm'}`;
|
|
336
|
+
if (!(await audioDb.put(key, memo.memo.blob)))
|
|
337
|
+
return 'This browser would not keep the recording. Discard it and save the text.';
|
|
338
|
+
value.audio = key;
|
|
339
|
+
}
|
|
279
340
|
if (share) {
|
|
280
341
|
const r = await call(stateUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slot: COMMENTS_SLOT, op: 'append', value }) });
|
|
281
342
|
if (r.status !== 200)
|
|
@@ -305,6 +366,8 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
305
366
|
const remove = async (item) => {
|
|
306
367
|
if (item.where === 'device') {
|
|
307
368
|
removeDeviceNote(artifactId, item.id);
|
|
369
|
+
if (isDeviceAudio(item.value.audio))
|
|
370
|
+
await audioDb.remove(item.value.audio);
|
|
308
371
|
refreshDevice();
|
|
309
372
|
}
|
|
310
373
|
else if (item.kind === 'personal') {
|
|
@@ -349,7 +412,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
349
412
|
const open = layout.placed.find((p) => p.item.id === thread);
|
|
350
413
|
const openItem = open?.item ?? layout.earlier.find((i) => i.id === thread);
|
|
351
414
|
const count = tops.length;
|
|
352
|
-
const overlay = mounted ? (_jsxs("div", { "data-comment-ui": true, "data-nospeak": true, className: "font-sans", children: [showPins ? layout.placed.map(({ item, left, top, box }) => (_jsxs("div", { children: [box ? (_jsx("div", { "data-comment-box": item.id, "aria-hidden": true, className: "pointer-events-none absolute z-30 rounded-md border-2", style: { left: box.left, top: box.top, width: box.width, height: box.height, borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', opacity: thread === item.id ? 1 : 0.55 } })) : null, _jsx(Pin, { item: item, left: left, top: top, accent: accent, ground: ground, onOpen: () => setThread(thread === item.id ? null : item.id) })] }, item.id))) : null, chip && !draft ? (_jsx("button", { type: "button", "data-comment-chip": true, className: "absolute z-50 -translate-x-full rounded-full px-3 py-1 text-xs font-medium shadow-lg", style: { left: chip.x, top: chip.y, background: accent, color: 'var(--a-on-accent, #111)' }, onMouseDown: (e) => e.preventDefault(), onClick: () => { setDraft(chip); setChip(null); }, children: "Comment" })) : null, draft ? (_jsx(Floating, { x: draft.x, y: draft.y, children: _jsx(CommentCard, { ownerName: ownerName, mode: comments, canShare: canShare, signedIn: signedIn && onServer, quote: draft.quote, accent: accent, signIn: signIn, onSave: (body, share) => saveNew(body, share, draft), onCancel: () => setDraft(null) }) })) : null, openItem && open ? (_jsx(Floating, { x: open.left + 28, y: open.top, children: _jsx(Thread, { item: openItem, replies: items.filter((i) => i.value.parent === openItem.id), canReply: openItem.kind === 'shared' && canShare, accent: accent, onClose: () => setThread(null), onRemove: remove, onEdit: edit, onReply: reply }) })) : null, drag ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-40 rounded-md border-2 border-dashed", style: { left: Math.min(drag.x1, drag.x2), top: Math.min(drag.y1, drag.y2), width: Math.abs(drag.x2 - drag.x1), height: Math.abs(drag.y2 - drag.y1), borderColor: accent, background: `color-mix(in srgb, ${accent} 12%, transparent)` } })) : null, _jsxs("div", { className: "fixed right-4 z-40 flex flex-col items-end gap-2", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)' }, children: [panel ? (_jsxs("div", { "data-comment-panel": true, className: "w-64 rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-xs text-[color:var(--a-strong)] shadow-xl", children: [_jsx("p", { className: "opacity-80", children: "Select any text, or hold and drag a box over anything, to leave a comment." }), isOwner ? _jsx("p", { "data-comment-owner": true, className: "mt-1 opacity-80", children: "This is your page: you see every comment, with the name of who left it." }) : null, _jsx("button", { type: "button", "data-comment-pins-toggle": true, className: "mt-2 underline", onClick: () => setShowPins((v) => !v), children: showPins ? 'Hide comments' : 'Show comments' })] })) : null, _jsx("button", { type: "button", "data-comment-button": true, "aria-expanded": panel, onClick: () => setPanel((v) => !v), className: "rounded-full border px-3 py-1.5 text-xs font-medium shadow-lg backdrop-blur", style: { borderColor: accent, color: accent, background: `color-mix(in srgb, ${ground} 85%, transparent)` }, children: count ? `Comments · ${count}` : 'Comment' })] })] })) : null;
|
|
415
|
+
const overlay = mounted ? (_jsxs("div", { "data-comment-ui": true, "data-nospeak": true, className: "font-sans", style: theme, children: [showPins ? layout.placed.map(({ item, left, top, box }) => (_jsxs("div", { children: [box ? (_jsx("div", { "data-comment-box": item.id, "aria-hidden": true, className: "pointer-events-none absolute z-30 rounded-md border-2", style: { left: box.left, top: box.top, width: box.width, height: box.height, borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', opacity: thread === item.id ? 1 : 0.55 } })) : null, _jsx(Pin, { item: item, left: left, top: top, accent: accent, ground: ground, onOpen: () => setThread(thread === item.id ? null : item.id) })] }, item.id))) : null, chip && !draft ? (_jsx("button", { type: "button", "data-comment-chip": true, className: "absolute z-50 -translate-x-full rounded-full px-3 py-1 text-xs font-medium shadow-lg", style: { left: chip.x, top: chip.y, background: accent, color: 'var(--a-on-accent, #111)' }, onMouseDown: (e) => e.preventDefault(), onClick: () => { setDraft(chip); setChip(null); }, children: "Comment" })) : null, draft ? (_jsx(Floating, { x: draft.x, y: draft.y, children: _jsx(CommentCard, { ownerName: ownerName, mode: comments, canShare: canShare, signedIn: signedIn && onServer, quote: draft.quote, accent: accent, signIn: signIn, voice: host, onSave: (body, share, memo) => saveNew(body, share, draft, memo), onCancel: () => setDraft(null) }) })) : null, openItem && open ? (_jsx(Floating, { x: open.left + 28, y: open.top, children: _jsx(Thread, { item: openItem, replies: items.filter((i) => i.value.parent === openItem.id), canReply: openItem.kind === 'shared' && canShare, accent: accent, audioDb: audioDb, onClose: () => setThread(null), onRemove: remove, onEdit: edit, onReply: reply }) })) : null, drag ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-40 rounded-md border-2 border-dashed", style: { left: Math.min(drag.x1, drag.x2), top: Math.min(drag.y1, drag.y2), width: Math.abs(drag.x2 - drag.x1), height: Math.abs(drag.y2 - drag.y1), borderColor: accent, background: `color-mix(in srgb, ${accent} 12%, transparent)` } })) : null, _jsxs("div", { className: "fixed right-4 z-40 flex flex-col items-end gap-2", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)' }, children: [panel ? (_jsxs("div", { "data-comment-panel": true, className: "w-64 rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-xs text-[color:var(--a-strong)] shadow-xl", children: [_jsx("p", { className: "opacity-80", children: "Select any text, or hold and drag a box over anything, to leave a comment." }), isOwner ? _jsx("p", { "data-comment-owner": true, className: "mt-1 opacity-80", children: "This is your page: you see every comment, with the name of who left it." }) : null, _jsx("button", { type: "button", "data-comment-pins-toggle": true, className: "mt-2 underline", onClick: () => setShowPins((v) => !v), children: showPins ? 'Hide comments' : 'Show comments' })] })) : null, _jsx("button", { type: "button", "data-comment-button": true, "aria-expanded": panel, onClick: () => setPanel((v) => !v), className: "rounded-full border px-3 py-1.5 text-xs font-medium shadow-lg backdrop-blur", style: { borderColor: accent, color: accent, background: `color-mix(in srgb, ${ground} 85%, transparent)` }, children: count ? `Comments · ${count}` : 'Comment' })] })] })) : null;
|
|
353
416
|
return (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, "data-comment-layer": comments, "data-comment-can-share": canShare ? 'yes' : 'no' }), mounted && layout.earlier.length ? (_jsxs("section", { "data-comments-earlier": true, "data-comment-ui": true, "data-nospeak": true, className: "mx-auto max-w-2xl px-6 pb-16 text-sm", children: [_jsx("p", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: "Comments on earlier versions" }), _jsx("ul", { className: "m-0 mt-2 grid list-none gap-3 p-0", children: layout.earlier.map((item) => (_jsxs("li", { className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid' }, children: [item.value.anchor.kind === 'text' ? _jsxs("span", { className: "block text-[13px] italic opacity-70", children: ["\u201C", item.value.anchor.quote, "\u201D"] }) : _jsx("span", { className: "block text-[13px] italic opacity-70", children: "A box on part of the page that has changed" }), _jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-[color:var(--a-strong)]", children: item.value.body }), _jsxs("span", { className: "block text-xs opacity-60", children: [item.kind === 'personal' ? 'only you' : item.name, ` · version ${item.value.version}`, item.mine ? _jsx("button", { type: "button", onClick: () => void remove(item), className: "ml-2 underline", children: "delete" }) : null] })] }, item.id))) })] })) : null, overlay ? createPortal(overlay, document.body) : null] }));
|
|
354
417
|
}
|
|
355
418
|
function Pin({ item, left, top, accent, ground, onOpen }) {
|
|
@@ -362,19 +425,38 @@ function Floating({ x, y, children }) {
|
|
|
362
425
|
const left = typeof window === 'undefined' ? x : Math.max(window.scrollX + 8, Math.min(x, window.scrollX + window.innerWidth - width - 8));
|
|
363
426
|
return _jsx("div", { className: "absolute z-50", style: { left, top: y }, children: children });
|
|
364
427
|
}
|
|
365
|
-
function Thread({ item, replies, canReply, accent, onClose, onRemove, onEdit, onReply }) {
|
|
428
|
+
function Thread({ item, replies, canReply, accent, audioDb, onClose, onRemove, onEdit, onReply }) {
|
|
366
429
|
const [replying, setReplying] = useState(false);
|
|
367
|
-
return (_jsxs("div", { "data-comment-thread": true, "data-comment-ui": true, "data-nospeak": true, className: "w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-sm text-[color:var(--a-strong)] shadow-xl", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.15em]", style: { color: accent }, children: [_jsx("span", { children: item.kind === 'personal' ? 'Only you' : 'Comment' }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", className: "opacity-70 hover:opacity-100", children: "\u00D7" })] }), item.value.anchor.kind === 'text' ? _jsx("p", { className: "mb-2 line-clamp-2 border-l-2 pl-2 text-[13px] italic opacity-70", style: { borderColor: accent }, children: item.value.anchor.quote }) : null, _jsx(Entry, { item: item, onRemove: onRemove, onEdit: onEdit }), replies.length ? _jsx("ul", { className: "m-0 mt-2 grid list-none gap-2 border-l pl-3 p-0", children: replies.map((r) => _jsx("li", { className: "m-0", children: _jsx(Entry, { item: r, onRemove: onRemove, onEdit: onEdit }) }, r.id)) }) : null, canReply ? (replying
|
|
430
|
+
return (_jsxs("div", { "data-comment-thread": true, "data-comment-ui": true, "data-nospeak": true, className: "w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-sm text-[color:var(--a-strong)] shadow-xl", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.15em]", style: { color: accent }, children: [_jsx("span", { children: item.kind === 'personal' ? 'Only you' : 'Comment' }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", className: "opacity-70 hover:opacity-100", children: "\u00D7" })] }), item.value.anchor.kind === 'text' ? _jsx("p", { className: "mb-2 line-clamp-2 border-l-2 pl-2 text-[13px] italic opacity-70", style: { borderColor: accent }, children: item.value.anchor.quote }) : null, _jsx(Entry, { item: item, accent: accent, audioDb: audioDb, onRemove: onRemove, onEdit: onEdit }), replies.length ? _jsx("ul", { className: "m-0 mt-2 grid list-none gap-2 border-l pl-3 p-0", children: replies.map((r) => _jsx("li", { className: "m-0", children: _jsx(Entry, { item: r, accent: accent, audioDb: audioDb, onRemove: onRemove, onEdit: onEdit }) }, r.id)) }) : null, canReply ? (replying
|
|
368
431
|
? _jsx(InlineForm, { label: "Reply", accent: accent, onCancel: () => setReplying(false), onSubmit: async (b) => { const e = await onReply(item, b); if (!e)
|
|
369
432
|
setReplying(false); return e; } })
|
|
370
433
|
: _jsx("button", { type: "button", "data-comment-reply": true, onClick: () => setReplying(true), className: "mt-2 text-xs underline", children: "Reply" })) : null] }));
|
|
371
434
|
}
|
|
372
|
-
|
|
435
|
+
/** Where a comment's recording plays from: its signed URL, or this browser's copy of a device note's. */
|
|
436
|
+
function useAudioSrc(item, audioDb) {
|
|
437
|
+
const [local, setLocal] = useState(null);
|
|
438
|
+
const key = item.where === 'device' && isDeviceAudio(item.value.audio) ? item.value.audio : null;
|
|
439
|
+
useEffect(() => {
|
|
440
|
+
if (!key || typeof URL.createObjectURL !== 'function')
|
|
441
|
+
return;
|
|
442
|
+
let url = null;
|
|
443
|
+
let live = true;
|
|
444
|
+
void audioDb.get(key).then((b) => { if (live && b) {
|
|
445
|
+
url = URL.createObjectURL(b);
|
|
446
|
+
setLocal(url);
|
|
447
|
+
} });
|
|
448
|
+
return () => { live = false; if (url)
|
|
449
|
+
URL.revokeObjectURL(url); };
|
|
450
|
+
}, [key, audioDb]);
|
|
451
|
+
return item.audioUrl ?? local;
|
|
452
|
+
}
|
|
453
|
+
function Entry({ item, accent, audioDb, onRemove, onEdit }) {
|
|
373
454
|
const [editing, setEditing] = useState(false);
|
|
455
|
+
const src = useAudioSrc(item, audioDb);
|
|
374
456
|
if (editing)
|
|
375
457
|
return _jsx(InlineForm, { label: "Save", initial: item.value.body, onCancel: () => setEditing(false), onSubmit: async (b) => { const e = await onEdit(item, b); if (!e)
|
|
376
458
|
setEditing(false); return e; } });
|
|
377
|
-
return (_jsxs("div", { children: [_jsx("p", { className: "m-0 whitespace-pre-wrap text-[15px]", children: item.value.body }), _jsxs("p", { className: "m-0 text-xs opacity-60", children: [item.kind === 'personal' ? (item.where === 'device' ? 'only you, on this device' : 'only you') : item.name, item.mine ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "data-comment-edit": true, onClick: () => setEditing(true), className: "ml-2 underline", children: "edit" }), _jsx("button", { type: "button", "data-comment-delete": true, onClick: () => void onRemove(item), className: "ml-2 underline", children: "delete" })] })) : null] })] }));
|
|
459
|
+
return (_jsxs("div", { children: [src ? _jsx(AudioPlay, { src: src, accent: accent }) : null, item.value.body ? _jsx("p", { className: "m-0 whitespace-pre-wrap text-[15px]", children: item.value.body }) : null, _jsxs("p", { className: "m-0 text-xs opacity-60", children: [item.kind === 'personal' ? (item.where === 'device' ? 'only you, on this device' : 'only you') : item.name, item.mine ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "data-comment-edit": true, onClick: () => setEditing(true), className: "ml-2 underline", children: "edit" }), _jsx("button", { type: "button", "data-comment-delete": true, onClick: () => void onRemove(item), className: "ml-2 underline", children: "delete" })] })) : null] })] }));
|
|
378
460
|
}
|
|
379
461
|
function InlineForm({ label, initial = '', accent = 'currentColor', onSubmit, onCancel }) {
|
|
380
462
|
const [text, setText] = useState(initial);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const isDeviceAudio: (p: unknown) => p is string;
|
|
2
|
+
export type DeviceAudio = {
|
|
3
|
+
put(key: string, blob: Blob): Promise<boolean>;
|
|
4
|
+
get(key: string): Promise<Blob | null>;
|
|
5
|
+
remove(key: string): Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
export declare const deviceAudio: DeviceAudio;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Recordings on personal notes for a reader who is not signed in: kept in this browser's IndexedDB
|
|
2
|
+
// and nowhere else, like the notes themselves (device-notes.ts). A note names its recording as
|
|
3
|
+
// `device/<memo>.<ext>`; on sign-in the comment layer uploads it to the reader's account and
|
|
4
|
+
// deletes it here. A browser without IndexedDB (or a private window that refuses it) keeps no
|
|
5
|
+
// recording, and nothing breaks: every call answers null or false.
|
|
6
|
+
const DB = 'artifact-voice';
|
|
7
|
+
const STORE = 'memos';
|
|
8
|
+
export const isDeviceAudio = (p) => typeof p === 'string' && p.startsWith('device/');
|
|
9
|
+
function open() {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
try {
|
|
12
|
+
if (typeof indexedDB === 'undefined')
|
|
13
|
+
return resolve(null);
|
|
14
|
+
const req = indexedDB.open(DB, 1);
|
|
15
|
+
req.onupgradeneeded = () => { req.result.createObjectStore(STORE); };
|
|
16
|
+
req.onsuccess = () => resolve(req.result);
|
|
17
|
+
req.onerror = () => resolve(null);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
resolve(null);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
async function run(mode, fn) {
|
|
25
|
+
const db = await open();
|
|
26
|
+
if (!db)
|
|
27
|
+
return null;
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
try {
|
|
30
|
+
const req = fn(db.transaction(STORE, mode).objectStore(STORE));
|
|
31
|
+
req.onsuccess = () => resolve(req.result ?? null);
|
|
32
|
+
req.onerror = () => resolve(null);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
resolve(null);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export const deviceAudio = {
|
|
40
|
+
put: async (key, blob) => (await run('readwrite', (s) => s.put(blob, key))) !== null,
|
|
41
|
+
get: async (key) => { const b = await run('readonly', (s) => s.get(key)); return b instanceof Blob ? b : null; },
|
|
42
|
+
remove: async (key) => { await run('readwrite', (s) => s.delete(key)); },
|
|
43
|
+
};
|
package/lib/reader/index.d.ts
CHANGED
|
@@ -6,3 +6,6 @@ export { CommentLayer } from './comment-layer.js';
|
|
|
6
6
|
export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
|
|
7
7
|
export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
|
|
8
8
|
export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom, type TextIndex, type Placement } from './comment-place.js';
|
|
9
|
+
export { VoiceRecorder, AudioPlay, hostVoice, pickRecordingType, newMemoId, recognitionCtor, LIVE_LINE, type VoiceHost, type VoiceMemo, type VoiceScope } from './voice-recorder.js';
|
|
10
|
+
export { deviceAudio, isDeviceAudio, type DeviceAudio } from './device-audio.js';
|
|
11
|
+
export { type SavedMemo } from './comment-card.js';
|
package/lib/reader/index.js
CHANGED
|
@@ -8,3 +8,5 @@ export { CommentLayer } from './comment-layer.js';
|
|
|
8
8
|
export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
|
|
9
9
|
export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
|
|
10
10
|
export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom } from './comment-place.js';
|
|
11
|
+
export { VoiceRecorder, AudioPlay, hostVoice, pickRecordingType, newMemoId, recognitionCtor, LIVE_LINE } from './voice-recorder.js';
|
|
12
|
+
export { deviceAudio, isDeviceAudio } from './device-audio.js';
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** The page's theme variables (`--a-*`) as they resolve around `el`. Computed style covers variables set by a stylesheet (the `system` mode's media
|
|
2
|
+
* query); the inline walk covers engines that do not list custom properties in computed style. */
|
|
3
|
+
export declare function themeVarsAround(el: Element | null): Record<string, string>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Anything the reader portals onto document.body leaves the wrapper that sets the page's theme,
|
|
2
|
+
// so it has to carry the theme with it. One helper for every portal (comment overlay, definition
|
|
3
|
+
// sheet, version history).
|
|
4
|
+
/** The page's theme variables (`--a-*`) as they resolve around `el`. Computed style covers variables set by a stylesheet (the `system` mode's media
|
|
5
|
+
* query); the inline walk covers engines that do not list custom properties in computed style. */
|
|
6
|
+
export function themeVarsAround(el) {
|
|
7
|
+
const out = {};
|
|
8
|
+
if (!el)
|
|
9
|
+
return out;
|
|
10
|
+
const cs = getComputedStyle(el);
|
|
11
|
+
for (let i = 0; i < cs.length; i++) {
|
|
12
|
+
const name = cs[i];
|
|
13
|
+
if (name.startsWith('--a-')) {
|
|
14
|
+
const v = cs.getPropertyValue(name).trim();
|
|
15
|
+
if (v)
|
|
16
|
+
out[name] = v;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
for (let n = el; n; n = n.parentElement) {
|
|
20
|
+
const st = n.style;
|
|
21
|
+
if (!st)
|
|
22
|
+
continue;
|
|
23
|
+
for (let i = 0; i < st.length; i++) {
|
|
24
|
+
const name = st[i];
|
|
25
|
+
if (name.startsWith('--a-') && !(name in out)) {
|
|
26
|
+
const v = st.getPropertyValue(name).trim();
|
|
27
|
+
if (v)
|
|
28
|
+
out[name] = v;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type VoiceMemo = {
|
|
2
|
+
blob: Blob;
|
|
3
|
+
mime: string;
|
|
4
|
+
};
|
|
5
|
+
export type VoiceScope = 'comment' | 'personal';
|
|
6
|
+
/** The page's own voice routes, from the reader's browser. */
|
|
7
|
+
export type VoiceHost = {
|
|
8
|
+
/** Upload a memo; the stored path, or null when the page could not keep it. */
|
|
9
|
+
upload(memo: VoiceMemo, scope: VoiceScope): Promise<string | null>;
|
|
10
|
+
/** The host's transcript of an uploaded memo; null when it has no transcriber (404) or failed. */
|
|
11
|
+
transcribe(path: string): Promise<string | null>;
|
|
12
|
+
};
|
|
13
|
+
export declare const LIVE_LINE = "Your browser transcribes this as you speak.";
|
|
14
|
+
/** The recording type to ask for: the first this browser supports, else its default. */
|
|
15
|
+
export declare function pickRecordingType(isSupported?: (t: string) => boolean): string | undefined;
|
|
16
|
+
/** A memo's random file id: 22 url-safe characters, drawn in the browser. */
|
|
17
|
+
export declare function newMemoId(): string;
|
|
18
|
+
type Recognition = {
|
|
19
|
+
continuous: boolean;
|
|
20
|
+
interimResults: boolean;
|
|
21
|
+
lang: string;
|
|
22
|
+
onresult: ((e: {
|
|
23
|
+
results: ArrayLike<ArrayLike<{
|
|
24
|
+
transcript: string;
|
|
25
|
+
}> & {
|
|
26
|
+
isFinal?: boolean;
|
|
27
|
+
}>;
|
|
28
|
+
}) => void) | null;
|
|
29
|
+
onerror: (() => void) | null;
|
|
30
|
+
start(): void;
|
|
31
|
+
stop(): void;
|
|
32
|
+
};
|
|
33
|
+
type RecognitionCtor = new () => Recognition;
|
|
34
|
+
export declare function recognitionCtor(): RecognitionCtor | null;
|
|
35
|
+
/** The page's own routes as a VoiceHost. Every failure is a null, never a throw. */
|
|
36
|
+
export declare function hostVoice(artifactId: string): VoiceHost;
|
|
37
|
+
/** The mic button. Renders nothing where the browser cannot record. */
|
|
38
|
+
export declare function VoiceRecorder({ onStart, onLive, onStop, onError, accent, maxMs }: {
|
|
39
|
+
onStart?: () => void;
|
|
40
|
+
/** The live transcript so far, whole, each time it changes. */
|
|
41
|
+
onLive?: (text: string) => void;
|
|
42
|
+
onStop: (memo: VoiceMemo) => void;
|
|
43
|
+
onError?: (message: string) => void;
|
|
44
|
+
accent?: string;
|
|
45
|
+
maxMs?: number;
|
|
46
|
+
}): import("react").JSX.Element | null;
|
|
47
|
+
/** A small play control for a saved memo. `src` is a URL (signed, or a blob: URL on the device). */
|
|
48
|
+
export declare function AudioPlay({ src, accent }: {
|
|
49
|
+
src: string;
|
|
50
|
+
accent?: string;
|
|
51
|
+
}): import("react").JSX.Element;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// Say it instead of typing it. The microphone in the comment card: MediaRecorder for the memo
|
|
4
|
+
// itself, and, where the browser has it, speech recognition writing into the box while you speak.
|
|
5
|
+
//
|
|
6
|
+
// Chrome records audio/webm (opus), iPhone Safari audio/mp4; the first the browser supports is
|
|
7
|
+
// used, else the browser's own default, and a type the page cannot keep is refused before upload.
|
|
8
|
+
// A memo stops itself at three minutes. The host's transcription (if it has one) happens after the
|
|
9
|
+
// recording stops, in the card, through `hostVoice`, which never sees a key: it only calls this
|
|
10
|
+
// page's own routes.
|
|
11
|
+
//
|
|
12
|
+
// Everything here draws with the page's theme variables (--a-*) and the accent it is handed: the
|
|
13
|
+
// card is portalled onto document.body, and a hard-coded colour there is dark text on a dark page.
|
|
14
|
+
import { useEffect, useRef, useState } from 'react';
|
|
15
|
+
import { MAX_MEMO_MS, audioExtFor, baseMime } from '../artifacts/audio.js';
|
|
16
|
+
export const LIVE_LINE = 'Your browser transcribes this as you speak.';
|
|
17
|
+
const SEEN_KEY = 'artifact-voice-live-seen';
|
|
18
|
+
/** The recording type to ask for: the first this browser supports, else its default. */
|
|
19
|
+
export function pickRecordingType(isSupported = (t) => typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(t)) {
|
|
20
|
+
for (const t of ['audio/webm;codecs=opus', 'audio/mp4']) {
|
|
21
|
+
try {
|
|
22
|
+
if (isSupported(t))
|
|
23
|
+
return t;
|
|
24
|
+
}
|
|
25
|
+
catch { /* an engine that throws on the question supports neither */ }
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
/** A memo's random file id: 22 url-safe characters, drawn in the browser. */
|
|
30
|
+
export function newMemoId() {
|
|
31
|
+
const bytes = new Uint8Array(16);
|
|
32
|
+
crypto.getRandomValues(bytes);
|
|
33
|
+
let s = '';
|
|
34
|
+
for (const b of bytes)
|
|
35
|
+
s += 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'[b & 63];
|
|
36
|
+
return s + Date.now().toString(36).slice(-6);
|
|
37
|
+
}
|
|
38
|
+
export function recognitionCtor() {
|
|
39
|
+
if (typeof window === 'undefined')
|
|
40
|
+
return null;
|
|
41
|
+
const w = window;
|
|
42
|
+
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
|
43
|
+
}
|
|
44
|
+
const canRecord = () => typeof window !== 'undefined' && typeof MediaRecorder !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
|
|
45
|
+
/** The page's own routes as a VoiceHost. Every failure is a null, never a throw. */
|
|
46
|
+
export function hostVoice(artifactId) {
|
|
47
|
+
const post = (url, body) => fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
48
|
+
return {
|
|
49
|
+
async upload(memo, scope) {
|
|
50
|
+
try {
|
|
51
|
+
const ext = audioExtFor(memo.mime);
|
|
52
|
+
if (!ext)
|
|
53
|
+
return null;
|
|
54
|
+
const url = `/api/artifacts/${artifactId}/uploads/${newMemoId()}.${ext}`;
|
|
55
|
+
const signed = await post(url, { audio: scope });
|
|
56
|
+
if (signed.status !== 201)
|
|
57
|
+
return null;
|
|
58
|
+
const { uploadUrl, headers } = (await signed.json());
|
|
59
|
+
const put = await fetch(uploadUrl, { method: 'PUT', headers, body: memo.blob });
|
|
60
|
+
if (!put.ok)
|
|
61
|
+
return null;
|
|
62
|
+
const done = await post(url, { audio: scope, done: true });
|
|
63
|
+
return done.status === 201 ? (await done.json()).path : null;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
async transcribe(path) {
|
|
70
|
+
try {
|
|
71
|
+
const r = await post(`/api/artifacts/${artifactId}/transcribe`, { asset: path });
|
|
72
|
+
if (r.status !== 200)
|
|
73
|
+
return null;
|
|
74
|
+
const b = (await r.json());
|
|
75
|
+
return typeof b.text === 'string' && b.text.trim() ? b.text.trim() : null;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const clock = (ms) => { const s = Math.floor(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
|
|
84
|
+
/** The mic button. Renders nothing where the browser cannot record. */
|
|
85
|
+
export function VoiceRecorder({ onStart, onLive, onStop, onError, accent = 'currentColor', maxMs = MAX_MEMO_MS }) {
|
|
86
|
+
const [supported, setSupported] = useState(false);
|
|
87
|
+
const [live, setLive] = useState(false);
|
|
88
|
+
const [note, setNote] = useState(false);
|
|
89
|
+
const [recording, setRecording] = useState(false);
|
|
90
|
+
const [elapsed, setElapsed] = useState(0);
|
|
91
|
+
const rec = useRef(null);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
setSupported(canRecord());
|
|
94
|
+
const has = !!recognitionCtor();
|
|
95
|
+
setLive(has);
|
|
96
|
+
try {
|
|
97
|
+
setNote(has && !window.localStorage.getItem(SEEN_KEY));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
setNote(has);
|
|
101
|
+
}
|
|
102
|
+
return () => { const r = rec.current; if (r) {
|
|
103
|
+
clearTimeout(r.timer);
|
|
104
|
+
clearInterval(r.tick);
|
|
105
|
+
r.recog?.stop();
|
|
106
|
+
r.stream.getTracks().forEach((t) => t.stop());
|
|
107
|
+
} };
|
|
108
|
+
}, []);
|
|
109
|
+
const stop = () => {
|
|
110
|
+
const r = rec.current;
|
|
111
|
+
if (!r)
|
|
112
|
+
return;
|
|
113
|
+
clearTimeout(r.timer);
|
|
114
|
+
clearInterval(r.tick);
|
|
115
|
+
try {
|
|
116
|
+
r.recog?.stop();
|
|
117
|
+
}
|
|
118
|
+
catch { /* already stopped */ }
|
|
119
|
+
if (r.recorder.state !== 'inactive')
|
|
120
|
+
r.recorder.stop();
|
|
121
|
+
};
|
|
122
|
+
const start = async () => {
|
|
123
|
+
let stream;
|
|
124
|
+
try {
|
|
125
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
onError?.('The microphone is blocked. Allow it for this site in your browser, then try again.');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const asked = pickRecordingType();
|
|
132
|
+
let recorder;
|
|
133
|
+
try {
|
|
134
|
+
recorder = asked ? new MediaRecorder(stream, { mimeType: asked }) : new MediaRecorder(stream);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
138
|
+
onError?.('This browser cannot record here.');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const chunks = [];
|
|
142
|
+
recorder.ondataavailable = (e) => { if (e.data && e.data.size)
|
|
143
|
+
chunks.push(e.data); };
|
|
144
|
+
recorder.onstop = () => {
|
|
145
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
146
|
+
rec.current = null;
|
|
147
|
+
setRecording(false);
|
|
148
|
+
const type = recorder.mimeType || asked || chunks[0]?.type || '';
|
|
149
|
+
if (!audioExtFor(type)) {
|
|
150
|
+
onError?.('This browser records in a format the page cannot keep.');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
onStop({ blob: new Blob(chunks, { type: baseMime(type) }), mime: baseMime(type) });
|
|
154
|
+
};
|
|
155
|
+
let recog = null;
|
|
156
|
+
const Ctor = recognitionCtor();
|
|
157
|
+
if (Ctor) {
|
|
158
|
+
try {
|
|
159
|
+
recog = new Ctor();
|
|
160
|
+
recog.continuous = true;
|
|
161
|
+
recog.interimResults = true;
|
|
162
|
+
recog.lang = document.documentElement.lang || navigator.language || 'en-US';
|
|
163
|
+
recog.onresult = (e) => {
|
|
164
|
+
let text = '';
|
|
165
|
+
for (let i = 0; i < e.results.length; i++)
|
|
166
|
+
text += e.results[i][0]?.transcript ?? '';
|
|
167
|
+
onLive?.(text.replace(/\s+/g, ' ').trim());
|
|
168
|
+
};
|
|
169
|
+
recog.onerror = () => { };
|
|
170
|
+
recog.start();
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
recog = null;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
window.localStorage.setItem(SEEN_KEY, '1');
|
|
177
|
+
}
|
|
178
|
+
catch { /* private window */ }
|
|
179
|
+
}
|
|
180
|
+
const began = Date.now();
|
|
181
|
+
rec.current = {
|
|
182
|
+
recorder, stream, recog,
|
|
183
|
+
timer: setTimeout(stop, maxMs),
|
|
184
|
+
tick: setInterval(() => setElapsed(Date.now() - began), 250),
|
|
185
|
+
};
|
|
186
|
+
setElapsed(0);
|
|
187
|
+
setRecording(true);
|
|
188
|
+
onStart?.();
|
|
189
|
+
recorder.start(1000);
|
|
190
|
+
};
|
|
191
|
+
if (!supported)
|
|
192
|
+
return null;
|
|
193
|
+
return (_jsxs("div", { "data-voice-recorder": true, className: "mt-2 text-[13px] text-[color:var(--a-strong)]", children: [_jsxs("button", { type: "button", "data-voice-mic": true, "aria-pressed": recording, onClick: () => (recording ? stop() : void start()), className: "inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium", style: recording ? { borderColor: accent, background: accent, color: 'var(--a-on-accent, currentColor)' } : { borderColor: accent, color: accent }, children: [_jsx("span", { "aria-hidden": true, children: recording ? '■' : '●' }), recording ? `Stop · ${clock(elapsed)}` : 'Record'] }), live && note ? _jsx("p", { "data-voice-live-note": true, className: "m-0 mt-1 text-xs opacity-70", children: LIVE_LINE }) : null] }));
|
|
194
|
+
}
|
|
195
|
+
/** A small play control for a saved memo. `src` is a URL (signed, or a blob: URL on the device). */
|
|
196
|
+
export function AudioPlay({ src, accent = 'currentColor' }) {
|
|
197
|
+
const [playing, setPlaying] = useState(false);
|
|
198
|
+
const el = useRef(null);
|
|
199
|
+
useEffect(() => () => { el.current?.pause(); }, []);
|
|
200
|
+
const toggle = () => {
|
|
201
|
+
if (!el.current) {
|
|
202
|
+
el.current = new Audio(src);
|
|
203
|
+
el.current.onended = () => setPlaying(false);
|
|
204
|
+
el.current.onpause = () => setPlaying(false);
|
|
205
|
+
el.current.onplay = () => setPlaying(true);
|
|
206
|
+
}
|
|
207
|
+
if (el.current.paused)
|
|
208
|
+
void el.current.play()?.catch?.(() => setPlaying(false));
|
|
209
|
+
else
|
|
210
|
+
el.current.pause();
|
|
211
|
+
};
|
|
212
|
+
return (_jsxs("button", { type: "button", "data-voice-play": true, onClick: toggle, "aria-label": playing ? 'Pause the recording' : 'Play the recording', className: "mr-2 inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-[color:var(--a-strong)]", style: { borderColor: accent }, children: [_jsx("span", { "aria-hidden": true, style: { color: accent }, children: playing ? '❚❚' : '▶' }), playing ? 'Pause' : 'Play'] }));
|
|
213
|
+
}
|
|
@@ -7,6 +7,7 @@ import type { BrandPack } from '../brand/pack.js';
|
|
|
7
7
|
import { type Access } from '../artifacts/reader.js';
|
|
8
8
|
import { type ReadersStore } from '../artifacts/readers-store.js';
|
|
9
9
|
import type { PersonalStore } from '../artifacts/personal-store.js';
|
|
10
|
+
import type { Transcriber } from '../artifacts/transcribe.js';
|
|
10
11
|
export type ArtifactRoutesConfig = {
|
|
11
12
|
store: ArtifactStore;
|
|
12
13
|
/** Where readers' answers live. Without it every state route answers 501. */
|
|
@@ -49,6 +50,11 @@ export type ArtifactRoutesConfig = {
|
|
|
49
50
|
* its writer's name, whatever `comments_visible:` says. Without it nobody is the owner, and
|
|
50
51
|
* the publisher reads comments through /responses. */
|
|
51
52
|
ownerEmail?: string;
|
|
53
|
+
/** Turns a reader's voice memo into text on the server, so the service's key never reaches a
|
|
54
|
+
* browser: `deepgramTranscriber(key)` or `openaiTranscriber(key)` from `./artifacts`, or your
|
|
55
|
+
* own. Without it the transcribe route answers 404 and readers get the browser's live
|
|
56
|
+
* transcript, or an empty box to type into. */
|
|
57
|
+
transcribe?: Transcriber;
|
|
52
58
|
};
|
|
53
59
|
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
54
60
|
export declare const ARTIFACT_ID: RegExp;
|
|
@@ -152,13 +158,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
|
152
158
|
id: string;
|
|
153
159
|
name: string;
|
|
154
160
|
}>;
|
|
155
|
-
}) => Promise<NextResponse<
|
|
156
|
-
error: string;
|
|
157
|
-
}> | NextResponse<{
|
|
158
|
-
id: string;
|
|
159
|
-
name: string;
|
|
160
|
-
url: string;
|
|
161
|
-
}>>;
|
|
161
|
+
}) => Promise<NextResponse<unknown>>;
|
|
162
162
|
SHARE_IMAGE: (_request: NextRequest, { params }: Params) => Promise<Response>;
|
|
163
163
|
ENTER: (request: NextRequest) => Promise<NextResponse<unknown>>;
|
|
164
164
|
LEAVE: (request: NextRequest) => Promise<NextResponse<unknown>>;
|
|
@@ -191,5 +191,10 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
|
191
191
|
access: Access | null;
|
|
192
192
|
views: number;
|
|
193
193
|
}>>;
|
|
194
|
+
TRANSCRIBE: (req: NextRequest, { params }: {
|
|
195
|
+
params: Promise<{
|
|
196
|
+
id: string;
|
|
197
|
+
}>;
|
|
198
|
+
}) => Promise<NextResponse<unknown>>;
|
|
194
199
|
};
|
|
195
200
|
export {};
|