Files
yt-channel-scraper/src/yt_scraper/webapp/static/index.html
T

1099 lines
83 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="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 &ldquo;Pending .md&rdquo;.
<span class="text-zinc-500">&ldquo;no_subtitles&rdquo; often just means the language policy rejected the auto-generated captions &mdash; 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 &mdash; 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>