reddit-video-bot/templates/index.html
sylyx c5f724bac4 Add FastAPI web dashboard with live logs and video gallery
Dark industrial-themed UI with WebSocket log streaming, bot trigger button,
settings panel (subreddits, Whisper model, TTS voice, thresholds), and
video gallery with inline player. Settings are passed to the bot subprocess
via env vars so the pipeline respects UI config at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:41:42 +02:00

456 lines
19 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reddit Video Bot</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500&family=Fira+Code:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #080810;
--card: #0e0e1a;
--card2: #12121e;
--border: #1c1c30;
--accent: #ff3a1a;
--glow: rgba(255,58,26,0.35);
--success: #00e5a0;
--muted: #505070;
--text: #dde0f0;
}
* { box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: 'DM Sans', sans-serif;
min-height: 100vh;
}
/* subtle noise overlay */
body::before {
content: '';
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E");
opacity: 0.5;
}
.z { position: relative; z-index: 1; }
/* Typography */
.display { font-family: 'Bebas Neue', cursive; }
.mono { font-family: 'Fira Code', monospace; }
.label { font-family: 'Bebas Neue', cursive; font-size: 10px; letter-spacing: 0.22em; color: var(--muted); }
/* Card */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
}
/* ON AIR button */
#runBtn {
width: 152px; height: 152px;
border-radius: 50%;
border: none; cursor: pointer;
background: radial-gradient(circle at 38% 32%, #ff6040 0%, #be1800 100%);
box-shadow:
0 0 0 8px rgba(255,58,26,0.12),
0 0 0 16px rgba(255,58,26,0.06),
0 10px 40px rgba(255,58,26,0.45),
inset 0 -6px 16px rgba(0,0,0,0.45),
inset 0 4px 8px rgba(255,120,80,0.25);
transition: transform 0.18s, box-shadow 0.18s;
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 2px;
}
#runBtn:hover:not(:disabled) {
transform: scale(1.04);
box-shadow:
0 0 0 10px rgba(255,58,26,0.2),
0 0 0 22px rgba(255,58,26,0.08),
0 16px 60px rgba(255,58,26,0.65),
inset 0 -6px 16px rgba(0,0,0,0.45),
inset 0 4px 8px rgba(255,120,80,0.25);
}
#runBtn:active:not(:disabled) { transform: scale(0.97); }
#runBtn:disabled { cursor: not-allowed; }
#runBtn.running {
animation: pulse-halo 1.6s ease-in-out infinite;
}
@keyframes pulse-halo {
0%,100% { box-shadow: 0 0 0 8px rgba(255,58,26,0.18), 0 0 0 16px rgba(255,58,26,0.07), 0 10px 40px rgba(255,58,26,0.5), inset 0 -6px 16px rgba(0,0,0,0.45), inset 0 4px 8px rgba(255,120,80,0.25); }
50% { box-shadow: 0 0 0 14px rgba(255,58,26,0.08), 0 0 0 30px rgba(255,58,26,0.03), 0 10px 60px rgba(255,58,26,0.75), inset 0 -6px 16px rgba(0,0,0,0.45), inset 0 4px 8px rgba(255,120,80,0.25); }
}
/* LED */
.led {
width: 9px; height: 9px;
border-radius: 50%;
display: inline-block;
}
.led-idle { background: var(--muted); }
.led-running {
background: var(--success);
box-shadow: 0 0 10px var(--success);
animation: blink 1s ease-in-out infinite;
}
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
/* Progress bar */
#progressBar {
height: 2px; width: 0%;
background: linear-gradient(90deg, var(--accent), #ff7040);
transition: width 0.4s;
}
#progressBar.running {
width: 100% !important;
animation: slide 1.4s ease-in-out infinite;
}
@keyframes slide {
0% { transform: translateX(-100%) scaleX(0.4); }
50% { transform: translateX(0%) scaleX(1); }
100% { transform: translateX(100%) scaleX(0.4); }
}
/* Terminal */
#logOutput {
font-family: 'Fira Code', monospace;
font-size: 11.5px; line-height: 1.7;
color: #90ffb0;
}
.log-err { color: #ff6060; }
.log-done { color: var(--success); }
.log-info { color: #7070cc; }
/* Scanlines on terminal */
.scanlines { position: relative; }
.scanlines::after {
content: ''; position: absolute; inset: 0; pointer-events: none;
background: repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(0,0,0,0.07) 3px, rgba(0,0,0,0.07) 4px);
}
/* Form controls */
input, select, textarea {
background: #090914;
border: 1px solid var(--border);
color: var(--text);
border-radius: 7px;
padding: 6px 10px;
font-family: 'DM Sans', sans-serif;
font-size: 13px;
width: 100%;
outline: none;
transition: border-color 0.2s;
appearance: none;
}
input:focus, select:focus { border-color: rgba(255,58,26,0.5); }
select { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23505070' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; padding-right: 30px; }
/* Buttons */
.btn-ghost {
background: rgba(255,58,26,0.1);
border: 1px solid rgba(255,58,26,0.3);
color: var(--accent);
border-radius: 7px;
padding: 5px 14px;
font-family: 'Bebas Neue', cursive;
font-size: 13px; letter-spacing: 0.08em;
cursor: pointer; transition: all 0.18s;
white-space: nowrap;
}
.btn-ghost:hover { background: rgba(255,58,26,0.2); border-color: var(--accent); }
/* Tag */
.tag {
display: inline-flex; align-items: center; gap: 5px;
background: #14142a; border: 1px solid #252540;
border-radius: 5px; padding: 2px 8px;
font-family: 'Fira Code', monospace; font-size: 11px; color: #a0a0d0;
}
.tag-x { cursor: pointer; color: var(--muted); transition: color 0.15s; }
.tag-x:hover { color: var(--accent); }
/* Video card */
.vcard { transition: transform 0.2s, box-shadow 0.2s; }
.vcard:hover { transform: translateY(-4px); box-shadow: 0 16px 48px rgba(0,0,0,0.7); }
/* Scrollbar */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #252540; border-radius: 2px; }
</style>
</head>
<body>
<!-- Header -->
<header class="z border-b border-[#1c1c30] px-7 py-4 flex items-center justify-between">
<div class="flex items-center gap-4">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<polygon points="14,2 26,8 26,20 14,26 2,20 2,8" fill="none" stroke="#ff3a1a" stroke-width="1.5"/>
<polygon points="14,7 21,11 21,17 14,21 7,17 7,11" fill="#ff3a1a" opacity="0.2"/>
<circle cx="14" cy="14" r="3" fill="#ff3a1a"/>
</svg>
<div>
<h1 class="display text-[22px] tracking-[0.18em] text-[var(--text)] leading-none">REDDIT VIDEO BOT</h1>
<div class="mono text-[10px] text-[var(--muted)] tracking-[0.15em] mt-0.5">AUTOMATED CONTENT PIPELINE</div>
</div>
</div>
<div class="flex items-center gap-3">
<span class="led led-idle" id="statusLed"></span>
<span class="mono text-[11px] text-[var(--muted)] tracking-widest" id="statusText">IDLE</span>
</div>
</header>
<!-- Progress -->
<div class="z overflow-hidden" style="height:2px;background:var(--border)">
<div id="progressBar"></div>
</div>
<!-- Main -->
<main class="z p-6 grid gap-6" style="grid-template-columns: 280px 1fr; min-height: calc(100vh - 70px)">
<!-- Left -->
<div class="flex flex-col gap-5">
<!-- Control -->
<div class="card p-6 flex flex-col items-center gap-5">
<span class="label">CONTROL</span>
<button id="runBtn" onclick="runBot()">
<span class="display text-white text-[30px] leading-none tracking-wide" style="text-shadow:0 2px 12px rgba(0,0,0,0.6)">RUN</span>
<span class="display text-[11px] tracking-[0.35em]" style="color:rgba(255,255,255,0.5)">BOT</span>
</button>
<div class="mono text-[11px] text-[var(--muted)] tracking-widest" id="btnLabel">READY</div>
</div>
<!-- Settings -->
<div class="card p-5 flex flex-col gap-4 flex-1">
<div class="flex items-center justify-between">
<span class="label">SETTINGS</span>
<button class="btn-ghost" id="saveBtn" onclick="saveSettings()">SAVE</button>
</div>
<!-- Subreddits -->
<div>
<div class="label mb-2">SUBREDDITS</div>
<div class="flex flex-wrap gap-1.5 mb-2 min-h-[24px]" id="tagContainer"></div>
<div class="flex gap-2">
<input type="text" id="newSub" placeholder="add subreddit…" onkeydown="if(event.key==='Enter')addSub()">
<button class="btn-ghost px-3" onclick="addSub()" style="padding:5px 12px">+</button>
</div>
</div>
<!-- Whisper -->
<div>
<div class="label mb-2">WHISPER MODEL</div>
<select id="whisperModel">
<option value="base">base — fast, lower accuracy</option>
<option value="small">small — balanced</option>
<option value="medium">medium — accurate, slow</option>
<option value="large">large — best quality</option>
</select>
</div>
<!-- Voice -->
<div>
<div class="label mb-2">TTS VOICE</div>
<select id="voice">
<option value="en-US-ChristopherNeural">Christopher · Male · US</option>
<option value="en-US-GuyNeural">Guy · Male · US</option>
<option value="en-US-JennyNeural">Jenny · Female · US</option>
<option value="en-US-AriaNeural">Aria · Female · US</option>
<option value="en-GB-RyanNeural">Ryan · Male · UK</option>
<option value="en-AU-WilliamNeural">William · Male · AU</option>
</select>
</div>
<!-- Thresholds -->
<div class="grid grid-cols-2 gap-3">
<div>
<div class="label mb-2">MIN SCORE</div>
<input type="number" id="minScore">
</div>
<div>
<div class="label mb-2">MIN WORDS</div>
<input type="number" id="minWords">
</div>
</div>
<div>
<div class="label mb-2">MAX WORDS</div>
<input type="number" id="maxWords">
</div>
</div>
</div>
<!-- Right -->
<div class="flex flex-col gap-5">
<!-- Log terminal -->
<div class="card flex flex-col" style="height:370px">
<div class="px-5 py-3 border-b border-[#1c1c30] flex items-center justify-between flex-shrink-0">
<span class="label">LIVE LOG</span>
<button onclick="clearLog()" class="mono text-[10px] text-[var(--muted)] hover:text-[var(--text)] transition-colors">CLEAR</button>
</div>
<div class="flex-1 overflow-y-auto p-4 scanlines" id="logScroll">
<div id="logOutput"><div class="log-info">// Waiting for run…</div></div>
</div>
</div>
<!-- Video gallery -->
<div class="card flex-1 p-5">
<div class="flex items-center justify-between mb-4">
<span class="label">OUTPUT VIDEOS</span>
<button onclick="loadVideos()" class="mono text-[10px] text-[var(--muted)] hover:text-[var(--text)] transition-colors">REFRESH</button>
</div>
<div id="gallery" class="grid gap-4" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
<div class="col-span-full text-center text-[var(--muted)] mono text-xs py-10">No videos yet.</div>
</div>
</div>
</div>
</main>
<script>
let ws, isRunning = false, subreddits = [];
// ── WebSocket ─────────────────────────────────────────
function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/logs`);
ws.onmessage = ({ data }) => {
if (data === '__RUNNING__') { setRunning(true); return; }
if (data.startsWith('__DONE__')) { onDone(parseInt(data.slice(8))); return; }
appendLog(data);
};
ws.onclose = () => setTimeout(connect, 3000);
}
function appendLog(text, cls = '') {
const d = document.createElement('div');
if (cls) d.className = cls;
else if (text.includes('[ERROR]') || text.includes('Error')) d.className = 'log-err';
else if (text.includes('//')) d.className = 'log-info';
d.textContent = text;
document.getElementById('logOutput').appendChild(d);
const s = document.getElementById('logScroll');
s.scrollTop = s.scrollHeight;
}
function clearLog() {
document.getElementById('logOutput').innerHTML = '<div class="log-info">// Log cleared.</div>';
}
// ── Bot ───────────────────────────────────────────────
async function runBot() {
if (isRunning) return;
const r = await fetch('/api/run', { method: 'POST' });
const d = await r.json();
if (d.error) { appendLog(`// ${d.error}`, 'log-err'); return; }
setRunning(true);
appendLog('// Bot started…', 'log-info');
}
function onDone(code) {
setRunning(false);
appendLog(code === 0 ? '// ✓ Completed successfully.' : `// ✗ Exited with code ${code}.`, code === 0 ? 'log-done' : 'log-err');
loadVideos();
}
function setRunning(v) {
isRunning = v;
const btn = document.getElementById('runBtn');
const led = document.getElementById('statusLed');
const bar = document.getElementById('progressBar');
btn.disabled = v;
btn.classList.toggle('running', v);
led.className = v ? 'led led-running' : 'led led-idle';
document.getElementById('statusText').textContent = v ? 'RUNNING' : 'IDLE';
document.getElementById('btnLabel').textContent = v ? 'PROCESSING…' : 'READY';
bar.classList.toggle('running', v);
if (!v) bar.style.width = '0%';
}
// ── Settings ──────────────────────────────────────────
async function loadSettings() {
const s = await (await fetch('/api/settings')).json();
subreddits = s.subreddits || [];
renderTags();
document.getElementById('whisperModel').value = s.whisper_model || 'base';
document.getElementById('voice').value = s.voice || 'en-US-ChristopherNeural';
document.getElementById('minScore').value = s.min_score ?? 1000;
document.getElementById('minWords').value = s.min_words ?? 200;
document.getElementById('maxWords').value = s.max_words ?? 1500;
}
async function saveSettings() {
await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subreddits,
whisper_model: document.getElementById('whisperModel').value,
voice: document.getElementById('voice').value,
min_score: +document.getElementById('minScore').value,
min_words: +document.getElementById('minWords').value,
max_words: +document.getElementById('maxWords').value,
}),
});
const btn = document.getElementById('saveBtn');
btn.textContent = 'SAVED ✓';
setTimeout(() => btn.textContent = 'SAVE', 1800);
}
function renderTags() {
const c = document.getElementById('tagContainer');
c.innerHTML = '';
subreddits.forEach((s, i) => {
const t = document.createElement('span');
t.className = 'tag';
t.innerHTML = `r/${s}<span class="tag-x" onclick="removeSub(${i})">×</span>`;
c.appendChild(t);
});
}
function addSub() {
const inp = document.getElementById('newSub');
const v = inp.value.trim().replace(/^r\//, '');
if (v && !subreddits.includes(v)) { subreddits.push(v); renderTags(); }
inp.value = '';
}
function removeSub(i) { subreddits.splice(i, 1); renderTags(); }
// ── Videos ────────────────────────────────────────────
async function loadVideos() {
const videos = await (await fetch('/api/videos')).json();
const g = document.getElementById('gallery');
if (!videos.length) {
g.innerHTML = '<div class="col-span-full text-center text-[var(--muted)] mono text-xs py-10">No videos yet.</div>';
return;
}
g.innerHTML = '';
videos.forEach(v => {
const c = document.createElement('div');
c.className = 'vcard card overflow-hidden';
c.innerHTML = `
<video controls preload="metadata" style="width:100%;display:block;background:#000;max-height:280px">
<source src="${v.url}" type="video/mp4">
</video>
<div class="p-3">
<div class="mono text-[11px] text-[var(--text)] truncate" title="${v.name}">${v.name}</div>
<div class="mono text-[10px] text-[var(--muted)] mt-1">${(v.size/1024/1024).toFixed(1)} MB</div>
<a href="${v.url}" download class="btn-ghost mt-2 block text-center text-[11px]" style="text-decoration:none;padding:4px 0">DOWNLOAD</a>
</div>`;
g.appendChild(c);
});
}
// Init
connect();
loadSettings();
loadVideos();
</script>
</body>
</html>