/* global React, ReactDOM */ const { useState, useEffect, useRef, useMemo, useCallback } = React; const API_BASE = (window.__LECTERN_API_BASE__ || '').replace(/\/$/, ''); const apiUrl = (path) => `${API_BASE}${path}`; const readEnvelope = async (response) => { const payload = await response.json().catch(() => null); if (!response.ok) { const message = payload?.error || `Request failed (${response.status})`; throw new Error(message); } return payload?.data ?? payload; }; const mapApiMedia = (item) => ({ id: item.id, title: item.title || item.filename, filename: item.filename, status: item.status || 'pending', duration_s: Math.round(item.duration_seconds || 0), org: item.org_id || ORGS[0], created_at: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10), job_id: item.job_id || '', error_message: item.error_message || '', transcript_s3_key: item.transcript_s3_key || '', }); // ───────────────────────────────────────────────────────────────────────────── // Icons (stroke 1.5, no flourish) // ───────────────────────────────────────────────────────────────────────────── const Svg = ({ children, size = 16, ...rest }) => ( {children} ); const Icon = { Search: (p) => , Upload: (p) => , X: (p) => , Check: (p) => , Doc: (p) => , ChevL: (p) => , ChevR: (p) => , ChevD: (p) => , Filter: (p) => , Refresh:(p) => , Trash: (p) => , Cross: (p) => , }; // ───────────────────────────────────────────────────────────────────────────── // Mock data // ───────────────────────────────────────────────────────────────────────────── const ORGS = [ 'Diocese of Trier', 'Pontifical North American College', 'Holy Cross Abbey', 'St. Meinrad Archabbey', 'Diocese of Charleston', 'Mount Angel Seminary', ]; const UPLOAD_ACCEPT = 'audio/*,video/mp4,video/webm,.mp3,.wav,.m4a,.flac,.ogg,.opus,.amr,.mp4,.webm'; const UPLOAD_HINT = 'MP3 · WAV · M4A · FLAC · OGG/Opus · AMR · MP4 video · WebM video · up to 2 GB'; const MEDIA_SEED = [ ['Homily — Solemnity of the Assumption', 'assumption-2026-08-15.mp3', 'ready', 2241, '2026-04-22'], ['Conference: The Spirituality of the Diaconate', 'deaconate-conf-pt2.wav', 'transcribing', 3540, '2026-05-08'], ['Vespers Sermon — Feast of St. Augustine', 'vespers-augustine.mp3', 'ready', 1420, '2026-04-19'], ['Catechesis on the Eucharist, Part III', 'eucharist-cat-03.mp3', 'indexing', 2810, '2026-05-07'], ['Lectio Divina in the Patristic Tradition', 'lectio-patristic.wav', 'ready', 4120, '2026-04-15'], ['Homily — Christ the King', 'christ-king-2025.mp3', 'ready', 1380, '2026-04-12'], ['Retreat Address: Silence and the Soul', 'retreat-silence.m4a', 'ready', 2900, '2026-04-10'], ['Conference on Sacred Liturgy', 'sacred-liturgy-01.mp3', 'failed', 0, '2026-05-06'], ['Homily — Good Friday Service', 'good-friday-2026.mp3', 'ready', 1820, '2026-04-08'], ['Conference: The Theology of the Body', 'theology-body-04.mp3', 'ready', 3350, '2026-04-04'], ['Homily — Easter Vigil', 'easter-vigil-2026.wav', 'ready', 2640, '2026-04-04'], ['Address to Seminarians on Vocation', 'vocation-address.mp3', 'pending', 0, '2026-05-09'], ['Conference: Marian Devotion in the East', 'marian-east.wav', 'ready', 2980, '2026-03-28'], ['Homily — Feast of Corpus Christi', 'corpus-christi.mp3', 'ready', 1560, '2026-03-22'], ['Catechesis on the Sacrament of Confession', 'confession-cat.mp3', 'uploading', 0, '2026-05-09'], ['Conference: Patristic Exegesis of Genesis', 'genesis-exegesis.wav', 'ready', 4250, '2026-03-15'], ['Holy Hour Reflection', 'holy-hour-reflection.mp3', 'ready', 2050, '2026-03-10'], ['Address — Convocation of Priests', 'convocation-priests.m4a', 'ready', 2730, '2026-03-04'], ['Homily — Ash Wednesday', 'ash-wednesday.mp3', 'ready', 1290, '2026-02-18'], ['Conference: The Catholic Imagination', 'catholic-imagination.wav', 'ready', 3680, '2026-02-12'], ]; let _id = 1000; const makeMedia = () => MEDIA_SEED.map(([title, filename, status, dur, date], i) => ({ id: `med_${++_id}`, title, filename, status, duration_s: dur, org: ORGS[i % ORGS.length], created_at: date, })); // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── const fmtDur = (s) => { if (!s) return '—'; const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(ss).padStart(2, '0')}` : `${m}:${String(ss).padStart(2, '0')}`; }; const fmtDate = (iso) => new Date(iso + 'T12:00:00').toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); const STATUS_LABELS = { pending: 'Pending', uploading: 'Uploading', transcribing: 'Transcribing', indexing: 'Indexing', ready: 'Ready', failed: 'Failed', }; const STATUS_VARS = { pending: { fg: 'var(--st-pending-fg)', bg: 'var(--st-pending-bg)', bd: 'var(--st-pending-bd)' }, uploading: { fg: 'var(--st-uploading-fg)', bg: 'var(--st-uploading-bg)', bd: 'var(--st-uploading-bd)' }, transcribing: { fg: 'var(--st-progress-fg)', bg: 'var(--st-progress-bg)', bd: 'var(--st-progress-bd)' }, indexing: { fg: 'var(--st-progress-fg)', bg: 'var(--st-progress-bg)', bd: 'var(--st-progress-bd)' }, ready: { fg: 'var(--st-ready-fg)', bg: 'var(--st-ready-bg)', bd: 'var(--st-ready-bd)' }, failed: { fg: 'var(--st-failed-fg)', bg: 'var(--st-failed-bg)', bd: 'var(--st-failed-bd)' }, }; const StatusBadge = ({ status }) => { const v = STATUS_VARS[status] || STATUS_VARS.pending; return ( {STATUS_LABELS[status] || status} ); }; // ───────────────────────────────────────────────────────────────────────────── // Header // ───────────────────────────────────────────────────────────────────────────── const Header = ({ org, onOrgChange }) => (
Lectern Vox · Scriptum · Memoria
R
); // ───────────────────────────────────────────────────────────────────────────── // Upload panel (drag-drop + file picker + title + org + simulated PUT progress) // ───────────────────────────────────────────────────────────────────────────── const UploadPanel = ({ onUploadComplete, currentOrg }) => { const [file, setFile] = useState(null); const [title, setTitle] = useState(''); const [org, setOrg] = useState(currentOrg); const [phase, setPhase] = useState('idle'); // idle | uploading | done const [pct, setPct] = useState(0); const [drag, setDrag] = useState(false); const [error, setError] = useState(''); const [durationSeconds, setDurationSeconds] = useState(0); const inputRef = useRef(null); useEffect(() => setOrg(currentOrg), [currentOrg]); const onPick = (f) => { if (!f) return; setFile(f); setError(''); setDurationSeconds(0); if (!title) setTitle(f.name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ')); const media = document.createElement(f.type.startsWith('video/') ? 'video' : 'audio'); const src = URL.createObjectURL(f); media.preload = 'metadata'; media.onloadedmetadata = () => { URL.revokeObjectURL(src); if (Number.isFinite(media.duration)) { setDurationSeconds(Math.round(media.duration)); } }; media.onerror = () => URL.revokeObjectURL(src); media.src = src; }; const onDrop = (e) => { e.preventDefault(); setDrag(false); const f = e.dataTransfer.files?.[0]; onPick(f); }; const startUpload = async () => { if (!file || !title) return; setPhase('uploading'); setPct(8); setError(''); let p = 8; const tick = setInterval(() => { p = Math.min(92, p + Math.random() * 10 + 4); setPct(p); }, 220); try { const initResp = await fetch(apiUrl('/api/uploads/init'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.name, content_type: file.type || 'application/octet-stream', title, file_size_bytes: file.size, duration_seconds: durationSeconds, org_id: org, }), }); const initData = await readEnvelope(initResp); const finalizeBody = {}; if (initData.strategy === 'multipart') { const completedParts = []; let uploadedBytes = 0; for (const part of initData.parts || []) { const start = (part.part_number - 1) * initData.part_size_bytes; const end = Math.min(start + initData.part_size_bytes, file.size); const chunk = file.slice(start, end); const putResp = await uploadPartWithRetry(part.upload_url, chunk, file.type || 'application/octet-stream'); const etag = putResp.headers.get('etag'); if (!etag) { throw new Error(`Multipart upload missing ETag for part ${part.part_number}`); } completedParts.push({ part_number: part.part_number, etag }); uploadedBytes += chunk.size; setPct(Math.max(8, Math.min(99, (uploadedBytes / file.size) * 100))); } finalizeBody.upload_id = initData.upload_id; finalizeBody.parts = completedParts; } else { const putResp = await fetch(initData.upload_url, { method: 'PUT', headers: { 'Content-Type': file.type || 'application/octet-stream' }, body: file, }); if (!putResp.ok) { throw new Error(`S3 upload failed (${putResp.status})`); } } setPct(100); const finalizeResp = await fetch(apiUrl(`/api/media/${initData.media_id}/finalize`), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(finalizeBody), }); const finalizeData = await readEnvelope(finalizeResp); const media = { id: finalizeData.media_id || initData.media_id, title, filename: file.name, status: 'transcribing', duration_s: durationSeconds, org, created_at: new Date().toISOString().slice(0, 10), job_id: finalizeData.job_id || '', error_message: '', transcript_s3_key: '', }; onUploadComplete(media); setFile(null); setTitle(''); setPhase('idle'); setPct(0); } catch (err) { setError(err.message || 'Upload failed'); setPhase('idle'); setPct(0); } finally { clearInterval(tick); } }; const uploadPartWithRetry = async (url, chunk, contentType) => { let lastErr = null; for (let attempt = 1; attempt <= 3; attempt += 1) { try { const resp = await fetch(url, { method: 'PUT', headers: { 'Content-Type': contentType }, body: chunk, }); if (!resp.ok) { throw new Error(`S3 upload failed (${resp.status})`); } return resp; } catch (err) { lastErr = err; if (attempt < 3) { await new Promise((resolve) => setTimeout(resolve, attempt * 750)); } } } throw lastErr || new Error('Multipart upload failed'); }; return (
Acquisition

