983 lines
30 KiB
HTML
983 lines
30 KiB
HTML
<!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>
|