@supersuit/artifacts 0.11.1 → 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 +29 -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 +84 -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/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,9 @@ 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';
|
|
26
29
|
import { themeVarsAround } from './theme-vars.js';
|
|
27
30
|
import { addDeviceNote, deviceNotes, removeDeviceNote, replaceDeviceNote } from './device-notes.js';
|
|
28
31
|
import { boxOf, headingAt, indexText, offsetAt, placeAnchor, regionFrom } from './comment-place.js';
|
|
@@ -52,12 +55,20 @@ function sharedFrom(body) {
|
|
|
52
55
|
for (const r of rows.values()) {
|
|
53
56
|
if (!isValue(r.value))
|
|
54
57
|
continue;
|
|
55
|
-
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
|
+
});
|
|
56
62
|
}
|
|
57
63
|
return out.sort((a, b) => a.at.localeCompare(b.at));
|
|
58
64
|
}
|
|
59
|
-
const personalFrom = (notes, where) => notes.filter((n) => isValue(n.value)).map((n) => ({
|
|
60
|
-
|
|
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]);
|
|
61
72
|
const stateUrl = `/api/artifacts/${artifactId}/state`;
|
|
62
73
|
const personalUrl = `/api/artifacts/${artifactId}/personal`;
|
|
63
74
|
const [mounted, setMounted] = useState(false);
|
|
@@ -110,12 +121,34 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
110
121
|
}
|
|
111
122
|
let notes = r.body.notes ?? [];
|
|
112
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.
|
|
113
125
|
for (const n of deviceNotes(artifactId)) {
|
|
114
|
-
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 }) });
|
|
115
146
|
if (m.status !== 200)
|
|
116
147
|
break;
|
|
117
148
|
notes = m.body.notes ?? notes;
|
|
118
149
|
removeDeviceNote(artifactId, n.id);
|
|
150
|
+
if (key)
|
|
151
|
+
await audioDb.remove(key);
|
|
119
152
|
}
|
|
120
153
|
if (!live)
|
|
121
154
|
return;
|
|
@@ -130,7 +163,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
130
163
|
if (ro && root)
|
|
131
164
|
ro.observe(root);
|
|
132
165
|
return () => { live = false; window.removeEventListener('resize', relayout); ro?.disconnect(); };
|
|
133
|
-
}, [artifactId, comments, signedIn, stateUrl, personalUrl, rootId, call, refreshDevice]);
|
|
166
|
+
}, [artifactId, comments, signedIn, stateUrl, personalUrl, rootId, call, refreshDevice, host, audioDb]);
|
|
134
167
|
// Text selection inside the page shows the Comment chip.
|
|
135
168
|
useEffect(() => {
|
|
136
169
|
const root = document.getElementById(rootId);
|
|
@@ -284,8 +317,26 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
284
317
|
return { placed, earlier };
|
|
285
318
|
// `tick` re-measures after a resize.
|
|
286
319
|
}, [mounted, rootId, tops, tick]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
287
|
-
|
|
288
|
-
|
|
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
|
+
}
|
|
289
340
|
if (share) {
|
|
290
341
|
const r = await call(stateUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slot: COMMENTS_SLOT, op: 'append', value }) });
|
|
291
342
|
if (r.status !== 200)
|
|
@@ -315,6 +366,8 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
315
366
|
const remove = async (item) => {
|
|
316
367
|
if (item.where === 'device') {
|
|
317
368
|
removeDeviceNote(artifactId, item.id);
|
|
369
|
+
if (isDeviceAudio(item.value.audio))
|
|
370
|
+
await audioDb.remove(item.value.audio);
|
|
318
371
|
refreshDevice();
|
|
319
372
|
}
|
|
320
373
|
else if (item.kind === 'personal') {
|
|
@@ -359,7 +412,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
359
412
|
const open = layout.placed.find((p) => p.item.id === thread);
|
|
360
413
|
const openItem = open?.item ?? layout.earlier.find((i) => i.id === thread);
|
|
361
414
|
const count = tops.length;
|
|
362
|
-
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, 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;
|
|
363
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] }));
|
|
364
417
|
}
|
|
365
418
|
function Pin({ item, left, top, accent, ground, onOpen }) {
|
|
@@ -372,19 +425,38 @@ function Floating({ x, y, children }) {
|
|
|
372
425
|
const left = typeof window === 'undefined' ? x : Math.max(window.scrollX + 8, Math.min(x, window.scrollX + window.innerWidth - width - 8));
|
|
373
426
|
return _jsx("div", { className: "absolute z-50", style: { left, top: y }, children: children });
|
|
374
427
|
}
|
|
375
|
-
function Thread({ item, replies, canReply, accent, onClose, onRemove, onEdit, onReply }) {
|
|
428
|
+
function Thread({ item, replies, canReply, accent, audioDb, onClose, onRemove, onEdit, onReply }) {
|
|
376
429
|
const [replying, setReplying] = useState(false);
|
|
377
|
-
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
|
|
378
431
|
? _jsx(InlineForm, { label: "Reply", accent: accent, onCancel: () => setReplying(false), onSubmit: async (b) => { const e = await onReply(item, b); if (!e)
|
|
379
432
|
setReplying(false); return e; } })
|
|
380
433
|
: _jsx("button", { type: "button", "data-comment-reply": true, onClick: () => setReplying(true), className: "mt-2 text-xs underline", children: "Reply" })) : null] }));
|
|
381
434
|
}
|
|
382
|
-
|
|
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 }) {
|
|
383
454
|
const [editing, setEditing] = useState(false);
|
|
455
|
+
const src = useAudioSrc(item, audioDb);
|
|
384
456
|
if (editing)
|
|
385
457
|
return _jsx(InlineForm, { label: "Save", initial: item.value.body, onCancel: () => setEditing(false), onSubmit: async (b) => { const e = await onEdit(item, b); if (!e)
|
|
386
458
|
setEditing(false); return e; } });
|
|
387
|
-
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] })] }));
|
|
388
460
|
}
|
|
389
461
|
function InlineForm({ label, initial = '', accent = 'currentColor', onSubmit, onCancel }) {
|
|
390
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,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 {};
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -28,6 +28,7 @@ import { AckDoor, ConfidentialBanner, NO_PRINT_CSS, NotAllowedDoor, SignInDoor,
|
|
|
28
28
|
import { ARTIFACT_ID_RE } from './ids.js';
|
|
29
29
|
import { createStateRoutes } from './state-routes.js';
|
|
30
30
|
import { createPersonalRoutes } from './personal-routes.js';
|
|
31
|
+
import { createVoiceRoutes } from './voice-routes.js';
|
|
31
32
|
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
32
33
|
export const ARTIFACT_ID = ARTIFACT_ID_RE;
|
|
33
34
|
/** A version number as it appears in a URL: no zero, no leading zero, so each version has one address. */
|
|
@@ -53,11 +54,18 @@ export function createArtifactRoutes(config) {
|
|
|
53
54
|
const readerSecret = () => config.readerSecret?.();
|
|
54
55
|
const signOutUrl = (id) => `/api/reader/leave?to=${encodeURIComponent(pagePath(id))}`;
|
|
55
56
|
const shareCardUrl = (id, updatedAt) => `${pageUrl(id)}/share.png?v=${encodeURIComponent(updatedAt)}`;
|
|
57
|
+
// Readers' recordings are played through short-lived signed URLs, issued only beside the comment
|
|
58
|
+
// or note they belong to; a host whose assets cannot sign them shows no play control.
|
|
59
|
+
const audioUrl = config.assets?.audioUrl ? (id, path) => config.assets.audioUrl(id, path) : undefined;
|
|
56
60
|
const stateRoutes = createStateRoutes({
|
|
57
61
|
store, state: config.state, readers: config.readers, readerSecret, publishKey: config.publishKey, pageUrl, signInOrigin, siteUrl,
|
|
58
|
-
clientIp: config.clientIp, ownerEmail: config.ownerEmail,
|
|
62
|
+
clientIp: config.clientIp, ownerEmail: config.ownerEmail, audioUrl,
|
|
63
|
+
});
|
|
64
|
+
const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl, audioUrl });
|
|
65
|
+
const voiceRoutes = createVoiceRoutes({
|
|
66
|
+
store, state: config.state, personal: config.personal, readers: config.readers, assets: config.assets, transcribe: config.transcribe,
|
|
67
|
+
readerSecret, pageUrl, signInOrigin, siteUrl, clientIp: config.clientIp,
|
|
59
68
|
});
|
|
60
|
-
const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl });
|
|
61
69
|
async function generateMetadata({ params }) {
|
|
62
70
|
const { id } = await params;
|
|
63
71
|
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
@@ -439,6 +447,21 @@ export function createArtifactRoutes(config) {
|
|
|
439
447
|
* bytes never arrived. 501 on a host whose assets cannot sign, so the publisher falls back.
|
|
440
448
|
*/
|
|
441
449
|
async function UPLOAD(request, { params }) {
|
|
450
|
+
const raw = await request.text().catch(() => '');
|
|
451
|
+
let body = {};
|
|
452
|
+
try {
|
|
453
|
+
const parsed = raw.length <= 4096 ? JSON.parse(raw) : null;
|
|
454
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
455
|
+
body = parsed;
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
body = {};
|
|
459
|
+
}
|
|
460
|
+
// A reader's voice memo: the reader's own doors, never the publish key (voice-routes.ts).
|
|
461
|
+
if (body.audio !== undefined) {
|
|
462
|
+
const { id, name } = await params;
|
|
463
|
+
return voiceRoutes.readerUpload(request, id, name, body);
|
|
464
|
+
}
|
|
442
465
|
if (!isPublishAuthed(request, config.publishKey()))
|
|
443
466
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
444
467
|
const assets = config.assets;
|
|
@@ -454,7 +477,6 @@ export function createArtifactRoutes(config) {
|
|
|
454
477
|
const type = contentTypeFor(name);
|
|
455
478
|
if (!type)
|
|
456
479
|
return NextResponse.json({ error: 'asset type not allowed; use webp, png, jpg, gif, mp3, mp4 or json' }, { status: 415 });
|
|
457
|
-
const body = (await request.json().catch(() => ({})));
|
|
458
480
|
const digest = typeof body.digest === 'string' ? body.digest : '';
|
|
459
481
|
if (!ASSET_DIGEST.test(digest))
|
|
460
482
|
return NextResponse.json({ error: 'digest must be the first 8 hex of the sha256 of the bytes' }, { status: 400 });
|
|
@@ -508,5 +530,5 @@ export function createArtifactRoutes(config) {
|
|
|
508
530
|
revalidatePath(pagePath(id));
|
|
509
531
|
return NextResponse.json({ deleted: true });
|
|
510
532
|
}
|
|
511
|
-
return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, ...personalRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
533
|
+
return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
512
534
|
}
|