Upload Audio or Video

Audio and supported video files are uploaded directly to S3, then queued for transcription and indexing.

{ e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)} onDrop={onDrop} onClick={() => phase === 'idle' && inputRef.current?.click()} style={{ padding: '32px 20px', textAlign: 'center', cursor: phase === 'idle' ? 'pointer' : 'default' }}> onPick(e.target.files?.[0])} /> {!file ? ( <>
Drag a file here, or browse
{UPLOAD_HINT}
) : (
{file.name}
{(file.size / 1024 / 1024).toFixed(1)} MB
{phase === 'idle' && ( )}
)}
setTitle(e.target.value)} disabled={phase !== 'idle'} />
{phase !== 'idle' && (
{phase === 'done' ? 'Complete' : 'Uploading to S3'} {Math.round(pct)}%
)} {error && (
{error}
)}
); }; // ───────────────────────────────────────────────────────────────────────────── // Job status panel — polls every 3s, shows progress steps // ───────────────────────────────────────────────────────────────────────────── const JOB_STEPS = ['Uploaded', 'Transcribing', 'Indexing', 'Ready']; const JobPanel = ({ media, onClose, onRetry }) => { const [liveJob, setLiveJob] = useState(null); const [liveError, setLiveError] = useState(''); // step index simulated based on status const [step, setStep] = useState(() => { if (media.status === 'pending' || media.status === 'uploading') return 0; if (media.status === 'transcribing') return 1; if (media.status === 'indexing') return 2; if (media.status === 'ready' || media.status === 'failed') return 3; return 0; }); const [progress, setProgress] = useState(media.status === 'ready' ? 100 : media.status === 'failed' ? 100 : 32); const [log, setLog] = useState([ [`+0s`, `media accepted: ${media.id}`], [`+0s`, `s3 PUT complete (${media.filename})`], [`+1s`, `enqueued for transcription`], ]); const [tick, setTick] = useState(0); const isLiveJob = Boolean(media.job_id); useEffect(() => { if (isLiveJob) return; if (media.status === 'ready' || media.status === 'failed') return; const id = setInterval(() => setTick((t) => t + 1), 3000); return () => clearInterval(id); }, [media.status, isLiveJob]); useEffect(() => { if (!isLiveJob) return; let active = true; const poll = async () => { try { const resp = await fetch(apiUrl(`/api/jobs/${media.job_id}`)); const data = await readEnvelope(resp); if (!active) return; setLiveJob(data); setLiveError(''); } catch (err) { if (!active) return; setLiveError(err.message || 'Unable to load job status'); } }; poll(); const id = setInterval(poll, 3000); return () => { active = false; clearInterval(id); }; }, [isLiveJob, media.job_id]); useEffect(() => { if (isLiveJob) return; if (tick === 0) return; if (media.status === 'ready' || media.status === 'failed') return; setProgress((p) => { const next = p + Math.random() * 14 + 6; if (next >= 100) { if (step < 3) { setStep(step + 1); setLog((l) => [...l, [`+${tick * 3}s`, step === 0 ? 'transcript ready · 4,182 segments' : step === 1 ? 'chunks indexed · MySQL FULLTEXT' : 'media marked ready']]); return 0; } return 100; } return next; }); }, [tick]); // eslint-disable-line const liveStatus = liveJob?.status || 'queued'; const currentStep = isLiveJob ? (liveStatus === 'completed' ? 3 : liveStatus === 'failed' ? 3 : 1) : step; const currentProgress = isLiveJob ? (liveStatus === 'completed' ? 100 : liveStatus === 'processing' ? 70 : 30) : progress; const stepStatus = (i) => { if (i < currentStep) return 'done'; if (i === currentStep && currentStep < 3) return 'active'; if (currentStep >= 3 && i === 3) return 'done'; return 'pending'; }; return (
Processing Job

