Last active
April 24, 2026 11:17
-
-
Save adam137016/8d5e405e05050098a656f240d03c7d25 to your computer and use it in GitHub Desktop.
Template for UI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Sonic Adventure</title> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #0e0e10; | |
| --surface: #1a1a1e; | |
| --surface2: #25252b; | |
| --border: #333340; | |
| --accent: #7f77dd; | |
| --accent2: #534ab7; | |
| --text: #e8e8f0; | |
| --muted: #888899; | |
| --green: #1d9e75; | |
| --red: #e24b4a; | |
| --amber: #ef9f27; | |
| --radius: 10px; | |
| } | |
| body { | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| font-size: 15px; | |
| line-height: 1.5; | |
| min-height: 100vh; | |
| } | |
| header { | |
| padding: 1.25rem 2rem; | |
| border-bottom: 1px solid var(--border); | |
| display: flex; | |
| align-items: center; | |
| gap: 1rem; | |
| } | |
| header h1 { font-size: 20px; font-weight: 600; letter-spacing: -0.3px; } | |
| header .sub { color: var(--muted); font-size: 13px; } | |
| .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex-shrink: 0; } | |
| /* Tabs */ | |
| .tabs { | |
| display: flex; | |
| border-bottom: 1px solid var(--border); | |
| padding: 0 2rem; | |
| gap: 0; | |
| } | |
| .tab { | |
| padding: 0.75rem 1.5rem; | |
| font-size: 13px; | |
| font-weight: 500; | |
| color: var(--muted); | |
| cursor: pointer; | |
| border-bottom: 2px solid transparent; | |
| margin-bottom: -1px; | |
| transition: all 0.15s; | |
| } | |
| .tab.active { color: var(--accent); border-bottom-color: var(--accent); } | |
| .tab:hover:not(.active) { color: var(--text); } | |
| .tab-panel { display: none; } | |
| .tab-panel.active { display: block; } | |
| main { max-width: 900px; margin: 0 auto; padding: 2rem; } | |
| section { margin-bottom: 2rem; } | |
| label.section-label { | |
| display: block; | |
| font-size: 11px; | |
| font-weight: 600; | |
| letter-spacing: 0.8px; | |
| text-transform: uppercase; | |
| color: var(--muted); | |
| margin-bottom: 0.75rem; | |
| } | |
| /* Waypoints */ | |
| #waypoints { display: flex; flex-direction: column; gap: 0.75rem; } | |
| .waypoint { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 1rem 1.25rem; | |
| display: grid; | |
| grid-template-columns: 90px 1fr 1fr auto; | |
| gap: 0.75rem; | |
| align-items: center; | |
| } | |
| .waypoint-label { | |
| font-size: 11px; | |
| font-weight: 600; | |
| letter-spacing: 0.5px; | |
| color: var(--accent); | |
| text-transform: uppercase; | |
| } | |
| select { | |
| background: var(--surface2); | |
| color: var(--text); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| padding: 0.45rem 0.75rem; | |
| font-size: 13px; | |
| width: 100%; | |
| appearance: none; | |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23888899' d='M6 8L0 0h12z'/%3E%3C/svg%3E"); | |
| background-repeat: no-repeat; | |
| background-position: right 10px center; | |
| padding-right: 28px; | |
| cursor: pointer; | |
| } | |
| select:focus { outline: none; border-color: var(--accent); } | |
| .remove-btn { | |
| background: none; | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--muted); | |
| cursor: pointer; | |
| padding: 0.4rem 0.6rem; | |
| font-size: 14px; | |
| transition: all 0.15s; | |
| line-height: 1; | |
| } | |
| .remove-btn:hover { background: var(--surface2); color: var(--red); border-color: var(--red); } | |
| .remove-btn:disabled { opacity: 0.3; cursor: not-allowed; } | |
| .connector { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| padding: 0 1.25rem; | |
| color: var(--muted); | |
| font-size: 12px; | |
| } | |
| .connector-line { | |
| flex: 1; | |
| height: 1px; | |
| background: repeating-linear-gradient(90deg, var(--border) 0, var(--border) 4px, transparent 4px, transparent 8px); | |
| } | |
| .controls-row { | |
| display: flex; | |
| gap: 0.75rem; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| } | |
| button.btn { | |
| padding: 0.55rem 1.1rem; | |
| border-radius: 7px; | |
| font-size: 13px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| border: 1px solid var(--border); | |
| transition: all 0.15s; | |
| line-height: 1.4; | |
| } | |
| button.btn-ghost { background: var(--surface); color: var(--text); } | |
| button.btn-ghost:hover { background: var(--surface2); border-color: var(--accent); } | |
| button.btn-primary { | |
| background: var(--accent2); | |
| border-color: var(--accent); | |
| color: #fff; | |
| font-size: 14px; | |
| padding: 0.65rem 1.5rem; | |
| } | |
| button.btn-primary:hover { background: var(--accent); } | |
| button.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; } | |
| button.btn-green { | |
| background: var(--green); | |
| border-color: var(--green); | |
| color: #fff; | |
| font-size: 14px; | |
| padding: 0.65rem 1.5rem; | |
| } | |
| button.btn-green:hover { opacity: 0.85; } | |
| button.btn-green:disabled { opacity: 0.4; cursor: not-allowed; } | |
| .options-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 0.75rem; | |
| } | |
| .option-card { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 1rem 1.25rem; | |
| } | |
| .option-card label { | |
| display: block; | |
| font-size: 11px; | |
| font-weight: 600; | |
| letter-spacing: 0.5px; | |
| text-transform: uppercase; | |
| color: var(--muted); | |
| margin-bottom: 0.5rem; | |
| } | |
| input[type="text"], input[type="number"] { | |
| background: var(--surface2); | |
| color: var(--text); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| padding: 0.45rem 0.75rem; | |
| font-size: 13px; | |
| width: 100%; | |
| } | |
| input[type="text"]:focus, input[type="number"]:focus { outline: none; border-color: var(--accent); } | |
| input[type="text"]::placeholder { color: var(--muted); } | |
| .threshold-btns { display: flex; gap: 0.5rem; } | |
| .threshold-btn { | |
| flex: 1; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--muted); | |
| cursor: pointer; | |
| padding: 0.45rem; | |
| font-size: 13px; | |
| text-align: center; | |
| transition: all 0.15s; | |
| } | |
| .threshold-btn.active { background: var(--accent2); border-color: var(--accent); color: #fff; } | |
| .threshold-btn:hover:not(.active) { border-color: var(--accent); color: var(--text); } | |
| /* Log */ | |
| .log-section { display: none; margin-top: 1.5rem; } | |
| .log-box { | |
| background: #0a0a0c; | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 1rem 1.25rem; | |
| font-family: "Consolas", "Courier New", monospace; | |
| font-size: 12px; | |
| line-height: 1.7; | |
| max-height: 300px; | |
| overflow-y: auto; | |
| color: #b0b0c0; | |
| white-space: pre-wrap; | |
| } | |
| .log-ok { color: var(--green); } | |
| .log-warn { color: var(--amber); } | |
| .log-err { color: var(--red); } | |
| .log-head { color: var(--accent); font-weight: bold; } | |
| .result-banner { | |
| display: none; | |
| margin-top: 1rem; | |
| background: var(--surface); | |
| border: 1px solid var(--green); | |
| border-radius: var(--radius); | |
| padding: 1rem 1.25rem; | |
| color: var(--green); | |
| font-weight: 500; | |
| font-size: 14px; | |
| } | |
| .spinner { | |
| display: inline-block; | |
| width: 14px; height: 14px; | |
| border: 2px solid var(--border); | |
| border-top-color: var(--accent); | |
| border-radius: 50%; | |
| animation: spin 0.7s linear infinite; | |
| vertical-align: middle; | |
| margin-right: 6px; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| /* Genre grid */ | |
| .genre-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); | |
| gap: 0.5rem; | |
| } | |
| .genre-toggle { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.6rem; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 7px; | |
| padding: 0.55rem 0.85rem; | |
| cursor: pointer; | |
| transition: all 0.15s; | |
| user-select: none; | |
| } | |
| .genre-toggle.on { border-color: var(--accent); background: #1e1c30; } | |
| .genre-toggle.on .toggle-dot { background: var(--accent); } | |
| .genre-toggle.off { opacity: 0.5; } | |
| .toggle-dot { | |
| width: 8px; height: 8px; | |
| border-radius: 50%; | |
| background: var(--border); | |
| flex-shrink: 0; | |
| transition: background 0.15s; | |
| } | |
| .genre-name { font-size: 12px; line-height: 1.3; } | |
| /* Schedule status card */ | |
| .schedule-card { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 1.25rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 1rem; | |
| flex-wrap: wrap; | |
| } | |
| .schedule-info { font-size: 13px; color: var(--muted); } | |
| .schedule-info strong { color: var(--text); font-weight: 500; } | |
| .schedule-info .next { color: var(--accent); font-size: 12px; margin-top: 2px; } | |
| .counter-row { display: flex; gap: 0.5rem; align-items: center; } | |
| .counter-btn { | |
| width: 30px; height: 30px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--text); | |
| font-size: 16px; | |
| cursor: pointer; | |
| display: flex; align-items: center; justify-content: center; | |
| transition: all 0.15s; | |
| line-height: 1; | |
| } | |
| .counter-btn:hover { border-color: var(--accent); color: var(--accent); } | |
| .counter-val { min-width: 28px; text-align: center; font-size: 15px; font-weight: 500; } | |
| .auto-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr 1fr; | |
| gap: 0.75rem; | |
| margin-bottom: 1.5rem; | |
| } | |
| @media (max-width: 600px) { | |
| .waypoint { grid-template-columns: 1fr; } | |
| .options-grid { grid-template-columns: 1fr; } | |
| .auto-grid { grid-template-columns: 1fr; } | |
| .genre-grid { grid-template-columns: 1fr 1fr; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="dot"></div> | |
| <div> | |
| <h1>Sonic Adventure</h1> | |
| <div class="sub">Playlist generator — Plex Music</div> | |
| </div> | |
| </header> | |
| <div class="tabs"> | |
| <div class="tab active" onclick="switchTab('manual')">Manual</div> | |
| <div class="tab" onclick="switchTab('auto')">Auto</div> | |
| </div> | |
| <main> | |
| <!-- ===================== MANUAL TAB ===================== --> | |
| <div id="tab-manual" class="tab-panel active"> | |
| <section> | |
| <label class="section-label">Genre Waypoints</label> | |
| <div id="waypoints"></div> | |
| <div style="margin-top: 0.75rem;" class="controls-row"> | |
| <button class="btn btn-ghost" onclick="addWaypoint()">+ Add waypoint</button> | |
| <span style="color: var(--muted); font-size: 12px;" id="waypoint-count"></span> | |
| </div> | |
| </section> | |
| <section> | |
| <label class="section-label">Options</label> | |
| <div class="options-grid"> | |
| <div class="option-card"> | |
| <label>Genre tag threshold</label> | |
| <div class="threshold-btns"> | |
| <div class="threshold-btn" data-val="1" onclick="setThreshold(1)">1 — broad</div> | |
| <div class="threshold-btn active" data-val="2" onclick="setThreshold(2)">2 — balanced</div> | |
| <div class="threshold-btn" data-val="3" onclick="setThreshold(3)">3 — strict</div> | |
| </div> | |
| </div> | |
| <div class="option-card"> | |
| <label>Playlist name (optional)</label> | |
| <input type="text" id="playlist-name" placeholder="Auto-generated if blank"> | |
| </div> | |
| </div> | |
| </section> | |
| <section> | |
| <button class="btn btn-primary" id="generate-btn" onclick="generate()">Generate playlist</button> | |
| </section> | |
| <div id="manual-log-section" class="log-section"> | |
| <label class="section-label">Progress</label> | |
| <div id="manual-log-box" class="log-box"></div> | |
| </div> | |
| <div id="manual-result-banner" class="result-banner"></div> | |
| </div> | |
| <!-- ===================== AUTO TAB ===================== --> | |
| <div id="tab-auto" class="tab-panel"> | |
| <section> | |
| <label class="section-label">Schedule</label> | |
| <div class="schedule-card"> | |
| <div class="schedule-info"> | |
| <strong id="schedule-status">Loading...</strong> | |
| <div class="next" id="schedule-next"></div> | |
| </div> | |
| <div class="controls-row"> | |
| <select id="schedule-interval" onchange="scheduleChanged()" style="width: auto;"> | |
| <option value="0">Off</option> | |
| <option value="1">Every day</option> | |
| <option value="2" selected>Every 2 days</option> | |
| <option value="3">Every 3 days</option> | |
| <option value="7">Every week</option> | |
| </select> | |
| <button class="btn btn-ghost" onclick="saveSchedule()">Save schedule</button> | |
| </div> | |
| </div> | |
| </section> | |
| <section> | |
| <label class="section-label">Run settings</label> | |
| <div class="auto-grid"> | |
| <div class="option-card"> | |
| <label>Playlists per run</label> | |
| <div style="font-size: 15px; font-weight: 500; padding-top: 4px;" id="playlists-display">All enabled genres</div> | |
| <div style="font-size: 11px; color: var(--muted); margin-top: 4px;" id="playlists-count-sub"></div> | |
| </div> | |
| <div class="option-card"> | |
| <label>Seeds per playlist</label> | |
| <div class="counter-row"> | |
| <button class="counter-btn" onclick="adjustCounter('seeds', -1)">−</button> | |
| <div class="counter-val" id="seeds-val">3</div> | |
| <button class="counter-btn" onclick="adjustCounter('seeds', 1)">+</button> | |
| </div> | |
| </div> | |
| <div class="option-card"> | |
| <label>Style threshold</label> | |
| <div class="counter-row"> | |
| <button class="counter-btn" onclick="adjustCounter('threshold_auto', -1)">−</button> | |
| <div class="counter-val" id="threshold_auto-val">2</div> | |
| <button class="counter-btn" onclick="adjustCounter('threshold_auto', 1)">+</button> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <section> | |
| <label class="section-label"> | |
| Enabled genres | |
| <span style="font-weight: 400; text-transform: none; letter-spacing: 0; margin-left: 0.5rem;"> | |
| <span id="genre-count" style="color: var(--accent);"></span> | |
| · | |
| <span onclick="toggleAllGenres(true)" style="color: var(--muted); cursor: pointer; font-size: 11px;">all on</span> | |
| / | |
| <span onclick="toggleAllGenres(false)" style="color: var(--muted); cursor: pointer; font-size: 11px;">all off</span> | |
| </span> | |
| </label> | |
| <div class="genre-grid" id="genre-grid"></div> | |
| </section> | |
| <section class="controls-row"> | |
| <button class="btn btn-green" id="auto-run-btn" onclick="runAuto()">Run now</button> | |
| <button class="btn btn-primary" id="auto-save-btn" onclick="saveAutoSettings()">Save settings</button> | |
| <span style="color: var(--muted); font-size: 12px;" id="auto-save-status"></span> | |
| </section> | |
| <div id="auto-log-section" class="log-section"> | |
| <label class="section-label">Progress</label> | |
| <div id="auto-log-box" class="log-box"></div> | |
| </div> | |
| <div id="auto-result-banner" class="result-banner"></div> | |
| </div> | |
| </main> | |
| <script> | |
| const PRESETS = {{ presets | tojson }}; | |
| const MODES = {{ modes | tojson }}; | |
| // ── Tab switching ──────────────────────────────────────── | |
| function switchTab(name) { | |
| document.querySelectorAll('.tab').forEach((t, i) => { | |
| t.classList.toggle('active', ['manual','auto'][i] === name); | |
| }); | |
| document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); | |
| document.getElementById('tab-' + name).classList.add('active'); | |
| } | |
| // ── Manual tab ─────────────────────────────────────────── | |
| let threshold = 2; | |
| let waypoints = []; | |
| let manualPolling = null; | |
| function setThreshold(val) { | |
| threshold = val; | |
| document.querySelectorAll(".threshold-btn").forEach(b => { | |
| b.classList.toggle("active", parseInt(b.dataset.val) === val); | |
| }); | |
| } | |
| function renderWaypoints() { | |
| const container = document.getElementById("waypoints"); | |
| container.innerHTML = ""; | |
| waypoints.forEach((wp, i) => { | |
| const label = i === 0 ? "Start" : i === waypoints.length - 1 ? "End" : `Middle ${i}`; | |
| const presetOpts = PRESETS.map(p => `<option value="${p.index}">${p.name}</option>`).join(""); | |
| const modeOpts = MODES.map(m => `<option value="${m.key}">${m.key}. ${m.label}</option>`).join(""); | |
| if (i > 0) { | |
| const conn = document.createElement("div"); | |
| conn.className = "connector"; | |
| conn.innerHTML = `<div class="connector-line"></div><span>↓</span><div class="connector-line"></div>`; | |
| container.appendChild(conn); | |
| } | |
| const div = document.createElement("div"); | |
| div.className = "waypoint"; | |
| div.innerHTML = ` | |
| <div class="waypoint-label">${label}</div> | |
| <select onchange="wpChange(${i},'preset',this.value)">${presetOpts}</select> | |
| <select onchange="wpChange(${i},'mode',this.value)">${modeOpts}</select> | |
| <button class="remove-btn" onclick="removeWaypoint(${i})" ${waypoints.length<=2?'disabled':''} title="Remove">✕</button> | |
| `; | |
| container.appendChild(div); | |
| div.querySelector("select:nth-of-type(1)").value = wp.preset_index; | |
| div.querySelector("select:nth-of-type(2)").value = wp.mode; | |
| }); | |
| document.getElementById("waypoint-count").textContent = | |
| `${waypoints.length} waypoints · ${waypoints.length-1} segment${waypoints.length>2?"s":""}`; | |
| } | |
| function wpChange(i, key, val) { | |
| if (key === "preset") waypoints[i].preset_index = parseInt(val); | |
| if (key === "mode") waypoints[i].mode = val; | |
| } | |
| function addWaypoint() { | |
| if (waypoints.length >= 6) return; | |
| waypoints.splice(waypoints.length - 1, 0, { preset_index: 0, mode: "1" }); | |
| renderWaypoints(); | |
| } | |
| function removeWaypoint(i) { | |
| if (waypoints.length <= 2) return; | |
| waypoints.splice(i, 1); | |
| renderWaypoints(); | |
| } | |
| function logLine(text, boxId) { | |
| const box = document.getElementById(boxId); | |
| const span = document.createElement("span"); | |
| if (text.includes("ERROR") || text.includes("FATAL") || text.includes("Aborting")) | |
| span.className = "log-err"; | |
| else if (text.includes("WARNING")) | |
| span.className = "log-warn"; | |
| else if (text.includes("Done!") || text.includes("Created") || text.includes("created")) | |
| span.className = "log-ok"; | |
| else if (text.startsWith("Loading") || text.startsWith("Building") || text.startsWith("Checking") || text.startsWith("Fetching")) | |
| span.className = "log-head"; | |
| span.textContent = text + "\n"; | |
| box.appendChild(span); | |
| box.scrollTop = box.scrollHeight; | |
| } | |
| async function generate() { | |
| const btn = document.getElementById("generate-btn"); | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="spinner"></span> Generating...'; | |
| document.getElementById("manual-log-section").style.display = "block"; | |
| document.getElementById("manual-log-box").innerHTML = ""; | |
| document.getElementById("manual-result-banner").style.display = "none"; | |
| const payload = { | |
| waypoints: waypoints, | |
| min_style_matches: threshold, | |
| playlist_name: document.getElementById("playlist-name").value.trim(), | |
| }; | |
| let job_id; | |
| try { | |
| const resp = await fetch("/api/generate", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(payload), | |
| }); | |
| const data = await resp.json(); | |
| if (data.error) throw new Error(data.error); | |
| job_id = data.job_id; | |
| } catch (e) { | |
| logLine("ERROR: " + e.message, "manual-log-box"); | |
| btn.disabled = false; | |
| btn.textContent = "Generate playlist"; | |
| return; | |
| } | |
| pollJob(job_id, "manual-log-box", "manual-result-banner", btn, "Generate playlist"); | |
| } | |
| function pollJob(job_id, logBoxId, bannerId, btn, btnLabel) { | |
| let lastLogLen = 0; | |
| const iv = setInterval(async () => { | |
| try { | |
| const resp = await fetch(`/api/status/${job_id}`); | |
| const data = await resp.json(); | |
| data.log.slice(lastLogLen).forEach(l => logLine(l, logBoxId)); | |
| lastLogLen = data.log.length; | |
| if (data.done) { | |
| clearInterval(iv); | |
| if (btn) { btn.disabled = false; btn.textContent = btnLabel; } | |
| if (data.status === "done") { | |
| const banner = document.getElementById(bannerId); | |
| banner.style.display = "block"; | |
| banner.textContent = `✓ "${data.playlist_name}" — ${data.track_count} tracks. Open Plex to listen.`; | |
| } | |
| } | |
| } catch (e) { | |
| clearInterval(iv); | |
| logLine("ERROR polling: " + e.message, logBoxId); | |
| if (btn) { btn.disabled = false; btn.textContent = btnLabel; } | |
| } | |
| }, 1500); | |
| } | |
| // ── Auto tab ───────────────────────────────────────────── | |
| let genreEnabled = {}; | |
| let autoCounters = { playlists: 3, seeds: 3, threshold_auto: 2 }; | |
| let autoCounterLimits = { playlists: [1,10], seeds: [2,6], threshold_auto: [1,3] }; | |
| function adjustCounter(key, delta) { | |
| const [min, max] = autoCounterLimits[key]; | |
| autoCounters[key] = Math.max(min, Math.min(max, autoCounters[key] + delta)); | |
| document.getElementById(key + "-val").textContent = autoCounters[key]; | |
| } | |
| function buildGenreGrid() { | |
| const grid = document.getElementById("genre-grid"); | |
| grid.innerHTML = ""; | |
| PRESETS.forEach(p => { | |
| if (!(p.name in genreEnabled)) genreEnabled[p.name] = true; | |
| const el = document.createElement("div"); | |
| el.className = "genre-toggle " + (genreEnabled[p.name] ? "on" : "off"); | |
| el.id = "genre-" + p.index; | |
| el.innerHTML = `<div class="toggle-dot"></div><div class="genre-name">${p.name}</div>`; | |
| el.onclick = () => toggleGenre(p.name, p.index); | |
| grid.appendChild(el); | |
| }); | |
| updateGenreCount(); | |
| } | |
| function toggleGenre(name, index) { | |
| genreEnabled[name] = !genreEnabled[name]; | |
| const el = document.getElementById("genre-" + index); | |
| el.classList.toggle("on", genreEnabled[name]); | |
| el.classList.toggle("off", !genreEnabled[name]); | |
| el.querySelector(".toggle-dot").style.background = genreEnabled[name] ? "var(--accent)" : "var(--border)"; | |
| updateGenreCount(); | |
| } | |
| function toggleAllGenres(state) { | |
| PRESETS.forEach(p => { | |
| genreEnabled[p.name] = state; | |
| const el = document.getElementById("genre-" + p.index); | |
| if (el) { | |
| el.classList.toggle("on", state); | |
| el.classList.toggle("off", !state); | |
| el.querySelector(".toggle-dot").style.background = state ? "var(--accent)" : "var(--border)"; | |
| } | |
| }); | |
| updateGenreCount(); | |
| } | |
| function updateGenreCount() { | |
| const on = Object.values(genreEnabled).filter(Boolean).length; | |
| document.getElementById("genre-count").textContent = `${on} of ${PRESETS.length} enabled`; | |
| const sub = document.getElementById("playlists-count-sub"); | |
| if (sub) sub.textContent = `${on} playlist${on !== 1 ? "s" : ""} will be created`; | |
| } | |
| async function loadAutoSettings() { | |
| try { | |
| const resp = await fetch("/api/auto/settings"); | |
| const data = await resp.json(); | |
| if (data.num_seeds) { autoCounters.seeds = data.num_seeds; document.getElementById("seeds-val").textContent = data.num_seeds; } | |
| if (data.style_matches) { autoCounters.threshold_auto = data.style_matches; document.getElementById("threshold_auto-val").textContent = data.style_matches; } | |
| if (data.genre_enabled) { genreEnabled = data.genre_enabled; buildGenreGrid(); } | |
| const interval = (data.interval_days !== undefined) ? data.interval_days : 2; | |
| document.getElementById("schedule-interval").value = interval; | |
| updateScheduleDisplay(data); | |
| } catch(e) { | |
| console.log("Could not load auto settings, using defaults:", e); | |
| document.getElementById("schedule-interval").value = 2; | |
| updateScheduleDisplay({ interval_days: 2, next_run: null }); | |
| } | |
| } | |
| function scheduleChanged() {} | |
| async function saveSchedule() { | |
| const interval = parseInt(document.getElementById("schedule-interval").value); | |
| await fetch("/api/auto/schedule", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ interval_days: interval }), | |
| }); | |
| await loadAutoSettings(); | |
| } | |
| async function saveAutoSettings() { | |
| const btn = document.getElementById("auto-save-btn"); | |
| btn.disabled = true; | |
| const payload = { | |
| num_seeds: autoCounters.seeds, | |
| style_matches: autoCounters.threshold_auto, | |
| genre_enabled: genreEnabled, | |
| interval_days: parseInt(document.getElementById("schedule-interval").value), | |
| }; | |
| try { | |
| await fetch("/api/auto/settings", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(payload), | |
| }); | |
| document.getElementById("auto-save-status").textContent = "Saved!"; | |
| setTimeout(() => document.getElementById("auto-save-status").textContent = "", 2000); | |
| await loadAutoSettings(); | |
| } catch(e) { | |
| document.getElementById("auto-save-status").textContent = "Error saving."; | |
| } | |
| btn.disabled = false; | |
| } | |
| function updateScheduleDisplay(data) { | |
| const interval = data.interval_days || 0; | |
| const statusEl = document.getElementById("schedule-status"); | |
| const nextEl = document.getElementById("schedule-next"); | |
| if (!interval) { | |
| statusEl.textContent = "Scheduling off"; | |
| nextEl.textContent = ""; | |
| return; | |
| } | |
| statusEl.textContent = interval === 1 ? "Runs every day" : `Runs every ${interval} days`; | |
| if (data.next_run) { | |
| const d = new Date(data.next_run); | |
| nextEl.textContent = "Next run: " + d.toLocaleString(); | |
| } else { | |
| nextEl.textContent = "Will run on next scheduled interval"; | |
| } | |
| } | |
| async function runAuto() { | |
| const btn = document.getElementById("auto-run-btn"); | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="spinner"></span> Running...'; | |
| document.getElementById("auto-log-section").style.display = "block"; | |
| document.getElementById("auto-log-box").innerHTML = ""; | |
| document.getElementById("auto-result-banner").style.display = "none"; | |
| const payload = { | |
| num_seeds: autoCounters.seeds, | |
| style_matches: autoCounters.threshold_auto, | |
| genre_enabled: genreEnabled, | |
| }; | |
| try { | |
| const resp = await fetch("/api/auto/run", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(payload), | |
| }); | |
| const data = await resp.json(); | |
| if (data.error) throw new Error(data.error); | |
| pollJob(data.job_id, "auto-log-box", "auto-result-banner", btn, "Run now"); | |
| } catch(e) { | |
| logLine("ERROR: " + e.message, "auto-log-box"); | |
| btn.disabled = false; | |
| btn.textContent = "Run now"; | |
| } | |
| } | |
| // ── Init ───────────────────────────────────────────────── | |
| waypoints = [ | |
| { preset_index: 0, mode: "1" }, | |
| { preset_index: 10, mode: "1" }, | |
| { preset_index: 5, mode: "1" }, | |
| ]; | |
| renderWaypoints(); | |
| buildGenreGrid(); | |
| loadAutoSettings(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment