// Presentational components for the Reader prototype. // All state-free. Consumed by reader-app.jsx. const COLOR_KEYS = { scout: "var(--agent-scout)", analyst: "var(--agent-analyst)", watchdog: "var(--agent-watchdog)", risk: "var(--agent-risk)", executioner: "var(--agent-executioner)", orchestrator: "var(--agent-orchestrator)", }; window.COLOR_KEYS = COLOR_KEYS; window.fmtTime = function fmtTime(s) { const m = Math.floor(s / 60); const ss = String(Math.floor(s % 60)).padStart(2, "0"); return `${m}:${ss}`; }; window.speakerColor = function speakerColor(sp) { if (!sp) return "var(--accent)"; if (sp.colorKey === "_accent") return "var(--accent)"; return COLOR_KEYS[sp.colorKey] || "var(--accent)"; }; // SUPERB-ER emits 3-letter codes (ang/hap/neu/sad). Map to friendly labels // and a color hint for the per-turn chip. Unknown labels pass through raw. const EMOTION_META = { ang: { label: "angry", color: "#f4677e" }, hap: { label: "happy", color: "#f5c84a" }, neu: { label: "neutral", color: "#6f7686" }, sad: { label: "sad", color: "#4fa3ff" }, }; window.emotionLabel = function emotionLabel(code) { return (EMOTION_META[code] && EMOTION_META[code].label) || code; }; window.emotionColor = function emotionColor(code) { return (EMOTION_META[code] && EMOTION_META[code].color) || "var(--text-muted)"; }; // 0.18.0: the exact local date and time of a Facebook post or comment, e.g. // "Sep 28, 2026, 2:05 PM PDT". Takes an ISO string or epoch milliseconds; // returns "" when the value is missing or unparseable. window.fmtExactTime = function fmtExactTime(value) { if (value == null || value === "") return ""; const d = new Date(value); if (isNaN(d.getTime())) return ""; return d.toLocaleString("en-US", { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short", }); }; // ---------- icons ---------- const I = { link: (p) => ( ), play: (p) => (), check: (p) => (), search: (p) => (), copy: (p) => (), download: (p) => (), upload: (p) => (), user: (p) => (), clock: (p) => (), users: (p) => (), film: (p) => (), edit: (p) => (), plus: (p) => (), x: (p) => (), trash: (p) => (), refresh: (p) => (), alert: (p) => (), spark: (p) => (), arrow: (p) => (), arrowBack: (p) => (), waveform: (p) => (), notes: (p) => (), logo: (p) => ( ), }; window.I = I; // ---------- threadComments ---------- // 0.18.0: threads a flat comment list for the page and for Copy. Replies nest // under their parent to any depth (Facebook has replies to replies). A // visited set means every comment renders once, even on a parent_id cycle or // a comment naming itself as its parent. A reply whose parent was not // captured goes to the top level with `orphan` set. Returns the top-level // nodes ({comment, replies, orphan}) and how many comments they hold, which // is what the comment headers count. function threadComments(comments) { const ids = new Set(comments.map((c) => c.id)); const hasParent = (c) => !!c.parent_id && c.parent_id !== c.id && ids.has(c.parent_id); const childrenOf = new Map(); for (const c of comments) { if (!hasParent(c)) continue; if (!childrenOf.has(c.parent_id)) childrenOf.set(c.parent_id, []); childrenOf.get(c.parent_id).push(c); } const visited = new Set(); const build = (c, orphan) => { visited.add(c.id); const replies = []; for (const r of childrenOf.get(c.id) || []) { if (!visited.has(r.id)) replies.push(build(r, false)); } return { comment: c, replies, orphan }; }; const roots = []; for (const c of comments) { if (!visited.has(c.id) && !hasParent(c)) { roots.push(build(c, !!c.parent_id && c.parent_id !== c.id)); } } // Whatever is left sits on a parent_id cycle with no top-level comment // above it: each one is shown once, at the top level. for (const c of comments) { if (!visited.has(c.id)) roots.push(build(c, false)); } return { roots, rendered: visited.size }; } window.threadComments = threadComments; // 0.19.0: how many comments a list of threadComments nodes holds, replies to // replies included: under one comment, its captured replies at all depths. function countReplies(nodes) { return nodes.reduce((n, node) => n + 1 + countReplies(node.replies), 0); } // ---------- CommentsSection ---------- // Renders the source-platform comments scraped by yt-dlp when the user // ticks "Capture comments" before a run. Top-level comments first, replies // nested under their parents. Pinned and creator-reply comments get a small // badge so they stand out in political-analysis review. // // 0.18.0: `post` is set for a Facebook result. It switches on the Facebook // meta line, the "of about M reported" count and two empty states (R15): // comments null means they were not captured, [] that none were visible. // Every other result renders nothing for both, as before. function CommentsSection({ comments, post }) { const facebook = !!post; const empty = !comments || comments.length === 0; if (empty && !facebook) return null; const { roots, rendered } = empty ? { roots: [], rendered: 0 } : threadComments(comments); const reported = facebook ? post.comment_count : null; const notes = facebook ? post.notes || [] : []; // A walk that stopped before its first comment (a block, the budget, a // cancel) stores [] with comments_complete false: that is "not captured", // not "none visible", and its notes say why. const notCaptured = empty && (comments == null || (facebook && post.comments_complete === false)); const emptyStyle = { margin: "0 0 8px", color: "var(--text-secondary)", fontSize: 14, lineHeight: 1.55, whiteSpace: "pre-wrap", wordBreak: "break-word", }; return (
COMMENTS {!empty && ( {reported != null ? `${rendered.toLocaleString()} of about ${reported.toLocaleString()} reported` : `${rendered.toLocaleString()} total`} {" · "}{roots.length} threads )}
{notCaptured && ( <>

Comments not captured.

{(notes.length > 0 ? notes : ["Tick Capture comments and run the link again."]).map((line, i) => (

{line}

))} )} {empty && !notCaptured && (

No comments were visible to your account on this post.

)} {roots.map((node) => ( ))}
); } window.CommentsSection = CommentsSection; // 0.18.0: `replies` are threadComments nodes, rendered recursively to any // depth. `orphan` marks a reply whose parent was not captured. `facebook` // switches the meta line: reactions instead of likes, the exact time when // there is no relative one, the attachment kind (it explains an empty text) // and the creator badge relabeled "author". YouTube's line is unchanged. // // 0.19.0 (Facebook only): the per-type reaction breakdown after the count, // the saved picture's kind in the attachment badge (the raw attachment // string can be a renderer name), "k of N replies captured" when Facebook // reports more replies under the comment than were captured, and the // comment's picture or link card under its text. `postId` is the post's id, // which the picture addresses need. function CommentBlock({ comment, replies, orphan, facebook, postId }) { const c = comment; const when = c.relative_time || (facebook && c.timestamp ? window.fmtExactTime(c.timestamp * 1000) : ""); const breakdown = facebook ? Object.entries(c.reactions || {}).map(([name, n]) => `${name} ${n.toLocaleString()}`).join(" · ") : ""; const kind = facebook ? (c.media && c.media.kind) || c.attachment : null; const missingReplies = facebook && c.replies_complete === false && c.reply_count != null; const badge = { display: "inline-block", fontSize: 10, padding: "1px 6px", border: "1px solid", borderRadius: 3, textTransform: "uppercase", letterSpacing: "0.04em", fontWeight: 600, }; return (
{c.author} {c.is_creator_reply && ( {facebook ? "author" : "creator"} )} {c.is_pinned && ( pinned )} {kind && ( {kind} )} {orphan && ( reply to a comment not captured )} {missingReplies && ( {countReplies(replies).toLocaleString()} of {c.reply_count.toLocaleString()} {c.reply_count === 1 ? "reply" : "replies"} captured )} {c.like_count > 0 && ( {c.like_count.toLocaleString()} {facebook ? (c.like_count === 1 ? "reaction" : "reactions") : "likes"} {breakdown ? ` (${breakdown})` : null} )} {when && ( {when} )}

