331 lines
12 KiB
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
// 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=<job_id> 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> · ' : '') +
|
|
(meta.audio_seconds ? '<strong>' + formatTime(meta.audio_seconds) + '</strong> audio · ' : '') +
|
|
(meta.transcribe_backend ? 'TX: <strong>' + esc(meta.transcribe_model || meta.transcribe_backend) + '</strong> · ' : '') +
|
|
(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>
|