456 lines
19 KiB
HTML
456 lines
19 KiB
HTML
|
|
<!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>
|