wip: spinner por-fila en Investigate + spawn directo de python en launcher (incluye trabajo acumulado del arbol)

This commit is contained in:
urieljareth
2026-08-22 14:03:39 -06:00
parent cd9f16f329
commit b04a2710cf
3 changed files with 702 additions and 129 deletions
+16 -6
View File
@@ -132,10 +132,18 @@ $proc = $null
foreach ($candidate in $candidates) { foreach ($candidate in $candidates) {
Remove-Item '.run\server.log' -Force -ErrorAction SilentlyContinue Remove-Item '.run\server.log' -Force -ErrorAction SilentlyContinue
Remove-Item '.run\server.err.log' -Force -ErrorAction SilentlyContinue
Out-Line " [...] Encendiendo el servidor (intento $candidate)..." Gray Out-Line " [...] Encendiendo el servidor (intento $candidate)..." Gray
$cmdLine = '/c "' + $PythonExe + '" -m uvicorn yt_scraper.webapp.app:app --host 127.0.0.1 --port ' + $candidate + ' --log-level info > ".run\server.log" 2>&1' # Spawn directo de python: un wrapper cmd /c con la ruta entre comillas
# dispara el quote-stripping de cmd cuando la ruta tiene espacios y el
# comando muere en silencio. Start-Process con array de args no sufre eso,
# y ademas server.info registra el PID real de python (no el del wrapper).
try { try {
$proc = Start-Process -FilePath 'cmd.exe' -ArgumentList $cmdLine -WorkingDirectory $root -WindowStyle Minimized -PassThru $proc = Start-Process -FilePath $PythonExe `
-ArgumentList @('-m','uvicorn','yt_scraper.webapp.app:app','--host','127.0.0.1','--port',"$candidate",'--log-level','info') `
-WorkingDirectory $root -WindowStyle Minimized -PassThru `
-RedirectStandardOutput "$root\.run\server.log" `
-RedirectStandardError "$root\.run\server.err.log"
} catch { } catch {
Out-Line " [warn] Start-Process fallo en $candidate : $($_.Exception.Message)" Yellow Out-Line " [warn] Start-Process fallo en $candidate : $($_.Exception.Message)" Yellow
continue continue
@@ -158,14 +166,16 @@ foreach ($candidate in $candidates) {
if (-not $bound) { if (-not $bound) {
Out-Line " [warn] Este intento no respondio; probando el siguiente..." Yellow Out-Line " [warn] Este intento no respondio; probando el siguiente..." Yellow
# Show the actual reason from the log so the user can diagnose. # Show the actual reason from the logs so the user can diagnose.
if (Test-Path '.run\server.log') { foreach ($logName in @('.run\server.err.log', '.run\server.log')) {
$tail = Get-Content '.run\server.log' -Tail 6 -ErrorAction SilentlyContinue if (Test-Path $logName) {
$tail = Get-Content $logName -Tail 6 -ErrorAction SilentlyContinue
if ($tail) { if ($tail) {
Out-Line " (ultimas lineas del log):" DarkGray Out-Line " (ultimas lineas de $logName):" DarkGray
$tail | ForEach-Object { Out-Line " $_" DarkGray } $tail | ForEach-Object { Out-Line " $_" DarkGray }
} }
} }
}
try { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } catch {} try { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } catch {}
try { Get-NetTCPConnection -State Listen -LocalPort $candidate -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } } catch {} try { Get-NetTCPConnection -State Listen -LocalPort $candidate -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } } catch {}
continue continue
+464 -59
View File
@@ -34,17 +34,20 @@
health: false, health: false,
// ----- data stores ----- // ----- data stores -----
dash: { channels: [], status_breakdown: {}, uploads_over_time: [], duration_histogram: [], top_tags: [] }, dash: { channels: [], status_breakdown: {}, top_tags: [] },
channels: { items: [], pending: {} }, channels: { items: [], pending: {} },
videos: { items: [], total: 0, page: 1, size: 25, selected: [] }, videos: { items: [], total: 0, page: 1, size: 25, selected: [] },
sidebarOpen: false,
detail: { video: null, transcript: [], hasAudio: false, audioPlaying: false, activeSeg: -1 }, detail: { video: null, transcript: [], hasAudio: false, audioPlaying: false, activeSeg: -1 },
search: { q: "", channel: "", items: [], ran: false }, search: { q: "", channel: "", items: [], ran: false },
analysis: { tab: "wordcloud", channel: "", term: "", words: [], timeline: [] }, analysis: { tab: "wordcloud", channel: "", term: "", words: [], timeline: [] },
scrape: { scrape: {
form: { channel_id: "", limit: "", since: "", languages: "", include_shorts: false, no_live: false }, form: { channel_id: "", limit: "", since: "", languages: "", include_shorts: false, no_live: false, full: false },
jobId: null, progress: { completed: 0, total: 0 }, log: [], jobs: [], es: null, done: false, error: null, jobId: null, progress: { completed: 0, total: 0 }, log: [], jobs: [], es: null, done: false, error: null, result: null,
kind: "", kind: "",
// channel_id al que apunta el discovery activo; null = todos los canales.
targetChannel: null,
}, },
cookies: { items: [], drag: false, testResult: {}, uploadMsg: "" }, cookies: { items: [], drag: false, testResult: {}, uploadMsg: "" },
@@ -54,19 +57,28 @@
// ----- stats panel ----- // ----- stats panel -----
stats: { open: false, data: null, loading: false, channel: "" }, stats: { open: false, data: null, loading: false, channel: "" },
// ----- in-app confirm dialog (never use window.confirm) -----
confirmBox: { open: false, title: "", message: "", confirmLabel: "Confirm", cancelLabel: "Cancel", danger: false, _resolve: null },
// ----- floating job widget ----- // ----- floating job widget -----
jobWidget: { collapsed: false, dismissed: false, _hideTimer: null }, jobWidget: { collapsed: false, dismissed: false, _hideTimer: null },
// ----- tools / folders ----- // ----- tools / folders -----
folders: { markdown: { path: "", exists: false }, exports: { path: "", exists: false }, audio: { path: "", exists: false }, analysis: { path: "", exists: false }, database: { path: "", exists: false } }, folders: { markdown: { path: "", exists: false }, exports: { path: "", exists: false }, audio: { path: "", exists: false }, analysis: { path: "", exists: false }, database: { path: "", exists: false } },
tools: { reRendering: false, msg: "", fmt: null, thumbScope: "all", audioScope: "all", busy: "", watchCmd: "" }, tools: { reRendering: false, msg: "", fmt: null, thumbScope: "all", audioScope: "all", avatarScope: "all", busy: "", watchCmd: "" },
// ----- forms ----- // ----- forms -----
forms: { channelUrl: "", channelError: "", exportFormat: "json", exportChannel: "" }, forms: { channelUrl: "", channelError: "", exportFormat: "json", exportChannel: "" },
filters: { channel: "", status: "", from: "", to: "", min_dur: "", q: "", sort: "upload_date" }, filters: { channel: "", status: "", from: "", to: "", min_dur: "", q: "", sort: "upload_date" },
// ----- loading flags ----- // ----- loading flags -----
loading: { dashboard: false, channels: false, videos: false, detail: false, transcript: false, search: false, analysis: false, jobs: false, cookies: false, addChannel: false, startScrape: false }, loading: { dashboard: false, channels: false, videos: false, detail: false, transcript: false, search: false, analysis: false, jobs: false, cookies: false, addChannel: false, startScrape: false, reset: false, reconcile: false },
// ----- channel sync -----
sync: { busy: "", lastResult: "" },
// ----- videos stuck in a retryable terminal status -----
retryable: { counts: {}, total: 0 },
// ----- chart instances ----- // ----- chart instances -----
charts: {}, charts: {},
@@ -76,9 +88,59 @@
// ================================================================= // =================================================================
init() { init() {
setupChartDefaults(); setupChartDefaults();
this.sidebarOpen = window.innerWidth >= 768 ? true : localStorage.getItem("sidebar-open") === "true";
const size = Number(localStorage.getItem("videos-size"));
if ([10, 25, 50, 100].includes(size)) this.videos.size = size;
this.hydrateURL();
this.checkHealth(); this.checkHealth();
this.loadDashboard(); this.loadDashboard();
this.loadChannels(); this.loadChannels();
if (this.view === "videos") this.loadVideos(this.videos.page);
if (this.view === "search" && this.search.q) this.loadSearch();
this.loadRetryable();
this.startLivePolling();
},
hydrateURL() {
const p = new URLSearchParams(window.location.search);
const views = ["dashboard", "channels", "videos", "detail", "search", "analysis", "scrape", "cookies", "tools", "export"];
if (views.includes(p.get("view"))) this.view = p.get("view");
if (this.view === "videos") {
["channel", "status", "from", "to", "min_dur", "q", "sort"].forEach(k => { if (p.has(k)) this.filters[k] = p.get(k); });
const page = Number(p.get("page"));
const size = Number(p.get("size"));
if (page > 0) this.videos.page = page;
if ([10, 25, 50, 100].includes(size)) this.videos.size = size;
}
if (this.view === "search") {
this.search.q = p.get("q") || "";
this.search.channel = p.get("channel") || "";
}
},
syncURL() {
clearTimeout(this._urlTimer);
this._urlTimer = setTimeout(() => {
const p = new URLSearchParams({ view: this.view });
if (this.view === "videos") {
Object.entries(this.filters).forEach(([k, v]) => { if (v) p.set(k, v); });
p.set("page", this.videos.page); p.set("size", this.videos.size);
} else if (this.view === "search") {
if (this.search.q) p.set("q", this.search.q);
if (this.search.channel) p.set("channel", this.search.channel);
}
const next = "?" + p.toString();
if (next !== window.location.search) history.replaceState(null, "", next);
}, 120);
},
toggleSidebar() {
this.sidebarOpen = !this.sidebarOpen;
localStorage.setItem("sidebar-open", String(this.sidebarOpen));
},
closeSidebar() {
this.sidebarOpen = false;
localStorage.setItem("sidebar-open", "false");
}, },
async api(url, opts) { async api(url, opts) {
@@ -94,17 +156,38 @@
}, },
async checkHealth() { async checkHealth() {
try { const d = await this.api("/healthz"); this.health = d && d.status === "ok"; } this._healthAttempt = (this._healthAttempt || 0) + 1;
catch (_) { this.health = false; } // Exponential backoff capped at 30s.
const delays = [1, 1, 2, 2, 4, 4, 8, 15, 30];
const wait = delays[Math.min(this._healthAttempt - 1, delays.length - 1)] * 1000;
try {
const d = await this.api("/healthz");
this.health = d && d.status === "ok";
this.healthFails = 0;
if (!this.health) {
setTimeout(() => this.checkHealth(), wait);
} else {
// healthy: ping every 30s
setTimeout(() => this.checkHealth(), 30000);
}
} catch (_) {
this.health = false;
this.healthFails = (this.healthFails || 0) + 1;
setTimeout(() => this.checkHealth(), wait);
}
}, },
setView(v) { setView(v) {
this.view = v; this.view = v;
this.syncURL();
if (window.innerWidth < 768) this.closeSidebar();
// destroy stray canvases when leaving chart-bearing views // destroy stray canvases when leaving chart-bearing views
if (v !== "dashboard") this.destroyCharts(["uploads", "duration", "tags"]);
if (v !== "analysis") this.destroyCharts(["topwords", "timeline"]); if (v !== "analysis") this.destroyCharts(["topwords", "timeline"]);
if (v === "dashboard") this.$nextTick(() => this.renderDashboardCharts()); if (v === "dashboard") this.loadDashboard();
if (v === "videos" && this.videos.items.length === 0) this.loadVideos(1); // Always reload, not only when empty: statuses change underneath us
// (jobs, CLI runs, reconcile) and a cached list would show stale rows.
if (v === "videos") this.loadVideos();
if (v === "channels") this.loadChannels();
if (v === "scrape") this.loadJobs(); if (v === "scrape") this.loadJobs();
if (v === "cookies") this.loadCookies(); if (v === "cookies") this.loadCookies();
if (v === "analysis") this.loadAnalysis(); if (v === "analysis") this.loadAnalysis();
@@ -119,33 +202,10 @@
try { try {
const d = await this.api("/api/dashboard"); const d = await this.api("/api/dashboard");
this.dash = d; this.dash = d;
this.$nextTick(() => this.renderDashboardCharts());
} catch (e) { this.toast("Failed to load dashboard: " + e.message, "error"); } } catch (e) { this.toast("Failed to load dashboard: " + e.message, "error"); }
finally { this.loading.dashboard = false; } finally { this.loading.dashboard = false; }
}, },
renderDashboardCharts() {
if (!window.Chart) return;
const up = this.dash.uploads_over_time || [];
this.makeChart("uploads", document.getElementById("chart-uploads"), {
type: "line",
data: { labels: up.map(u => this.fmtMonth(u.month)), datasets: [{ label: "uploads", data: up.map(u => u.count), borderColor: "#f43f5e", backgroundColor: "rgba(244,63,94,0.18)", fill: true, tension: 0.35, pointRadius: 2 }] },
options: this.lineOpts(),
});
const dh = this.dash.duration_histogram || [];
this.makeChart("duration", document.getElementById("chart-duration"), {
type: "bar",
data: { labels: dh.map(d => d.bucket), datasets: [{ label: "videos", data: dh.map(d => d.count), backgroundColor: "rgba(244,63,94,0.6)", borderRadius: 4 }] },
options: this.barOpts(),
});
const tt = (this.dash.top_tags || []).slice(0, 12);
this.makeChart("tags", document.getElementById("chart-tags"), {
type: "bar",
data: { labels: tt.map(t => t.tag), datasets: [{ label: "count", data: tt.map(t => t.count), backgroundColor: "rgba(251,113,133,0.7)", borderRadius: 4 }] },
options: this.barOpts(true),
});
},
// ================================================================= // =================================================================
// channels // channels
// ================================================================= // =================================================================
@@ -166,12 +226,42 @@
finally { this.loading.channels = false; } finally { this.loading.channels = false; }
}, },
async syncChannels(channelId) {
const body = channelId ? { channel_id: channelId } : {};
const isAll = !channelId;
this.sync.busy = isAll ? "all" : channelId;
try {
const d = await this.api("/api/tools/sync-channels", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const summary = "Synced " + (d.synced || 0) + ", avatars cached: " + (d.avatars_cached || 0) +
(d.avatars_recovered_via_deep_fallback ? " (recovered: " + d.avatars_recovered_via_deep_fallback + ")" : "") +
(d.errors && d.errors.length ? ", errors: " + d.errors.length : "");
this._setSyncResult(summary, d.errors && d.errors.length ? "error" : "success");
this.toast(summary, d.errors && d.errors.length ? "error" : "success");
await this.loadChannels();
await this.loadDashboard();
} catch (e) {
this._setSyncResult("Sync failed: " + e.message, "error");
this.toast("Sync failed: " + e.message, "error");
} finally {
this.sync.busy = "";
}
},
_setSyncResult(text, kind) {
this.sync.lastResult = text;
this.sync.lastResultKind = kind || "ok";
if (this._syncResultTimer) clearTimeout(this._syncResultTimer);
this._syncResultTimer = setTimeout(() => {
this.sync.lastResult = "";
this.sync.lastResultKind = "";
}, 8000);
},
async loadChannelPending(id) { async loadChannelPending(id) {
try { try {
const d = await this.api("/api/videos?channel=" + encodeURIComponent(id) + "&status=pending&size=1"); const d = await this.api("/api/videos?channel=" + encodeURIComponent(id) + "&status=pending&size=1");
if (!this.channels.pending) this.channels.pending = {}; if (!this.channels.pending) this.channels.pending = {};
this.channels.pending[id] = (d && d.total) || 0; this.channels.pending[id] = (d && d.total) || 0;
} catch (_) {} } catch (_) { console.warn("loadChannelPending failed", id, _); }
}, },
async addChannel() { async addChannel() {
@@ -183,8 +273,11 @@
const d = await this.api("/api/channels", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }) }); const d = await this.api("/api/channels", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }) });
this.forms.channelUrl = ""; this.forms.channelUrl = "";
await this.loadChannels(); await this.loadChannels();
this.toast("Added " + (d.name || "channel") + " (" + (d.video_count || 0) + " videos)"); let msg = "Added " + (d.name || "channel") + " (" + (d.video_count || 0) + " videos)";
// Auto-cache thumbnails in the background so the catalog looks populated immediately if (d.avatar && d.avatar_cached) msg += " · avatar cached";
else if (d.avatar) msg += " · avatar URL set (download pending)";
else msg += " · avatar not available";
this.toast(msg);
if (d.channel_id) { if (d.channel_id) {
this.api("/api/tools/thumbnails", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ channel_id: d.channel_id }) }) this.api("/api/tools/thumbnails", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ channel_id: d.channel_id }) })
.then((r) => { if (r && r.downloaded) this.toast((r.downloaded) + " thumbnails cached"); }) .then((r) => { if (r && r.downloaded) this.toast((r.downloaded) + " thumbnails cached"); })
@@ -195,7 +288,8 @@
}, },
async removeChannel(id) { async removeChannel(id) {
if (!confirm("Remove this channel and its videos?")) return; const ok = await this.confirmDialog({ title: "Remove channel", message: "Remove this channel and its videos from the platform?", confirmLabel: "Remove", danger: true });
if (!ok) return;
try { await this.api("/api/channels/" + encodeURIComponent(id), { method: "DELETE" }); await this.loadChannels(); this.loadDashboard(); } try { await this.api("/api/channels/" + encodeURIComponent(id), { method: "DELETE" }); await this.loadChannels(); this.loadDashboard(); }
catch (e) { this.toast("Remove failed: " + e.message, "error"); } catch (e) { this.toast("Remove failed: " + e.message, "error"); }
}, },
@@ -203,13 +297,21 @@
// ================================================================= // =================================================================
// videos // videos
// ================================================================= // =================================================================
// Sort keys live in the <select> in index.html and must match the map in
// store._order_clause — a key the backend does not recognise used to
// degrade silently to a raw upload_date DESC that ignored the
// chronological inference for un-scraped videos.
async loadVideos(page) { async loadVideos(page) {
if (page) this.videos.page = page; if (page) this.videos.page = page;
this.syncURL();
this.loading.videos = true; this.loading.videos = true;
const p = new URLSearchParams(); const p = new URLSearchParams();
const f = this.filters; const f = this.filters;
if (f.channel) p.set("channel", f.channel); if (f.channel) p.set("channel", f.channel);
if (f.status) p.set("status", f.status); // "__blocked__" is not a status — it is a cross-status filter for
// videos that cannot be fetched at all (members-only, private, …).
if (f.status === "__blocked__") p.set("blocked", "true");
else if (f.status) p.set("status", f.status);
if (f.from) p.set("from", f.from); if (f.from) p.set("from", f.from);
if (f.to) p.set("to", f.to); if (f.to) p.set("to", f.to);
if (f.min_dur) p.set("min_dur", f.min_dur); if (f.min_dur) p.set("min_dur", f.min_dur);
@@ -239,7 +341,7 @@
try { try {
const t = await this.api("/api/videos/" + encodeURIComponent(id) + "/transcript"); const t = await this.api("/api/videos/" + encodeURIComponent(id) + "/transcript");
this.detail.transcript = (t && t.items) || []; this.detail.transcript = (t && t.items) || [];
} catch (_) { this.detail.transcript = []; } } catch (e) { this.detail.transcript = []; this.toast("Could not load transcript: " + e.message, "error"); }
finally { this.loading.transcript = false; } finally { this.loading.transcript = false; }
// probe audio availability (HEAD) for the integrated player // probe audio availability (HEAD) for the integrated player
this.checkAudio(id); this.checkAudio(id);
@@ -318,8 +420,18 @@
if (best) { const el = document.getElementById("seg-" + best.idx); if (el) el.scrollIntoView({ behavior: "smooth", block: "center" }); } if (best) { const el = document.getElementById("seg-" + best.idx); if (el) el.scrollIntoView({ behavior: "smooth", block: "center" }); }
}, },
// ================================================================= get pageCount() { return Math.max(1, Math.ceil((this.videos.total || 0) / this.videos.size)); },
// selection (videos view) get pageNumbers() {
const n = this.pageCount, current = this.videos.page;
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1);
const out = [1];
if (current > 4) out.push("…");
for (let i = Math.max(2, current - 1); i <= Math.min(n - 1, current + 1); i++) out.push(i);
if (current < n - 3) out.push("…");
out.push(n);
return out;
},
// ================================================================= // =================================================================
toggleSelect(id) { toggleSelect(id) {
const i = this.videos.selected.indexOf(id); const i = this.videos.selected.indexOf(id);
@@ -375,7 +487,7 @@
if (!ids.length) { this.toast("No videos selected", "error"); return; } if (!ids.length) { this.toast("No videos selected", "error"); return; }
try { try {
const d = await this.api("/api/scrape/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ video_ids: ids }) }); const d = await this.api("/api/scrape/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ video_ids: ids }) });
this.toast("Downloading .md + thumbnails for " + (d.count || ids.length) + " videos"); this.toast("Generating .md for " + (d.count || ids.length) + " videos");
this.subscribeJob(d.job_id, "batch"); this.subscribeJob(d.job_id, "batch");
if (!videoIds) this.selectNone(); if (!videoIds) this.selectNone();
} catch (e) { this.toast("Batch failed: " + e.message, "error"); } } catch (e) { this.toast("Batch failed: " + e.message, "error"); }
@@ -417,6 +529,19 @@
finally { this.tools.busy = ""; } finally { this.tools.busy = ""; }
}, },
async downloadAvatarsScope(scope) {
const body = {};
if (scope && scope !== "all") body.channel_id = scope;
this.tools.busy = "avatars";
try {
const d = await this.api("/api/tools/avatars", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
this.toast("Downloaded " + (d.downloaded || 0) + " avatar(s)");
await this.loadChannels();
await this.loadDashboard();
} catch (e) { this.toast("Avatars failed: " + e.message, "error"); }
finally { this.tools.busy = ""; }
},
async downloadAudioScope(scope) { async downloadAudioScope(scope) {
const body = {}; const body = {};
if (scope === "selection") { if (scope === "selection") {
@@ -498,6 +623,39 @@
}, },
closeStats() { this.stats.open = false; }, closeStats() { this.stats.open = false; },
// In-platform replacement for window.confirm: renders the confirmBox
// modal and resolves to true/false. All destructive actions go through
// this so the browser never shows a native dialog.
confirmDialog(opts) {
opts = opts || {};
if (this.confirmBox._resolve) { this.confirmBox._resolve(false); this.confirmBox._resolve = null; }
this.confirmBox.title = opts.title || "Are you sure?";
this.confirmBox.message = opts.message || "";
this.confirmBox.confirmLabel = opts.confirmLabel || "Confirm";
this.confirmBox.cancelLabel = opts.cancelLabel || "Cancel";
this.confirmBox.danger = !!opts.danger;
this.confirmBox.open = true;
return new Promise((resolve) => { this.confirmBox._resolve = resolve; });
},
resolveConfirm(answer) {
this.confirmBox.open = false;
if (this.confirmBox._resolve) { this.confirmBox._resolve(!!answer); this.confirmBox._resolve = null; }
},
onGlobalEscape(e) {
if (this.sidebarOpen && window.innerWidth < 768) { this.closeSidebar(); return; }
// The confirm modal sits on top of everything — close it first.
if (this.confirmBox.open) { this.resolveConfirm(false); return; }
// Close the most-recently-opened modal/dropdown first; ignore
// when typing in form fields (let the browser handle IME, etc.).
const tag = e.target && e.target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || (e.target && e.target.isContentEditable)) return;
if (this.stats.open) { this.closeStats(); return; }
if (this.clip.open) { this.closeClip(); return; }
if (this.cookies && this.cookies.drag) { this.cookies.drag = false; return; }
},
// ================================================================= // =================================================================
// channels: pending download // channels: pending download
// ================================================================= // =================================================================
@@ -518,6 +676,7 @@
async loadSearch() { async loadSearch() {
const q = (this.search.q || "").trim(); const q = (this.search.q || "").trim();
if (!q) return; if (!q) return;
this.syncURL();
this.loading.search = true; this.loading.search = true;
this.search.ran = true; this.search.ran = true;
const p = new URLSearchParams({ q, limit: 50 }); const p = new URLSearchParams({ q, limit: 50 });
@@ -528,6 +687,14 @@
} catch (e) { this.toast("Search failed: " + e.message, "error"); this.search.items = []; } } catch (e) { this.toast("Search failed: " + e.message, "error"); this.search.items = []; }
finally { this.loading.search = false; } finally { this.loading.search = false; }
}, },
resetSearch() {
this.search.q = "";
this.search.channel = "";
this.search.items = [];
this.search.ran = false;
this.syncURL();
},
highlight(snippet, q) { highlight(snippet, q) {
if (!snippet) return ""; if (!snippet) return "";
@@ -612,6 +779,7 @@
languages: f.languages ? f.languages.split(",").map(s => s.trim()).filter(Boolean) : null, languages: f.languages ? f.languages.split(",").map(s => s.trim()).filter(Boolean) : null,
include_shorts: !!f.include_shorts, include_shorts: !!f.include_shorts,
no_live: !!f.no_live, no_live: !!f.no_live,
full: !!f.full,
}, },
}; };
try { try {
@@ -622,11 +790,136 @@
finally { this.loading.startScrape = false; } finally { this.loading.startScrape = false; }
}, },
// full=false (default) only reads pages newer than the last video we have.
// full=true re-walks every page — slow and request-hungry, so it is opt-in.
async startDiscovery(channelId, full) {
if (this.jobActive()) {
this.toast("A job is already running", "error");
return;
}
if (!channelId && (!this.channels.items || this.channels.items.length === 0)) {
this.toast("No tracked channels", "error");
return;
}
if (full) {
const ok = await this.confirmDialog({
title: "Full rescan",
message: "Full rescan re-reads every page of the channel. Slow and heavy on YouTube's limits — only needed if videos are missing.",
confirmLabel: "Continue",
});
if (!ok) return;
}
this.loading.startScrape = true;
const body = {
channel_id: channelId || null,
opts: full ? { mode: "discover", full: true } : { mode: "discover" },
};
try {
const d = await this.api("/api/scrape", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
this.scrape.done = false; this.scrape.error = null;
this.scrape.targetChannel = channelId || null;
this.subscribeJob(d.job_id, "discovery");
} catch (e) { this.toast("Investigation failed: " + e.message, "error"); }
finally { this.loading.startScrape = false; }
},
jobActive() {
return !!(this.scrape.jobId && !this.scrape.done && !this.scrape.error);
},
// =================================================================
// live state — the UI must reflect DB + disk without a manual reload
// =================================================================
// Reload whatever the current view is actually showing. Cheap enough to
// call liberally; deliberately does NOT reload views the user cannot see.
refreshLiveState(opts) {
const o = opts || {};
if (this.view === "videos" || o.force) this.loadVideos();
if (this.view === "channels" || o.force) this.loadChannels();
if (this.view === "dashboard" || o.force) this.loadDashboard();
// Deliberately NOT the detail view: openVideo() resets the audio
// player, so auto-refreshing it would interrupt playback.
this.loadRetryable();
},
// Poll while a job is running so progress is visible in the table itself,
// not only in the job widget. Stops as soon as nothing is running.
startLivePolling() {
if (this._livePoll) return;
this._livePoll = setInterval(() => {
if (document.hidden) return;
if (this.jobActive()) this.refreshLiveState();
}, 5000);
// Coming back to the tab is the other moment state is likely stale —
// a CLI run or another tab may have changed things meanwhile.
document.addEventListener("visibilitychange", () => {
if (!document.hidden) this.refreshLiveState();
});
window.addEventListener("focus", () => {
if (!this._lastFocusRefresh || Date.now() - this._lastFocusRefresh > 10000) {
this._lastFocusRefresh = Date.now();
this.refreshLiveState();
}
});
},
async loadRetryable() {
try {
const q = this.filters.channel ? "?channel=" + encodeURIComponent(this.filters.channel) : "";
const d = await this.api("/api/videos-retryable" + q);
this.retryable = d || { counts: {}, total: 0 };
} catch (_) { /* non-critical */ }
},
// Send error / no_subtitles videos back to pending so they can be retried.
async resetVideos(channelId, statuses) {
const body = {
channel_id: channelId || this.filters.channel || null,
statuses: statuses || ["error", "no_subtitles"],
};
const scope = body.channel_id ? "this channel" : "ALL channels";
const n = this.retryable && this.retryable.total ? this.retryable.total : "the stuck";
const ok = await this.confirmDialog({ title: "Reset videos", message: "Send " + n + " failed/skipped videos in " + scope + " back to pending so they can be retried?", confirmLabel: "Reset" });
if (!ok) return;
this.loading.reset = true;
try {
const d = await this.api("/api/videos/reset", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
this.toast((d.reset || 0) + " video(s) re-queued as pending", "success");
this.refreshLiveState({ force: true });
} catch (e) { this.toast("Reset failed: " + e.message, "error"); }
finally { this.loading.reset = false; }
},
// Re-scan data/markdown and repair statuses that disagree with disk.
async reconcile() {
this.loading.reconcile = true;
try {
const d = await this.api("/api/tools/reconcile", {
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
});
const bits = [];
if (d.repaired_done) bits.push(d.repaired_done + " marked done from disk");
if (d.missing_md) bits.push(d.missing_md + " re-queued (.md gone)");
if (d.backfilled) bits.push(d.backfilled + " backfilled");
if (d.orphan_md) bits.push(d.orphan_md + " orphan .md");
this.toast(
"Scanned " + (d.scanned || 0) + " .md — " + (bits.length ? bits.join(", ") : "already in sync"),
"success"
);
this.refreshLiveState({ force: true });
} catch (e) { this.toast("Reconcile failed: " + e.message, "error"); }
finally { this.loading.reconcile = false; }
},
subscribeJob(jobId, kind) { subscribeJob(jobId, kind) {
this.closeStream(); this.closeStream();
this.scrape.jobId = jobId; this.scrape.jobId = jobId;
this.scrape.kind = kind || "job"; this.scrape.kind = kind || "job";
this.scrape.done = false; this.scrape.error = null; this.scrape.done = false; this.scrape.error = null;
this.scrape.result = null;
this.scrape.log = ["[stream] connecting…"]; this.scrape.log = ["[stream] connecting…"];
this.scrape.progress = { completed: 0, total: 0 }; this.scrape.progress = { completed: 0, total: 0 };
this.jobWidget.collapsed = false; this.jobWidget.collapsed = false;
@@ -636,38 +929,90 @@
this.scrape.es = es; this.scrape.es = es;
es.addEventListener("log", e => { try { const d = JSON.parse(e.data); this.scrape.log.push(d.msg || ""); } catch (_) {} }); es.addEventListener("log", e => { try { const d = JSON.parse(e.data); this.scrape.log.push(d.msg || ""); } catch (_) {} });
es.addEventListener("progress", e => { try { const d = JSON.parse(e.data); this.scrape.progress = d; } catch (_) {} }); es.addEventListener("progress", e => { try { const d = JSON.parse(e.data); this.scrape.progress = d; } catch (_) {} });
es.addEventListener("done", () => { es.addEventListener("done", e => {
let result = {};
try { result = e.data ? JSON.parse(e.data) : {}; } catch (_) {}
this.scrape.result = result;
this.scrape.done = true; this.scrape.done = true;
this.scrape.log.push("[done] job finished"); this.scrape.log.push("[done] job finished");
this.closeStream(); this.closeStream();
this.loadJobs(); this.loadJobs();
this.toast((this.scrape.kind === "audio" ? "Audio job complete" : "Job complete — .md ready"), "success"); if (this.scrape.kind === "audio") {
// refresh current view data so statuses/counts update this.toast("Audio job complete", "success");
if (this.view === "videos") this.loadVideos(); } else if (this.scrape.kind === "discovery") {
if (this.view === "channels") this.loadChannels(); const n = Number(result.new_videos || 0);
if (this.view === "dashboard") this.loadDashboard(); const errors = Number(result.errors || 0);
const fetched = Number(result.fetched || 0);
this.toast(
"Investigation complete — " + n + " new video(s)" +
(fetched ? " (scanned " + fetched + ")" : "") +
(errors ? ", " + errors + " channel error(s)" : ""),
errors ? "error" : "success"
);
this.loadChannels();
} else {
const processed = Number(result.processed || 0);
const noSubtitles = Number(result.no_subtitles || 0);
const errors = Number(result.errors || 0);
const parts = [];
if (processed) parts.push(processed + " .md ready");
if (noSubtitles) parts.push(noSubtitles + " without transcript; no .md generated");
if (errors) parts.push(errors + " processing error(s)");
this.toast(
parts.length ? "Job finished — " + parts.join(", ") : "Job finished",
noSubtitles || errors ? "error" : "success",
);
}
// Refresh current view data so statuses/counts update. This block
// only ever reloaded the ACTIVE view, so a job started from the
// Scrape tab (where startScrape lives) refreshed nothing at all, and
// the setView cache guard then kept the stale rows on navigation.
this.refreshLiveState({ force: true });
if (this.view === "detail" && this.detail.video) this.openVideo(this.detail.video.video_id);
this._armAutoHide(); this._armAutoHide();
}); });
es.addEventListener("cancelled", () => { this.scrape.log.push("[cancelled]"); this.closeStream(); this.loadJobs(); this._armAutoHide(); }); es.addEventListener("cancelled", () => { this.scrape.log.push("[cancelled]"); this.closeStream(); this.loadJobs(); this._armAutoHide(); });
es.addEventListener("error", e => { es.addEventListener("error", e => {
if (this.scrape.es === null) return; // already closed by done/cancelled if (this.scrape.es === null) return; // already closed by done/cancelled
let msg = "connection error"; let msg = "connection error";
try { if (e.data) { const d = JSON.parse(e.data); msg = d.msg || msg; this.scrape.error = msg; this.scrape.log.push("[error] " + msg); } } catch (_) { this.scrape.error = msg; } // The backend sends `message` on error events (`msg` is only used by
// `log` events), so reading `msg` alone meant every failure — including
// the throttle breaker's detailed explanation — surfaced as the
// generic fallback below.
let throttled = false;
try { if (e.data) { const d = JSON.parse(e.data); msg = d.message || d.msg || msg; throttled = !!d.throttled; this.scrape.error = msg; this.scrape.log.push("[error] " + msg); } } catch (_) { this.scrape.error = msg; this.scrape.log.push("[error] " + msg); }
this.closeStream(); this.loadJobs(); this.closeStream(); this.loadJobs();
// Throttling is not a crash: the run stopped on purpose and everything
// it did not reach is still pending. Say that instead of "failed".
this.toast(throttled ? "Stopped by YouTube rate limiting — " + msg : "Job failed: " + msg, "error");
this._armAutoHide(); this._armAutoHide();
}); });
}, },
closeStream() { if (this.scrape.es) { try { this.scrape.es.close(); } catch (_) {} this.scrape.es = null; } }, closeStream() { if (this.scrape.es) { try { this.scrape.es.close(); } catch (_) {} this.scrape.es = null; } },
cancelScrape() { async cancelScrape() {
if (!this.scrape.jobId) return; if (!this.scrape.jobId) return;
const ok = await this.confirmDialog({ title: "Cancel job", message: "Cancel this running job? Videos already processed stay in your library.", confirmLabel: "Cancel job", danger: true });
if (!ok) return;
fetch("/api/scrape/" + encodeURIComponent(this.scrape.jobId), { method: "DELETE" }).then(() => { this.scrape.log.push("[cancel] requested"); this.closeStream(); this.loadJobs(); }).catch(() => {}); fetch("/api/scrape/" + encodeURIComponent(this.scrape.jobId), { method: "DELETE" }).then(() => { this.scrape.log.push("[cancel] requested"); this.closeStream(); this.loadJobs(); }).catch(() => {});
}, },
async deleteJob(id) { async deleteJob(id) {
const job = (this.scrape.jobs || []).find(j => j.id === id);
const isTerminal = job && this.jobIsTerminal(job);
const msg = isTerminal
? "Remove this finished job from history? Downloaded .md files are not affected."
: "Cancel this running job? Videos already processed stay in your library.";
const ok = await this.confirmDialog({
title: isTerminal ? "Clear job" : "Cancel job",
message: msg,
confirmLabel: isTerminal ? "Remove" : "Cancel job",
danger: !isTerminal,
});
if (!ok) return;
try { await this.api("/api/scrape/" + encodeURIComponent(id), { method: "DELETE" }); this.loadJobs(); } try { await this.api("/api/scrape/" + encodeURIComponent(id), { method: "DELETE" }); this.loadJobs(); }
catch (e) { this.toast("Delete failed: " + e.message, "error"); } catch (e) { this.toast("Action failed: " + e.message, "error"); }
}, },
closeJobWidget() { closeJobWidget() {
@@ -679,14 +1024,29 @@
this.jobWidget.dismissed = true; this.jobWidget.dismissed = true;
}, },
_armAutoHide() { _armAutoHide() {
// Auto-dismiss the widget a few seconds after a terminal state (done /
// cancelled / error). The user can still keep it open by hovering,
// clicking, or dismissing manually before the timer fires — the
// pointer/focus listeners and closeJobWidget() cancel the timer.
this._disarmAutoHide(); this._disarmAutoHide();
this.jobWidget._hideTimer = setTimeout(() => { this.jobWidget.dismissed = true; }, 6000); const delayMs = this._autoHideDelayMs || 5000;
this.jobWidget._hideTimer = setTimeout(() => {
this.jobWidget._hideTimer = null;
if (!this.scrape.done && !this.scrape.error) return; // a new job started
this.jobWidget.dismissed = true;
}, delayMs);
}, },
_disarmAutoHide() { _disarmAutoHide() {
if (this.jobWidget._hideTimer) { clearTimeout(this.jobWidget._hideTimer); this.jobWidget._hideTimer = null; } if (this.jobWidget._hideTimer) { clearTimeout(this.jobWidget._hideTimer); this.jobWidget._hideTimer = null; }
}, },
_bumpAutoHide() {
// The user is interacting with the widget (hover/focus) — restart
// the countdown so it doesn't fade while they're reading the result.
if (this.jobWidget._hideTimer) this._armAutoHide();
},
async clearJobHistory() { async clearJobHistory() {
if (!confirm("Clear all finished jobs from history?\nDownloaded .md files and video data are NOT affected.")) return; const ok = await this.confirmDialog({ title: "Clear history", message: "Clear all finished jobs from history? Downloaded .md files and video data are NOT affected.", confirmLabel: "Clear", danger: true });
if (!ok) return;
try { try {
const d = await this.api("/api/scrape/history", { method: "DELETE" }); const d = await this.api("/api/scrape/history", { method: "DELETE" });
this.toast("Cleared " + ((d && d.deleted) || 0) + " finished job(s)", "success"); this.toast("Cleared " + ((d && d.deleted) || 0) + " finished job(s)", "success");
@@ -705,7 +1065,7 @@
async loadJobs() { async loadJobs() {
this.loading.jobs = true; this.loading.jobs = true;
try { const d = await this.api("/api/scrape"); this.scrape.jobs = (d && d.items) || []; } try { const d = await this.api("/api/scrape"); this.scrape.jobs = (d && d.items) || []; }
catch (_) {} catch (e) { console.warn("loadJobs failed", e); this.toast("Could not load jobs: " + e.message, "error"); }
finally { this.loading.jobs = false; } finally { this.loading.jobs = false; }
}, },
@@ -767,7 +1127,8 @@
}, },
async removeCookie(id) { async removeCookie(id) {
if (!confirm("Delete this cookie file?")) return; const ok = await this.confirmDialog({ title: "Delete cookie", message: "Delete this cookie file?", confirmLabel: "Delete", danger: true });
if (!ok) return;
try { await this.api("/api/cookies/" + encodeURIComponent(id), { method: "DELETE" }); this.loadCookies(); } try { await this.api("/api/cookies/" + encodeURIComponent(id), { method: "DELETE" }); this.loadCookies(); }
catch (e) { this.toast("Delete failed: " + e.message, "error"); } catch (e) { this.toast("Delete failed: " + e.message, "error"); }
}, },
@@ -792,12 +1153,12 @@
async loadFolders() { async loadFolders() {
try { const d = await this.api("/api/folders"); if (d && d.items) this.folders = d.items; } try { const d = await this.api("/api/folders"); if (d && d.items) this.folders = d.items; }
catch (_) {} catch (e) { console.warn("loadFolders failed", e); }
}, },
async loadFormat() { async loadFormat() {
try { const d = await this.api("/api/tools/format"); this.tools.fmt = d; } try { const d = await this.api("/api/tools/format"); this.tools.fmt = d; }
catch (_) {} catch (e) { console.warn("loadFormat failed", e); }
}, },
async openFolder(kind, channelId) { async openFolder(kind, channelId) {
@@ -893,6 +1254,24 @@
return s; return s;
}, },
// The date shown in the videos list. A video discovery has not extracted
// yet has no upload_date of its own, so the backend infers one from its
// position in the channel listing — that is what the row is sorted by, so
// showing it (marked "~") is what makes the order look explicable instead
// of a column full of dashes in the middle of a sorted list.
videoDate(v) {
if (!v) return "—";
if (v.upload_date) return this.fmtDate(v.upload_date);
if (v.sort_date) return "~ " + this.fmtDate(v.sort_date);
return "—";
},
videoDateTitle(v) {
if (!v) return "";
if (v.upload_date) return "Upload date";
if (v.date_estimated) return "Not scraped yet — approximate date, taken from the next newer video in the channel. Download the .md to learn the exact one.";
return "Not scraped yet — YouTube's channel listing does not report upload dates. Download the .md to learn it.";
},
fmtDateTime(d) { fmtDateTime(d) {
if (!d) return "—"; if (!d) return "—";
const s = String(d).replace("T", " "); const s = String(d).replace("T", " ");
@@ -907,10 +1286,36 @@
}, },
chanName(id) { chanName(id) {
if (!id) return "Unknown channel";
const c = (this.channels.items || []).find(c => c.channel_id === id); const c = (this.channels.items || []).find(c => c.channel_id === id);
return c ? c.name : (id || "—"); return c && c.name ? c.name : id;
}, },
// ---- gated content -------------------------------------------------
// block_reason comes from the API: null when fetchable, otherwise why not.
// Discovery reports it (availability=subscriber_only) before we ever try,
// so the badge shows up without a failed attempt first.
blockLabel(v) {
return {
members_only: "members only",
premium_only: "premium",
private: "private",
needs_auth: "sign-in required",
removed: "removed",
}[v && v.block_reason] || "";
},
blockTitle(v) {
const label = this.blockLabel(v);
if (!label) return "";
if (v.block_reason === "members_only") {
return "Members-only video. It cannot be fetched without a paid channel membership on the account your cookies come from — retrying will not help.";
}
return "Not fetchable: " + label + ". Retrying will not help.";
},
isBlocked(v) { return !!(v && v.block_reason); },
statusClass(status) { statusClass(status) {
status = String(status || "").toLowerCase(); status = String(status || "").toLowerCase();
const map = { done: "st-done", ok: "st-done", completed: "st-done", success: "st-done", error: "st-error", failed: "st-error", fail: "st-error", pending: "st-pending", queued: "st-pending", running: "st-running", active: "st-running", skipped: "st-skipped", skip: "st-skipped", new: "st-new" }; const map = { done: "st-done", ok: "st-done", completed: "st-done", success: "st-done", error: "st-error", failed: "st-error", fail: "st-error", pending: "st-pending", queued: "st-pending", running: "st-running", active: "st-running", skipped: "st-skipped", skip: "st-skipped", new: "st-new" };
@@ -918,7 +1323,7 @@
}, },
toast(msg, kind) { toast(msg, kind) {
// simple visible status — log + alert fallback // transient on-page toast — messages never leave the platform
if (kind === "error") console.error("[platform]", msg); if (kind === "error") console.error("[platform]", msg);
else console.log("[platform]", msg); else console.log("[platform]", msg);
// transient on-page toast // transient on-page toast
+215 -57
View File
@@ -21,10 +21,13 @@
<link rel="stylesheet" href="/static/styles.css" /> <link rel="stylesheet" href="/static/styles.css" />
</head> </head>
<body class="min-h-screen relative"> <body class="min-h-screen relative">
<div id="root" x-cloak x-data="platform()" x-init="init()" class="relative z-10 flex min-h-screen"> <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 ============ --> <!-- ============ 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"> <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="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"> <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> <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>
@@ -46,14 +49,23 @@
<div class="px-4 py-4 border-t border-zinc-800/70 text-[0.7rem] text-zinc-500"> <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"> <div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :class="health ? 'bg-emerald-500' : 'bg-zinc-600'"></span> <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' : 'checking…'"></span> <span x-text="health ? 'backend online' : (healthFails ? 'backend unreachable' : 'checking…')"></span>
</div> </div>
</div> </div>
</aside> </aside>
<!-- ============ MAIN ============ --> <!-- ============ MAIN ============ -->
<main class="flex-1 ml-64 min-w-0"> <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"> <div class="max-w-7xl mx-auto px-8 py-8">
<!-- ===== Dashboard ===== --> <!-- ===== Dashboard ===== -->
@@ -107,12 +119,18 @@
</template> </template>
<div class="grid md:grid-cols-2 gap-4"> <div class="grid md:grid-cols-2 gap-4">
<template x-for="c in dash.channels" :key="c.channel_id"> <template x-for="c in dash.channels" :key="c.channel_id">
<div class="glass p-5 hover:border-rose-500/40 transition-colors"> <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-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="min-w-0">
<div class="text-white font-semibold truncate" x-text="c.name"></div> <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 class="text-xs text-zinc-500 truncate" x-text="c.handle || c.channel_id"></div>
</div> </div>
</div>
<div class="text-right shrink-0"> <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-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 class="text-[0.65rem] text-zinc-500 uppercase">videos</div>
@@ -127,29 +145,32 @@
</template> </template>
</div> </div>
</div> </div>
<!-- charts -->
<div class="grid lg:grid-cols-2 gap-4">
<div class="glass p-5">
<h3 class="text-sm font-semibold text-zinc-200 mb-3">Uploads over time</h3>
<div class="h-64"><canvas id="chart-uploads"></canvas></div>
</div>
<div class="glass p-5">
<h3 class="text-sm font-semibold text-zinc-200 mb-3">Duration distribution</h3>
<div class="h-64"><canvas id="chart-duration"></canvas></div>
</div>
<div class="glass p-5 lg:col-span-2">
<h3 class="text-sm font-semibold text-zinc-200 mb-3">Top tags</h3>
<div class="h-64"><canvas id="chart-tags"></canvas></div>
</div>
</div>
</section> </section>
</template> </template>
<!-- ===== Channels ===== --> <!-- ===== Channels ===== -->
<template x-if="view==='channels'"> <template x-if="view==='channels'">
<section class="space-y-6" x-transition.opacity> <section class="space-y-6" x-transition.opacity>
<header><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></header> <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()"> <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" /> <input class="field flex-1" type="text" placeholder="https://www.youtube.com/@handle" x-model="forms.channelUrl" />
@@ -162,20 +183,35 @@
<div class="glass overflow-hidden"> <div class="glass overflow-hidden">
<table class="tbl"> <table class="tbl">
<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> <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> <tbody>
<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"><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="6" class="text-center text-zinc-500 py-8">No channels tracked yet.</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"> <template x-for="c in channels.items" :key="c.channel_id">
<tr class="row-hover"> <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>
<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 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><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> <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> <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>
<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-zinc-400 text-xs" x-text="c.last_scraped ? fmtDate(c.last_scraped) : '—'"></td>
<td class="text-right whitespace-nowrap"> <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="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 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 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>
@@ -192,17 +228,52 @@
<!-- ===== Videos ===== --> <!-- ===== Videos ===== -->
<template x-if="view==='videos'"> <template x-if="view==='videos'">
<section class="space-y-5" x-transition.opacity> <section class="space-y-5" x-transition.opacity>
<header><h1 class="text-2xl font-bold text-white">Videos</h1><p class="text-sm text-zinc-500">Browse and filter the catalog</p></header> <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"> <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.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)"> <select class="field" x-model="filters.status" @change="loadVideos(1)">
<option value="">Any status</option> <option value="">Any status</option>
<option value="done">Done</option><option value="pending">Pending</option><option value="error">Error</option><option value="skipped">Skipped</option><option value="new">New</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> </select>
<input class="field" type="date" x-model="filters.from" @change="loadVideos(1)" /> <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="date" x-model="filters.to" @change="loadVideos(1)" />
<input class="field" type="number" min="0" step="60" placeholder="min sec" x-model="filters.min_dur" @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"> <div class="flex gap-2">
<input class="field flex-1" type="text" placeholder="search title…" x-model="filters.q" @keydown.enter="loadVideos(1)" /> <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> <button class="btn btn-ghost" @click="loadVideos(1)">Go</button>
@@ -213,9 +284,9 @@
<template x-if="videos.selected.length > 0"> <template x-if="videos.selected.length > 0">
<div class="bulk-bar glass p-3 flex flex-wrap items-center gap-2"> <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> <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="Download .md + thumbnails for the selection"> <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> <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 + thumbs Download .md
</button> </button>
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="downloadAudio()"> <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> <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>
@@ -232,26 +303,44 @@
<thead><tr> <thead><tr>
<th class="w-8"><input type="checkbox" class="accent-rose-500" :checked="allSelected" @change="toggleSelectAll()" title="Select page" /></th> <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 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)"> <!-- No @click on the th: the select bubbles up to it, so a plain
<option value="upload_date">Uploaded</option><option value="duration">Duration</option><option value="view_count">Views</option><option value="like_count">Likes</option> click on the dropdown used to cycle the sort as well. -->
</select></th> <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> <th>Duration</th><th>Views</th><th>Status</th><th class="text-right">Actions</th>
</tr></thead> </tr></thead>
<tbody> <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"><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-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"> <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)"> <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 @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><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-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 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-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="ts(v.duration)"></td>
<td class="font-mono text-zinc-300 text-xs" x-text="fmtNum(v.view_count)"></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>
<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> <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 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> <button class="btn btn-ghost !py-1 !px-2 text-xs" @click="openClip(v.video_id)" title="Extract transcript clip">Clip</button>
</td> </td>
</tr> </tr>
@@ -261,13 +350,16 @@
</div> </div>
<!-- pagination --> <!-- pagination -->
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between gap-3 text-sm flex-wrap">
<div class="text-zinc-500" x-text="videos.total ? (videos.size*(videos.page-1)+1)+'–'+Math.min(videos.size*videos.page, videos.total)+' of '+videos.total : '0 results'"></div> <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 gap-2"> <div class="flex items-center gap-2">
<button class="btn btn-ghost" :disabled="videos.page<=1" @click="loadVideos(videos.page-1)">Prev</button> <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" :disabled="videos.size*videos.page >= videos.total" @click="loadVideos(videos.page+1)">Next</button> <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>
</div> </div>
</section> </section>
</template> </template>
@@ -288,10 +380,25 @@
<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">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">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><span class="text-zinc-500">Likes </span><span class="text-zinc-200 font-mono" x-text="fmtNum(detail.video.like_count)"></span></div>
<div><span class="pill" :class="statusClass(detail.video.status)" x-text="detail.video.status"></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>
<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"> <div class="flex flex-wrap gap-2 mt-4">
<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 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'"> <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> <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>
@@ -301,6 +408,13 @@
<div class="flex flex-wrap gap-1.5 mt-3" x-show="(detail.video.tags||[]).length"> <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> <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>
<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>
</div> </div>
@@ -372,12 +486,21 @@
<section class="space-y-5" x-transition.opacity> <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> <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()"> <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…" x-model="search.q" /> <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"><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 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</button> <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> </form>
<template x-if="loading.search"><div class="glass p-10 text-center text-zinc-500">searching…</div></template> <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 text-zinc-500">No matches found.</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"> <div class="space-y-3">
<template x-for="r in search.items" :key="r.video_id+r.start_sec"> <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="glass p-4 hover:border-rose-500/40 transition-colors cursor-pointer" @click="openVideo(r.video_id)">
@@ -457,12 +580,13 @@
</div> </div>
</div> </div>
<div> <div>
<label class="block text-xs text-zinc-500 uppercase tracking-wider mb-1">Languages <span class="text-zinc-600 normal-case">(comma-sep)</span></label> <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" /> <input class="field" type="text" placeholder="en, es" x-model="scrape.form.languages" />
</div> </div>
<div class="flex gap-6 pt-1"> <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.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"><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> </div>
<button class="btn accent-grad btn-primary w-full" :disabled="loading.startScrape || !scrape.form.channel_id"> <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> <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>
@@ -478,7 +602,7 @@
<span class="pill" :class="scrape.error ? 'st-error' : (scrape.done ? 'st-done' : 'st-running')" x-text="scrape.error ? 'error' : (scrape.done ? 'done' : 'running')"></span> <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> </template>
</div> </div>
<template x-if="!scrape.jobId"><div class="flex-1 flex items-center justify-center text-zinc-600 text-sm">No active job. Start one to see live progress.</div></template> <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"> <template x-if="scrape.jobId">
<div class="mt-4 space-y-3 flex-1 flex flex-col"> <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="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>
@@ -500,7 +624,7 @@
<div class="px-4 py-3 border-b border-zinc-800/70 flex items-center justify-between"> <div class="px-4 py-3 border-b border-zinc-800/70 flex items-center justify-between">
<div class="flex flex-col"> <div class="flex flex-col">
<h3 class="text-sm font-semibold text-zinc-200">Recent jobs</h3> <h3 class="text-sm font-semibold text-zinc-200">Recent jobs</h3>
<span class="text-[0.65rem] text-zinc-600 mt-0.5">Clearing history removes job records only — downloaded .md files stay.</span> <span class="text-[0.65rem] text-zinc-500 mt-0.5">Clearing history removes job records only — downloaded .md files stay.</span>
</div> </div>
<div class="flex gap-1.5"> <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-ghost !py-1 !px-2 text-xs" @click="loadJobs()">Refresh</button>
@@ -647,6 +771,21 @@
</button> </button>
</div> </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 --> <!-- Audio -->
<div class="tool-card glass p-5"> <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> <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>
@@ -787,7 +926,7 @@
<!-- ============ FLOATING JOB WIDGET ============ --> <!-- ============ FLOATING JOB WIDGET ============ -->
<template x-if="scrape.jobId && view !== 'scrape' && !jobWidget.dismissed"> <template x-if="scrape.jobId && view !== 'scrape' && !jobWidget.dismissed">
<div class="job-widget" x-transition.opacity> <div class="job-widget" x-transition.opacity @mouseenter="_bumpAutoHide()" @focusin="_bumpAutoHide()">
<template x-if="!jobWidget.collapsed"> <template x-if="!jobWidget.collapsed">
<div class="space-y-2.5"> <div class="space-y-2.5">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
@@ -796,7 +935,7 @@
<span class="text-xs font-semibold text-white uppercase tracking-wider" x-text="scrape.kind ? scrape.kind + ' job' : 'job'"></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> <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> </div>
<button class="jw-x" @click="closeJobWidget()" :title="(!scrape.done && !scrape.error) ? 'Minimize' : 'Dismiss'">✕</button> <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>
<div class="flex justify-between text-[0.7rem] text-zinc-500"> <div class="flex justify-between text-[0.7rem] text-zinc-500">
<span>Progress</span> <span>Progress</span>
@@ -832,7 +971,7 @@
<div class="modal-card w-full max-w-2xl" @click.stop> <div class="modal-card w-full max-w-2xl" @click.stop>
<div class="modal-head"> <div class="modal-head">
<h2 class="text-base font-semibold text-white">Clip extractor</h2> <h2 class="text-base font-semibold text-white">Clip extractor</h2>
<button class="jw-x" @click="closeClip()">✕</button> <button class="jw-x" @click="closeClip()" aria-label="Close clip extractor" title="Close (Esc)">✕</button>
</div> </div>
<div class="p-5 space-y-3"> <div class="p-5 space-y-3">
<div> <div>
@@ -882,7 +1021,7 @@
<div class="modal-card w-full max-w-3xl" @click.stop> <div class="modal-card w-full max-w-3xl" @click.stop>
<div class="modal-head"> <div class="modal-head">
<h2 class="text-base font-semibold text-white">Stats</h2> <h2 class="text-base font-semibold text-white">Stats</h2>
<button class="jw-x" @click="closeStats()">✕</button> <button class="jw-x" @click="closeStats()" aria-label="Close stats panel" title="Close (Esc)">✕</button>
</div> </div>
<div class="p-5 space-y-5"> <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"><div class="text-zinc-500 text-sm text-center py-8">crunching…</div></template>
@@ -935,6 +1074,25 @@
</div> </div>
</template> </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> </div>
</body> </body>
</html> </html>