{c.text}

{facebook && c.media && } {replies.length > 0 && (
{replies.map((r) => ( ))}
)}
); } // ---------- HistoryDropdown ---------- // Past transcripts. Clicking the row body reloads that transcript into the // current view (POST /history/{id}/load + refetch); a small link icon at // the end opens the source video in a new tab as a separate action. Lives // in the HeroForm during idle state and slides up to the TopBar once a // transcript is loaded. function HistoryDropdown({ entries, variant, onLoadFromHistory }) { const [open, setOpen] = React.useState(false); const ref = React.useRef(null); React.useEffect(() => { if (!open) return; const onDocClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("click", onDocClick); return () => document.removeEventListener("click", onDocClick); }, [open]); if (!entries || entries.length === 0) return null; const containerStyle = variant === "topbar" ? { position: "relative", marginRight: 8 } : { position: "relative", marginTop: 16 }; const btnStyle = { display: "inline-flex", alignItems: "center", gap: 6, padding: variant === "topbar" ? "5px 10px" : "8px 14px", border: "1px solid var(--border-default)", background: "var(--bg-inset)", color: "var(--text-secondary)", borderRadius: 6, fontFamily: "inherit", fontSize: variant === "topbar" ? 12 : 13, cursor: "pointer", whiteSpace: "nowrap", }; const panelStyle = { position: "absolute", top: "calc(100% + 6px)", left: 0, minWidth: 280, maxWidth: 380, maxHeight: 360, overflowY: "auto", padding: 4, background: "var(--bg-elevated)", border: "1px solid var(--border-default)", borderRadius: 8, boxShadow: "0 12px 32px rgba(0,0,0,0.45)", zIndex: 50, display: "flex", flexDirection: "column", gap: 2, }; const rowStyle = { display: "flex", alignItems: "center", gap: 6, flexShrink: 0, padding: "6px 6px 6px 10px", borderRadius: 4, }; const titleBtnStyle = { flex: "1 1 auto", minWidth: 0, textAlign: "left", background: "transparent", border: 0, color: "var(--text-primary)", fontFamily: "inherit", fontSize: 13, lineHeight: 1.35, cursor: "pointer", padding: "2px 4px", overflow: "hidden", }; const sourceLinkStyle = { flex: "0 0 auto", display: "inline-flex", alignItems: "center", color: "var(--text-muted)", padding: "4px 6px", borderRadius: 4, textDecoration: "none", }; const subStyle = { fontSize: 11, color: "var(--text-muted)", marginTop: 2, }; return (
{open && (
ev.stopPropagation()}> {entries.map((entry) => (
{entry.url && !/^file:/.test(entry.url) && ( ev.stopPropagation()} > )}
))}
)}
); } window.HistoryDropdown = HistoryDropdown; // ---------- InlineUrlForm ---------- // Slim URL input that lives in the TopBar once a transcript is on screen. // Submitting kicks off a new run (replaces the current transcript). function InlineUrlForm({ onSubmit, interpret, setInterpret, captureComments, setCaptureComments, captureEmotionCategorical, setCaptureEmotionCategorical, captureEmotionDimensional, setCaptureEmotionDimensional, captureSounds, setCaptureSounds, }) { const [url, setUrl] = React.useState(""); const fileRef = React.useRef(null); const submit = (e) => { e.preventDefault(); const t = url.trim(); if (t) { onSubmit(t); setUrl(""); } }; return (
setUrl(e.target.value)} placeholder="Paste a new URL…" spellCheck={false} style={{ background: "transparent", border: 0, outline: "none", color: "var(--text-primary)", fontFamily: "inherit", fontSize: 12.5, width: 240, }} /> { const f = e.target.files[0]; e.target.value = ""; if (f) { onSubmit(f); setUrl(""); } }} /> ); } window.InlineUrlForm = InlineUrlForm; // ---------- TopBar ---------- function TopBar({ view, onNavigate, onHome, status, history, onLoadFromHistory, onSubmit, interpret, setInterpret, captureComments, setCaptureComments, captureEmotionCategorical, setCaptureEmotionCategorical, captureEmotionDimensional, setCaptureEmotionDimensional, captureSounds, setCaptureSounds, modelsLoaded, modelsBusy, onToggleModels, }) { const openTweaks = () => window.postMessage({ type: "__activate_edit_mode" }, "*"); return (
{/* Brand = home: resets a done/error job to the idle hero (the page people mean by "the main page"), not just the view toggle. */}
(onHome ? onHome() : onNavigate("main"))} role="button" tabIndex={0} style={{ cursor: "pointer" }}>
Transcriptor
YouTube + Reels → speaker-labeled transcript
{(status.kind === "idle" || status.kind === "running") && (
{status.label}
)} {(status.kind === "done" || status.kind === "error") && ( <> )}
); } window.TopBar = TopBar; // ---------- URL form (idle hero) ---------- function HeroForm({ onSubmit, interpret, setInterpret, captureComments, setCaptureComments, captureEmotionCategorical, setCaptureEmotionCategorical, captureEmotionDimensional, setCaptureEmotionDimensional, captureSounds, setCaptureSounds, defaultUrl, history, onLoadFromHistory, }) { const [url, setUrl] = React.useState(defaultUrl); const fileRef = React.useRef(null); return (
v2.4 · local-first transcription

