Files
recap-relay/public/job-output-view.html

331 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Job output — Recap Relay</title>
<!--
Stand-alone render of a stored job's transcript + analysis JSON.
Loaded by the operator's dashboard "View" link on a Jobs row.
Visual style: mirror of Recap's results panel (two-pane —
topic list on the left, transcript on the right, click a topic
to jump to its timestamp range in the transcript). Sourced from
Recap's index.html .chunk + .transcript-line styling so changes
there stay aesthetically aligned here.
Data source: GET /admin/job-output/:id returns
{
job_id, batch_id, source, saved_at,
transcript: "[MM:SS] line\n[MM:SS] line...",
analysis: { sections: [{ title, summary, startIndex, endIndex }] } | null
analysis_raw_text: string | null // when JSON-parse failed
meta: { title, media_url, audio_seconds, ... }
}
-->
<style>
:root {
--bg: #0a0e1a;
--panel: #111827;
--panel-2: #1e293b;
--line: #1e293b;
--line-2: #334155;
--fg: #e2e8f0;
--fg-dim: #94a3b8;
--fg-faint: #64748b;
--accent: #818cf8;
--accent-soft: #a5b4fc;
--good: #4ade80;
--bad: #fca5a5;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 0;
background: var(--bg); color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 13px; line-height: 1.55;
min-height: 100vh;
}
a { color: var(--accent-soft); text-decoration: none; }
a:hover { text-decoration: underline; }
.header {
padding: 14px 24px;
background: var(--panel);
border-bottom: 1px solid var(--line);
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
}
.header h1 {
margin: 0; font-size: 16px; font-weight: 700;
color: var(--fg); max-width: 800px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.header .meta { font-size: 11px; color: var(--fg-faint); }
.header .meta strong { color: var(--fg-dim); }
.header .pill {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.04em;
background: rgba(129,140,248,0.18); color: var(--accent-soft);
}
.split { display: flex; min-height: calc(100vh - 60px); }
.left {
flex: 0 0 42%; max-width: 42%;
border-right: 1px solid var(--line);
overflow-y: auto;
padding: 16px;
background: var(--bg);
}
.right {
flex: 1; min-width: 0;
overflow-y: auto;
padding: 16px;
background: var(--panel);
}
@media (max-width: 900px) {
.split { flex-direction: column; }
.left, .right { flex: none; max-width: 100%; border-right: none; }
.left { border-bottom: 1px solid var(--line); max-height: 50vh; }
}
/* Topic / chunk card */
.chunk {
padding: 12px 14px; margin-bottom: 8px;
background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.chunk:hover { border-color: var(--accent); }
.chunk.active {
border-color: var(--accent);
background: rgba(129,140,248,0.06);
box-shadow: 0 2px 16px rgba(129,140,248,0.10);
}
.chunk-title {
font-size: 13px; font-weight: 700; color: var(--fg);
margin-bottom: 4px;
}
.chunk-time {
font-size: 10px; color: var(--fg-faint);
font-variant-numeric: tabular-nums; margin-left: 6px;
font-weight: 500;
}
.chunk-summary {
font-size: 12px; color: var(--fg-dim); line-height: 1.5;
}
/* Transcript pane */
.transcript-line {
display: flex; gap: 10px; padding: 4px 8px;
border-radius: 6px; line-height: 1.6;
scroll-margin-top: 80px;
}
.transcript-line.hl { background: rgba(129,140,248,0.10); }
.ts-badge {
flex: 0 0 auto;
font-family: "SF Mono", Menlo, monospace;
font-size: 11px; color: var(--accent-soft);
min-width: 56px;
}
.ts-text { flex: 1; font-size: 13px; color: var(--fg); }
.empty {
padding: 40px 20px; text-align: center;
color: var(--fg-faint); font-size: 13px;
}
.error {
padding: 20px; background: var(--panel);
border: 1px solid var(--bad); border-radius: 10px;
color: var(--bad); margin: 20px;
}
pre.raw {
background: var(--panel); padding: 12px;
border: 1px solid var(--line); border-radius: 8px;
overflow: auto; font-size: 11px; color: var(--fg-dim);
max-height: 300px; white-space: pre-wrap;
}
</style>
</head>
<body>
<div id="root">
<div class="empty">Loading job output…</div>
</div>
<script>
// Strip script tags + on-event attrs from any HTML the data
// accidentally contains. Transcript + analysis text comes from
// Gemini / Parakeet so it's unlikely to contain malicious HTML
// but escape it anyway — we're rendering server data in an
// admin context.
function esc(s) {
if (s == null) return "";
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// Parse "[MM:SS] text" or "[H:MM:SS] text" lines into entries
// with offset-seconds + text. Mirrors Recap's parser so the
// analysis startIndex/endIndex map onto the same entry indices.
function parseTimestampedTranscript(text) {
if (!text) return [];
const entries = [];
const re = /^\s*\[(\d+):(\d{2})(?::(\d{2}))?\]\s*(.*)$/;
for (const line of String(text).split(/\r?\n/)) {
const m = line.match(re);
if (!m) continue;
const hasHours = m[3] !== undefined;
const offset = hasHours
? parseInt(m[1], 10) * 3600 + parseInt(m[2], 10) * 60 + parseInt(m[3], 10)
: parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
entries.push({ offset, text: m[4].trim() });
}
return entries;
}
function formatTime(sec) {
sec = Math.max(0, Math.round(sec || 0));
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const pad = (n) => String(n).padStart(2, "0");
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
function getJobIdFromURL() {
const u = new URL(location.href);
return u.searchParams.get("id") || "";
}
async function load() {
const root = document.getElementById("root");
const jobId = getJobIdFromURL();
if (!jobId) {
root.innerHTML = '<div class="error">Missing ?id=&lt;job_id&gt; in URL.</div>';
return;
}
let data;
try {
const r = await fetch("/admin/job-output/" + encodeURIComponent(jobId));
if (r.status === 404) {
root.innerHTML = '<div class="error">No stored output for job <code>' + esc(jobId) + '</code>. The output may have been deleted, or this job ran before output-storage was enabled.</div>';
return;
}
if (!r.ok) throw new Error("HTTP " + r.status);
data = await r.json();
} catch (err) {
root.innerHTML = '<div class="error">Failed to load: ' + esc(err?.message || err) + '</div>';
return;
}
render(data);
}
function render(data) {
const root = document.getElementById("root");
const meta = data.meta || {};
const entries = parseTimestampedTranscript(data.transcript || "");
const sections = (data.analysis && Array.isArray(data.analysis.sections))
? data.analysis.sections
: null;
// Header block — title, source, models, batch link back to dashboard.
const sourceLabel = data.source === "admin-test"
? '<span class="pill">Test run</span>'
: (data.source === "admin-test-shared-tx" ? '<span class="pill">Shared TX</span>' : '');
const headerHTML =
'<div class="header">' +
'<h1>' + esc(meta.title || meta.media_url || data.job_id) + '</h1>' +
sourceLabel +
'<div class="meta">' +
(meta.media_url ? '<a href="' + esc(meta.media_url) + '" target="_blank" rel="noopener">source ↗</a> &middot; ' : '') +
(meta.audio_seconds ? '<strong>' + formatTime(meta.audio_seconds) + '</strong> audio &middot; ' : '') +
(meta.transcribe_backend ? 'TX: <strong>' + esc(meta.transcribe_model || meta.transcribe_backend) + '</strong> &middot; ' : '') +
(meta.analyze_backend ? 'AN: <strong>' + esc(meta.analyze_model || meta.analyze_backend) + '</strong>' : '') +
'</div>' +
'<div style="margin-left:auto;"><a href="/" title="Back to dashboard">← Dashboard</a></div>' +
'</div>';
// Empty states.
if (entries.length === 0) {
root.innerHTML = headerHTML +
'<div class="empty">No transcript text was saved for this job.</div>';
return;
}
// Left pane: topic list.
let leftHTML = '<div class="left" id="topics-pane">';
if (!sections || sections.length === 0) {
leftHTML += '<div class="empty">No analysis sections were saved for this job.';
if (data.analysis_raw_text) {
leftHTML += '<pre class="raw" style="text-align:left; margin-top: 12px;">' + esc(data.analysis_raw_text.slice(0, 4000)) + '</pre>';
}
leftHTML += '</div>';
} else {
sections.forEach((s, i) => {
const startIdx = Math.max(0, Math.min(s.startIndex || 0, entries.length - 1));
const startTs = entries[startIdx]?.offset || 0;
const endIdx = Math.max(startIdx, Math.min(s.endIndex || 0, entries.length - 1));
const endTs = entries[endIdx]?.offset || 0;
leftHTML +=
'<div class="chunk" data-section-idx="' + i + '" data-start="' + startIdx + '" onclick="onChunkClick(' + i + ')">' +
'<div class="chunk-title">' +
esc(s.title || "(untitled)") +
'<span class="chunk-time">' + formatTime(startTs) + ' — ' + formatTime(endTs) + '</span>' +
'</div>' +
'<div class="chunk-summary">' + esc(s.summary || "") + '</div>' +
'</div>';
});
}
leftHTML += '</div>';
// Right pane: transcript.
let rightHTML = '<div class="right" id="transcript-pane">';
entries.forEach((e, i) => {
rightHTML +=
'<div class="transcript-line" id="entry-' + i + '">' +
'<span class="ts-badge">' + formatTime(e.offset) + '</span>' +
'<span class="ts-text">' + esc(e.text) + '</span>' +
'</div>';
});
rightHTML += '</div>';
root.innerHTML = headerHTML + '<div class="split">' + leftHTML + rightHTML + '</div>';
// Expose for click handlers.
window._entries = entries;
window._sections = sections;
}
// Click a topic in the left pane: scroll the matching entry into
// view on the right pane and apply a highlight band over the
// section's entry range. Highlight clears after a couple seconds.
function onChunkClick(sectionIdx) {
const sections = window._sections;
if (!sections || !sections[sectionIdx]) return;
// Mark active chunk visually.
document.querySelectorAll(".chunk.active").forEach((el) => el.classList.remove("active"));
const chunkEl = document.querySelector('.chunk[data-section-idx="' + sectionIdx + '"]');
if (chunkEl) chunkEl.classList.add("active");
const s = sections[sectionIdx];
const start = Math.max(0, s.startIndex || 0);
const end = Math.max(start, s.endIndex || start);
// Scroll the start entry into view in the transcript pane.
const target = document.getElementById("entry-" + start);
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
// Highlight the section's range; clear prior highlights first.
document.querySelectorAll(".transcript-line.hl").forEach((el) => el.classList.remove("hl"));
for (let i = start; i <= end; i++) {
const el = document.getElementById("entry-" + i);
if (el) el.classList.add("hl");
}
}
load();
</script>
</body>
</html>