{media.title}

{media.id} · {media.filename}
Duration
{fmtDur(media.duration_s)}
Created
{fmtDate(media.created_at)}
Organisation
{media.org}
Status
Pipeline
    {JOB_STEPS.map((label, i) => { const s = stepStatus(i); const isLast = i === JOB_STEPS.length - 1; return (
  1. {s === 'done' ? : s === 'active' ? : }
    {!isLast &&
    }
    {label}
    {s === 'active' && (
    {Math.round(currentProgress)}% · polling every 3s
    )} {s === 'done' && i < 3 && (
    complete
    )}
  2. ); })}
Event log
{isLiveJob ? ( <>
+0s   media accepted: {media.id}
+0s   upload finalized, job {media.job_id}
+3s   job status: {liveStatus}
{liveError &&
! {liveError}
} {media.error_message &&
! {media.error_message}
} ) : ( log.map(([t, msg], i) => (
{t}   {msg}
)) )}
{(['failed', 'transcribing', 'indexing', 'uploading', 'pending'].includes(media.status)) && (
)}
); }; // ───────────────────────────────────────────────────────────────────────────── // Library table with status filter + pagination // ───────────────────────────────────────────────────────────────────────────── const STATUS_FILTERS = ['all', 'pending', 'uploading', 'transcribing', 'indexing', 'ready', 'failed']; const PAGE_SIZE = 8; const Library = ({ media, selectedId, onSelect }) => { const [filter, setFilter] = useState('all'); const [query, setQuery] = useState(''); const [page, setPage] = useState(1); const filtered = useMemo(() => { let m = media; if (filter !== 'all') m = m.filter(x => x.status === filter); if (query) { const q = query.toLowerCase(); m = m.filter(x => x.title.toLowerCase().includes(q) || x.filename.toLowerCase().includes(q)); } return m; }, [media, filter, query]); useEffect(() => { setPage(1); }, [filter, query]); const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const slice = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); return (
{/* Filter bar */}
{STATUS_FILTERS.map((s, i) => ( ))}
setQuery(e.target.value)} style={{ paddingLeft: 32 }} />
{/* Table */}
{['Title', 'Filename', 'Duration', 'Status', 'Created', ''].map((h, i) => (
{h}
))}
{slice.length === 0 ? (
No records match this filter.
) : slice.map((m) => (
onSelect(m)} className={`row-hover ${selectedId === m.id ? 'active' : ''}`} style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.7fr) minmax(0, 1fr) 110px 130px 130px 28px', gap: 20, padding: '16px 24px', borderBottom: '1px solid var(--navy-04)', alignItems: 'center', borderLeft: selectedId === m.id ? '2px solid var(--gold)' : '2px solid transparent', paddingLeft: selectedId === m.id ? 22 : 24, }}>
{m.title}
{m.org}
{m.filename}
{fmtDur(m.duration_s)}
{fmtDate(m.created_at)}
))}
{/* Pagination */}
Showing {filtered.length === 0 ? 0 : (page - 1) * PAGE_SIZE + 1}– {Math.min(page * PAGE_SIZE, filtered.length)} of {filtered.length}
{Array.from({ length: pageCount }, (_, i) => i + 1).map((p) => ( ))}
); }; // ───────────────────────────────────────────────────────────────────────────── // App // ───────────────────────────────────────────────────────────────────────────── const App = () => { const [media, setMedia] = useState(makeMedia); const [selected, setSel] = useState(null); const [org, setOrg] = useState(ORGS[0]); const loadMedia = useCallback(async (activeRef) => { try { const resp = await fetch(apiUrl('/api/media')); const data = await readEnvelope(resp); if (activeRef && !activeRef.current) return; const items = Array.isArray(data) ? data : []; if (items.length > 0) { setMedia(items.map(mapApiMedia)); } else { setMedia(makeMedia()); } } catch { if (!activeRef || activeRef.current) setMedia(makeMedia()); } }, []); useEffect(() => { const active = { current: true }; loadMedia(active); return () => { active.current = false; }; }, [loadMedia]); useEffect(() => { const hasInflight = media.some((m) => ['transcribing', 'indexing', 'uploading', 'pending'].includes(m.status)); if (!hasInflight) return; const active = { current: true }; const id = setInterval(() => { loadMedia(active); }, 3000); return () => { active.current = false; clearInterval(id); }; }, [loadMedia, media]); useEffect(() => { if (!selected) return; const next = media.find((m) => m.id === selected.id); if (!next || next === selected) return; const merged = next.job_id ? next : { ...next, job_id: selected.job_id }; if (merged.status !== selected.status || merged.job_id !== selected.job_id || merged.title !== selected.title) { setSel(merged); } }, [media, selected]); const onUploadComplete = (m) => { setMedia((prev) => [m, ...prev]); setSel(m); }; const onRetryMedia = async (mediaID) => { try { const resp = await fetch(apiUrl(`/api/media/${mediaID}/retry`), { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); const item = await readEnvelope(resp); const mapped = mapApiMedia(item); setMedia((prev) => prev.map((m) => (m.id === mediaID ? mapped : m))); setSel(mapped); } catch (err) { const message = err.message || 'Retry failed'; setMedia((prev) => prev.map((m) => (m.id === mediaID ? { ...m, error_message: message } : m))); if (selected?.id === mediaID) { setSel((prev) => prev ? { ...prev, error_message: message } : prev); } } }; const stats = useMemo(() => ({ total: media.length, ready: media.filter(m => m.status === 'ready').length, inflight: media.filter(m => ['transcribing', 'indexing', 'uploading', 'pending'].includes(m.status)).length, failed: media.filter(m => m.status === 'failed').length, }), [media]); return (
{/* Page header */}
Catalog

Media Library

{[['Total', stats.total], ['Ready', stats.ready], ['In flight', stats.inflight], ['Failed', stats.failed]].map(([l, n]) => (
{l}
{String(n).padStart(2, '0')}
))}
{/* Two-column layout */}
); }; ReactDOM.createRoot(document.getElementById('root')).render();