Speaker-labeled transcripts from YouTube, Instagram and Facebook.

Paste a URL or upload a file. Get a clean transcript with speaker diarization, voiceprint matching, and optional scene descriptions — all running on the Pi in your closet.

{ e.preventDefault(); if (url.trim()) onSubmit(url.trim()); }} > setUrl(e.target.value)} placeholder="Paste a YouTube, Instagram or Facebook URL…" spellCheck={false} /> { const f = e.target.files[0]; e.target.value = ""; if (f) onSubmit(f); }} />
); } window.HeroForm = HeroForm; // ---------- Running card (in-progress hero) ---------- // 0.18.0: `facebook` is true for a full-mode Facebook job (its strip starts // at fb_post). `batch` is /status's {done, total} or null; during // fb_comments those are the captured and reported comment counts. function RunningCard({ url, phaseIdx, subIdx, phases, elapsed, gpu, onCancel, interpret, facebook, batch }) { const fmt = window.fmtTime; const totalPhases = phases.length; const cur = phases[phaseIdx]; const commentProgress = cur?.id === "fb_comments" && batch ? (batch.total > 0 ? `${batch.done.toLocaleString()} of about ${batch.total.toLocaleString()} comments` : `${batch.done.toLocaleString()} comments`) : null; return (
RUNNING {fmt(elapsed)} · {url}

