// Meetings page (0.17.0) — served by templates/meetings.html. // // Lists the recordings dropped into the meetings folder (GET /meetings/list), // takes the meeting-notes options, and POSTs to /transcribe with either // meeting_file= or a local upload, plus meeting_outputs, // meeting_brain and interpret_video. On 200 it hands off to "/" where the // home page's running view picks the job up from /status. 400/409 detail is // shown inline. Uses the shared icon set `I` from reader-views.jsx, which // meetings.html loads first. const { useState, useEffect, useRef } = React; // Same accept list as the home page's upload buttons. const MEETING_ACCEPT = "video/*,audio/*,.mkv,.webm,.m4a,.wav,.mp3,.mp4,.mov"; function fmtSizeMb(bytes) { return `${((bytes || 0) / 1048576).toFixed(1)} MB`; } function fmtModified(iso) { if (!iso) return ""; try { return new Date(iso).toLocaleString("en-US", { month: "numeric", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", hour12: true, }); } catch (err) { return ""; } } function MeetingsApp() { const [dir, setDir] = useState(""); const [files, setFiles] = useState([]); const [listError, setListError] = useState(null); const [loading, setLoading] = useState(true); // { kind: "meeting", name } for a folder recording, { kind: "file", file } // for an upload. Picking one clears the other. const [selected, setSelected] = useState(null); const [wantSummary, setWantSummary] = useState(true); const [wantActions, setWantActions] = useState(false); const [wantFollowups, setWantFollowups] = useState(false); const [brain, setBrain] = useState("local"); // local | claude const [interpret, setInterpret] = useState(false); const [busy, setBusy] = useState(false); const [runError, setRunError] = useState(null); const fileRef = useRef(null); const fetchList = async () => { setLoading(true); setListError(null); try { const resp = await fetch("/meetings/list"); if (!resp.ok) { setListError(`HTTP ${resp.status}`); return; } const body = await resp.json(); setDir(body.dir || ""); setFiles(body.files || []); } catch (err) { setListError(`Network error: ${err}`); } finally { setLoading(false); } }; useEffect(() => { fetchList(); }, []); const outputs = [ wantSummary && "summary", wantActions && "actions", wantFollowups && "followups", ].filter(Boolean); const canRun = !!selected && outputs.length > 0 && !busy; const run = async () => { if (!canRun) return; setBusy(true); setRunError(null); const fd = new FormData(); if (selected.kind === "file") fd.append("file", selected.file, selected.file.name); else fd.append("meeting_file", selected.name); fd.append("meeting_outputs", outputs.join(",")); fd.append("meeting_brain", brain); if (interpret) fd.append("interpret_video", "on"); let resp; try { resp = await fetch("/transcribe", { method: "POST", body: fd }); } catch (err) { setRunError(`Network error: ${err}`); setBusy(false); return; } if (!resp.ok) { const body = await resp.json().catch(() => ({})); setRunError(body.detail || (resp.status === 409 ? "A job is already running." : `HTTP ${resp.status}`)); setBusy(false); return; } // The home page's mount effect sees state "running" on /status and // restores the running card, phase strip and clock. window.location.assign("/"); }; const targetLabel = !selected ? "Pick a recording or upload a file" : selected.kind === "file" ? `upload · ${selected.file.name}` : selected.name; return (
Transcriptor
Recordings → summary, actions, follow-ups
meetings
v{window.APP_VERSION} · meeting notes

Meeting notes from a recording.

Pick a recording from the meetings folder or upload one. You get the speaker-labeled transcript plus a summary, action items and follow-ups, written by the local model or by Claude.

{runError && (
Couldn't start the job
{runError}
)}
{/* Recordings + upload */}
RECORDINGS {dir}
{listError ? (

Couldn't list recordings.

{listError}

) : loading && files.length === 0 ? (

Loading…

) : files.length === 0 ? (

No recordings yet.

Drop meeting recordings into {dir || "the meetings folder"} and refresh, or upload one below.

) : (
    {files.map((f) => { const isSel = selected && selected.kind === "meeting" && selected.name === f.name; return (
  • ); })}
)}
{ const f = e.target.files[0]; e.target.value = ""; if (f) setSelected({ kind: "file", file: f }); }} /> {selected && selected.kind === "file" && ( {selected.file.name} · {fmtSizeMb(selected.file.size)} )}
{/* Options + run */}
OUTPUTS
BRAIN
{brain === "claude" && (
Claude sends the transcript text to Anthropic's API
)} VIDEO
{targetLabel}
); } ReactDOM.createRoot(document.getElementById("root")).render();