Files
songtrace/src/karaoke_maker/static/index.html
T

983 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>karaoke-maker</title>
<style>
@font-face {
font-family: "Chakra Petch";
src: url("/static/fonts/chakra-petch-600.woff2") format("woff2");
font-weight: 600; font-display: swap;
}
@font-face {
font-family: "Chakra Petch";
src: url("/static/fonts/chakra-petch-700.woff2") format("woff2");
font-weight: 700; font-display: swap;
}
:root {
--bg: #0A0A0F;
--panel: #14101F;
--panel-raised: #1E1730;
--border: #2E2440;
--control: #756890; /* interactive field edges: clears 3:1 on every surface */
--text: #F0EDF7;
--text-muted: #9B92AD;
--purple: #A855F7;
--aqua: #22D3EE;
--warn: #FBBF24;
--fail: #FB5E7E;
--done: #C4A5F5;
--font-display: "Chakra Petch", system-ui, sans-serif;
--font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
height: 100vh;
height: 100dvh;
overflow: hidden;
display: grid;
grid-template-columns: 260px 1fr;
grid-template-rows: 1fr auto;
grid-template-areas: "sidebar main" "now now";
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: 15px;
line-height: 1.5;
}
:focus-visible { outline: 2px solid var(--aqua); outline-offset: 2px; }
.eyebrow {
margin: 0;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.68rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-muted);
}
.empty-note { margin: 0; font-size: 0.82rem; color: var(--text-muted); }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* --- sidebar ------------------------------------------------------------ */
#sidebar {
grid-area: sidebar;
overflow-y: auto;
padding: 24px 0 28px;
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 22px;
}
.brand { padding: 0 18px; }
.wordmark {
margin: 0;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.15rem;
line-height: 0.98;
letter-spacing: 0.2em;
}
.wordmark span { display: block; }
.wordmark .second { color: var(--purple); }
.kana {
margin-top: 9px;
font-size: 0.72rem;
letter-spacing: 0.4em;
color: var(--purple);
}
#sidebar .eyebrow, #jobs .empty-note { padding: 0 18px; }
#jobs { display: flex; flex-direction: column; gap: 1px; margin-top: 8px; }
.job {
position: relative;
display: grid;
grid-template-columns: 1fr auto;
gap: 1px 10px;
width: 100%;
padding: 10px 16px 11px 15px;
border: 0;
border-left: 3px solid var(--border);
border-radius: 0;
background: var(--panel);
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background 120ms ease;
}
/* inset so the sidebar's scroll container cannot clip the left edge */
.job:focus-visible { outline-offset: -2px; }
.job:hover { background: var(--panel-raised); }
.job[aria-current="true"] { background: var(--panel-raised); }
.job[aria-current="true"]::after {
content: "";
position: absolute;
inset: 0 0 0 auto;
width: 2px;
background: var(--purple);
}
.job-title {
grid-area: 1 / 1;
font-family: var(--font-display);
font-weight: 600;
font-size: 0.9rem;
letter-spacing: 0.01em;
}
.job-artist { grid-area: 2 / 1; font-size: 0.78rem; color: var(--text-muted); }
.job-title, .job-artist { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.job-state {
grid-area: 3 / 1;
margin-top: 4px;
display: flex;
align-items: baseline;
gap: 7px;
font-size: 0.72rem;
color: var(--text-muted);
}
.job-glyph { font-size: 0.8rem; }
.job-elapsed {
grid-area: 3 / 2;
align-self: end;
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-muted);
}
.st-queued { border-left-color: var(--control); }
.st-queued .job-glyph { color: var(--text-muted); }
.st-running { border-left-color: var(--aqua); box-shadow: inset 7px 0 14px -7px var(--aqua); }
.st-running .job-glyph, .st-running .job-state { color: var(--aqua); }
.st-done { border-left-color: var(--done); }
.st-done .job-glyph { color: var(--done); }
.st-failed { border-left-color: var(--fail); }
.st-failed .job-glyph, .st-failed .job-state { color: var(--fail); }
/* --- main --------------------------------------------------------------- */
#main { grid-area: main; overflow-y: auto; padding: 30px 34px 36px; }
#main > section { max-width: 760px; margin-inline: auto; }
#main h2 {
margin: 0 0 18px;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.8rem;
letter-spacing: 0.22em;
color: var(--text-muted);
}
#song-form { display: grid; gap: 18px; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field > span {
font-family: var(--font-display);
font-weight: 600;
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-muted);
}
input, textarea {
font-family: var(--font-body);
font-size: 0.95rem;
color: var(--text);
background: transparent;
border: 0;
border-bottom: 1px solid var(--control);
border-radius: 0;
padding: 7px 2px;
transition: border-color 120ms ease;
}
input:not([type="file"]):hover { border-bottom-color: var(--text-muted); }
input:not([type="file"]):focus { border-bottom-color: var(--aqua); }
textarea {
min-height: 8.5rem;
padding: 11px 13px;
line-height: 1.6;
background: var(--panel);
border: 1px solid var(--control);
resize: vertical;
}
textarea:hover { border-color: var(--text-muted); }
textarea:focus { border-color: var(--aqua); }
input[type="file"] {
font-size: 0.82rem;
color: var(--text-muted);
padding: 10px;
background: var(--panel);
border: 1px dashed var(--control);
}
input[type="file"]:hover { border-color: var(--text-muted); }
.field.dropping input[type="file"] {
color: var(--text);
background: var(--panel-raised);
border-color: var(--aqua);
}
input[type="file"]::file-selector-button {
margin-right: 11px;
padding: 6px 11px;
font-family: var(--font-display);
font-weight: 600;
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text);
background: var(--panel-raised);
border: 1px solid var(--control);
border-radius: 2px;
cursor: pointer;
}
details { border-top: 1px solid var(--border); padding-top: 15px; }
summary {
display: flex;
align-items: center;
gap: 9px;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-muted);
cursor: pointer;
list-style: none;
}
summary::-webkit-details-marker { display: none; }
/* the second `content` keeps the marker out of the accessible name; browsers
without alt-text support drop it and fall back to the plain marker */
summary::before {
content: "+";
content: "+" / "";
font-family: var(--font-mono);
color: var(--aqua);
}
details[open] > summary::before {
content: "\2212";
content: "\2212" / "";
}
summary:hover { color: var(--text); }
details > .stack { display: grid; gap: 18px; margin-top: 18px; }
button { cursor: pointer; }
.primary {
justify-self: start;
padding: 12px 24px;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #12061F;
background: var(--purple);
border: 0;
border-radius: 2px;
transition: background 120ms ease;
}
.primary:hover { background: #BE7BF9; }
#form-error { margin: 12px 0 0; min-height: 1.2em; font-size: 0.85rem; color: var(--fail); }
#review { margin-top: 38px; }
#review h3 {
margin: -8px 0 16px;
font-family: var(--font-display);
font-weight: 600;
font-size: 1rem;
}
#review h3 span { color: var(--text-muted); font-weight: 400; }
#warnings { display: flex; flex-direction: column; gap: 1px; }
.warn-row {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--panel);
border: 1px solid var(--border);
}
.warn-row + .warn-row { margin-top: 6px; }
.warn-icon { color: var(--warn); }
.warn-count { font-size: 0.85rem; }
.warn-range { justify-self: end; font-family: var(--font-mono); font-size: 0.82rem; color: var(--text-muted); }
.review-error { margin: 0 0 14px; font-size: 0.85rem; color: var(--fail); }
.output-path {
display: flex;
align-items: center;
gap: 12px;
margin: 14px 0 0;
padding: 10px 12px;
background: var(--panel);
border: 1px solid var(--border);
}
.output-path code { flex: 1; overflow-wrap: anywhere; font-family: var(--font-mono); font-size: 0.82rem; }
#copy-path, .action, #cancel-rebuild {
flex: none;
padding: 5px 12px;
font-family: var(--font-display);
font-weight: 600;
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text);
background: var(--panel-raised);
border: 1px solid var(--control);
border-radius: 2px;
}
#copy-path:hover, .action:hover, #cancel-rebuild:hover { border-color: var(--text-muted); }
.form-actions { display: flex; align-items: center; gap: 14px; }
.keep-note { margin: -8px 0 0; font-size: 0.78rem; color: var(--aqua); }
#review-actions { display: flex; gap: 10px; margin-top: 14px; }
#review-lineage { margin: -10px 0 14px; }
.lineage {
padding: 0;
font: inherit;
font-size: 0.8rem;
color: var(--aqua);
background: none;
border: 0;
border-bottom: 1px solid transparent;
}
.lineage:hover { border-bottom-color: var(--aqua); }
.job-mark { grid-area: 1 / 2; justify-self: end; font-size: 0.8rem; color: var(--text-muted); }
#detail {
margin: 13px 0 0;
padding: 14px;
max-height: 20rem;
overflow-y: auto;
white-space: pre-wrap;
font-family: var(--font-mono);
font-size: 0.78rem;
line-height: 1.65;
color: var(--text-muted);
background: var(--panel);
border: 1px solid var(--border);
}
/* --- now bar ------------------------------------------------------------ */
#now-bar {
grid-area: now;
display: flex;
align-items: center;
gap: 18px;
padding: 13px 20px;
background: var(--panel);
border-top: 1px solid var(--border);
}
#now-label {
flex: none;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.62rem;
letter-spacing: 0.22em;
color: var(--text-muted);
}
#now-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#now-bar.idle #now-title { color: var(--text-muted); font-weight: 400; font-family: var(--font-body); font-size: 0.82rem; }
#stage-track { display: flex; align-items: center; gap: 15px; margin-left: auto; }
.stage { display: flex; align-items: baseline; gap: 6px; color: var(--text-muted); }
.stage-dot { font-size: 0.72rem; }
.stage-label {
font-family: var(--font-display);
font-weight: 600;
font-size: 0.62rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.stage-done { color: var(--done); }
.stage-active { color: var(--aqua); }
@keyframes stage-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.45; } }
.stage-active .stage-dot { color: var(--aqua); animation: stage-pulse 1.6s ease-in-out infinite; }
.stage-counter {
padding-left: 3px;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-muted);
}
@media (max-width: 1180px) {
#stage-track { gap: 11px; }
/* clipped rather than display:none so the names stay in the accessibility tree */
.stage:not(.stage-active) .stage-label { position: absolute; clip-path: inset(50%); }
}
/* --- responsive --------------------------------------------------------- */
@media (max-width: 900px) {
body {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-areas: "sidebar" "main" "now";
}
#sidebar {
max-height: 42vh;
border-right: 0;
border-bottom: 1px solid var(--border);
}
#main { padding: 24px 18px 28px; }
.pair { grid-template-columns: 1fr; }
#now-bar { flex-wrap: wrap; gap: 8px 16px; }
#stage-track { margin-left: 0; flex-wrap: wrap; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
</style>
</head>
<body>
<aside id="sidebar">
<div class="brand">
<h1 class="wordmark"><span>KARAOKE</span> <span class="second">MAKER</span></h1>
<div class="kana" aria-hidden="true">カラオケ</div>
</div>
<div>
<h2 class="eyebrow">QUEUE</h2>
<div id="jobs"></div>
</div>
</aside>
<main id="main">
<section id="new-song">
<h2>NEW SONG</h2>
<form id="song-form">
<div class="pair">
<label class="field"><span>Title</span><input name="title" required></label>
<label class="field"><span>Artist</span><input name="artist" required></label>
</div>
<label class="field"><span>Lyrics</span><textarea name="lyrics" required></textarea></label>
<details>
<summary>Advanced</summary>
<div class="stack">
<label class="field"><span>YouTube URL</span><input name="youtube_url"></label>
<label class="field"><span>Video file</span><input type="file" name="video"></label>
<p class="keep-note" data-keep="video" hidden></p>
<div class="pair">
<label class="field"><span>Genre</span><input name="genre"></label>
<label class="field"><span>Year</span><input name="year" type="number"></label>
</div>
<div class="pair">
<label class="field"><span>Language</span><input name="language"></label>
<label class="field"><span>BPM override</span><input name="bpm" type="number" step="any"></label>
</div>
<label class="field"><span>Cover image</span><input type="file" name="cover"></label>
<p class="keep-note" data-keep="cover" hidden></p>
</div>
</details>
<div class="form-actions">
<button type="submit" class="primary">CREATE SONG</button>
<button type="button" id="cancel-rebuild" hidden>CANCEL</button>
</div>
</form>
<p id="form-error" role="alert"></p>
</section>
<section id="review">
<h2>REVIEW</h2>
<h3 id="review-title"></h3>
<p id="review-lineage" hidden></p>
<p id="review-error" class="review-error" hidden></p>
<div id="warnings"><p class="empty-note">no songs queued yet — nothing to review</p></div>
<div id="output-path"></div>
<div id="review-actions"></div>
<details>
<summary>Log</summary>
<pre id="detail">no songs queued yet — nothing to log</pre>
</details>
</section>
</main>
<footer id="now-bar" class="idle">
<span id="now-label">IDLE</span>
<span id="now-title">nothing in progress</span>
<div id="stage-track"></div>
</footer>
<script>
const form = document.getElementById("song-form");
const formError = document.getElementById("form-error");
const jobsDiv = document.getElementById("jobs");
const detail = document.getElementById("detail");
const reviewTitle = document.getElementById("review-title");
const reviewError = document.getElementById("review-error");
const outputPathDiv = document.getElementById("output-path");
const nowBar = document.getElementById("now-bar");
const nowLabel = document.getElementById("now-label");
const nowTitle = document.getElementById("now-title");
const stageTrack = document.getElementById("stage-track");
const reviewActions = document.getElementById("review-actions");
const reviewLineage = document.getElementById("review-lineage");
const cancelRebuild = document.getElementById("cancel-rebuild");
const formHeading = document.querySelector("#new-song h2");
const submitButton = form.querySelector("button[type=submit]");
let rebuildOf = null;
let JOBS = [];
let selectedId = null;
let STAGES = [];
let pollTimer = null;
const WARN_RE = /^(\d+)\s+low-confidence notes?\s+near\s+(\d+:\d{2})–(\d+:\d{2})/;
const STATE = {
queued: { glyph: "○", cls: "st-queued" },
running: { glyph: "◈", cls: "st-running" },
done: { glyph: "●", cls: "st-done" },
failed: { glyph: "✕", cls: "st-failed" },
};
function elapsed(job) {
if (!job.started_at) return "";
const end = job.finished_at ?? Date.now() / 1000;
const secs = Math.max(0, Math.round(end - job.started_at));
const m = Math.floor(secs / 60), s = secs % 60;
return m ? `${m}m${String(s).padStart(2, "0")}s` : `${s}s`;
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
formError.textContent = "";
const data = new FormData(form);
for (const [name, value] of [...data.entries()]) {
const isFile = value instanceof File;
if (isFile && value.size === 0) data.delete(name);
else if (!isFile && value === "") data.delete(name);
}
const url = rebuildOf ? `/api/jobs/${rebuildOf}/rebuild` : "/api/jobs";
const resp = await fetch(url, { method: "POST", body: data });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
formError.textContent = body.detail || `error: ${resp.status}`;
return; // before the reset below, so a rejected submission is never retyped
}
selectedId = (await resp.json()).id;
exitRebuild();
refreshJobs();
});
for (const input of form.querySelectorAll('input[type="file"]')) {
const zone = input.closest(".field");
zone.addEventListener("dragover", (e) => {
e.preventDefault();
zone.classList.add("dropping");
});
zone.addEventListener("dragleave", (e) => {
if (!zone.contains(e.relatedTarget)) zone.classList.remove("dropping");
});
zone.addEventListener("drop", (e) => {
e.preventDefault();
zone.classList.remove("dropping");
const [file] = e.dataTransfer.files;
if (!file) return;
// a single-file input needs a one-item FileList, which only DataTransfer can build
const picked = new DataTransfer();
picked.items.add(file);
input.files = picked.files;
});
}
cancelRebuild.addEventListener("click", exitRebuild);
function jobCard(job) {
const card = document.createElement("button");
card.type = "button";
card.dataset.id = job.id;
const title = document.createElement("span");
title.className = "job-title";
const artist = document.createElement("span");
artist.className = "job-artist";
const glyph = document.createElement("span");
glyph.className = "job-glyph";
glyph.setAttribute("aria-hidden", "true");
const status = document.createElement("span");
const stateLine = document.createElement("span");
stateLine.className = "job-state";
stateLine.append(glyph, status);
const time = document.createElement("span");
time.className = "job-elapsed";
const mark = document.createElement("span");
mark.className = "job-mark";
const markGlyph = document.createElement("span");
markGlyph.setAttribute("aria-hidden", "true");
const markLabel = document.createElement("span");
markLabel.className = "sr-only";
mark.append(markGlyph, markLabel);
card.append(title, artist, stateLine, time, mark);
card.addEventListener("click", () => selectJob(job.id));
paintCard(card, job);
return card;
}
function paintCard(card, job) {
const state = STATE[job.status] ?? { glyph: "·", cls: "st-queued" };
const [title, artist, stateLine, time, mark] = card.children;
card.className = `job ${state.cls}`;
if (job.id === selectedId) card.setAttribute("aria-current", "true");
else card.removeAttribute("aria-current");
title.textContent = job.title;
artist.textContent = job.artist;
stateLine.children[0].textContent = state.glyph;
stateLine.children[1].textContent = job.stage ? `${job.status} · ${job.stage}` : job.status;
time.textContent = elapsed(job);
mark.children[0].textContent = job.rebuilt_from ? "↻" : "";
mark.children[1].textContent = job.rebuilt_from ? " rebuilt" : "";
}
// patched in place rather than replaced wholesale: detaching a card would blur
// it, so a keyboard user would lose the queue on every poll
function renderJobs(jobs) {
if (!jobs.length) {
jobsDiv.replaceChildren(emptyNote("no songs yet — create one to get started"));
return;
}
const existing = new Map(
[...jobsDiv.querySelectorAll(".job")].map(card => [card.dataset.id, card]));
jobs.forEach((job, i) => {
let card = existing.get(job.id);
if (card) { existing.delete(job.id); paintCard(card, job); }
else card = jobCard(job);
if (jobsDiv.children[i] !== card) jobsDiv.insertBefore(card, jobsDiv.children[i] ?? null);
});
for (const stale of existing.values()) stale.remove();
while (jobsDiv.children.length > jobs.length) jobsDiv.lastElementChild.remove();
}
async function loadStages() {
const resp = await fetch("/api/stages");
STAGES = (await resp.json()).stages;
}
function renderStageTrack(job) {
if (!STAGES.length || !job || job.status === "queued") {
stageTrack.replaceChildren();
return;
}
const idx = job.stage ? STAGES.indexOf(job.stage) : -1;
const dots = STAGES.map((name, i) => {
const done = job.status === "done" || i < idx;
const active = !done && i === idx;
const dot = document.createElement("span");
dot.className = "stage " + (done ? "stage-done" : active ? "stage-active" : "stage-pending");
if (active) dot.setAttribute("aria-current", "step");
const mark = document.createElement("span");
mark.className = "stage-dot";
mark.setAttribute("aria-hidden", "true");
mark.textContent = done ? "●" : active ? "◈" : "○";
const label = document.createElement("span");
label.className = "stage-label";
label.textContent = name;
// colour and glyph alone must never carry state
const word = document.createElement("span");
word.className = "sr-only";
word.textContent = done ? " done" : active ? " running" : " pending";
dot.append(mark, label, word);
return dot;
});
const counter = document.createElement("span");
counter.className = "stage-counter";
// an unrecognised stage name gives idx === -1, which reads as 0 done
counter.textContent = job.status === "done"
? `${STAGES.length}/${STAGES.length}`
: `${Math.max(0, idx + 1)}/${STAGES.length}`;
stageTrack.replaceChildren(...dots, counter);
}
function renderNowBar(jobs) {
const running = jobs.find(j => j.status === "running");
nowBar.classList.toggle("idle", !running);
nowLabel.textContent = running ? "NOW RUNNING" : "IDLE";
nowTitle.textContent = running ? `${running.title} · ${running.artist}` : "nothing in progress";
renderStageTrack(running);
}
function schedulePoll(jobs) {
const busy = jobs.some(j => j.status === "queued" || j.status === "running");
if (busy && pollTimer === null) {
pollTimer = setInterval(refreshJobs, 3000);
} else if (!busy && pollTimer !== null) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function refreshJobs() {
const resp = await fetch("/api/jobs");
const jobs = await resp.json();
JOBS = jobs;
if (selectedId === null && jobs.length) selectedId = jobs[0].id;
renderJobs(jobs);
renderNowBar(jobs);
if (selectedId) refreshDetail();
schedulePoll(jobs);
}
async function selectJob(id) {
selectedId = id;
for (const card of jobsDiv.querySelectorAll(".job")) {
if (card.dataset.id === id) card.setAttribute("aria-current", "true");
else card.removeAttribute("aria-current");
}
refreshDetail();
}
function emptyNote(text) {
const p = document.createElement("p");
p.className = "empty-note";
p.textContent = text;
return p;
}
function renderWarnings(job) {
const box = document.getElementById("warnings");
if (job.status === "failed") {
box.replaceChildren(emptyNote("job failed — nothing to review"));
return;
}
if (job.status !== "done") {
box.replaceChildren(emptyNote("waiting for this song to finish…"));
return;
}
if (!job.warnings.length) {
box.replaceChildren(emptyNote("no low-confidence regions — nothing to review"));
return;
}
const rows = job.warnings.map(w => {
const m = WARN_RE.exec(w);
const row = document.createElement("div");
row.className = "warn-row";
if (m) {
row.setAttribute("data-count", m[1]);
row.setAttribute("data-start", m[2]);
row.setAttribute("data-end", m[3]);
const icon = document.createElement("span");
icon.className = "warn-icon";
icon.textContent = "⚠";
const count = document.createElement("span");
count.className = "warn-count";
count.textContent = `${m[1]} note${m[1] === "1" ? "" : "s"}`;
const range = document.createElement("span");
range.className = "warn-range";
range.textContent = `${m[2]}–${m[3]}`;
row.append(icon, count, range);
} else {
row.textContent = w;
}
return row;
});
box.replaceChildren(...rows);
}
async function copyPath(path, button) {
let ok = false;
if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(path);
ok = true;
} catch {}
}
// navigator.clipboard is undefined outside secure contexts, which is the
// normal case for this intranet tool served over plain http
if (!ok) {
const ta = document.createElement("textarea");
ta.value = path;
ta.style.position = "fixed";
ta.style.left = "-1000px";
document.body.appendChild(ta);
ta.focus();
ta.select();
try { ok = document.execCommand("copy"); } catch { ok = false; }
document.body.removeChild(ta);
}
if (!ok) {
const code = outputPathDiv.querySelector("code");
if (code) {
const range = document.createRange();
range.selectNodeContents(code);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
}
button.textContent = ok ? "copied" : "copy failed";
setTimeout(() => { button.textContent = "copy"; }, 1500);
}
function renderOutputPath(job) {
const path = job.status === "done" ? job.output_dir ?? null : null;
const shown = outputPathDiv.querySelector("code")?.textContent ?? null;
// rebuilding an unchanged panel would detach a focused #copy-path on every poll
if (path === shown) return;
if (path === null) {
outputPathDiv.replaceChildren();
return;
}
const wrap = document.createElement("div");
wrap.className = "output-path";
const code = document.createElement("code");
code.textContent = path;
const button = document.createElement("button");
button.id = "copy-path";
button.type = "button";
button.textContent = "copy";
button.addEventListener("click", () => copyPath(path, button));
wrap.append(code, button);
outputPathDiv.replaceChildren(wrap);
}
function setFormMode(inp) {
formHeading.textContent = rebuildOf ? "REBUILD" : "NEW SONG";
submitButton.textContent = rebuildOf ? "REBUILD SONG" : "CREATE SONG";
cancelRebuild.hidden = !rebuildOf;
for (const [field, key] of [["video", "video_filename"], ["cover", "cover_filename"]]) {
const note = form.querySelector(`[data-keep="${field}"]`);
const kept = rebuildOf && inp ? inp[key] : null;
note.textContent = kept ? `keeping ${kept} — attach a file to replace it` : "";
note.hidden = !kept;
}
}
function exitRebuild() {
rebuildOf = null;
form.reset();
setFormMode(null);
}
async function startRebuild(id) {
const resp = await fetch(`/api/jobs/${id}/input`);
if (!resp.ok) return;
const inp = await resp.json();
form.reset();
for (const [name, value] of Object.entries(inp)) {
// the payload is keyed by form field name; *_filename match nothing, by design
const el = form.elements[name];
if (el && value !== null) el.value = value;
}
rebuildOf = id;
setFormMode(inp);
formError.textContent = "";
form.elements.title.focus();
}
async function rerunJob(id) {
formError.textContent = "";
const resp = await fetch(`/api/jobs/${id}/rerun`, { method: "POST" });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
formError.textContent = body.detail || `error: ${resp.status}`;
return;
}
selectedId = (await resp.json()).id;
refreshJobs();
}
function actionButton(action, jobId) {
const button = document.createElement("button");
button.type = "button";
button.className = "action";
button.dataset.action = action;
button.textContent = action === "rerun" ? "RERUN" : "EDIT & REBUILD";
button.addEventListener("click",
() => action === "rerun" ? rerunJob(jobId) : startRebuild(jobId));
return button;
}
function renderActions(job) {
// no controls mid-run: both endpoints answer 409 until the job settles
const wanted = job.status === "failed" ? ["rerun", "edit"]
: job.status === "done" ? ["edit"] : [];
const shown = [...reviewActions.children].map(b => b.dataset.action);
// rebuilding an unchanged row would detach a focused action on every poll
if (reviewActions.dataset.jobId === job.id && shown.join() === wanted.join()) return;
reviewActions.dataset.jobId = job.id;
reviewActions.replaceChildren(...wanted.map(a => actionButton(a, job.id)));
}
function renderLineage(job) {
if (reviewLineage.dataset.jobId === job.id) return;
reviewLineage.dataset.jobId = job.id;
reviewLineage.replaceChildren();
reviewLineage.hidden = !job.rebuilt_from;
if (!job.rebuilt_from) return;
const parent = JOBS.find(j => j.id === job.rebuilt_from);
const link = document.createElement("button");
link.type = "button";
link.className = "lineage";
link.textContent = parent ? `rebuilt from ${parent.title}`
: "rebuilt from an earlier run";
link.addEventListener("click", () => selectJob(job.rebuilt_from));
reviewLineage.append(link);
}
async function refreshDetail() {
const id = selectedId;
const resp = await fetch(`/api/jobs/${id}`);
if (!resp.ok) return;
const job = await resp.json();
if (id !== selectedId) return;
const artist = document.createElement("span");
artist.textContent = job.artist;
reviewTitle.replaceChildren(`${job.title} `, artist);
reviewError.hidden = !job.error;
reviewError.textContent = job.error ?? "";
renderWarnings(job);
renderOutputPath(job);
renderLineage(job);
renderActions(job);
detail.textContent = job.log.join("\n");
}
loadStages().catch(() => {}).then(refreshJobs);
</script>
</body>
</html>