1099 lines
83 KiB
HTML
1099 lines
83 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en" class="dark" style="background:#0a0a0f;color:#e5e7eb">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>yt-scraper · command center</title>
|
||
<!-- Tailwind -->
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script>
|
||
tailwind.config = {
|
||
darkMode: 'class',
|
||
theme: { extend: { colors: { accent: '#f43f5e' }, fontFamily: { mono: ['ui-monospace', 'JetBrains Mono', 'Cascadia Code', 'monospace'] } } }
|
||
};
|
||
</script>
|
||
<!-- Chart.js -->
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||
<!-- app (defines window.platform BEFORE Alpine runs; both deferred, app first) -->
|
||
<script defer src="/static/app.js"></script>
|
||
<!-- Alpine (loaded after app.js so window.platform exists at init) -->
|
||
<script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script>
|
||
<link rel="stylesheet" href="/static/styles.css" />
|
||
</head>
|
||
<body class="min-h-screen relative">
|
||
<div id="root" x-cloak x-data="platform()" x-init="init()" @keydown.escape.window="onGlobalEscape($event)" class="relative z-10 flex min-h-screen">
|
||
|
||
<button class="fixed top-4 left-4 z-40 md:hidden btn btn-ghost !p-2" @click="toggleSidebar()" :aria-label="sidebarOpen ? 'Close navigation' : 'Open navigation'"><svg x-show="!sidebarOpen" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" d="M4 6h16M4 12h16M4 18h16"/></svg><svg x-show="sidebarOpen" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" d="M6 6l12 12M18 6L6 18"/></svg></button>
|
||
<div x-show="sidebarOpen" x-transition.opacity @click="closeSidebar()" class="fixed inset-0 z-20 bg-black/60 md:hidden"></div>
|
||
|
||
<!-- ============ SIDEBAR ============ -->
|
||
<aside class="w-64 shrink-0 border-r border-zinc-800/80 bg-zinc-950/70 backdrop-blur-xl flex flex-col fixed inset-y-0 left-0 z-30 transform transition-transform md:translate-x-0" :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'" x-transition>
|
||
<div class="px-5 py-5 flex items-center gap-3 border-b border-zinc-800/70">
|
||
<div class="w-9 h-9 rounded-xl accent-grad flex items-center justify-center font-bold text-white shadow-lg shadow-rose-500/20">
|
||
<svg viewBox="0 0 24 24" class="w-5 h-5" fill="currentColor"><path d="M10 15.5v-7l6 3.5-6 3.5zM21 6.5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v11c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-11z"/></svg>
|
||
</div>
|
||
<div>
|
||
<div class="text-sm font-bold text-white leading-tight">yt-scraper</div>
|
||
<div class="text-[0.65rem] text-zinc-500 uppercase tracking-widest">command center</div>
|
||
</div>
|
||
</div>
|
||
|
||
<nav class="flex-1 overflow-y-auto px-3 py-4 space-y-1">
|
||
<template x-for="n in nav" :key="n.id">
|
||
<button class="nav-item" :class="view===n.id ? 'active' : ''" @click="setView(n.id)">
|
||
<span class="nav-ico" x-html="n.icon"></span>
|
||
<span x-text="n.label"></span>
|
||
</button>
|
||
</template>
|
||
</nav>
|
||
|
||
<div class="px-4 py-4 border-t border-zinc-800/70 text-[0.7rem] text-zinc-500">
|
||
<div class="flex items-center gap-2">
|
||
<span class="w-2 h-2 rounded-full" :class="health ? 'bg-emerald-500' : (healthFails ? 'bg-rose-500 animate-pulse' : 'bg-zinc-600')"></span>
|
||
<span x-text="health ? 'backend online' : (healthFails ? 'backend unreachable' : 'checking…')"></span>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- ============ MAIN ============ -->
|
||
<main class="flex-1 ml-0 md:ml-64 min-w-0">
|
||
<div x-show="!health && healthFails" x-transition.opacity class="bg-rose-500/15 border-b border-rose-500/40 text-rose-200" role="alert" aria-live="polite">
|
||
<div class="max-w-7xl mx-auto px-8 py-2.5 flex items-center justify-between text-sm">
|
||
<div class="flex items-center gap-2">
|
||
<svg class="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01M4.93 4.93l14.14 14.14M12 2a10 10 0 100 20 10 10 0 000-20z"/></svg>
|
||
<span><span x-text="healthFails"></span> failed health check(s). The backend may be down; requests will retry automatically.</span>
|
||
</div>
|
||
<button class="text-rose-200 hover:text-white underline text-xs" @click="checkHealth()">Retry now</button>
|
||
</div>
|
||
</div>
|
||
<div class="max-w-7xl mx-auto px-8 py-8">
|
||
|
||
<!-- ===== Dashboard ===== -->
|
||
<template x-if="view==='dashboard'">
|
||
<section class="space-y-6" x-transition.opacity>
|
||
<header class="flex items-end justify-between">
|
||
<div>
|
||
<h1 class="text-2xl font-bold text-white">Dashboard</h1>
|
||
<p class="text-sm text-zinc-500">Overview across all tracked channels</p>
|
||
</div>
|
||
<button class="btn btn-ghost" @click="loadDashboard()" :disabled="loading.dashboard">
|
||
<svg x-show="loading.dashboard" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
|
||
Refresh
|
||
</button>
|
||
</header>
|
||
|
||
<!-- stat row -->
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div class="glass p-4">
|
||
<div class="text-xs text-zinc-500 uppercase tracking-wider">Channels</div>
|
||
<div class="mt-1 text-2xl font-bold text-white font-mono" x-text="dash.channels.length"></div>
|
||
</div>
|
||
<div class="glass p-4">
|
||
<div class="text-xs text-zinc-500 uppercase tracking-wider">Videos</div>
|
||
<div class="mt-1 text-2xl font-bold text-white font-mono" x-text="fmtNum(dash.channels.reduce((s,c)=>s+(c.video_count||0),0))"></div>
|
||
</div>
|
||
<div class="glass p-4">
|
||
<div class="text-xs text-zinc-500 uppercase tracking-wider">Total Views</div>
|
||
<div class="mt-1 text-2xl font-bold text-white font-mono" x-text="fmtNum(dash.channels.reduce((s,c)=>s+(c.total_views||0),0))"></div>
|
||
</div>
|
||
<div class="glass p-4">
|
||
<div class="text-xs text-zinc-500 uppercase tracking-wider">Total Duration</div>
|
||
<div class="mt-1 text-2xl font-bold text-white font-mono" x-text="humanDur(dash.channels.reduce((s,c)=>s+(c.total_duration||0),0))"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- status pills -->
|
||
<div class="glass p-4 flex flex-wrap items-center gap-2">
|
||
<span class="text-xs text-zinc-500 uppercase tracking-wider mr-2">Status</span>
|
||
<template x-if="loading.dashboard"><span class="text-sm text-zinc-500">loading…</span></template>
|
||
<template x-for="(n, k) in dash.status_breakdown" :key="k">
|
||
<span class="pill" :class="statusClass(k)"><span x-text="k"></span><span class="font-mono" x-text="n"></span></span>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- channel cards -->
|
||
<div class="space-y-3">
|
||
<h2 class="text-sm font-semibold text-zinc-300 uppercase tracking-wider">Channels</h2>
|
||
<template x-if="!loading.dashboard && dash.channels.length===0">
|
||
<div class="glass p-8 text-center text-zinc-500 text-sm">No channels yet. Add one in the <button class="accent-text underline" @click="setView('channels')">Channels</button> view.</div>
|
||
</template>
|
||
<div class="grid md:grid-cols-2 gap-4">
|
||
<template x-for="c in dash.channels" :key="c.channel_id">
|
||
<div class="glass p-5 hover:border-rose-500/40 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-rose-500/40" role="button" tabindex="0" @click="openChannel(c.channel_id)" @keydown.enter.prevent="openChannel(c.channel_id)" @keydown.space.prevent="openChannel(c.channel_id)" :aria-label="'Open videos for ' + (c.name || c.channel_id)">
|
||
<div class="flex items-start justify-between gap-3">
|
||
<div class="flex items-center gap-3 min-w-0">
|
||
<div class="relative w-10 h-10 shrink-0">
|
||
<img class="avatar w-10 h-10 shrink-0" :src="c.avatar || '/api/avatars/'+c.channel_id" :alt="c.name" :data-fb-src="'/api/avatars/'+c.channel_id" onerror="if(!this.dataset.swapped){this.dataset.swapped='1'; this.src=this.dataset.fbSrc||''}else{this.style.visibility='hidden'; if(this.nextElementSibling) this.nextElementSibling.style.display='inline-flex'}" />
|
||
<span class="avatar avatar-initial rounded-full w-10 h-10 text-sm absolute inset-0 hidden" :text-content="(c.name||c.channel_id||'?').charAt(0)" aria-hidden="true"></span>
|
||
</div>
|
||
<div class="min-w-0">
|
||
<div class="text-white font-semibold truncate" x-text="c.name"></div>
|
||
<div class="text-xs text-zinc-500 truncate" x-text="c.handle || c.channel_id"></div>
|
||
</div>
|
||
</div>
|
||
<div class="text-right shrink-0">
|
||
<div class="text-lg font-bold text-white font-mono" x-text="fmtNum(c.video_count)"></div>
|
||
<div class="text-[0.65rem] text-zinc-500 uppercase">videos</div>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-3 gap-2 mt-4 pt-4 border-t border-zinc-800/70 text-xs">
|
||
<div><div class="text-zinc-500">Views</div><div class="text-zinc-200 font-mono" x-text="fmtNum(c.total_views)"></div></div>
|
||
<div><div class="text-zinc-500">Length</div><div class="text-zinc-200 font-mono" x-text="humanDur(c.total_duration)"></div></div>
|
||
<div><div class="text-zinc-500">Range</div><div class="text-zinc-200 font-mono text-[0.7rem]" x-text="fmtDate(c.date_min)+' → '+fmtDate(c.date_max)"></div></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ===== Channels ===== -->
|
||
<template x-if="view==='channels'">
|
||
<section class="space-y-6" x-transition.opacity>
|
||
<header class="flex items-end justify-between gap-3 flex-wrap">
|
||
<div>
|
||
<h1 class="text-2xl font-bold text-white">Channels</h1>
|
||
<p class="text-sm text-zinc-500">Add a channel URL to start tracking</p>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<span x-show="sync.lastResult" class="text-xs transition-opacity" :class="sync.lastResultKind === 'error' ? 'text-rose-400' : 'text-emerald-400'" x-text="sync.lastResult"></span>
|
||
<button class="btn accent-grad btn-primary" :disabled="jobActive() || channels.items.length===0" @click="startDiscovery()" title="Scan only what was uploaded since the last video we have">
|
||
<svg x-show="jobActive() && scrape.kind==='discovery'" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
|
||
Investigate all
|
||
</button>
|
||
<button class="btn btn-ghost" :disabled="jobActive() || channels.items.length===0" @click="startDiscovery(null, true)" title="Re-read every page of every channel — slow, only if videos are missing">
|
||
Full rescan
|
||
</button>
|
||
<button class="btn btn-ghost" :disabled="sync.busy==='all' || channels.items.length===0" @click="syncChannels()">
|
||
<svg x-show="sync.busy==='all'" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
|
||
Sync channels
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<form class="glass p-4 flex gap-2" @submit.prevent="addChannel()">
|
||
<input class="field flex-1" type="text" placeholder="https://www.youtube.com/@handle" x-model="forms.channelUrl" />
|
||
<button class="btn accent-grad btn-primary" :disabled="loading.addChannel">
|
||
<svg x-show="loading.addChannel" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
|
||
Add channel
|
||
</button>
|
||
</form>
|
||
<p x-show="forms.channelError" class="text-sm text-rose-400 -mt-3" x-text="forms.channelError"></p>
|
||
|
||
<div class="glass overflow-hidden">
|
||
<table class="tbl">
|
||
<thead><tr><th>Name</th><th>Handle</th><th>Videos</th><th>Pending</th><th>Latest video</th><th>Last scraped</th><th class="text-right">Actions</th></tr></thead>
|
||
<tbody>
|
||
<template x-if="loading.channels"><tr><td colspan="7" class="text-center text-zinc-500 py-8">loading…</td></tr></template>
|
||
<template x-if="!loading.channels && channels.items.length===0"><tr><td colspan="7" class="text-center text-zinc-500 py-8">No channels tracked yet.</td></tr></template>
|
||
<template x-for="c in channels.items" :key="c.channel_id">
|
||
<tr class="row-hover">
|
||
<td>
|
||
<div class="flex items-center gap-2">
|
||
<div class="relative w-7 h-7 shrink-0">
|
||
<img class="avatar w-7 h-7 shrink-0" :src="c.avatar || '/api/avatars/'+c.channel_id" :alt="c.name" :data-fb-src="'/api/avatars/'+c.channel_id" onerror="if(!this.dataset.swapped){this.dataset.swapped='1'; this.src=this.dataset.fbSrc||''}else{this.style.visibility='hidden'; if(this.nextElementSibling) this.nextElementSibling.style.display='inline-flex'}" />
|
||
<span class="avatar avatar-initial rounded-full w-7 h-7 text-xs absolute inset-0 hidden" :text-content="(c.name||c.channel_id||'?').charAt(0)" aria-hidden="true"></span>
|
||
</div>
|
||
<button class="text-white font-medium hover:text-rose-400 transition-colors text-left" @click="openChannel(c.channel_id)" x-text="c.name"></button>
|
||
</div>
|
||
</td>
|
||
<td class="text-zinc-400" x-text="c.handle || '—'"></td>
|
||
<td><button class="font-mono text-zinc-300 hover:text-rose-400 transition-colors" @click="openChannel(c.channel_id)" x-text="fmtNum(c.video_count)"></button></td>
|
||
<td>
|
||
<span class="pill" :class="(channels.pending[c.channel_id]||0) > 0 ? 'st-pending' : 'st-skipped'" x-text="(channels.pending[c.channel_id]||0) + ' pending'"></span>
|
||
</td>
|
||
<td class="text-zinc-400 text-xs font-mono" x-text="fmtDate(c.last_video_date)" title="Newest upload we know of — incremental scans start here"></td>
|
||
<td class="text-zinc-400 text-xs" x-text="c.last_scraped ? fmtDate(c.last_scraped) : '—'"></td>
|
||
<td class="text-right whitespace-nowrap">
|
||
<button class="btn accent-grad btn-primary !py-1 !px-2 text-xs" @click="startDiscovery(c.channel_id)" :disabled="jobActive()" title="Find videos uploaded since the latest one we have">
|
||
<svg x-show="jobActive() && scrape.kind==='discovery' && scrape.targetChannel===c.channel_id" class="spin w-3 h-3" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"></circle></svg>
|
||
Investigate
|
||
</button>
|
||
<button class="btn btn-ghost !py-1 !px-2 text-xs" @click="startDiscovery(c.channel_id, true)" :disabled="jobActive()" title="Re-read every page of this channel">Full</button>
|
||
<button class="btn btn-ghost !py-1 !px-2 text-xs" @click="syncChannels(c.channel_id)" :disabled="sync.busy===c.channel_id || sync.busy==='all'" title="Re-scrape channel info + avatar">Sync</button>
|
||
<button class="btn btn-ghost !py-1 !px-2 text-xs" @click="openChannel(c.channel_id)">Videos</button>
|
||
<button class="btn btn-ghost !py-1 !px-2 text-xs" @click="openFolder('markdown', c.channel_id)" title="Open .md folder">.md</button>
|
||
<button class="btn accent-grad btn-primary !py-1 !px-2 text-xs" @click="downloadChannelPending(c.channel_id)" title="Process pending videos to .md" :disabled="(channels.pending[c.channel_id]||0)===0">Pending .md</button>
|
||
<button class="btn btn-danger !py-1 !px-2 text-xs" @click="removeChannel(c.channel_id)">Remove</button>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ===== Videos ===== -->
|
||
<template x-if="view==='videos'">
|
||
<section class="space-y-5" x-transition.opacity>
|
||
<header class="flex items-end justify-between gap-3 flex-wrap">
|
||
<div><h1 class="text-2xl font-bold text-white">Videos</h1><p class="text-sm text-zinc-500">Browse and filter the catalog</p></div>
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<button class="btn btn-ghost" :disabled="loading.reconcile" @click="reconcile()" title="Re-scan data/markdown and repair any status that disagrees with what is on disk">
|
||
<svg x-show="loading.reconcile" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
|
||
Sync from disk
|
||
</button>
|
||
<button class="btn btn-ghost" x-show="retryable.total > 0" :disabled="loading.reset || jobActive()" @click="resetVideos()"
|
||
:title="'Send ' + retryable.total + ' failed/skipped videos back to pending so they can be retried'">
|
||
<svg x-show="loading.reset" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
|
||
<span x-text="'Retry ' + retryable.total + ' stuck'"></span>
|
||
</button>
|
||
<button class="btn accent-grad btn-primary" :disabled="jobActive() || channels.items.length===0" @click="startDiscovery(filters.channel || null)">
|
||
<svg x-show="jobActive() && scrape.kind==='discovery'" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
|
||
<span x-text="filters.channel ? 'Investigate channel' : 'Investigate all'"></span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div x-show="retryable.total > 0" class="glass px-4 py-3 text-sm text-zinc-300 flex items-start gap-3">
|
||
<span class="text-rose-400 font-bold shrink-0">!</span>
|
||
<div>
|
||
<span x-text="retryable.total"></span> video(s) sit in a terminal status
|
||
(<span x-text="(retryable.counts.no_subtitles||0) + ' no_subtitles, ' + (retryable.counts.error||0) + ' error'"></span>)
|
||
and will never be picked up by “Pending .md”.
|
||
<span class="text-zinc-500">“no_subtitles” often just means the language policy rejected the auto-generated captions — hover a status badge for the recorded reason.</span>
|
||
<span x-show="retryable.permanent > 0" class="block mt-1 text-zinc-500">
|
||
A further <span class="text-zinc-300" x-text="retryable.permanent"></span> are permanently blocked
|
||
(members-only, private or removed) and are excluded from retries — no amount of re-running will fetch them.
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="glass p-4 grid md:grid-cols-6 gap-3">
|
||
<select class="field" x-model="filters.channel" @change="loadVideos(1)"><option value="">All channels</option><template x-for="c in channels.items" :key="c.channel_id"><option :value="c.channel_id" x-text="c.name"></option></template></select>
|
||
<select class="field" x-model="filters.status" @change="loadVideos(1)">
|
||
<option value="">Any status</option>
|
||
<option value="done">Done</option>
|
||
<option value="pending">Pending</option>
|
||
<option value="no_subtitles">No subtitles</option>
|
||
<option value="error">Error</option>
|
||
<option value="__blocked__">Locked (members-only…)</option>
|
||
</select>
|
||
<input class="field" type="date" x-model="filters.from" @change="loadVideos(1)" />
|
||
<input class="field" type="date" x-model="filters.to" @change="loadVideos(1)" />
|
||
<input class="field" type="number" min="0" step="30" placeholder="min duration (s)" title="Hide videos shorter than this many seconds" x-model="filters.min_dur" @change="loadVideos(1)" aria-label="Minimum duration in seconds" />
|
||
<div class="flex gap-2">
|
||
<input class="field flex-1" type="text" placeholder="search title…" x-model="filters.q" @keydown.enter="loadVideos(1)" />
|
||
<button class="btn btn-ghost" @click="loadVideos(1)">Go</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- bulk action bar -->
|
||
<template x-if="videos.selected.length > 0">
|
||
<div class="bulk-bar glass p-3 flex flex-wrap items-center gap-2">
|
||
<span class="pill accent-grad btn-primary font-bold" x-text="videos.selected.length + ' selected'"></span>
|
||
<button class="btn accent-grad btn-primary !py-1.5 !px-3 text-xs" @click="downloadMdBatch()" title="Generate the .md notes for the selection. Thumbnails are not touched — they already come from the channel scrape.">
|
||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 18v2h16v-2"/></svg>
|
||
Download .md
|
||
</button>
|
||
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="downloadAudio()">
|
||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V6l10-2v12M9 18a3 3 0 11-6 0 3 3 0 016 0zm10-2a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||
Audio
|
||
</button>
|
||
<div class="flex-1"></div>
|
||
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="toggleSelectAll()" x-text="allSelected ? 'Unselect page' : 'Select page'"></button>
|
||
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="selectNone()">Clear</button>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="glass overflow-hidden">
|
||
<table class="tbl">
|
||
<thead><tr>
|
||
<th class="w-8"><input type="checkbox" class="accent-rose-500" :checked="allSelected" @change="toggleSelectAll()" title="Select page" /></th>
|
||
<th class="w-24">Thumb</th><th>Title</th><th>Channel</th>
|
||
<!-- No @click on the th: the select bubbles up to it, so a plain
|
||
click on the dropdown used to cycle the sort as well. -->
|
||
<th class="select-none">
|
||
<select class="bg-transparent text-zinc-300 uppercase text-[0.7rem] tracking-wider font-semibold cursor-pointer hover:text-white focus:outline-none" x-model="filters.sort" @change="loadVideos(1)" aria-label="Sort videos" title="Sort order — newest upload first by default">
|
||
<option value="upload_date">Newest ▼</option>
|
||
<option value="oldest">Oldest ▲</option>
|
||
<option value="view_count">Views ▼</option>
|
||
<option value="like_count">Likes ▼</option>
|
||
<option value="duration">Duration ▼</option>
|
||
<option value="title">Title A→Z</option>
|
||
</select>
|
||
</th>
|
||
<th>Duration</th><th>Views</th><th>Status</th><th class="text-right">Actions</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<template x-if="loading.videos"><tr><td colspan="9" class="text-center text-zinc-500 py-10">loading…</td></tr></template>
|
||
<template x-if="!loading.videos && videos.items.length===0"><tr><td colspan="9" class="text-center text-zinc-500 py-10"><div>No videos match these filters.</div><button class="btn btn-ghost mt-3" @click.stop="filters={ channel:'', status:'', from:'', to:'', min_dur:'', q:'', sort:'upload_date' }; loadVideos(1)">Clear filters</button></td></tr></template>
|
||
<template x-for="v in videos.items" :key="v.video_id">
|
||
<tr class="row-hover" :class="isSelected(v.video_id) ? 'row-selected' : ''" @click="openVideo(v.video_id)">
|
||
<td @click.stop><input type="checkbox" class="accent-rose-500" :checked="isSelected(v.video_id)" @change="toggleSelect(v.video_id)" /></td>
|
||
<td><img class="thumb w-20 h-12" :src="'/api/thumbnails/'+v.video_id" :alt="v.title" onerror="this.style.visibility='hidden'" /></td>
|
||
<td class="text-zinc-100 max-w-md truncate" x-text="v.title"></td>
|
||
<td class="text-zinc-400 text-xs" x-text="chanName(v.channel_id)"></td>
|
||
<td class="font-mono text-xs" :class="v.upload_date ? 'text-zinc-400' : 'text-zinc-600 italic'" x-text="videoDate(v)" :title="videoDateTitle(v)"></td>
|
||
<td class="font-mono text-zinc-300 text-xs" x-text="ts(v.duration)"></td>
|
||
<td class="font-mono text-zinc-300 text-xs" x-text="fmtNum(v.view_count)"></td>
|
||
<td>
|
||
<div class="flex items-center gap-1 flex-wrap">
|
||
<span class="pill" :class="statusClass(v.status)" x-text="v.status" :title="v.error_msg || v.status"></span>
|
||
<span x-show="isBlocked(v)" class="pill st-locked" :title="blockTitle(v)">
|
||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 1 1 8 0v3"/></svg>
|
||
<span x-text="blockLabel(v)"></span>
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td class="text-right whitespace-nowrap" @click.stop>
|
||
<button x-show="!isBlocked(v) || v.status==='done'" class="btn btn-ghost !py-1 !px-2 text-xs" :class="v.status==='done' ? 'md-done' : 'md-todo'" @click="v.status==='done' ? downloadMd(v.video_id) : processOne(v.video_id)" :title="v.status==='done' ? 'Download .md' : 'Process to .md'" x-text="v.status==='done' ? '.md' : 'Process'"></button>
|
||
<button x-show="isBlocked(v) && v.status!=='done'" class="btn btn-ghost !py-1 !px-2 text-xs opacity-50" @click="processOne(v.video_id)" :title="blockTitle(v) + ' Click anyway if you have since bought the membership and refreshed your cookies.'">Locked</button>
|
||
<button class="btn btn-ghost !py-1 !px-2 text-xs" @click="openClip(v.video_id)" title="Extract transcript clip">Clip</button>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- pagination -->
|
||
<div class="flex items-center justify-between gap-3 text-sm flex-wrap">
|
||
<div class="text-zinc-500"><span x-text="videos.total ? (videos.size*(videos.page-1)+1)+'–'+Math.min(videos.size*videos.page, videos.total)+' of '+videos.total : '0 results'"></span><span class="ml-3" x-text="'Page '+(videos.total ? videos.page : 0)+' of '+pageCount"></span></div>
|
||
<div class="flex items-center gap-2">
|
||
<label class="text-zinc-500 text-xs" for="videos-size">Per page</label><select id="videos-size" class="field !py-1.5 !px-2 w-20" x-model.number="videos.size" @change="localStorage.setItem('videos-size', videos.size); loadVideos(1)"><template x-for="n in [10,25,50,100]" :key="n"><option :value="n" x-text="n"></option></template></select>
|
||
<button class="btn btn-ghost !py-1.5 !px-2" :disabled="videos.page<=1" @click="loadVideos(videos.page-1); window.scrollTo({top:0,behavior:'smooth'})">Prev</button>
|
||
<template x-for="item in pageNumbers" :key="item"><button x-show="item !== '…'" class="btn btn-ghost !py-1.5 !px-2 min-w-8" :class="item===videos.page ? 'accent-grad btn-primary' : ''" @click="loadVideos(item); window.scrollTo({top:0,behavior:'smooth'})" x-text="item"></button><span x-show="item === '…'" class="px-1 text-zinc-500">…</span></template>
|
||
<button class="btn btn-ghost !py-1.5 !px-2" :disabled="videos.page>=pageCount" @click="loadVideos(videos.page+1); window.scrollTo({top:0,behavior:'smooth'})">Next</button>
|
||
</div>
|
||
</div>
|
||
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ===== Detail ===== -->
|
||
<template x-if="view==='detail'">
|
||
<section class="space-y-5" x-transition.opacity>
|
||
<button class="btn btn-ghost" @click="setView('videos')">← Back to videos</button>
|
||
<template x-if="loading.detail"><div class="glass p-10 text-center text-zinc-500">loading…</div></template>
|
||
<template x-if="!loading.detail && detail.video">
|
||
<div class="space-y-5">
|
||
<div class="glass p-5 flex flex-col md:flex-row gap-5">
|
||
<img class="thumb w-full md:w-80 h-44 md:h-48" :src="'/api/thumbnails/'+detail.video.video_id" :alt="detail.video.title" onerror="this.style.visibility='hidden'" />
|
||
<div class="flex-1 min-w-0">
|
||
<h1 class="text-xl font-bold text-white" x-text="detail.video.title"></h1>
|
||
<div class="text-sm text-zinc-500 mt-1" x-text="chanName(detail.video.channel_id)"></div>
|
||
<div class="flex flex-wrap gap-4 mt-4 text-sm">
|
||
<div><span class="text-zinc-500">Uploaded </span><span class="text-zinc-200 font-mono" x-text="fmtDate(detail.video.upload_date)"></span></div>
|
||
<div><span class="text-zinc-500">Duration </span><span class="text-zinc-200 font-mono" x-text="ts(detail.video.duration)"></span></div>
|
||
<div><span class="text-zinc-500">Views </span><span class="text-zinc-200 font-mono" x-text="fmtNum(detail.video.view_count)"></span></div>
|
||
<div><span class="text-zinc-500">Likes </span><span class="text-zinc-200 font-mono" x-text="fmtNum(detail.video.like_count)"></span></div>
|
||
<div class="flex items-center gap-1">
|
||
<span class="pill" :class="statusClass(detail.video.status)" x-text="detail.video.status"></span>
|
||
<span x-show="isBlocked(detail.video)" class="pill st-locked" :title="blockTitle(detail.video)">
|
||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 1 1 8 0v3"/></svg>
|
||
<span x-text="blockLabel(detail.video)"></span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div x-show="isBlocked(detail.video)" class="mt-3 text-xs text-amber-300/90" x-text="blockTitle(detail.video)"></div>
|
||
<div x-show="detail.video.status==='no_subtitles' || detail.video.status==='error'" class="mt-3 text-xs text-amber-300/90">
|
||
No <span class="font-mono">.md</span> was generated.
|
||
<span x-show="detail.video.error_msg" class="block mt-1 text-zinc-400 font-mono" x-text="detail.video.error_msg"></span>
|
||
<span x-show="!detail.video.error_msg" class="block mt-1 text-zinc-500">No reason was recorded (processed before reasons were stored). Re-process to find out why.</span>
|
||
</div>
|
||
<div x-show="detail.video.status==='error' && detail.video.error_msg" class="mt-3 text-xs text-rose-300" x-text="detail.video.error_msg"></div>
|
||
<div class="flex flex-wrap gap-2 mt-4">
|
||
<button class="btn accent-grad btn-primary !py-1.5 !px-3 text-xs" @click="startDiscovery(detail.video.channel_id)" :disabled="jobActive()" title="Find recent videos from this channel without downloading transcripts">Investigate channel</button>
|
||
<button x-show="!isBlocked(detail.video) || detail.video.status==='done'" class="btn accent-grad btn-primary !py-1.5 !px-3 text-xs" @click="detail.video.status==='done' ? downloadMd(detail.video.video_id) : processOne(detail.video.video_id)" x-text="detail.video.status==='done' ? 'Download .md' : 'Process to .md'"></button>
|
||
<button x-show="isBlocked(detail.video) && detail.video.status!=='done'" class="btn btn-ghost !py-1.5 !px-3 text-xs opacity-60" @click="processOne(detail.video.video_id)" :title="blockTitle(detail.video) + ' Click anyway if you have since bought the membership and refreshed your cookies.'">Try anyway</button>
|
||
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="downloadAudioOne(detail.video.video_id)" x-show="detail.video.status==='done'" x-text="detail.hasAudio ? 'Re-download audio' : 'Download audio'">
|
||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V6l10-2v12M9 18a3 3 0 11-6 0 3 3 0 016 0zm10-2a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||
</button>
|
||
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="openClip(detail.video.video_id)">Clip</button>
|
||
<template x-if="detail.video.url"><a class="btn btn-ghost !py-1.5 !px-3 text-xs" :href="detail.video.url" target="_blank" rel="noopener">Open on YouTube ↗</a></template>
|
||
</div>
|
||
<div class="flex flex-wrap gap-1.5 mt-3" x-show="(detail.video.tags||[]).length">
|
||
<template x-for="t in (detail.video.tags||[]).slice(0,8)" :key="t"><span class="pill st-skipped" x-text="t"></span></template>
|
||
</div>
|
||
<div x-show="detail.video.description" x-data="{ descOpen: false }" class="mt-3 pt-3 border-t border-zinc-800/70">
|
||
<button class="flex items-center gap-2 text-xs text-zinc-400 hover:text-rose-400 transition-colors w-full text-left" @click="descOpen = !descOpen" :aria-expanded="descOpen ? 'true' : 'false'" title="Click to toggle description">
|
||
<svg :class="descOpen ? 'rotate-90' : ''" class="w-3 h-3 transition-transform shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||
<span class="uppercase tracking-wider font-semibold">Description</span>
|
||
</button>
|
||
<div x-show="descOpen" x-transition.opacity class="mt-3 text-sm text-zinc-300 whitespace-pre-wrap leading-relaxed max-h-72 overflow-y-auto pr-1" x-text="detail.video.description"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- audio player + synced transcript follower -->
|
||
<div class="glass p-4" x-show="detail.video && detail.video.status==='done'">
|
||
<template x-if="detail.hasAudio">
|
||
<div class="space-y-2">
|
||
<div class="flex items-center gap-2">
|
||
<audio x-ref="audioPlayer" :src="'/api/videos/'+detail.video.video_id+'/audio'" controls preload="metadata" class="w-full audio-bar"
|
||
@timeupdate="onAudioTime($event)" @pause="detail.audioPlaying=false" @play="detail.audioPlaying=true" @ended="detail.activeSeg=-1; detail.audioPlaying=false"></audio>
|
||
</div>
|
||
<div class="text-xs text-zinc-500 flex items-center gap-2">
|
||
<span class="w-1.5 h-1.5 rounded-full" :class="detail.audioPlaying ? 'bg-rose-500 animate-pulse' : 'bg-zinc-600'"></span>
|
||
<span x-text="detail.audioPlaying ? 'transcript following playback' : 'press play — transcript follows the audio'"></span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template x-if="!detail.hasAudio">
|
||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||
<div class="text-sm text-zinc-400">No audio downloaded — download the MP3 to unlock the synced transcript player.</div>
|
||
<button class="btn accent-grad btn-primary !py-1.5 !px-3 text-xs" @click="downloadAudioOne(detail.video.video_id)">
|
||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V6l10-2v12M9 18a3 3 0 11-6 0 3 3 0 016 0zm10-2a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||
Download audio
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="grid lg:grid-cols-3 gap-5">
|
||
<!-- chapters -->
|
||
<div class="glass p-5 lg:col-span-1" x-show="detail.video.has_chapters && (detail.video.chapters||[]).length">
|
||
<h3 class="text-sm font-semibold text-zinc-200 mb-3">Chapters</h3>
|
||
<div class="space-y-1 max-h-[28rem] overflow-y-auto">
|
||
<template x-for="(ch, i) in (detail.video.chapters||[])" :key="i">
|
||
<button class="w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded hover:bg-zinc-800/60" @click="seekTo(ch.start_sec)">
|
||
<span class="font-mono accent-text shrink-0" x-text="ts(ch.start_sec)"></span>
|
||
<span class="text-zinc-300 truncate" x-text="ch.title"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- transcript -->
|
||
<div class="glass p-5" :class="(detail.video.has_chapters && (detail.video.chapters||[]).length) ? 'lg:col-span-2' : 'lg:col-span-3'">
|
||
<h3 class="text-sm font-semibold text-zinc-200 mb-3">Transcript</h3>
|
||
<template x-if="loading.transcript"><div class="text-zinc-500 text-sm">loading…</div></template>
|
||
<template x-if="!loading.transcript && detail.transcript.length===0"><div class="text-zinc-500 text-sm">No transcript available.</div></template>
|
||
<div class="space-y-1 max-h-[34rem] overflow-y-auto pr-1 transcript-scroll">
|
||
<template x-for="seg in detail.transcript" :key="seg.idx">
|
||
<div class="seg-row flex gap-3 text-sm leading-relaxed rounded-lg px-3 py-1.5 transition-all duration-200"
|
||
:class="detail.activeSeg===seg.idx ? 'seg-active' : 'hover:bg-zinc-800/40'"
|
||
:id="'seg-'+seg.idx">
|
||
<button class="font-mono shrink-0 mt-0.5 cursor-pointer hover:text-rose-300 transition-colors"
|
||
:class="detail.activeSeg===seg.idx ? 'accent-text font-bold' : 'text-zinc-500'"
|
||
@click="seekTo(seg.start_sec)" x-text="ts(seg.start_sec)"></button>
|
||
<span :class="detail.activeSeg===seg.idx ? 'text-white' : 'text-zinc-300'" x-text="seg.text"></span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ===== Search ===== -->
|
||
<template x-if="view==='search'">
|
||
<section class="space-y-5" x-transition.opacity>
|
||
<header><h1 class="text-2xl font-bold text-white">Search transcripts</h1><p class="text-sm text-zinc-500">Find moments across all videos</p></header>
|
||
<form class="glass p-4 flex flex-col md:flex-row gap-2" @submit.prevent="loadSearch()">
|
||
<input class="field flex-1" type="text" placeholder="search query… (Press Enter)" x-model="search.q" aria-label="Search query" />
|
||
<select class="field md:w-56" x-model="search.channel" @change="if(search.ran) loadSearch()" aria-label="Filter by channel"><option value="">All channels</option><template x-for="c in channels.items" :key="c.channel_id"><option :value="c.channel_id" x-text="c.name"></option></template></select>
|
||
<button class="btn accent-grad btn-primary" :disabled="loading.search || !search.q.trim()" @click="loadSearch()">Search</button>
|
||
<button class="btn btn-ghost" type="button" @click="resetSearch()" x-show="search.ran || search.q || search.channel" title="Clear search">Clear</button>
|
||
</form>
|
||
<template x-if="loading.search"><div class="glass p-10 text-center text-zinc-500">searching…</div></template>
|
||
<template x-if="!loading.search && search.ran && search.items.length===0">
|
||
<div class="glass p-10 text-center">
|
||
<div class="text-zinc-500 mb-3">No matches for <span class="text-zinc-300 font-mono" x-text="search.q"></span><span x-show="search.channel"> in <span class="text-zinc-300" x-text="chanName(search.channel)"></span></span>.</div>
|
||
<button class="btn btn-ghost" @click="resetSearch()">Clear filters</button>
|
||
</div>
|
||
</template>
|
||
<template x-if="!loading.search && !search.ran">
|
||
<div class="glass p-10 text-center text-zinc-500">Type a query and press <kbd class="kbd">Enter</kbd> to search transcripts across all channels.<br><span class="text-xs text-zinc-500">Tip: try a phrase in its original language for best matches.</span></div>
|
||
</template>
|
||
<div class="space-y-3">
|
||
<template x-for="r in search.items" :key="r.video_id+r.start_sec">
|
||
<div class="glass p-4 hover:border-rose-500/40 transition-colors cursor-pointer" @click="openVideo(r.video_id)">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div class="text-white font-medium truncate" x-text="r.title"></div>
|
||
<div class="font-mono accent-text text-sm shrink-0" x-text="ts(r.start_sec)"></div>
|
||
</div>
|
||
<div class="text-xs text-zinc-500 mt-0.5" x-text="chanName(r.channel_id)"></div>
|
||
<div class="text-sm text-zinc-300 mt-2 line-clamp-2" x-html="highlight(r.snippet, search.q)"></div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ===== Analysis ===== -->
|
||
<template x-if="view==='analysis'">
|
||
<section class="space-y-5" x-transition.opacity>
|
||
<header class="flex items-end justify-between">
|
||
<div><h1 class="text-2xl font-bold text-white">Analysis</h1><p class="text-sm text-zinc-500">Language patterns across the corpus</p></div>
|
||
<div class="flex gap-1 glass-soft p-1">
|
||
<button class="tab" :class="analysis.tab==='wordcloud'?'active':''" @click="analysis.tab='wordcloud'; loadAnalysis()">Word cloud</button>
|
||
<button class="tab" :class="analysis.tab==='topwords'?'active':''" @click="analysis.tab='topwords'; loadAnalysis()">Top words</button>
|
||
<button class="tab" :class="analysis.tab==='timeline'?'active':''" @click="analysis.tab='timeline'; loadAnalysis()">Timeline</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="glass p-4 flex flex-wrap gap-3 items-center">
|
||
<select class="field md:w-56" x-model="analysis.channel" @change="loadAnalysis()"><option value="">All channels</option><template x-for="c in channels.items" :key="c.channel_id"><option :value="c.channel_id" x-text="c.name"></option></template></select>
|
||
<template x-if="analysis.tab==='timeline'">
|
||
<input class="field md:w-56" type="text" placeholder="term e.g. ai" x-model="analysis.term" @keydown.enter="loadAnalysis()" />
|
||
</template>
|
||
<button class="btn btn-ghost" @click="loadAnalysis()" :disabled="loading.analysis">Run</button>
|
||
</div>
|
||
|
||
<template x-if="loading.analysis"><div class="glass p-16 text-center text-zinc-500">crunching…</div></template>
|
||
|
||
<template x-if="!loading.analysis && analysis.tab==='wordcloud'">
|
||
<div class="glass p-8 min-h-[24rem] flex flex-wrap items-center justify-center">
|
||
<template x-if="analysis.words.length===0"><div class="text-zinc-500 text-sm">No data.</div></template>
|
||
<template x-for="(w,i) in analysis.words" :key="w.word+i">
|
||
<span class="cloud-word" :style="cloudStyle(w)" x-text="w.word"></span>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<template x-if="!loading.analysis && analysis.tab==='topwords'">
|
||
<div class="glass p-5"><div class="h-96"><canvas id="chart-topwords"></canvas></div></div>
|
||
</template>
|
||
|
||
<template x-if="!loading.analysis && analysis.tab==='timeline'">
|
||
<div class="glass p-5"><div class="h-96"><canvas id="chart-timeline"></canvas></div></div>
|
||
</template>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ===== Scrape ===== -->
|
||
<template x-if="view==='scrape'">
|
||
<section class="space-y-5" x-transition.opacity>
|
||
<header><h1 class="text-2xl font-bold text-white">Scrape</h1><p class="text-sm text-zinc-500">Launch a channel harvest job</p></header>
|
||
|
||
<div class="grid lg:grid-cols-2 gap-5">
|
||
<form class="glass p-5 space-y-3" @submit.prevent="startScrape()">
|
||
<label class="block text-xs text-zinc-500 uppercase tracking-wider">Channel</label>
|
||
<select class="field" x-model="scrape.form.channel_id" required>
|
||
<option value="">Select a channel…</option>
|
||
<template x-for="c in channels.items" :key="c.channel_id"><option :value="c.channel_id" x-text="c.name"></option></template>
|
||
</select>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label class="block text-xs text-zinc-500 uppercase tracking-wider mb-1">Limit</label>
|
||
<input class="field" type="number" min="1" placeholder="∞" x-model="scrape.form.limit" />
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs text-zinc-500 uppercase tracking-wider mb-1">Since</label>
|
||
<input class="field" type="date" x-model="scrape.form.since" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs text-zinc-500 uppercase tracking-wider mb-1">Languages <span class="text-zinc-500 normal-case">(comma-sep)</span></label>
|
||
<input class="field" type="text" placeholder="en, es" x-model="scrape.form.languages" />
|
||
</div>
|
||
<div class="flex gap-6 pt-1">
|
||
<label class="flex items-center gap-2 text-sm text-zinc-300 cursor-pointer"><input type="checkbox" class="accent-rose-500" x-model="scrape.form.include_shorts" /> Include shorts</label>
|
||
<label class="flex items-center gap-2 text-sm text-zinc-300 cursor-pointer"><input type="checkbox" class="accent-rose-500" x-model="scrape.form.no_live" /> Skip live</label>
|
||
<label class="flex items-center gap-2 text-sm text-zinc-300 cursor-pointer" title="Off: discovery reads only what was uploaded since the latest video we have"><input type="checkbox" class="accent-rose-500" x-model="scrape.form.full" /> Full channel rescan</label>
|
||
</div>
|
||
<button class="btn accent-grad btn-primary w-full" :disabled="loading.startScrape || !scrape.form.channel_id">
|
||
<svg x-show="loading.startScrape" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
|
||
Start scrape
|
||
</button>
|
||
</form>
|
||
|
||
<!-- live progress -->
|
||
<div class="glass p-5 flex flex-col">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="text-sm font-semibold text-zinc-200">Live job</h3>
|
||
<template x-if="scrape.jobId">
|
||
<span class="pill" :class="scrape.error ? 'st-error' : (scrape.done ? 'st-done' : 'st-running')" x-text="scrape.error ? 'error' : (scrape.done ? 'done' : 'running')"></span>
|
||
</template>
|
||
</div>
|
||
<template x-if="!scrape.jobId"><div class="flex-1 flex items-center justify-center text-zinc-500 text-sm">No active job. Start one to see live progress.</div></template>
|
||
<template x-if="scrape.jobId">
|
||
<div class="mt-4 space-y-3 flex-1 flex flex-col">
|
||
<div class="flex justify-between text-xs text-zinc-500"><span>Progress</span><span class="font-mono" x-text="(scrape.progress.completed||0)+' / '+(scrape.progress.total||0)"></span></div>
|
||
<div class="progress-track"><div class="progress-fill" :style="'width:' + scrapePct() + '%'"></div></div>
|
||
<div class="log-panel flex-1 overflow-y-auto p-3 space-y-0.5 max-h-72">
|
||
<template x-for="(l,i) in scrape.log" :key="i"><div :class="l.startsWith('[') ? 'accent-text' : 'text-zinc-400'" x-text="l"></div></template>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<button class="btn btn-danger flex-1" @click="cancelScrape()" :disabled="scrape.done || scrape.error">Cancel</button>
|
||
<button class="btn btn-ghost flex-1" @click="closeStream()">Close stream</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- recent jobs -->
|
||
<div class="glass overflow-hidden">
|
||
<div class="px-4 py-3 border-b border-zinc-800/70 flex items-center justify-between">
|
||
<div class="flex flex-col">
|
||
<h3 class="text-sm font-semibold text-zinc-200">Recent jobs</h3>
|
||
<span class="text-[0.65rem] text-zinc-500 mt-0.5">Clearing history removes job records only — downloaded .md files stay.</span>
|
||
</div>
|
||
<div class="flex gap-1.5">
|
||
<button class="btn btn-ghost !py-1 !px-2 text-xs" @click="loadJobs()">Refresh</button>
|
||
<button class="btn btn-danger !py-1 !px-2 text-xs" @click="clearJobHistory()" :disabled="scrape.jobs.length === 0">Clear history</button>
|
||
</div>
|
||
</div>
|
||
<table class="tbl">
|
||
<thead><tr><th>Channel</th><th>Status</th><th>Progress</th><th>Started</th><th>Finished</th><th></th></tr></thead>
|
||
<tbody>
|
||
<template x-if="loading.jobs"><tr><td colspan="6" class="text-center text-zinc-500 py-6">loading…</td></tr></template>
|
||
<template x-if="!loading.jobs && scrape.jobs.length===0"><tr><td colspan="6" class="text-center text-zinc-500 py-6">No jobs yet.</td></tr></template>
|
||
<template x-for="j in scrape.jobs" :key="j.id">
|
||
<tr>
|
||
<td class="text-zinc-200 font-mono text-xs" x-text="j.channel_id"></td>
|
||
<td><span class="pill" :class="statusClass(j.status)" x-text="j.status"></span></td>
|
||
<td class="font-mono text-xs text-zinc-400" x-text="(j.completed||0)+' / '+(j.total||0)"></td>
|
||
<td class="font-mono text-xs text-zinc-500" x-text="j.started_at ? fmtDateTime(j.started_at) : '—'"></td>
|
||
<td class="font-mono text-xs text-zinc-500" x-text="j.finished_at ? fmtDateTime(j.finished_at) : '—'"></td>
|
||
<td class="text-right"><button class="btn !py-1 !px-2 text-xs" :class="jobIsTerminal(j) ? 'btn-ghost' : 'btn-danger'" @click="deleteJob(j.id)" x-text="jobActionLabel(j)" :title="jobIsTerminal(j) ? 'Remove this job from history (downloads stay)' : 'Cancel this running job'"></button></td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ===== Cookies ===== -->
|
||
<template x-if="view==='cookies'">
|
||
<section class="space-y-5" x-transition.opacity>
|
||
<header><h1 class="text-2xl font-bold text-white">Cookie vault</h1><p class="text-sm text-zinc-500">Upload YouTube cookie files to authenticate</p></header>
|
||
|
||
<div class="dropzone p-8 text-center transition-all" :class="cookies.drag ? 'drag' : ''"
|
||
@click="$refs.cookieFile.click()"
|
||
@dragover.prevent="cookies.drag=true"
|
||
@dragleave.prevent="cookies.drag=false"
|
||
@drop.prevent="handleDrop($event)">
|
||
<svg class="w-10 h-10 mx-auto text-zinc-600 mb-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 7.5 7.5 12M12 7.5V18"/></svg>
|
||
<div class="text-sm text-zinc-300 font-medium">Drop <span class="font-mono accent-text">.txt</span> cookie files here</div>
|
||
<div class="text-xs text-zinc-500 mt-1">or click to browse</div>
|
||
<input x-ref="cookieFile" type="file" multiple accept=".txt,text/plain" class="hidden" @change="handleFiles($event.target.files)" />
|
||
</div>
|
||
<p x-show="cookies.uploadMsg" class="text-sm text-emerald-400" x-text="cookies.uploadMsg"></p>
|
||
|
||
<div class="glass overflow-hidden">
|
||
<div class="px-4 py-3 border-b border-zinc-800/70 flex items-center justify-between">
|
||
<h3 class="text-sm font-semibold text-zinc-200">Vault</h3>
|
||
<button class="btn btn-ghost !py-1 !px-2 text-xs" @click="loadCookies()">Refresh</button>
|
||
</div>
|
||
<table class="tbl">
|
||
<thead><tr><th>Label</th><th>File</th><th>Expires</th><th>Session</th><th>Active</th><th>Test</th><th></th></tr></thead>
|
||
<tbody>
|
||
<template x-if="loading.cookies"><tr><td colspan="7" class="text-center text-zinc-500 py-6">loading…</td></tr></template>
|
||
<template x-if="!loading.cookies && cookies.items.length===0"><tr><td colspan="7" class="text-center text-zinc-500 py-6">Vault is empty.</td></tr></template>
|
||
<template x-for="c in cookies.items" :key="c.id">
|
||
<tr>
|
||
<td class="text-white font-medium" x-text="c.label || c.filename"></td>
|
||
<td class="text-zinc-400 text-xs font-mono truncate max-w-[12rem]" x-text="c.filename"></td>
|
||
<td class="text-xs"><span class="font-mono" :class="c.expired ? 'text-rose-400' : 'text-zinc-300'" x-text="c.expires_at ? fmtDateTime(c.expires_at) : '—'"></span></td>
|
||
<td><span class="pill" :class="c.has_session ? 'st-done' : 'st-skipped'" x-text="c.has_session ? 'session' : 'none'"></span></td>
|
||
<td><input type="radio" :name="'cookie-active'" :checked="c.is_active" @change="activateCookie(c.id)" class="accent-rose-500" /></td>
|
||
<td>
|
||
<button class="btn btn-ghost !py-1 !px-2 text-xs" @click="testCookie(c.id)">Test</button>
|
||
<span class="text-xs ml-2" :class="cookies.testResult[c.id]===null ? 'text-zinc-500' : (cookies.testResult[c.id]?.ok ? 'text-emerald-400' : 'text-rose-400')" x-text="testLabel(c.id)"></span>
|
||
</td>
|
||
<td class="text-right"><button class="btn btn-danger !py-1 !px-2 text-xs" @click="removeCookie(c.id)">Delete</button></td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ===== Tools (knowledge base) ===== -->
|
||
<template x-if="view==='tools'">
|
||
<section class="space-y-6" x-transition.opacity>
|
||
<header><h1 class="text-2xl font-bold text-white">Tools · Knowledge Base</h1><p class="text-sm text-zinc-500">Download channels as <span class="font-mono accent-text">.md</span> notes in the established Obsidian-ready format</p></header>
|
||
|
||
<!-- hero tool: scrape to markdown -->
|
||
<div class="glass p-6 flex flex-col md:flex-row md:items-center gap-4 border-rose-500/20">
|
||
<div class="w-12 h-12 rounded-xl accent-grad flex items-center justify-center shrink-0">
|
||
<svg viewBox="0 0 24 24" class="w-6 h-6 text-white" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 18v2h16v-2"/></svg>
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<div class="text-white font-semibold">Download as Markdown</div>
|
||
<div class="text-sm text-zinc-500 mt-0.5">Scrape a channel → one <span class="font-mono">.md</span> per video (frontmatter + transcript + chapters). Resumes automatically.</div>
|
||
</div>
|
||
<button class="btn accent-grad btn-primary" @click="setView('scrape')">Open scraper</button>
|
||
</div>
|
||
|
||
<!-- tool grid -->
|
||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
|
||
<!-- Re-render -->
|
||
<div class="tool-card glass p-5">
|
||
<div class="tool-ico accent-grad"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992V4.355M3.985 19.644v-4.99h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0011.667 0l3.181-3.183m-4.991-2.696v-4.99h-4.992"/></svg></div>
|
||
<h3 class="text-sm font-semibold text-zinc-100">Re-render Markdown</h3>
|
||
<p class="text-xs text-zinc-500 mt-1 mb-3">Rebuild every <span class="font-mono">.md</span> from stored segments using the current template.</p>
|
||
<button class="btn btn-ghost w-full" :disabled="tools.reRendering" @click="reRender()">
|
||
<svg x-show="tools.reRendering" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
|
||
Re-render all
|
||
</button>
|
||
<p x-show="tools.msg" class="text-xs mt-2" :class="tools.msg.startsWith('Failed') || tools.msg.startsWith('Fail') ? 'text-rose-400' : 'text-emerald-400'" x-text="tools.msg"></p>
|
||
</div>
|
||
|
||
<!-- Export -->
|
||
<div class="tool-card glass p-5">
|
||
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg></div>
|
||
<h3 class="text-sm font-semibold text-zinc-100">Export catalog</h3>
|
||
<p class="text-xs text-zinc-500 mt-1 mb-3">JSON / CSV / browsable HTML gallery of all videos.</p>
|
||
<button class="btn btn-ghost w-full" @click="setView('export')">Export tools</button>
|
||
</div>
|
||
|
||
<!-- Search -->
|
||
<div class="tool-card glass p-5">
|
||
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg></div>
|
||
<h3 class="text-sm font-semibold text-zinc-100">Search transcripts</h3>
|
||
<p class="text-xs text-zinc-500 mt-1 mb-3">Find moments across all scraped videos.</p>
|
||
<button class="btn btn-ghost w-full" @click="setView('search')">Search</button>
|
||
</div>
|
||
|
||
<!-- Analysis -->
|
||
<div class="tool-card glass p-5">
|
||
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 20h16v2H4zM6 16V8h3v8zm4 0V4h3v12zm4 0v-6h3v6z"/></svg></div>
|
||
<h3 class="text-sm font-semibold text-zinc-100">Analysis</h3>
|
||
<p class="text-xs text-zinc-500 mt-1 mb-3">Word cloud, top words, term timeline.</p>
|
||
<button class="btn btn-ghost w-full" @click="setView('analysis')">Open analysis</button>
|
||
</div>
|
||
|
||
<!-- Thumbnails -->
|
||
<div class="tool-card glass p-5">
|
||
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l1.409 1.409M3.75 3.75h16.5a2.25 2.25 0 012.25 2.25v11.25A2.25 2.25 0 0120.25 19.5H3.75A2.25 2.25 0 011.5 17.25V6a2.25 2.25 0 012.25-2.25z"/></svg></div>
|
||
<h3 class="text-sm font-semibold text-zinc-100">Download thumbnails</h3>
|
||
<p class="text-xs text-zinc-500 mt-1 mb-2">Cache thumbnails locally for offline viewing.</p>
|
||
<select class="field !text-xs !py-1.5 mb-2" x-model="tools.thumbScope">
|
||
<option value="all">All done videos</option>
|
||
<option value="selection" :disabled="videos.selected.length===0" x-text="'Current selection (' + videos.selected.length + ')'"></option>
|
||
<template x-for="c in channels.items" :key="c.channel_id"><option :value="c.channel_id" x-text="c.name"></option></template>
|
||
</select>
|
||
<button class="btn btn-ghost w-full" :disabled="tools.busy==='thumbnails'" @click="downloadThumbnailsScope(tools.thumbScope)">
|
||
<svg x-show="tools.busy==='thumbnails'" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
|
||
Download
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Channel avatars -->
|
||
<div class="tool-card glass p-5">
|
||
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z"/></svg></div>
|
||
<h3 class="text-sm font-semibold text-zinc-100">Download avatars</h3>
|
||
<p class="text-xs text-zinc-500 mt-1 mb-2">Scrape + cache channel profile images locally.</p>
|
||
<select class="field !text-xs !py-1.5 mb-2" x-model="tools.avatarScope">
|
||
<option value="all">All tracked channels</option>
|
||
<template x-for="c in channels.items" :key="c.channel_id"><option :value="c.channel_id" x-text="c.name"></option></template>
|
||
</select>
|
||
<button class="btn btn-ghost w-full" :disabled="tools.busy==='avatars'" @click="downloadAvatarsScope(tools.avatarScope)">
|
||
<svg x-show="tools.busy==='avatars'" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
|
||
Download
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Audio -->
|
||
<div class="tool-card glass p-5">
|
||
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V6l10-2v12M9 18a3 3 0 11-6 0 3 3 0 016 0zm10-2a3 3 0 11-6 0 3 3 0 016 0z"/></svg></div>
|
||
<h3 class="text-sm font-semibold text-zinc-100">Download audio (MP3)</h3>
|
||
<p class="text-xs text-zinc-500 mt-1 mb-2">Pull audio tracks as MP3 files.</p>
|
||
<select class="field !text-xs !py-1.5 mb-2" x-model="tools.audioScope">
|
||
<option value="all">All done videos</option>
|
||
<option value="selection" :disabled="videos.selected.length===0" x-text="'Current selection (' + videos.selected.length + ')'"></option>
|
||
<template x-for="c in channels.items" :key="c.channel_id"><option :value="c.channel_id" x-text="c.name"></option></template>
|
||
</select>
|
||
<button class="btn btn-ghost w-full" :disabled="tools.busy==='audio'" @click="downloadAudioScope(tools.audioScope)">
|
||
<svg x-show="tools.busy==='audio'" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
|
||
Download
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Clip -->
|
||
<div class="tool-card glass p-5">
|
||
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M7.848 8.25l1.536.887M7.848 8.25a3 3 0 11-5.196-3 3 3 0 015.196 3zm1.536.887a2.165 2.165 0 011.083 1.839c.005.351.054.695.14 1.024m9.211-3.294a3 3 0 11-5.196 3 3 3 0 015.196 3zm-.845 4.491c-.42.179-.84.302-1.262.367a2.165 2.165 0 00-1.083 1.839m0 0a2.16 2.16 0 01-.013.255m-1.78 1.054a3 3 0 11-5.196 3 3 3 0 015.196-3z"/></svg></div>
|
||
<h3 class="text-sm font-semibold text-zinc-100">Clip extractor</h3>
|
||
<p class="text-xs text-zinc-500 mt-1 mb-3">Pull transcript text between two timestamps.</p>
|
||
<button class="btn btn-ghost w-full" @click="openClip('')">Open clip tool</button>
|
||
</div>
|
||
|
||
<!-- Stats -->
|
||
<div class="tool-card glass p-5">
|
||
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5"/></svg></div>
|
||
<h3 class="text-sm font-semibold text-zinc-100">Stats</h3>
|
||
<p class="text-xs text-zinc-500 mt-1 mb-3">Totals, per-channel breakdown, top tags.</p>
|
||
<button class="btn btn-ghost w-full" @click="loadStats('')">Open stats</button>
|
||
</div>
|
||
|
||
<!-- Cookies -->
|
||
<div class="tool-card glass p-5">
|
||
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 11.5a8.5 8.5 0 11-9-8.46 2.5 2.5 0 003 3 2.5 2.5 0 003 3 2.5 2.5 0 003 2.46zM9 14a1 1 0 100-2 1 1 0 000 2zm3 3a1 1 0 100-2 1 1 0 000 2zm4-4a1 1 0 100-2 1 1 0 000 2z"/></svg></div>
|
||
<h3 class="text-sm font-semibold text-zinc-100">Cookie vault</h3>
|
||
<p class="text-xs text-zinc-500 mt-1 mb-3">Drag-and-drop a <span class="font-mono">.txt</span> session for members-only scraping.</p>
|
||
<button class="btn btn-ghost w-full" @click="setView('cookies')">Manage cookies</button>
|
||
</div>
|
||
|
||
<!-- Channels -->
|
||
<div class="tool-card glass p-5">
|
||
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h16v3H4zm0 6h16v3H4zm0 6h10v3H4z"/></svg></div>
|
||
<h3 class="text-sm font-semibold text-zinc-100">Manage channels</h3>
|
||
<p class="text-xs text-zinc-500 mt-1 mb-3">Add or remove tracked channels, process pending.</p>
|
||
<button class="btn btn-ghost w-full" @click="setView('channels')">Channels</button>
|
||
</div>
|
||
|
||
<!-- Watch mode (info) -->
|
||
<div class="tool-card glass p-5 border-dashed">
|
||
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||
<h3 class="text-sm font-semibold text-zinc-100">Watch mode</h3>
|
||
<p class="text-xs text-zinc-500 mt-1 mb-3">Auto-poll a channel for new videos via CLI.</p>
|
||
<div class="code-block" x-show="tools.watchCmd">
|
||
<code x-text="tools.watchCmd"></code>
|
||
<button class="btn btn-ghost !py-1 !px-2 !text-[0.65rem]" @click="copyText(tools.watchCmd)">Copy</button>
|
||
</div>
|
||
<button class="btn btn-ghost w-full !text-xs" x-show="!tools.watchCmd" @click="tools.watchCmd='yt-scraper watch --interval 6h --channel <url>'">Show command</button>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- open folders -->
|
||
<div class="space-y-3">
|
||
<h2 class="text-sm font-semibold text-zinc-300 uppercase tracking-wider">Open folders</h2>
|
||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
<button class="glass p-4 text-left hover:border-rose-500/40 transition-colors" @click="openFolder('markdown')">
|
||
<div class="flex items-center gap-2">
|
||
<svg viewBox="0 0 24 24" class="w-5 h-5 accent-text" fill="currentColor"><path d="M10 4H4a2 2 0 00-2 2v12a2 2 0 002 2h16a2 2 0 002-2V8a2 2 0 00-2-2h-8l-2-2z"/></svg>
|
||
<span class="text-white font-medium text-sm">Knowledge base (.md)</span>
|
||
</div>
|
||
<div class="text-[0.7rem] text-zinc-500 mt-1 font-mono truncate" x-text="folders.markdown?.path || '—'"></div>
|
||
</button>
|
||
<button class="glass p-4 text-left hover:border-rose-500/40 transition-colors" @click="openFolder('exports')">
|
||
<div class="flex items-center gap-2">
|
||
<svg viewBox="0 0 24 24" class="w-5 h-5 accent-text" fill="currentColor"><path d="M10 4H4a2 2 0 00-2 2v12a2 2 0 002 2h16a2 2 0 002-2V8a2 2 0 00-2-2h-8l-2-2z"/></svg>
|
||
<span class="text-white font-medium text-sm">Exports</span>
|
||
</div>
|
||
<div class="text-[0.7rem] text-zinc-500 mt-1 font-mono truncate" x-text="folders.exports?.path || '—'"></div>
|
||
</button>
|
||
<button class="glass p-4 text-left hover:border-rose-500/40 transition-colors" @click="openFolder('database')">
|
||
<div class="flex items-center gap-2">
|
||
<svg viewBox="0 0 24 24" class="w-5 h-5 accent-text" fill="currentColor"><path d="M12 3C7 3 3 4.8 3 7s4 4 9 4 9-1.8 9-4-4-4-9-4zm0 6c-3.3 0-6-.9-6-2s2.7-2 6-2 6 .9 6 2-2.7 2-6 2z"/></svg>
|
||
<span class="text-white font-medium text-sm">Database + data</span>
|
||
</div>
|
||
<div class="text-[0.7rem] text-zinc-500 mt-1 font-mono truncate" x-text="folders.database?.path || '—'"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- format reference -->
|
||
<div class="glass p-5" x-show="tools.fmt">
|
||
<h3 class="text-sm font-semibold text-zinc-200 mb-2">Established Markdown format</h3>
|
||
<p class="text-xs text-zinc-500 mb-3">Template: <span class="font-mono accent-text" x-text="tools.fmt?.template"></span></p>
|
||
<div class="text-xs text-zinc-500 mb-1 uppercase tracking-wider">Frontmatter</div>
|
||
<div class="flex flex-wrap gap-1 mb-3">
|
||
<template x-for="f in (tools.fmt?.frontmatter||[])" :key="f"><span class="pill st-skipped font-mono" x-text="f"></span></template>
|
||
</div>
|
||
<div class="text-xs text-zinc-500 mb-1 uppercase tracking-wider">Body</div>
|
||
<pre class="text-[0.72rem] text-zinc-400 font-mono bg-black/40 rounded-lg p-3 overflow-x-auto" x-text="tools.fmt?.body"></pre>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ===== Export ===== -->
|
||
<template x-if="view==='export'">
|
||
<section class="space-y-5" x-transition.opacity>
|
||
<header class="flex items-end justify-between">
|
||
<div><h1 class="text-2xl font-bold text-white">Export</h1><p class="text-sm text-zinc-500">Download the catalog</p></div>
|
||
<button class="btn btn-ghost" @click="openFolder('exports')">Open exports folder ↗</button>
|
||
</header>
|
||
<div class="glass p-6 space-y-5 max-w-xl">
|
||
<div>
|
||
<label class="block text-xs text-zinc-500 uppercase tracking-wider mb-2">Format</label>
|
||
<div class="flex gap-2">
|
||
<template x-for="f in ['json','csv','html']" :key="f">
|
||
<button class="tab flex-1" :class="forms.exportFormat===f?'active':''" @click="forms.exportFormat=f" x-text="f.toUpperCase()"></button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs text-zinc-500 uppercase tracking-wider mb-2">Channel</label>
|
||
<select class="field" x-model="forms.exportChannel">
|
||
<option value="">All channels</option>
|
||
<template x-for="c in channels.items" :key="c.channel_id"><option :value="c.channel_id" x-text="c.name"></option></template>
|
||
</select>
|
||
</div>
|
||
<button class="btn accent-grad btn-primary w-full" @click="doExport()">
|
||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 12l4.5 4.5m0 0l4.5-4.5M12 3v13.5"/></svg>
|
||
Download
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
</div>
|
||
</main>
|
||
|
||
<!-- ============ FLOATING JOB WIDGET ============ -->
|
||
<template x-if="scrape.jobId && view !== 'scrape' && !jobWidget.dismissed">
|
||
<div class="job-widget" x-transition.opacity @mouseenter="_bumpAutoHide()" @focusin="_bumpAutoHide()">
|
||
<template x-if="!jobWidget.collapsed">
|
||
<div class="space-y-2.5">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div class="flex items-center gap-2 min-w-0">
|
||
<span class="w-2 h-2 rounded-full shrink-0" :class="scrape.error ? 'bg-rose-500' : (scrape.done ? 'bg-emerald-500' : 'bg-blue-400 animate-pulse')"></span>
|
||
<span class="text-xs font-semibold text-white uppercase tracking-wider" x-text="scrape.kind ? scrape.kind + ' job' : 'job'"></span>
|
||
<span class="pill !text-[0.6rem] !py-0" :class="scrape.error ? 'st-error' : (scrape.done ? 'st-done' : 'st-running')" x-text="scrape.error ? 'error' : (scrape.done ? 'done' : 'running')"></span>
|
||
</div>
|
||
<button class="jw-x" @click="closeJobWidget()" :title="(!scrape.done && !scrape.error) ? 'Minimize' : 'Dismiss'" :aria-label="(!scrape.done && !scrape.error) ? 'Minimize job widget' : 'Dismiss job widget'">✕</button>
|
||
</div>
|
||
<div class="flex justify-between text-[0.7rem] text-zinc-500">
|
||
<span>Progress</span>
|
||
<span class="font-mono" x-text="(scrape.progress.completed||0) + ' / ' + (scrape.progress.total||0) + (scrape.progress.total ? ' ('+scrapePct()+'%)' : '')"></span>
|
||
</div>
|
||
<div class="progress-track"><div class="progress-fill" :style="'width:' + scrapePct() + '%'"></div></div>
|
||
<div class="log-panel !p-2 !max-h-16 overflow-y-auto" x-show="scrape.log.length">
|
||
<div class="text-[0.65rem]" :class="l.startsWith('[') ? 'accent-text' : 'text-zinc-500'" x-text="scrape.log[scrape.log.length-1]"></div>
|
||
</div>
|
||
<div class="flex gap-1.5">
|
||
<button class="btn accent-grad btn-primary !py-1 !px-2 !text-xs flex-1" @click="setView('scrape')">Open console</button>
|
||
<template x-if="scrape.done">
|
||
<button class="btn btn-ghost !py-1 !px-2 !text-xs flex-1" @click="openFolder('markdown')">.md folder</button>
|
||
</template>
|
||
<template x-if="!scrape.done && !scrape.error">
|
||
<button class="btn btn-danger !py-1 !px-2 !text-xs flex-1" @click="cancelScrape()">Cancel</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template x-if="jobWidget.collapsed">
|
||
<button class="jw-collapsed" @click="jobWidget.collapsed = false">
|
||
<span class="w-2 h-2 rounded-full" :class="scrape.error ? 'bg-rose-500' : (scrape.done ? 'bg-emerald-500' : 'bg-blue-400 animate-pulse')"></span>
|
||
<span class="text-xs font-semibold text-white" x-text="(scrape.kind||'job') + ' · ' + scrapePct() + '%'"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ============ CLIP MINI-TOOL MODAL ============ -->
|
||
<template x-if="clip.open">
|
||
<div class="modal-backdrop" @click.self="closeClip()" x-transition.opacity>
|
||
<div class="modal-card w-full max-w-2xl" @click.stop>
|
||
<div class="modal-head">
|
||
<h2 class="text-base font-semibold text-white">Clip extractor</h2>
|
||
<button class="jw-x" @click="closeClip()" aria-label="Close clip extractor" title="Close (Esc)">✕</button>
|
||
</div>
|
||
<div class="p-5 space-y-3">
|
||
<div>
|
||
<label class="block text-xs text-zinc-500 uppercase tracking-wider mb-1">Video ID</label>
|
||
<input class="field font-mono" type="text" placeholder="dQw4w9WgXcQ" x-model="clip.videoId" />
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label class="block text-xs text-zinc-500 uppercase tracking-wider mb-1">From (MM:SS)</label>
|
||
<input class="field font-mono" type="text" placeholder="01:23" x-model="clip.from" @keydown.enter="runClip()" />
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs text-zinc-500 uppercase tracking-wider mb-1">To (MM:SS)</label>
|
||
<input class="field font-mono" type="text" placeholder="02:45" x-model="clip.to" @keydown.enter="runClip()" />
|
||
</div>
|
||
</div>
|
||
<button class="btn accent-grad btn-primary w-full" :disabled="clip.loading" @click="runClip()">
|
||
<svg x-show="clip.loading" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
|
||
Extract clip
|
||
</button>
|
||
<p x-show="clip.error" class="text-sm text-rose-400" x-text="clip.error"></p>
|
||
|
||
<template x-if="clip.result">
|
||
<div class="space-y-2 pt-2 border-t border-zinc-800/70">
|
||
<div class="flex items-center justify-between gap-2">
|
||
<div class="text-xs text-zinc-500">
|
||
<span class="text-zinc-200 font-medium" x-text="clip.result.title || clip.result.video_id"></span>
|
||
· <span class="font-mono accent-text" x-text="clip.result.from + ' → ' + clip.result.to"></span>
|
||
· <span x-text="(clip.result.segment_count||0) + ' segs'"></span>
|
||
</div>
|
||
<div class="flex gap-1.5">
|
||
<button class="btn btn-ghost !py-1 !px-2 !text-xs" @click="copyClip()">Copy</button>
|
||
<button class="btn btn-ghost !py-1 !px-2 !text-xs" @click="downloadClipTxt()">.txt</button>
|
||
</div>
|
||
</div>
|
||
<pre class="clip-result" x-text="clip.result.text"></pre>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ============ STATS MODAL ============ -->
|
||
<template x-if="stats.open">
|
||
<div class="modal-backdrop" @click.self="closeStats()" x-transition.opacity>
|
||
<div class="modal-card w-full max-w-3xl" @click.stop>
|
||
<div class="modal-head">
|
||
<h2 class="text-base font-semibold text-white">Stats</h2>
|
||
<button class="jw-x" @click="closeStats()" aria-label="Close stats panel" title="Close (Esc)">✕</button>
|
||
</div>
|
||
<div class="p-5 space-y-5">
|
||
<template x-if="stats.loading"><div class="text-zinc-500 text-sm text-center py-8">crunching…</div></template>
|
||
<template x-if="!stats.loading && stats.data">
|
||
<!-- totals -->
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
<div class="stat-box"><div class="stat-lbl">Videos</div><div class="stat-val" x-text="fmtNum(stats.data.totals?.videos)"></div></div>
|
||
<div class="stat-box"><div class="stat-lbl">Duration</div><div class="stat-val" x-text="humanDur(stats.data.totals?.duration_sec)"></div></div>
|
||
<div class="stat-box"><div class="stat-lbl">Views</div><div class="stat-val" x-text="fmtNum(stats.data.totals?.views)"></div></div>
|
||
<div class="stat-box"><div class="stat-lbl">Likes</div><div class="stat-val" x-text="fmtNum(stats.data.totals?.likes)"></div></div>
|
||
</div>
|
||
|
||
<!-- status breakdown -->
|
||
<div x-show="stats.data.status_breakdown && Object.keys(stats.data.status_breakdown).length">
|
||
<div class="text-xs text-zinc-500 uppercase tracking-wider mb-2">Status breakdown</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<template x-for="(n, k) in (stats.data.status_breakdown||{})" :key="k">
|
||
<span class="pill" :class="statusClass(k)"><span x-text="k"></span><span class="font-mono" x-text="n"></span></span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- channels -->
|
||
<div x-show="stats.data.channels && stats.data.channels.length">
|
||
<div class="text-xs text-zinc-500 uppercase tracking-wider mb-2">Per channel</div>
|
||
<div class="overflow-hidden rounded-lg border border-zinc-800/70">
|
||
<table class="tbl">
|
||
<thead><tr><th>Channel</th><th>Videos</th><th>Duration</th><th>Views</th></tr></thead>
|
||
<tbody>
|
||
<template x-for="c in (stats.data.channels||[])" :key="c.channel_id">
|
||
<tr><td class="text-zinc-200" x-text="c.name || c.channel_id"></td><td class="font-mono text-xs text-zinc-300" x-text="fmtNum(c.video_count)"></td><td class="font-mono text-xs text-zinc-300" x-text="humanDur(c.total_duration)"></td><td class="font-mono text-xs text-zinc-300" x-text="fmtNum(c.total_views)"></td></tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- top tags -->
|
||
<div x-show="stats.data.top_tags && stats.data.top_tags.length">
|
||
<div class="text-xs text-zinc-500 uppercase tracking-wider mb-2">Top tags</div>
|
||
<div class="flex flex-wrap gap-1.5">
|
||
<template x-for="t in (stats.data.top_tags||[]).slice(0,20)" :key="t.tag">
|
||
<span class="pill st-skipped"><span x-text="t.tag"></span><span class="font-mono" x-text="t.count"></span></span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ============ IN-APP CONFIRM (replaces window.confirm) ============ -->
|
||
<template x-if="confirmBox.open">
|
||
<div class="modal-backdrop" @click.self="resolveConfirm(false)" x-transition.opacity>
|
||
<div class="modal-card w-full max-w-md" @click.stop role="alertdialog" aria-modal="true" :aria-label="confirmBox.title">
|
||
<div class="modal-head">
|
||
<h2 class="text-base font-semibold text-white" x-text="confirmBox.title"></h2>
|
||
<button class="jw-x" @click="resolveConfirm(false)" aria-label="Close dialog" title="Close (Esc)">✕</button>
|
||
</div>
|
||
<div class="p-5 space-y-4">
|
||
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-line" x-text="confirmBox.message"></p>
|
||
<div class="flex gap-2 justify-end">
|
||
<button class="btn btn-ghost flex-1" @click="resolveConfirm(false)" x-init="$el.focus()" x-text="confirmBox.cancelLabel"></button>
|
||
<button class="btn flex-1" :class="confirmBox.danger ? 'btn-danger' : 'accent-grad btn-primary'" @click="resolveConfirm(true)" x-text="confirmBox.confirmLabel"></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
</div>
|
||
</body>
|
||
</html>
|