{cur?.label || "Starting…"} step {phaseIdx + 1} of {totalPhases}

{commentProgress || cur?.sub?.[subIdx] || cur?.sub?.[0] || "…"}
{gpu && (
GPU
{gpu}
)}
{phases.map((p, i) => { const state = i < phaseIdx ? "done" : i === phaseIdx ? "active" : "pending"; return (
{state === "done" ? : state === "active" ? : {i+1}}
{p.label}
{state === "active" && (
)}
{i < phases.length - 1 && (
)} ); })}
{facebook ? ( <> Facebook capture · 3 to 6 s between requests ) : ( <> {interpret ? "Interpreting video content" : "Audio-only mode"} · whisper-large-v3 · pyannote-3.1{interpret ? " · qwen-vl" : ""} )}
); } window.RunningCard = RunningCard; // ---------- Done hero ---------- // 0.10.2: thumbnail now shows the real YouTube poster frame when the source // is a YouTube video, and the whole panel links out to the source. For // non-YouTube sources (e.g. Facebook Reels) we fall back to the original // glow-art placeholder but still make it clickable when a URL exists. // // 0.18.0: `video.postOnly` is a Facebook post with no video. Its hero says // "Post captured" (or "Partial capture" when the comment walk was cut short), // labels the art "Facebook" and shows the author, the posted date and the // captured comment count; duration, speakers, scenes, GPU peak and the play // overlay are left out. `post` is result.post; `commentCount` is how many // comments were captured, or null when they were not. function DoneHero({ video, meta, speakers, accent, onSaveSpeaker, post, commentCount }) { const postOnly = !!video.postOnly; const partial = postOnly && !!post && post.comments_complete === false; // YouTube video IDs are 11 chars of [A-Za-z0-9_-]. Use that to tell // YouTube (use ytimg poster) from anything else (fall back to glow art). const ytId = video.videoId && /^[A-Za-z0-9_-]{11}$/.test(video.videoId) ? video.videoId : null; const ytIsLikely = ytId && ( !video.sourceUrl || /youtube\.com|youtu\.be/.test(video.sourceUrl) ); const thumbUrl = ytIsLikely ? `https://i.ytimg.com/vi/${ytId}/hqdefault.jpg` : null; // Local uploads carry url "file:": nothing to link out to. const isLocal = /^file:/.test(video.sourceUrl || ""); const linkUrl = isLocal ? null : (video.sourceUrl || (ytId ? `https://www.youtube.com/watch?v=${ytId}` : null)); const thumbInner = thumbUrl ? (
YouTube {video.duration}
) : (
{postOnly ? "Facebook" : ytIsLikely ? "YouTube" : isLocal ? "Local file" : "Source"} {!postOnly && {video.duration}} {!postOnly &&
}
); return (
{linkUrl ? ( {thumbInner} ) : ( thumbInner )}
{postOnly ? ( {partial ? : } {partial ? "Partial capture" : "Post captured"} ) : ( Transcription complete )} {postOnly ? ( in {meta.elapsedTotal} ) : ( in {meta.elapsedTotal} · peak {meta.gpuPeak} )}

{video.title}

{video.channel} {!postOnly && ( <> · {video.duration} · {speakers.length} speakers · {meta.scenesDescribed} scenes )} {video.uploaded && ( <> · Posted {video.uploaded} )} {postOnly && commentCount != null && ( <> · {commentCount.toLocaleString()} {commentCount === 1 ? "comment" : "comments"} captured )} {!postOnly && video.transcribed && ( <> · Transcribed {video.transcribed} )}
{!postOnly && (
{speakers.map((sp) => ( onSaveSpeaker && onSaveSpeaker(sp)}/> ))}
)}
); } window.DoneHero = DoneHero; function SpeakerChipBar({ speaker, onSave }) { const sp = speaker; const color = window.speakerColor(sp); return (
{sp.display} {sp.lines} turns · {window.fmtTime(sp.durationSec)} {sp.matched ? ( voiceprint ) : ( )}
); } // ---------- Error banner ---------- // 0.18.0: `title` is optional; Facebook jobs pass "Facebook capture failed" // and the server's message is shown exactly as sent. function ErrorBanner({ message, onRetry, onDismiss, title }) { return (
{title || "Transcription failed"}
{message}
); } window.ErrorBanner = ErrorBanner; // ---------- Transcript toolbar ---------- function TranscriptToolbar({ wordCount, turnCount, sceneCount, showTs, setShowTs, showScenes, setShowScenes, showEmotion, setShowEmotion, query, setQuery, accent }) { return (
TRANSCRIPT {turnCount} turns · {sceneCount} scenes · ~{wordCount} words
setQuery(e.target.value)} placeholder="Search transcript" style={{ "--accent": accent }} /> {query && ( )}
); } window.TranscriptToolbar = TranscriptToolbar; // ---------- Speech turn ---------- // 0.17.0: `id` is optional and lets the meeting-notes panel scroll to the // turn nearest an action item's timestamp (reader-app's jumpToSegment). function SpeechTurn({ seg, speaker, showTs, showEmotion, query, accent, showAvatar, onRename, id }) { const color = window.speakerColor(speaker); const initials = speaker.display.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase(); const hasEmotion = !!(seg.emotionCategorical || seg.emotionDimensional); return (
{showAvatar && (
{initials}
)}
{speaker.display} {speaker.matched ? ( ) : ( {speaker.raw} )}
{showEmotion && hasEmotion && } {showTs && {window.fmtTime(seg.start)}}

); } window.SpeechTurn = SpeechTurn; // ---------- EmotionChip ---------- // Compact per-turn badge for SER output. Categorical comes from // superb/wav2vec2-base-superb-er (4-class). Dimensional comes from the // audeering MSP-Podcast model — values are 0-1 floats. We render both // when present, side by side, to keep the row scannable. function EmotionChip({ seg }) { const ec = seg.emotionCategorical; const ed = seg.emotionDimensional; return ( {ec && ( {window.emotionLabel(ec.label)} {Math.round((ec.confidence || 0) * 100)}% )} {ec && ed && ·} {ed && ( A{ed.arousal.toFixed(2)} V{ed.valence.toFixed(2)} D{ed.dominance.toFixed(2)} )} ); } window.EmotionChip = EmotionChip; // ---------- Scene block ---------- function SceneBlock({ seg, showTs, query, accent }) { return (
scene {showTs && {window.fmtTime(seg.start)}–{window.fmtTime(seg.end)}}

); } window.SceneBlock = SceneBlock; // ---------- Highlight helper ---------- function Highlight({ text, query, accent }) { if (!query || query.length < 2) return text; const lower = text.toLowerCase(); const q = query.toLowerCase(); const parts = []; let i = 0; let idx; while ((idx = lower.indexOf(q, i)) !== -1) { if (idx > i) parts.push(text.slice(i, idx)); parts.push( {text.slice(idx, idx + q.length)} ); i = idx + q.length; } if (i < text.length) parts.push(text.slice(i)); return parts; } // ---------- Meeting notes panel (0.17.0) ---------- // Renders result.meeting above the transcript: brain badge, the synthesis // error if the model step failed, the summary as paragraphs, then the // action-item and follow-up lists. Each item's [mm:ss] button calls onJump // with item.start; reader-app resolves that to the nearest transcript turn. // Timestamps are shown zero-padded because that is the form the model was // shown and cited ("[02:15]"), unlike the transcript's own m:ss. function fmtMmSs(s) { const m = String(Math.floor(s / 60)).padStart(2, "0"); const ss = String(Math.floor(s % 60)).padStart(2, "0"); return `${m}:${ss}`; } function MeetingNotesPanel({ meeting, onJump }) { if (!meeting) return null; const brain = meeting.brain === "claude" ? "claude" : "local"; const paragraphs = (meeting.summary || "") .split(/\n\s*\n/) .map((p) => p.trim()) .filter(Boolean); const actions = meeting.action_items || []; const followups = meeting.followups || []; return (
MEETING NOTES {brain === "claude" ? "Claude" : "local"}
{meeting.error && (
{meeting.error}
)} {paragraphs.length > 0 && (
{paragraphs.map((p, i) =>

{p}

)}
)} {actions.length > 0 && } {followups.length > 0 && }
); } window.MeetingNotesPanel = MeetingNotesPanel; function NotesList({ title, items, onJump }) { return (

{title}

    {items.map((it, i) => (
  • {it.text} {(it.owner || it.due) && ( {it.owner || ""} {it.owner && it.due ? " · " : ""} {it.due ? `due ${it.due}` : ""} )} {it.start != null && ( )} {!it.verified && ( unverified )}
    {it.quote &&
    “{it.quote}”
    }
  • ))}
); } // ---------- Facebook post panel (0.18.0) ---------- // Renders result.post above the transcript: the author and exact time, the // text, attachments (a link target is shown as text, never fetched and never // made a link), the reaction, comment and share counts, and every capture // note as a warning line. "Open on Facebook" is the one link: https only, // sent with no referrer. `comments` is the result's list, or null when // comments were not captured, and gives the captured count. // 0.19.0: an attachment with `media` shows its picture (FbMedia) with // Facebook's alt text and the picture's page, so its caption and address, // the same text, are not repeated. function PostPanel({ post, comments }) { if (!post) return null; const num = (v) => Number(v).toLocaleString("en-US"); const plural = (v, word) => `${num(v)} ${word}${v === 1 ? "" : "s"}`; const posted = window.fmtExactTime(post.created_at); const attachments = post.attachments || []; const notes = post.notes || []; const breakdown = Object.entries(post.reactions || {}) .map(([label, count]) => `${label} ${num(count)}`) .join(" · "); const reactionLine = post.reaction_count != null ? plural(post.reaction_count, "reaction") + (breakdown ? ` (${breakdown})` : "") : breakdown; const captured = Array.isArray(comments) ? comments.length : null; const commentBits = []; if (post.comment_count != null) commentBits.push(`${plural(post.comment_count, "comment")} reported`); if (captured != null) { commentBits.push(post.comment_count != null ? `${num(captured)} captured` : `${plural(captured, "comment")} captured`); } const completeness = post.comments_complete === true ? "Complete" : post.comments_complete === false ? "Partial" : null; const openUrl = /^https:\/\//i.test(post.url || "") ? post.url : null; return (
FACEBOOK POST {post.author && {post.author}} {posted && {posted}}
{post.text &&

{post.text}

} {attachments.length > 0 && (
    {attachments.map((a, i) => (
  • {a.kind} {a.caption && !(a.media && a.media.alt_text === a.caption) && {a.caption}} {a.url && !(a.media && a.media.url === a.url) && {a.url}} {a.media && }
  • ))}
)} {(reactionLine || commentBits.length > 0 || post.share_count != null) && (
{reactionLine &&
{reactionLine}
} {commentBits.length > 0 && (
{commentBits.join(" · ")} {completeness && ( {completeness} )} {post.comment_sort && {post.comment_sort}}
)} {post.share_count != null &&
{plural(post.share_count, "share")}
}
)} {openUrl && ( Open on Facebook )} {notes.map((note, i) => (
{note}
))}
); } window.PostPanel = PostPanel; // ---------- Facebook media (0.19.0) ---------- // A picture a Facebook capture saved and read (schema.FacebookMedia). The // page loads it only from the owner-only /facebook/media route, by the // post's id and the file's name, and clicking it opens it in a new tab. // Beside it: Facebook's alt text, the local model's one-line description, // and the text read from the picture under a collapsed "Text in image", // left out when there is none. A picture that was not saved or not read // says why in a muted line. A link title, site and address show whatever // the kind (a GIF's site, a photo's page), the address as plain text, // never fetched and never a link; a shared link or a Facebook video (no // picture in 0.19.0) is a small card. Nothing renders when there is // nothing to show. `variant` is "post" (up to the panel's width) or // "comment" (a thumbnail beside the text). function fbMediaSrc(postId, media) { if (!postId || !media.local_path) return null; return `/facebook/media/${encodeURIComponent(postId)}/${encodeURIComponent(media.local_path)}`; } function FbMedia({ media, postId, variant }) { const m = media; const src = fbMediaSrc(postId, m); if (!(src || m.link_title || m.link_source || m.url || m.alt_text || m.description || m.ocr_text || m.error)) { return null; } const card = m.kind === "link" || m.kind === "video"; return (
{src && ( {m.alt_text )}
{m.link_title &&
{m.link_title}
} {m.link_source &&
{m.link_source}
} {m.url &&
{m.url}
} {m.alt_text && (
Facebook alt text{m.alt_text}
)} {m.description && (
Description{m.description}
)} {m.ocr_text && (
Text in image
{m.ocr_text}
)} {m.error &&
{m.error}
}
); } window.FbMedia = FbMedia; // ---------- Sticky footer ---------- // 0.18.0: a post-only Facebook result has no timed segments, so it offers // no .srt and its copy button reads "Copy post". // 0.19.0: `hasZip` is a Facebook capture that saved pictures (post.media_dir // set); /download/zip packs its .json with them. function StickyFooter({ onCopy, copied, accent, onDownload, hasNotes, postOnly, hasZip }) { return (
EXPORT {(postOnly ? ["txt", "json"] : ["txt", "json", "srt"]).map((ext) => ( ))} {hasZip && ( )} {/* 0.17.0: /download/notes exists only when the result carries meeting notes. */} {hasNotes && ( )}
); } window.StickyFooter = StickyFooter; // ---------- Voiceprints view ---------- function VoiceprintsView({ voices, onBack, onDelete }) { return (

