/* 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 }) => (
);
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 }) => (
);
// ─────────────────────────────────────────────────────────────────────────────
// 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)}
Pipeline
{JOB_STEPS.map((label, i) => {
const s = stepStatus(i);
const isLast = i === JOB_STEPS.length - 1;
return (
-
{s === 'done' ?
: s === 'active' ?
: }
{!isLast &&
}
{label}
{s === 'active' && (
{Math.round(currentProgress)}% · polling every 3s
)}
{s === 'done' && i < 3 && (
complete
)}
);
})}
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.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 */}
{[['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();