Redesign the web UI with light and dark themes

This commit is contained in:
Justin Oros
2026-09-21 14:50:28 -07:00
parent a2d9702387
commit 6e3ea306a2
+355 -158
View File
@@ -1,118 +1,276 @@
<!doctype html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Shorts Autopilot</title>
<script>
(function(){try{var t=localStorage.getItem("sa-theme");if(t!=="light"&&t!=="dark"){t=window.matchMedia&&window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark"}document.documentElement.setAttribute("data-theme",t)}catch(e){}})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700;12..96,800&display=swap" rel="stylesheet">
<style>
:root{--bg:#0f1115;--panel:#181b22;--border:#2a2f3a;--text:#e6e8ee;--muted:#8a93a6;--accent:#e53935;--ok:#43a047}
:root{
--font-ui:"Atkinson Hyperlegible",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
--font-display:"Bricolage Grotesque","Avenir Next",-apple-system,BlinkMacSystemFont,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--r-lg:18px;--r-md:12px;--r-sm:8px;
}
[data-theme="dark"]{
color-scheme:dark;
--bg:#14162a;--bar:#171a31;--surface:#1d2040;--surface-2:#252953;--sunken:#101226;--line:#2e3263;--line-soft:#252852;
--text:#eceaf6;--muted:#9d9fc4;--faint:#6e7199;
--gold:#f5b83d;--gold-text:#f5b83d;--on-gold:#221a05;
--sage:#6fcf97;--coral:#ff7a70;--sky:#7cb8ff;--violet:#b69cff;--amber:#ffb86b;
--shadow:0 1px 0 rgba(255,255,255,.03) inset,0 12px 32px -18px rgba(0,0,0,.6);
}
[data-theme="light"]{
color-scheme:light;
--bg:#f5f6fb;--bar:#ffffff;--surface:#ffffff;--surface-2:#eef0f8;--sunken:#f0f1f8;--line:#dfe1ee;--line-soft:#eaebf4;
--text:#1c1f3a;--muted:#5f6388;--faint:#8e91b0;
--gold:#f2ae2b;--gold-text:#946000;--on-gold:#221a05;
--sage:#1d8150;--coral:#cf3f35;--sky:#2c68c9;--violet:#6a4dcf;--amber:#a65f00;
--shadow:0 1px 2px rgba(28,31,58,.05),0 10px 28px -20px rgba(28,31,58,.35);
}
*{box-sizing:border-box}
body{margin:0;font-family:system-ui,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text)}
header{display:flex;align-items:center;gap:16px;padding:14px 20px;border-bottom:1px solid var(--border)}
header h1{font-size:18px;margin:0;flex:1}
nav button{background:none;border:none;color:var(--muted);padding:8px 12px;cursor:pointer;font-size:14px}
nav button.active{color:var(--text);border-bottom:2px solid var(--accent)}
main{padding:20px;max-width:1100px;margin:0 auto}
.panel{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:16px;margin-bottom:16px;overflow-x:auto}
.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.btn{border:none;border-radius:8px;padding:10px 18px;font-size:15px;cursor:pointer;color:#fff;background:#3949ab;text-decoration:none;display:inline-block}
.btn:disabled{opacity:.4;cursor:default}
.btn.go{background:var(--ok);font-size:20px;padding:14px 40px;min-width:150px}
.btn.go.running{background:var(--accent)}
.btn.go.busy{background:#e65100}
.btn.stop{background:var(--accent)}
.btn.secondary{background:#394150}
.stat{min-width:150px}
.stat .k{color:var(--muted);font-size:12px}
.stat .v{font-size:17px;margin-top:4px}
.stat .v.small{font-size:13px;word-break:break-all}
.stat select{min-width:200px;margin-top:4px}
#progress-panel{display:none}
.bar{height:10px;background:#0f1115;border:1px solid var(--border);border-radius:6px;overflow:hidden;margin-top:6px}
.bar span{display:block;height:100%;width:0;background:var(--ok);transition:width .4s ease}
.bar.done span{background:#1b5e20}
.badge{display:none;background:#6a1b9a;color:#fff;border-radius:10px;padding:3px 10px;font-size:12px}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--border);vertical-align:top}
th{color:var(--muted);font-weight:500}
html,body{margin:0}
body{font-family:var(--font-ui);font-size:15px;line-height:1.5;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased}
a{color:var(--gold-text);text-decoration-thickness:1px;text-underline-offset:3px}
:focus-visible{outline:2px solid var(--gold);outline-offset:2px;border-radius:6px}
button{font:inherit;color:inherit}
.topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:24px;padding:12px 28px;background:color-mix(in srgb,var(--bar) 88%,transparent);backdrop-filter:blur(10px);border-bottom:1px solid var(--line-soft)}
.brand{display:flex;align-items:center;gap:10px;font-family:var(--font-display);font-weight:800;font-size:19px;letter-spacing:-.01em;white-space:nowrap}
.brand-mark{width:28px;height:28px;border-radius:9px;background:var(--gold);display:grid;place-items:center;color:var(--on-gold)}
.tabs{display:flex;gap:4px;flex:1}
.tabs button{background:none;border:0;padding:8px 14px;border-radius:999px;color:var(--muted);cursor:pointer;font-size:15px}
.tabs button:hover{color:var(--text);background:var(--surface-2)}
.tabs button.active{color:var(--text);background:var(--surface-2);font-weight:700}
.icon-btn{width:38px;height:38px;border-radius:999px;border:1px solid var(--line);background:var(--surface);display:grid;place-items:center;cursor:pointer;color:var(--muted)}
.icon-btn:hover{color:var(--text);border-color:var(--faint)}
.icon-btn .sun{display:none}
[data-theme="light"] .icon-btn .sun{display:block}
[data-theme="light"] .icon-btn .moon{display:none}
main{max-width:1180px;margin:0 auto;padding:28px 28px 120px}
.tab{display:none}.tab.active{display:block}
#log{background:#0a0c10;border:1px solid var(--border);border-radius:8px;height:65vh;overflow:auto;padding:10px;font-family:Consolas,monospace;font-size:12px;white-space:pre-wrap}
.ERROR,.CRITICAL{color:#ef5350}.WARNING{color:#ffb74d}.DEBUG{color:#78909c}
label{display:block;font-size:12px;color:var(--muted);margin-bottom:4px}
input,select{width:100%;padding:8px;background:#0f1115;border:1px solid var(--border);border-radius:6px;color:var(--text)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px}
.grid h3{grid-column:1/-1;margin:10px 0 0;font-size:14px;color:var(--text);border-bottom:1px solid var(--border);padding-bottom:6px}
.grid .wide{grid-column:1/-1}
.hint{font-size:11px;color:var(--muted);margin-top:3px;word-break:break-all}
.hint.notes{color:#ffb74d;word-break:normal}
a{color:#64b5f6}
.pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#394150}
.pill.published{background:#1b5e20}.pill.failed{background:#b71c1c}.pill.running{background:#e65100}.pill.review{background:#6a1b9a}.pill.scheduled{background:#1565c0}
h2,h3{font-family:var(--font-display);letter-spacing:-.01em;margin:0}
.muted{color:var(--muted)}
.small{font-size:13px}
.btn{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--line);background:var(--surface);color:var(--text);padding:9px 16px;border-radius:999px;cursor:pointer;font-size:14px;text-decoration:none;white-space:nowrap}
.btn:hover{border-color:var(--faint)}
.btn:disabled{opacity:.5;cursor:default}
.btn.primary{background:var(--gold);border-color:var(--gold);color:var(--on-gold);font-weight:700}
.btn.primary:hover{filter:brightness(1.05)}
.btn.danger{color:var(--coral);border-color:color-mix(in srgb,var(--coral) 45%,var(--line))}
.btn.quiet{border-color:transparent;background:transparent;color:var(--muted)}
.btn.quiet:hover{color:var(--text);background:var(--surface-2)}
.btn.sm{padding:6px 12px;font-size:13px}
.console{display:grid;grid-template-columns:auto 1fr;gap:32px;align-items:center;padding:28px 32px;background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-lg);box-shadow:var(--shadow)}
.go{position:relative;width:128px;height:128px;border-radius:50%;border:0;cursor:pointer;font-family:var(--font-display);font-weight:800;font-size:28px;letter-spacing:.02em;background:var(--gold);color:var(--on-gold);box-shadow:0 0 0 8px color-mix(in srgb,var(--gold) 16%,transparent),0 18px 40px -16px color-mix(in srgb,var(--gold) 70%,transparent);transition:background .25s,box-shadow .25s,transform .12s}
.go:hover{transform:scale(1.02)}
.go:active{transform:scale(.98)}
.go.running{background:var(--coral);color:#fff;box-shadow:0 0 0 8px color-mix(in srgb,var(--coral) 18%,transparent),0 18px 40px -16px color-mix(in srgb,var(--coral) 70%,transparent)}
.go.running::after{content:"";position:absolute;inset:-8px;border-radius:50%;border:2px solid var(--coral);opacity:0;animation:pulse 2.4s ease-out infinite}
.go.busy{background:var(--surface-2);color:var(--muted);box-shadow:0 0 0 8px var(--line-soft);cursor:progress}
.go:disabled{cursor:progress}
@keyframes pulse{0%{transform:scale(1);opacity:.55}100%{transform:scale(1.28);opacity:0}}
@media (prefers-reduced-motion:reduce){.go.running::after{animation:none}.go{transition:none}}
.state{min-width:0}
.state-line{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.stage{font-family:var(--font-display);font-weight:700;font-size:28px;line-height:1.15;letter-spacing:-.015em;margin:0;overflow-wrap:anywhere}
.stage::first-letter{text-transform:uppercase}
.chip{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;font-size:12.5px;font-weight:700;background:color-mix(in srgb,var(--c,var(--muted)) 16%,transparent);color:var(--c,var(--muted));white-space:nowrap}
.chip.kids{--c:var(--violet)}
.progress{margin-top:16px;display:none}
.progress.show{display:block}
.segs{display:flex;gap:4px;height:10px}
.segs i{flex:1;border-radius:3px;background:var(--line-soft)}
.segs i.on{background:var(--gold)}
.segs i.now{background:color-mix(in srgb,var(--gold) 45%,var(--line-soft))}
.pg-meta{margin-top:8px;font-size:13px;color:var(--muted);font-variant-numeric:tabular-nums}
.idle-note{margin-top:8px;color:var(--muted);font-size:14px}
.facts{display:grid;grid-template-columns:minmax(230px,1.5fr) repeat(5,minmax(0,1fr));gap:0;margin-top:20px;border:1px solid var(--line-soft);border-radius:var(--r-md);overflow:hidden;background:var(--surface)}
.fact{padding:14px 18px;border-right:1px solid var(--line-soft);min-width:0}
.fact:last-child{border-right:0}
.fact dt{font-size:13px;color:var(--muted);margin-bottom:4px}
.fact dd{margin:0;font-weight:700;font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fact dd.warn{color:var(--amber)}
.fact select{font-weight:700}
.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px 16px;flex-wrap:wrap;margin:36px 0 12px}
.section-head h2{font-size:22px}
.section-head .actions{display:flex;gap:8px;flex-wrap:wrap}
.table-wrap{background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-md);overflow-x:auto}
table{width:100%;min-width:760px;border-collapse:collapse;font-size:14px}
th{font-weight:700;color:var(--muted);text-align:left;font-size:13px;padding:12px 16px;border-bottom:1px solid var(--line-soft);white-space:nowrap}
td{padding:14px 16px;border-bottom:1px solid var(--line-soft);vertical-align:top}
tr:last-child td{border-bottom:0}
td.nowrap{white-space:nowrap;font-variant-numeric:tabular-nums}
td.title{font-weight:700}
td .sub{font-size:12.5px;color:var(--muted);margin-top:6px;overflow-wrap:anywhere}
td .sub.notes{color:var(--amber)}
td .sub.err{color:var(--coral)}
.st-published{--c:var(--sage)}.st-failed{--c:var(--coral)}.st-running{--c:var(--gold-text)}.st-review{--c:var(--violet)}.st-scheduled{--c:var(--sky)}.st-cancelled{--c:var(--muted)}
.links{display:flex;gap:6px}
.empty{padding:36px 16px;text-align:center;color:var(--muted)}
.source a{color:var(--text);text-decoration-color:var(--line)}
.source a:hover{text-decoration-color:var(--gold)}
label{display:block}
input[type=text],input[type=password],input[type=number],select{width:100%;padding:10px 12px;background:var(--sunken);border:1px solid var(--line);border-radius:var(--r-sm);color:var(--text);font:inherit;font-size:14.5px}
select{appearance:none;background-image:linear-gradient(45deg,transparent 50%,var(--muted) 50%),linear-gradient(135deg,var(--muted) 50%,transparent 50%);background-position:calc(100% - 17px) 55%,calc(100% - 12px) 55%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:34px}
input:focus,select:focus{outline:none;border-color:var(--gold);box-shadow:0 0 0 3px color-mix(in srgb,var(--gold) 22%,transparent)}
input:disabled,select:disabled{opacity:.55}
.settings{display:grid;grid-template-columns:220px 1fr;gap:36px;align-items:start}
.snav{position:sticky;top:84px;display:flex;flex-direction:column;gap:2px}
.snav a{display:block;padding:8px 12px;border-radius:var(--r-sm);color:var(--muted);text-decoration:none;font-size:14.5px}
.snav a:hover{color:var(--text);background:var(--surface-2)}
.snav a.active{color:var(--text);background:var(--surface-2);font-weight:700;box-shadow:inset 3px 0 0 var(--gold)}
.profile-bar{display:flex;gap:12px;align-items:end;flex-wrap:wrap;padding:18px 20px;background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-md);margin-bottom:28px}
.profile-bar .grow{flex:1;min-width:220px}
.profile-bar .lbl{font-size:13px;color:var(--muted);margin-bottom:6px}
.group{padding:4px 0 28px;margin-bottom:28px;border-bottom:1px solid var(--line-soft);scroll-margin-top:90px}
.group:last-child{border-bottom:0}
.group h3{font-size:20px}
.group .desc{color:var(--muted);font-size:14px;margin:4px 0 18px}
.fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 24px}
.field.wide{grid-column:1/-1}
.field .lbl{font-weight:700;font-size:14px;margin-bottom:6px}
.field .hint{font-size:13px;color:var(--muted);margin-top:6px}
.field .hint.path{font-family:var(--font-mono);font-size:12px;overflow-wrap:anywhere}
.field{transition:opacity .2s}
.toggle{display:flex;align-items:flex-start;gap:12px;cursor:pointer}
.toggle input{position:absolute;opacity:0;pointer-events:none}
.track{flex:0 0 auto;width:40px;height:24px;border-radius:999px;background:var(--line);position:relative;transition:background .2s;margin-top:1px}
.track::after{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.3);transition:transform .2s}
.toggle input:checked+.track{background:var(--gold)}
.toggle input:checked+.track::after{transform:translateX(16px)}
.toggle input:focus-visible+.track{outline:2px solid var(--gold);outline-offset:2px}
.toggle input:disabled+.track{opacity:.5}
.toggle .tl{font-weight:700;font-size:14px}
.days{display:flex;gap:6px;flex-wrap:wrap}
.day input{position:absolute;opacity:0;pointer-events:none}
.day span{display:inline-block;padding:7px 12px;border-radius:999px;border:1px solid var(--line);background:var(--sunken);font-size:13.5px;cursor:pointer;min-width:52px;text-align:center}
.day input:checked+span{background:color-mix(in srgb,var(--gold) 22%,transparent);border-color:var(--gold);color:var(--text);font-weight:700}
.day input:focus-visible+span{outline:2px solid var(--gold);outline-offset:2px}
.day input:disabled+span{opacity:.5;cursor:default}
.savebar{position:fixed;left:0;right:0;bottom:0;z-index:15;display:none;justify-content:center;padding:14px 28px;background:color-mix(in srgb,var(--bar) 92%,transparent);backdrop-filter:blur(10px);border-top:1px solid var(--line-soft)}
.savebar.show{display:flex}
.savebar .inner{width:100%;max-width:1180px;display:flex;align-items:center;justify-content:flex-end;gap:14px}
.saved{font-size:14px;color:var(--muted)}
.saved.ok{color:var(--sage)}.saved.bad{color:var(--coral)}
.logbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px}
.logbar .spacer{flex:1}
.check{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:14px;cursor:pointer}
.check input{accent-color:var(--gold);width:16px;height:16px}
#log{background:var(--sunken);border:1px solid var(--line-soft);border-radius:var(--r-md);height:calc(100vh - 230px);min-height:320px;overflow:auto;padding:14px 16px;font-family:var(--font-mono);font-size:12.5px;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere}
#log .ERROR,#log .CRITICAL{color:var(--coral)}
#log .WARNING{color:var(--amber)}
#log .DEBUG{color:var(--faint)}
.trend{margin-top:12px}
@media (max-width:860px){
.topbar{padding:10px 16px;gap:12px}
.brand span{display:none}
main{padding:20px 16px 120px}
.console{grid-template-columns:1fr;justify-items:start;padding:22px}
.settings{grid-template-columns:1fr}
.snav{position:static;flex-direction:row;overflow-x:auto;gap:6px}
.snav a{white-space:nowrap}
.fields{grid-template-columns:1fr}
.facts{grid-template-columns:1fr 1fr}
.fact{border-right:0;border-bottom:1px solid var(--line-soft)}
.fact:first-child{grid-column:1/-1}
.go{width:104px;height:104px;font-size:24px}
.stage{font-size:24px}
}
</style>
</head>
<body>
<header>
<h1>Shorts Autopilot</h1>
<nav>
<header class="topbar">
<div class="brand"><div class="brand-mark" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 14 14"><path d="M3 1.8v10.4c0 .6.7 1 1.2.7l8.3-5.2c.5-.3.5-1.1 0-1.4L4.2 1.1C3.7.8 3 1.2 3 1.8z" fill="currentColor"/></svg></div><span>Shorts Autopilot</span></div>
<nav class="tabs" aria-label="Sections">
<button data-tab="dash" class="active">Dashboard</button>
<button data-tab="settings">Settings</button>
<button data-tab="logs">Log</button>
</nav>
<button class="icon-btn" id="theme" aria-label="Switch between light and dark mode" title="Switch theme">
<svg class="moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
<svg class="sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4.5"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
</button>
</header>
<main>
<section id="dash" class="tab active">
<div class="panel row">
<button id="go" class="btn go">GO</button>
<div class="stat"><div class="k">Active profile</div><select id="prof-dash" class="prof-select"></select></div>
<div class="stat"><div class="k">Status</div><div class="v" id="st-stage">idle</div></div>
<div class="stat"><div class="k">Engine</div><div class="v" id="st-engine"></div></div>
<div class="stat"><div class="k">Published today</div><div class="v" id="st-today">0</div></div>
<div class="stat"><div class="k">Channel</div><div class="v" id="st-channel">Not linked</div></div>
<span class="badge" id="kids-badge">Made for kids: compliance checks on</span>
<div class="console">
<button id="go" class="go" aria-live="polite">GO</button>
<div class="state">
<div class="state-line"><p class="stage" id="st-stage">Idle</p><span class="chip kids" id="kids-badge" hidden>Made for kids checks on</span></div>
<div class="progress" id="progress"><div class="segs" id="pg-segs"></div><div class="pg-meta" id="pg-meta"></div></div>
<div class="idle-note" id="idle-note">Press GO to start making videos for the active profile.</div>
</div>
<div class="panel" id="progress-panel">
<div class="row" style="justify-content:space-between"><div id="pg-stage" style="font-size:15px"></div><div class="hint" id="pg-meta"></div></div>
<div class="bar"><span id="pg-fill"></span></div>
</div>
<div class="panel row">
<div class="stat"><div class="k">Storage folder</div><div class="v small" id="st-storage"></div></div>
<div class="stat"><div class="k">Free space</div><div class="v" id="st-free">?</div></div>
<div class="stat"><div class="k">After publishing</div><div class="v" id="st-after"></div></div>
<dl class="facts">
<div class="fact"><dt>Active profile</dt><dd><select id="prof-dash" class="prof-select" aria-label="Active profile"></select></dd></div>
<div class="fact"><dt>YouTube channel</dt><dd id="st-channel">Not linked</dd></div>
<div class="fact"><dt>Published today</dt><dd id="st-today">0</dd></div>
<div class="fact"><dt>Video engine</dt><dd id="st-engine"></dd></div>
<div class="fact"><dt>Free space</dt><dd id="st-free">?</dd></div>
<div class="fact"><dt>After publishing</dt><dd id="st-after"></dd></div>
</dl>
<div class="section-head">
<h2>Jobs</h2>
<div class="actions">
<button class="btn sm" id="link">Link YouTube channel</button>
<button class="btn sm" id="preview">Preview trending videos</button>
<button class="btn sm quiet" id="clear-jobs">Clear list</button>
</div>
<div class="panel row">
<button class="btn secondary" id="link">Link YouTube Channel</button>
<button class="btn secondary" id="preview">Preview Trending Videos</button>
<span class="hint" id="preview-msg"></span>
</div>
<div class="panel" id="trending-panel" style="display:none"><h3>Trending</h3><table id="trending"></table></div>
<div class="panel"><div class="row" style="justify-content:space-between"><h3 style="margin:0">Jobs</h3><button class="btn secondary" id="clear-jobs" style="padding:6px 12px;font-size:13px">Clear View</button></div><table id="jobs" style="margin-top:10px"></table></div>
<div class="table-wrap"><table id="jobs"></table></div>
<div id="trending-panel" hidden>
<div class="section-head"><h2>Trending right now</h2><span class="muted small" id="preview-msg"></span></div>
<div class="table-wrap"><table id="trending"></table></div>
</div>
<p class="muted small" id="st-storage" style="margin-top:18px"></p>
</section>
<section id="settings" class="tab">
<div class="panel row">
<div class="stat"><div class="k">Editing profile</div><select id="prof-set" class="prof-select"></select></div>
<button class="btn secondary" id="new-profile">New profile</button>
<button class="btn stop" id="del-profile">Delete profile</button>
<div class="settings">
<nav class="snav" id="snav" aria-label="Settings sections"></nav>
<div>
<div class="profile-bar">
<div class="grow"><div class="lbl">Editing profile</div><select id="prof-set" class="prof-select" aria-label="Editing profile"></select></div>
<button class="btn" id="new-profile">New profile</button>
<button class="btn danger" id="del-profile">Delete profile</button>
</div>
<div id="settings-grid"></div>
</div>
<div class="panel">
<div class="grid" id="settings-grid"></div>
<div class="row" style="margin-top:16px"><button class="btn" id="save">Save settings</button><span id="saved" class="hint"></span></div>
</div>
</section>
<section id="logs" class="tab">
<div class="panel row">
<label style="margin:0;display:flex;gap:6px;align-items:center"><input type="checkbox" id="autoscroll" checked style="width:auto">Auto scroll</label>
<button class="btn secondary" id="copy-log">Copy Log</button>
<button class="btn secondary" id="clear">Clear View</button>
<a href="/api/logs/download">Download full log</a>
<span class="hint" id="copy-msg"></span>
<div class="logbar">
<button class="btn sm" id="copy-log">Copy log</button>
<button class="btn sm" id="clear">Clear view</button>
<a class="btn sm quiet" href="/api/logs/download">Download full log</a>
<span class="muted small" id="copy-msg"></span>
<span class="spacer"></span>
<label class="check"><input type="checkbox" id="autoscroll" checked>Follow new lines</label>
</div>
<div id="log"></div>
<div id="log" role="log" aria-live="off"></div>
</section>
</main>
<div class="savebar" id="savebar"><div class="inner"><span class="saved" id="saved"></span><button class="btn primary" id="save">Save settings</button></div></div>
<script>
const CATS="1=Film and Animation|2=Autos and Vehicles|10=Music|15=Pets and Animals|17=Sports|19=Travel and Events|20=Gaming|22=People and Blogs|23=Comedy|24=Entertainment|25=News and Politics|26=Howto and Style|27=Education|28=Science and Technology";
const FIELDS=[
["","Profile: audience and channel","header","",""],
["","Channel and audience","header","Applies to the profile selected above",""],
["name","Profile name","text","","p"],
["channel_niche","Channel niche","wide","Steers every script, e.g. 60 second animated movies for kids ages 3 to 7","p"],
["made_for_kids","Made for kids","checkbox","Flags uploads as made for kids and enforces child-safe rules, word filters, LLM compliance review, and frame vision checks","p"],
@@ -130,26 +288,26 @@ const FIELDS=[
["trending_count","Videos to scan","select:10|25|50","","p"],
["videos_per_day","Videos per day","select:0=Unlimited|1|2|3|4|5|6|8|10|12|15|20","The default YouTube API quota allows about 6 uploads a day. Higher values need a quota increase from Google","p"],
["minutes_between_videos","Minutes between videos","select:0=No gap|30|60|120|180|240|360","","p"],
["","Publishing schedule","header","",""],
["","Publishing schedule","header","When finished videos go public on this profile's channel",""],
["publish_mode","When to publish","select:immediate=As soon as it is uploaded|scheduled=At set days and times","Scheduled videos upload as private and YouTube makes them public at the next open time slot","p"],
["publish_timezone","Publishing time zone","select:America/New_York=Eastern (US and Canada)|America/Chicago=Central (US and Canada)|America/Denver=Mountain (US and Canada)|America/Phoenix=Arizona|America/Los_Angeles=Pacific (US and Canada)|America/Anchorage=Alaska|Pacific/Honolulu=Hawaii|America/Halifax=Atlantic (Canada)|America/Mexico_City=Mexico City|Europe/London=London|Europe/Berlin=Central Europe|Asia/Kolkata=India|Australia/Sydney=Sydney|UTC=UTC","Times below are in this zone, pick your audience's","p"],
["publish_days","Publishing days","days","","p"],
["publish_time_1","Publish time 1","time","","p"],
["publish_time_2","Publish time 2","time:opt","Optional","p"],
["publish_time_3","Publish time 3","time:opt","Optional","p"],
["","Working hours (shared by all profiles)","header","",""],
["","Working hours","header","Shared by all profiles",""],
["work_hours_enabled","Only work during set hours","checkbox","Outside these hours no new video is started. A video already in progress finishes first. Uses this computer's clock","g"],
["work_days","Working days","days","","g"],
["work_start","Start work at","time","","g"],
["work_end","Stop starting new videos at","time","If this is earlier than the start time, the window runs overnight","g"],
["","Folders (shared by all profiles)","header","",""],
["","Folders and storage","header","Shared by all profiles",""],
["storage_dir","Storage folder","wide","Full path on a large disk, e.g. /Volumes/iDrive/shorts-autopilot. Jobs are built in output/ and moved to published/","g"],
["models_dir","Models folder","wide","Where AI models download to (several GB). Blank puts them in the storage folder","g"],
["temp_dir","Temp folder","wide","Scratch space used while rendering. Blank puts it in the storage folder","g"],
["delete_after_publish","Delete videos after publishing","checkbox","Removes the job folder once the upload succeeds instead of moving it to published","g"],
["keep_clips","Keep individual clips","checkbox","Keeps the scene clips, images, and narration alongside final.mp4","g"],
["auto_install","Install missing pieces automatically","checkbox","Downloads models and installs packages when a run needs them","g"],
["","Video engine (shared by all profiles)","header","",""],
["","Video engine","header","Shared by all profiles",""],
["video_engine","Video engine","select:local=Local (free: AI images, narration, motion)|veo=Veo (paid Google API)","Local runs entirely on this machine at no cost. Veo makes true AI video but needs billing","g"],
["sd_model","Image model","select:stabilityai/sdxl-turbo=Fast (SDXL Turbo)|stabilityai/stable-diffusion-xl-base-1.0=Best quality (SDXL Base, slower)","Picking a model fills in its recommended steps, guidance, and size. First use downloads about 7 GB","g"],
["sd_steps","Image steps","select:4|6|8|20|25|30","4 to 8 for Fast, 20 to 30 for Best quality","g"],
@@ -162,7 +320,7 @@ const FIELDS=[
["piper_model","Piper voice file","wide","Blank uses the first voice found in the voices folder under Models","g"],
["gemini_api_key","Gemini API key (Veo)","password","Only needed for the Veo engine. Billing must be enabled","g"],
["veo_model","Veo model","text","e.g. veo-3.1-fast-generate-preview","g"],
["","Text and shared options","header","",""],
["","Writing and timing","header","Shared by all profiles",""],
["ollama_url","Ollama URL","text","","g"],
["ollama_model","Ollama model","model:text","Writes scripts and tags. Models not installed yet are downloaded automatically","g"],
["vision_model","Ollama vision model","model:vision","Checks every scene on made-for-kids profiles. qwen2.5vl:7b is recommended, moondream is too weak to judge anatomy","g"],
@@ -174,51 +332,76 @@ const FIELDS=[
];
const DAYS=[["mon","Mon"],["tue","Tue"],["wed","Wed"],["thu","Thu"],["fri","Fri"],["sat","Sat"],["sun","Sun"]];
const TIMES=[];for(let h=0;h<24;h++)for(const m of [0,30]){const v=String(h).padStart(2,"0")+":"+String(m).padStart(2,"0");const hr=h%12||12;TIMES.push([v,hr+":"+String(m).padStart(2,"0")+(h<12?" AM":" PM")])}
const NUMERIC=new Set(["sd_steps","sd_guidance","sd_width","sd_height","trending_days","trending_count","videos_per_day","minutes_between_videos","target_seconds","clip_seconds","ollama_timeout","error_cooldown_minutes","tts_rate"]);
const $=s=>document.querySelector(s);
const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
async function api(path,opts={}){const r=await fetch(path,{headers:{"Content-Type":"application/json"},...opts});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.detail||r.statusText);return j}
document.querySelectorAll("nav button").forEach(b=>b.onclick=()=>{document.querySelectorAll("nav button").forEach(x=>x.classList.remove("active"));document.querySelectorAll(".tab").forEach(x=>x.classList.remove("active"));b.classList.add("active");$("#"+b.dataset.tab).classList.add("active")});
function opt(o){const i=o.indexOf("=");return i<0?[o,o]:[o.slice(0,i),o.slice(i+1)]}
$("#theme").onclick=()=>{const next=document.documentElement.getAttribute("data-theme")==="light"?"dark":"light";document.documentElement.setAttribute("data-theme",next);try{localStorage.setItem("sa-theme",next)}catch(e){}};
function showTab(name){
document.querySelectorAll(".tabs button").forEach(x=>x.classList.toggle("active",x.dataset.tab===name));
document.querySelectorAll(".tab").forEach(x=>x.classList.toggle("active",x.id===name));
$("#savebar").classList.toggle("show",name==="settings");
try{localStorage.setItem("sa-tab",name)}catch(e){}}
document.querySelectorAll(".tabs button").forEach(b=>b.onclick=()=>showTab(b.dataset.tab));
function renderProfiles(list,active){
document.querySelectorAll(".prof-select").forEach(sel=>{sel.innerHTML=list.map(p=>`<option value="${esc(p.id)}" ${p.id===active?"selected":""}>${esc(p.name)}${p.channel?" ("+esc(p.channel)+")":""}</option>`).join("")})}
document.querySelectorAll(".prof-select").forEach(sel=>{sel.innerHTML=list.map(p=>`<option value="${esc(p.id)}" ${p.id===active?"selected":""}>${esc(p.name)}${p.channel&&p.channel!==p.name?" ("+esc(p.channel)+")":""}</option>`).join("")})}
function fieldInput(k,type,s,data){
if(type.startsWith("select:")){const list=type.slice(7).split("|").map(opt);const cur=String(s[k]??"");if(cur!==""&&!list.some(([v])=>v===cur))list.unshift([cur,cur+" (current)"]);return `<select id="f-${k}">${list.map(([v,l])=>`<option value="${esc(v)}" ${cur===v?"selected":""}>${esc(l)}</option>`).join("")}</select>`}
if(type==="days"){const on=new Set(String(s[k]||"").split(","));return `<div class="days" id="f-${k}">${DAYS.map(([v,l])=>`<label class="day"><input type="checkbox" value="${v}" ${on.has(v)?"checked":""}><span>${l}</span></label>`).join("")}</div>`}
if(type.startsWith("time")){const cur=String(s[k]||"");const opts=(type==="time:opt"?[["","None"]]:[]).concat(TIMES);if(cur&&!opts.some(([v])=>v===cur))opts.push([cur,cur]);return `<select id="f-${k}">${opts.map(([v,l])=>`<option value="${esc(v)}" ${cur===v?"selected":""}>${esc(l)}</option>`).join("")}</select>`}
if(type.startsWith("model:")){
const kind=type.slice(6);
const all=data.installed_models||[];const vis=new Set(data.installed_vision_models||[]);
const installed=kind==="vision"?all.filter(m=>vis.has(m)):all.filter(m=>!vis.has(m));
const suggested=(kind==="vision"?data.suggested_vision_models:data.suggested_text_models)||[];
const cur=String(s[k]||"");const seen=new Set();const opts=[];
for(const m of installed){if(seen.has(m))continue;seen.add(m);opts.push(`<option value="${esc(m)}" ${cur===m?"selected":""}>${esc(m)}</option>`)}
for(const m of suggested){if(seen.has(m)||seen.has(m+":latest"))continue;seen.add(m);opts.push(`<option value="${esc(m)}" ${cur===m?"selected":""}>${esc(m)} (downloads on next run)</option>`)}
if(cur&&!seen.has(cur))opts.unshift(`<option value="${esc(cur)}" selected>${esc(cur)} (current)</option>`);
return `<select id="f-${k}">${opts.join("")}</select>`}
return `<input type="${type==="wide"?"text":type}" id="f-${k}" value="${esc(s[k])}">`}
let spy=null;
async function loadSettings(){
const data=await api("/api/settings");
renderProfiles(data.profiles,data.active);
const g=$("#settings-grid");g.innerHTML="";
const root=$("#settings-grid");root.innerHTML="";
const nav=$("#snav");nav.innerHTML="";
let grid=null,n=0;
for(const [k,label,type,hint,scope] of FIELDS){
if(type==="header"){const h=document.createElement("h3");h.textContent=label;g.appendChild(h);continue}
if(type==="header"){
n++;const sec=document.createElement("section");sec.className="group";sec.id="sec-"+n;
sec.innerHTML=`<h3>${esc(label)}</h3>${hint?`<p class="desc">${esc(hint)}</p>`:""}<div class="fields"></div>`;
root.appendChild(sec);grid=sec.querySelector(".fields");
const a=document.createElement("a");a.href="#sec-"+n;a.textContent=label;a.dataset.sec="sec-"+n;
a.onclick=e=>{e.preventDefault();sec.scrollIntoView({behavior:matchMedia("(prefers-reduced-motion: reduce)").matches?"auto":"smooth",block:"start"})};
nav.appendChild(a);continue}
const s=scope==="g"?data.global:data.profile;
let input;
if(type.startsWith("select:")){const list=type.slice(7).split("|").map(opt);const cur=String(s[k]??"");if(!list.some(([v])=>v===cur))list.unshift([cur,cur+" (current)"]);input=`<select id="f-${k}">${list.map(([v,l])=>`<option value="${esc(v)}" ${cur===v?"selected":""}>${esc(l)}</option>`).join("")}</select>`}
else if(type==="checkbox"){input=`<input type="checkbox" id="f-${k}" ${s[k]?"checked":""} style="width:auto">`}
else if(type==="days"){const on=new Set(String(s[k]||"").split(","));input=`<div class="row" id="f-${k}" data-days="1" style="gap:10px">${DAYS.map(([v,l])=>`<label style="margin:0;display:flex;gap:4px;align-items:center;color:var(--text)"><input type="checkbox" value="${v}" ${on.has(v)?"checked":""} style="width:auto">${l}</label>`).join("")}</div>`}
else if(type.startsWith("time")){const cur=String(s[k]||"");const opts=(type==="time:opt"?[["","None"]]:[]).concat(TIMES);if(cur&&!opts.some(([v])=>v===cur))opts.push([cur,cur]);input=`<select id="f-${k}">${opts.map(([v,l])=>`<option value="${esc(v)}" ${cur===v?"selected":""}>${esc(l)}</option>`).join("")}</select>`}
else if(type.startsWith("model:")){
const kind=type.slice(6);
const allInstalled=data.installed_models||[];const vis=new Set(data.installed_vision_models||[]);
const installed=kind==="vision"?allInstalled.filter(m=>vis.has(m)):allInstalled.filter(m=>!vis.has(m));
const suggested=(kind==="vision"?data.suggested_vision_models:data.suggested_text_models)||[];
const cur=String(s[k]||"");
const seen=new Set();
const opts=[];
for(const m of installed){if(seen.has(m))continue;seen.add(m);opts.push(`<option value="${esc(m)}" ${cur===m?"selected":""}>${esc(m)}</option>`)}
for(const m of suggested){if(seen.has(m)||seen.has(m+":latest"))continue;seen.add(m);opts.push(`<option value="${esc(m)}" ${cur===m?"selected":""}>${esc(m)} (not installed)</option>`)}
if(cur&&!seen.has(cur))opts.unshift(`<option value="${esc(cur)}" selected>${esc(cur)} (not installed)</option>`);
input=`<select id="f-${k}">${opts.join("")}</select>`}
else{input=`<input type="${type==="wide"?"text":type}" id="f-${k}" value="${esc(s[k])}">`}
const d=document.createElement("div");d.className="field"+(type==="wide"||type==="days"?" wide":"");
let extra="";
if(k==="storage_dir")extra=`<div class="hint">Resolves to: ${esc(data.storage_resolved)}</div>`;
if(k==="models_dir")extra=`<div class="hint">Resolves to: ${esc(data.models_resolved)}</div>`;
if(k==="temp_dir")extra=`<div class="hint">Resolves to: ${esc(data.temp_resolved)}</div>`;
const d=document.createElement("div");if(type==="wide")d.className="wide";
d.innerHTML=`<label>${label}</label>${input}${hint?`<div class="hint">${esc(hint)}</div>`:""}${extra}`;g.appendChild(d)}
if(k==="storage_dir")extra=`<div class="hint path">${esc(data.storage_resolved)}</div>`;
if(k==="models_dir")extra=`<div class="hint path">${esc(data.models_resolved)}</div>`;
if(k==="temp_dir")extra=`<div class="hint path">${esc(data.temp_resolved)}</div>`;
if(type==="checkbox"){
d.innerHTML=`<label class="toggle"><input type="checkbox" id="f-${k}" ${s[k]?"checked":""}><span class="track"></span><span><span class="tl">${esc(label)}</span>${hint?`<span class="hint" style="display:block">${esc(hint)}</span>`:""}</span></label>`}
else{
d.innerHTML=`<label class="lbl" for="f-${k}">${esc(label)}</label>${fieldInput(k,type,s,data)}${hint?`<div class="hint">${esc(hint)}</div>`:""}${extra}`}
grid.appendChild(d)}
if(spy)spy.disconnect();
spy=new IntersectionObserver(es=>{for(const e of es){if(e.isIntersecting){nav.querySelectorAll("a").forEach(a=>a.classList.toggle("active",a.dataset.sec===e.target.id))}}},{rootMargin:"-90px 0px -65% 0px"});
root.querySelectorAll(".group").forEach(g=>spy.observe(g));
const first=nav.querySelector("a");if(first)first.classList.add("active");
syncFields()}
function dim(ids,off){for(const id of ids){const el=$(id);if(!el)continue;el.querySelectorAll("input").forEach(x=>x.disabled=off);if(el.tagName!=="DIV")el.disabled=off;const f=el.closest(".field");if(f)f.style.opacity=off?.5:1}}
function syncFields(){
const kids=$("#f-made_for_kids");
if(kids){
for(const id of ["#f-kids_manual_review","#f-kids_llm_review"]){const el=$(id);if(!el)continue;el.disabled=!kids.checked;el.closest("div").style.opacity=kids.checked?1:.5}
kids.onchange=syncFields}
const dim=(ids,off)=>{for(const id of ids){const el=$(id);if(!el)continue;el.querySelectorAll?el.querySelectorAll("input").forEach(x=>x.disabled=off):0;if(el.tagName!=="DIV")el.disabled=off;el.closest(".grid > div").style.opacity=off?.5:1}};
if(kids){dim(["#f-kids_manual_review","#f-kids_llm_review"],!kids.checked);kids.onchange=syncFields}
const wh=$("#f-work_hours_enabled");
if(wh){dim(["#f-work_days","#f-work_start","#f-work_end"],!wh.checked);wh.onchange=syncFields}
const pm=$("#f-publish_mode");
@@ -229,45 +412,56 @@ const best=sdm.value.includes("stable-diffusion-xl-base");
const set=(id,v)=>{const el=$(id);if(!el)return;if(![...el.options].some(o=>o.value===v)){const o=document.createElement("option");o.value=v;o.textContent=v;el.appendChild(o)}el.value=v};
set("#f-sd_steps",best?"25":"4");set("#f-sd_guidance",best?"7":"2");set("#f-sd_width",best?"768":"704");set("#f-sd_height",best?"1344":"1216")})}
const eng=$("#f-video_engine");
if(eng){
const local=eng.value==="local";
for(const id of ["#f-sd_model","#f-sd_steps","#f-sd_guidance","#f-sd_width","#f-sd_height","#f-tts_engine","#f-tts_voice","#f-tts_rate","#f-piper_model"]){const el=$(id);if(!el)continue;el.disabled=!local;el.closest("div").style.opacity=local?1:.5}
for(const id of ["#f-gemini_api_key","#f-veo_model"]){const el=$(id);if(!el)continue;el.disabled=local;el.closest("div").style.opacity=local?.5:1}
if(eng){const local=eng.value==="local";
dim(["#f-sd_model","#f-sd_steps","#f-sd_guidance","#f-sd_width","#f-sd_height","#f-tts_engine","#f-tts_voice","#f-tts_rate","#f-piper_model"],!local);
dim(["#f-gemini_api_key","#f-veo_model"],local);
eng.onchange=syncFields}}
async function saveSettings(){
const body={global:{},profile:{}};
const NUMERIC=new Set(["sd_steps","sd_guidance","sd_width","sd_height","trending_days","trending_count","videos_per_day","minutes_between_videos","target_seconds","clip_seconds","ollama_timeout","error_cooldown_minutes","tts_rate"]);
for(const [k,,type,,scope] of FIELDS){if(type==="header")continue;const el=$("#f-"+k);const dest=body[scope==="g"?"global":"profile"];
for(const [k,,type,,scope] of FIELDS){if(type==="header")continue;const el=$("#f-"+k);if(!el)continue;const dest=body[scope==="g"?"global":"profile"];
if(type==="days"){dest[k]=[...el.querySelectorAll("input:checked")].map(x=>x.value).join(",");continue}
dest[k]=type==="checkbox"?el.checked:(type==="number"||NUMERIC.has(k))?Number(el.value):el.value}
try{await api("/api/settings",{method:"POST",body:JSON.stringify(body)});await loadSettings();$("#saved").textContent="Saved "+new Date().toLocaleTimeString();refresh()}catch(e){$("#saved").textContent="Error: "+e.message}}
const msg=$("#saved");const btn=$("#save");btn.disabled=true;
try{await api("/api/settings",{method:"POST",body:JSON.stringify(body)});await loadSettings();msg.className="saved ok";msg.textContent="Saved at "+new Date().toLocaleTimeString([], {hour:"numeric",minute:"2-digit"});refresh()}
catch(e){msg.className="saved bad";msg.textContent=e.message}
btn.disabled=false}
async function activate(id){try{await api("/api/profiles/activate",{method:"POST",body:JSON.stringify({id})})}catch(e){alert(e.message)}await loadSettings();refresh()}
const STATUS_TEXT={review:"Private, needs review",published:"Published",failed:"Failed",running:"Running",cancelled:"Cancelled",scheduled:"Scheduled"};
function fmtDur(d){d=Math.max(0,d|0);const h=Math.floor(d/3600),m=Math.floor(d%3600/60),s=d%60;return h?`${h}h ${m}m`:m?`${m}m ${s}s`:`${s}s`}
function fmtStart(t){if(!t)return"";const d=new Date(t.replace(" ","T"));if(isNaN(d))return t;return d.toLocaleString([], {month:"short",day:"numeric",hour:"numeric",minute:"2-digit"})}
async function refresh(){
try{
const s=await api("/api/status");
$("#st-stage").textContent=s.stage;
const stage=s.stage||"idle";
$("#st-stage").textContent=stage;
$("#st-engine").textContent=s.engine==="local"?"Local (free)":"Veo (paid)";
$("#st-today").textContent=s.published_today+(s.videos_per_day>0?" / "+s.videos_per_day:"");
$("#st-today").textContent=s.published_today+(s.videos_per_day>0?" of "+s.videos_per_day:"");
$("#st-channel").textContent=s.channel||"Not linked";
$("#st-storage").textContent=s.storage;
$("#st-free").textContent=s.storage_free_gb===null?"not created yet":s.storage_free_gb+" GB";
$("#st-free").style.color=s.storage_free_gb!==null&&s.storage_free_gb<10?"#ffb74d":"";
const free=$("#st-free");free.textContent=s.storage_free_gb===null?"Folder not created":s.storage_free_gb.toLocaleString()+" GB";free.classList.toggle("warn",s.storage_free_gb!==null&&s.storage_free_gb<10);
$("#st-after").textContent=s.delete_after_publish?"Delete files":"Move to published";
$("#kids-badge").style.display=s.made_for_kids?"inline-block":"none";
const panel=$("#progress-panel");
$("#st-storage").textContent=s.storage?"Videos are stored in "+s.storage:"";
$("#kids-badge").hidden=!s.made_for_kids;
const pg=$("#progress");const note=$("#idle-note");
if(s.steps>0&&(s.running||s.step<s.steps)){
panel.style.display="block";
pg.classList.add("show");note.hidden=true;
const segs=$("#pg-segs");if(segs.childElementCount!==s.steps){segs.innerHTML="<i></i>".repeat(s.steps)}
[...segs.children].forEach((el,i)=>{el.className=i<s.step-1?"on":i===s.step-1?"now":""});
const pct=Math.min(100,Math.round(s.step*100/s.steps));
$("#pg-fill").style.width=pct+"%";
$("#pg-stage").textContent=s.stage;
const mins=Math.floor(s.elapsed/60),secs=s.elapsed%60;
$("#pg-meta").textContent=pct+"% | step "+s.step+" of "+s.steps+(s.elapsed?" | "+(mins?mins+"m ":"")+secs+"s":"");
}else{panel.style.display="none"}
const lb=$("#link");lb.textContent=s.channel?"Unlink YouTube Channel":"Link YouTube Channel";lb.dataset.linked=s.channel?"1":"";
$("#pg-meta").textContent=`Step ${s.step} of ${s.steps}, ${pct}% done`+(s.elapsed?`, running for ${fmtDur(s.elapsed)}`:"");
}else{pg.classList.remove("show");note.hidden=false;note.textContent=s.running?"Waiting for the next video to start.":"Press GO to start making videos for the active profile."}
const lb=$("#link");lb.textContent=s.channel?"Unlink YouTube channel":"Link YouTube channel";lb.dataset.linked=s.channel?"1":"";
setButton(s.running);
const dur=h=>{if(!h.started_ts)return"";const end=h.finished_ts||(h.status==="running"?s.now:0);if(!end)return"";const d=Math.max(0,end-h.started_ts);const m=Math.floor(d/60),sec=d%60;return(m?m+"m ":"")+sec+"s"};
$("#jobs").innerHTML="<tr><th>Started</th><th>Time</th><th>Profile</th><th>Source</th><th>Title</th><th>Status</th><th>Video</th></tr>"+s.history.map(h=>`<tr><td>${esc(h.started)}</td><td style="white-space:nowrap">${esc(dur(h))}</td><td>${esc(h.profile)}</td><td><a target="_blank" href="https://youtube.com/watch?v=${esc(h.source_id)}">${esc(h.source_title)}</a></td><td>${esc(h.title)}</td><td><span class="pill ${esc(h.status)}">${h.status==="review"?"private, needs review":h.status==="scheduled"&&h.publish_at?"scheduled "+new Date(h.publish_at).toLocaleString([], {weekday:"short",month:"short",day:"numeric",hour:"numeric",minute:"2-digit"}):esc(h.status)}</span>${h.notes?`<div class="hint notes">Reviewer notes: ${esc(h.notes)}</div>`:""}${h.error?`<div class="hint">${esc(h.error)}</div>`:""}${h.location?`<div class="hint">${esc(h.location)}</div>`:""}</td><td>${h.youtube_id?`<a target="_blank" href="https://studio.youtube.com/video/${esc(h.youtube_id)}/edit">studio</a> <a target="_blank" href="https://youtube.com/shorts/${esc(h.youtube_id)}">open</a>`:""}</td></tr>`).join("")
const dur=h=>{if(!h.started_ts)return"";const end=h.finished_ts||(h.status==="running"?s.now:0);return end?fmtDur(end-h.started_ts):""};
const rows=s.history.map(h=>{
const label=h.status==="scheduled"&&h.publish_at?"Scheduled for "+new Date(h.publish_at).toLocaleString([], {weekday:"short",month:"short",day:"numeric",hour:"numeric",minute:"2-digit"}):(STATUS_TEXT[h.status]||h.status);
return `<tr><td class="nowrap">${esc(fmtStart(h.started))}</td><td class="nowrap">${esc(dur(h))}</td><td class="title">${esc(h.title||"Writing script")}<div class="sub">${esc(h.profile)}</div></td><td class="source"><a target="_blank" rel="noopener" href="https://youtube.com/watch?v=${esc(h.source_id)}">${esc(h.source_title)}</a></td><td><span class="chip st-${esc(h.status)}">${esc(label)}</span>${h.notes?`<div class="sub notes">Reviewer notes: ${esc(h.notes)}</div>`:""}${h.error?`<div class="sub err">${esc(h.error)}</div>`:""}</td><td>${h.youtube_id?`<div class="links"><a class="btn sm" target="_blank" rel="noopener" href="https://studio.youtube.com/video/${esc(h.youtube_id)}/edit">Studio</a><a class="btn sm" target="_blank" rel="noopener" href="https://youtube.com/shorts/${esc(h.youtube_id)}">Watch</a></div>`:""}</td></tr>`}).join("");
$("#jobs").innerHTML=`<thead><tr><th>Started</th><th>Time</th><th>Video</th><th>Inspired by</th><th>Status</th><th></th></tr></thead><tbody>${rows||`<tr><td colspan="6" class="empty">No jobs yet. Press GO and finished videos will show up here.</td></tr>`}</tbody>`;
}catch(e){}}
let since=0;
async function pollLogs(){
try{
@@ -276,25 +470,28 @@ for(const [seq,level,line] of r.lines){const d=document.createElement("div");d.c
while(box.childElementCount>3000)box.firstChild.remove();
if(r.lines.length&&$("#autoscroll").checked)box.scrollTop=box.scrollHeight
}catch(e){}}
document.querySelectorAll(".prof-select").forEach(sel=>sel.onchange=e=>activate(e.target.value));
$("#new-profile").onclick=async()=>{
const name=prompt("New profile name (starts as a copy of the current profile's settings)");
const name=prompt("Name for the new profile. It starts as a copy of the current profile's settings.");
if(!name)return;
try{await api("/api/profiles",{method:"POST",body:JSON.stringify({name})});await loadSettings();refresh()}catch(e){alert(e.message)}};
$("#del-profile").onclick=async()=>{
const sel=$("#prof-set");const label=sel.options[sel.selectedIndex]?.text||"";
if(!confirm(`Delete profile "${label}" and its linked channel token? Videos on disk are kept.`))return;
if(!confirm(`Delete the profile "${label}" and its linked channel? Videos already on disk are kept.`))return;
try{await api("/api/profiles/delete",{method:"POST",body:JSON.stringify({id:sel.value})});await loadSettings();refresh()}catch(e){alert(e.message)}};
function setButton(running){
const b=$("#go");
if(b.dataset.busy==="1")return;
b.classList.toggle("running",running);
b.textContent=running?"STOP":"GO";
b.setAttribute("aria-label",running?"Stop making videos":"Start making videos");
b.disabled=false}
$("#go").onclick=async()=>{
const b=$("#go");const running=b.classList.contains("running");
b.dataset.busy="1";b.disabled=true;b.classList.remove("running");b.classList.add("busy");
b.textContent=running?"Stopping...":"Starting...";
b.textContent="...";
try{await api(running?"/api/stop":"/api/start",{method:"POST"})}catch(e){alert(e.message)}
b.dataset.busy="";b.classList.remove("busy");
setButton(!running);
@@ -307,27 +504,27 @@ else{window.location.href="/auth/start"}};
$("#save").onclick=saveSettings;
$("#clear").onclick=()=>{$("#log").innerHTML=""};
$("#copy-log").onclick=async()=>{
const text=$("#log").innerText.trim();
if(!text){$("#copy-msg").textContent="Nothing to copy";return}
try{await navigator.clipboard.writeText(text);$("#copy-msg").textContent="Copied "+text.split("\n").length+" lines"}
catch(e){
const ta=document.createElement("textarea");ta.value=text;document.body.appendChild(ta);ta.select();
try{document.execCommand("copy");$("#copy-msg").textContent="Copied"}catch(err){$("#copy-msg").textContent="Could not copy"}
ta.remove()}
setTimeout(()=>{$("#copy-msg").textContent=""},3000)};
const text=$("#log").innerText.trim();const m=$("#copy-msg");
if(!text){m.textContent="Nothing to copy";return}
try{await navigator.clipboard.writeText(text);m.textContent="Copied "+text.split("\n").length+" lines"}
catch(e){const ta=document.createElement("textarea");ta.value=text;document.body.appendChild(ta);ta.select();try{document.execCommand("copy");m.textContent="Copied"}catch(err){m.textContent="Could not copy"}ta.remove()}
setTimeout(()=>{m.textContent=""},3000)};
$("#clear-jobs").onclick=async()=>{
const b=$("#clear-jobs");
if(b.disabled)return;
b.disabled=true;
const b=$("#clear-jobs");if(b.disabled)return;b.disabled=true;
try{await api("/api/history/clear",{method:"POST"});await refresh()}catch(e){alert(e.message)}
b.disabled=false};
$("#preview").onclick=async()=>{
$("#preview-msg").textContent="Loading...";
const panel=$("#trending-panel");const m=$("#preview-msg");const b=$("#preview");
panel.hidden=false;m.textContent="Loading";b.disabled=true;
try{
const r=await api("/api/trending");
$("#trending").innerHTML="<tr><th>Views</th><th>Title</th><th>Channel</th></tr>"+r.items.map(v=>`<tr><td>${v.views.toLocaleString()}</td><td><a target="_blank" href="https://youtube.com/watch?v=${esc(v.id)}">${esc(v.title)}</a></td><td>${esc(v.channel)}</td></tr>`).join("");
$("#trending-panel").style.display="block";$("#preview-msg").textContent=""
}catch(e){$("#preview-msg").textContent="Error: "+e.message}};
$("#trending").innerHTML=`<thead><tr><th>Views</th><th>Title</th><th>Channel</th></tr></thead><tbody>${r.items.map(v=>`<tr><td class="nowrap">${v.views.toLocaleString()}</td><td class="source"><a target="_blank" rel="noopener" href="https://youtube.com/watch?v=${esc(v.id)}">${esc(v.title)}</a></td><td>${esc(v.channel)}</td></tr>`).join("")||`<tr><td colspan="3" class="empty">No videos matched the current search settings.</td></tr>`}</tbody>`;
m.textContent=r.items.length+" videos";
}catch(e){m.textContent=e.message}
b.disabled=false};
let startTab="dash";try{const t=localStorage.getItem("sa-tab");if(["dash","settings","logs"].includes(t))startTab=t}catch(e){}
showTab(startTab);
loadSettings();refresh();pollLogs();
setInterval(refresh,3000);setInterval(pollLogs,2000);
</script>