Voiceprints

{voices.length} voices enrolled ·{" "} {voices.reduce((a, v) => a + v.samples.length, 0)} samples

{voices.length === 0 ? (

No voiceprints enrolled yet.

Run a transcription, then click Save voice on a speaker to enroll them.

) : (
{voices.map((v) => (
{v.name.split(" ").map(w => w[0]).join("").slice(0,2).toUpperCase()}

{v.name}

{v.samples.length} sample{v.samples.length === 1 ? "" : "s"} ·{" "} {(v.samples.reduce((a, s) => a + s.duration, 0) / 60).toFixed(1)} min
    {v.samples.map((s) => ( onDelete(v.name, s.id)} /> ))}
))}
)}
); } window.VoiceprintsView = VoiceprintsView; // ---------- VoiceSampleRow ---------- // Click the row to reveal the source jump-link and the transcript excerpt // captured at enrollment time. The delete button stops propagation so it // doesn't toggle expansion when clicked. // // IMPORTANT: ``.vp-sample`` in reader.css is a CSS grid with named child // classes (.vp-sample-time / .vp-sample-src / etc.) on specific columns. // We render those children as DIRECT children of the
  • , with the // optional detail panel as a final sibling that spans the whole row via // ``gridColumn: '1 / -1'``. Wrapping the row in an extra
    would // break the grid. function VoiceSampleRow({ sample, onDelete }) { const [expanded, setExpanded] = React.useState(false); const hasDetail = sample.excerpt || sample.jumpUrl; const handleRowClick = () => { if (hasDetail) setExpanded(!expanded); }; return (
  • {hasDetail && ( ▸ )} {sample.created} {sample.source} {sample.duration.toFixed(1)}s {expanded && (
    {sample.jumpUrl && ( e.stopPropagation()} style={{ color: "var(--status-active)", textDecoration: "none", fontSize: 12, display: "inline-flex", alignItems: "center", gap: 6, width: "fit-content", }} > source{sample.startTimeFormatted ? ` @ ${sample.startTimeFormatted}` : ""} )} {sample.excerpt && (
    e.stopPropagation()} style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: "var(--text-secondary)", maxHeight: "10rem", overflowY: "auto", whiteSpace: "pre-wrap", wordBreak: "break-word", }} > {sample.excerpt}
    )}
    )}
  • ); } window.VoiceSampleRow = VoiceSampleRow;