feat: batch .md download, multi-select, thumbnails, all OPPORTUNITIES tools

Videos view: multi-select (checkboxes) + select-all/deselect-all + bulk action
bar (Download .md batch, Download thumbnails, Download audio). Per-video buttons:
.md download (done) or Process to .md (pending), clip.

Channels: per-channel pending count + 'Download pending .md' (batches pending
video_ids). Each channel keeps Videos/.md folder/Remove actions.

Backend (jobs.py + api.py):
- POST /api/scrape/batch {video_ids} + POST /api/scrape/video/{id} (on-demand
  .md for any video incl. pending) as SSE-tracked jobs
- GET /api/videos/{id}/markdown (file download)
- POST /api/tools/thumbnails + GET /api/thumbnails/{id} (local cache, offline)
- GET /api/clip/{id}?from=&to= (transcript segment)
- GET /api/stats (aggregate totals)
- POST /api/tools/audio (mp3 job)

Tools view rebuilt as full Knowledge Base grid surfacing every OPPORTUNITIES.md
feature as electable buttons (md download, re-render, export, search, analysis,
thumbnails, audio, clip, stats, cookies, channels, watch, open folders).

Floating job widget (SSE) for any running job. Thumbnails served via
/api/thumbnails/{id} everywhere (works offline once cached).

Launcher: robust Open-Browser helper (3 fallback methods) auto-opens
http://127.0.0.1:<port> on start + on idempotent re-open. data/ gitignored.
This commit is contained in:
urieljareth
2026-07-26 23:39:05 -06:00
parent 621bbc5f5c
commit 0682d2d806
7 changed files with 852 additions and 33 deletions
+297 -17
View File
@@ -162,19 +162,23 @@
<div class="glass overflow-hidden">
<table class="tbl">
<thead><tr><th>Name</th><th>Handle</th><th>Videos</th><th>Last scraped</th><th class="text-right">Actions</th></tr></thead>
<thead><tr><th>Name</th><th>Handle</th><th>Videos</th><th>Pending</th><th>Last scraped</th><th class="text-right">Actions</th></tr></thead>
<tbody>
<template x-if="loading.channels"><tr><td colspan="5" class="text-center text-zinc-500 py-8">loading…</td></tr></template>
<template x-if="!loading.channels && channels.items.length===0"><tr><td colspan="5" class="text-center text-zinc-500 py-8">No channels tracked yet.</td></tr></template>
<template x-if="loading.channels"><tr><td colspan="6" class="text-center text-zinc-500 py-8">loading…</td></tr></template>
<template x-if="!loading.channels && channels.items.length===0"><tr><td colspan="6" 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><button class="text-white font-medium hover:text-rose-400 transition-colors text-left" @click="openChannel(c.channel_id)" x-text="c.name"></button></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" x-text="c.last_scraped ? fmtDate(c.last_scraped) : '—'"></td>
<td class="text-right whitespace-nowrap">
<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>
@@ -205,27 +209,55 @@
</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()">
<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="downloadThumbnails()">
<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="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>
Thumbnails
</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>
<th><select class="bg-transparent text-zinc-500 uppercase text-[0.7rem] tracking-wider font-semibold" x-model="filters.sort" @change="loadVideos(videos.page)">
<option value="upload_date">Uploaded</option><option value="duration">Duration</option><option value="view_count">Views</option><option value="like_count">Likes</option>
</select></th>
<th>Duration</th><th>Views</th><th>Status</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="7" class="text-center text-zinc-500 py-10">loading…</td></tr></template>
<template x-if="!loading.videos && videos.items.length===0"><tr><td colspan="7" class="text-center text-zinc-500 py-10">No videos match these filters.</td></tr></template>
<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">No videos match these filters.</td></tr></template>
<template x-for="v in videos.items" :key="v.video_id">
<tr class="row-hover" @click="openVideo(v.video_id)">
<td><img class="thumb w-20 h-12" :src="v.thumbnail || ''" :alt="v.title" onerror="this.style.visibility='hidden'" /></td>
<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="text-zinc-400 font-mono text-xs" x-text="fmtDate(v.upload_date)"></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><span class="pill" :class="statusClass(v.status)" x-text="v.status"></span></td>
<td class="text-right whitespace-nowrap" @click.stop>
<button 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 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>
@@ -251,7 +283,7 @@
<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="detail.video.thumbnail || ''" :alt="detail.video.title" onerror="this.style.visibility='hidden'" />
<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>
@@ -263,7 +295,12 @@
<div><span class="pill" :class="statusClass(detail.video.status)" x-text="detail.video.status"></span></div>
</div>
<div class="flex flex-wrap gap-2 mt-4">
<template x-if="detail.video.url"><a class="btn accent-grad btn-primary" :href="detail.video.url" target="_blank" rel="noopener">Open on YouTube ↗</a></template>
<button 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 class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="downloadThumbnails([detail.video.video_id])">Thumbnail</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>
@@ -524,27 +561,119 @@
<!-- tool grid -->
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="glass p-5">
<h3 class="text-sm font-semibold text-zinc-200">Re-render Markdown</h3>
<!-- 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') ? 'text-rose-400' : 'text-emerald-400'" x-text="tools.msg"></p>
<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>
<div class="glass p-5">
<h3 class="text-sm font-semibold text-zinc-200">Export catalog</h3>
<!-- 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>
<div class="glass p-5">
<h3 class="text-sm font-semibold text-zinc-200">Cookie vault</h3>
<!-- 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>
<!-- 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 -->
@@ -622,6 +751,157 @@
</div>
</main>
<!-- ============ FLOATING JOB WIDGET ============ -->
<template x-if="scrape.jobId && view !== 'scrape'">
<div class="job-widget" x-transition.opacity>
<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="jobWidget.collapsed = true" title="Collapse">—</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()">✕</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()">✕</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>
</div>
</body>
</html>