Show discovery progress in the top-bar indicator alongside downloads/tasks

The DownloadIndicator in Layout.jsx now queries discovery-status and shows
discovery progress the same way it shows active downloads and channel tasks:
- Spinning icon appears in the navbar when discovery is running
- Hover popover shows "Discovering channels" with X/Y count and a progress bar
- Polls every 10 s while running, 60 s when idle
- Primary label priority: download % > task phase > discovery X/Y

Discovery page header simplified: progress bar and verbose status removed
since the top-bar indicator now handles it. "Find more" button still
disables while running.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 02:58:13 +02:00
parent 1179b53f2e
commit bcbd552eab
2 changed files with 43 additions and 30 deletions

View File

@@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { useRef, useEffect } from "react"; import { useRef, useEffect } from "react";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import SearchBar from "./SearchBar"; import SearchBar from "./SearchBar";
import { getDownloads, getChannels, getActiveTasks, getMe } from "../api"; import { getDownloads, getChannels, getActiveTasks, getDiscoveryStatus, getMe } from "../api";
function BottomNav({ newCount }) { function BottomNav({ newCount }) {
const tabs = [ const tabs = [
@@ -91,20 +91,29 @@ function DownloadIndicator() {
refetchInterval: (query) => (query.state.data?.length > 0 ? 2000 : 10_000), refetchInterval: (query) => (query.state.data?.length > 0 ? 2000 : 10_000),
}); });
const { data: discStatus } = useQuery({
queryKey: ["discovery-status"],
queryFn: () => getDiscoveryStatus().then((r) => r.data),
refetchInterval: (query) => (query.state.data?.progress?.running ? 10_000 : 60_000),
staleTime: 10_000,
});
const activeDownloads = (downloads ?? []).filter( const activeDownloads = (downloads ?? []).filter(
(d) => d.status === "pending" || d.status === "downloading" (d) => d.status === "pending" || d.status === "downloading"
); );
const discRunning = discStatus?.progress?.running;
const discProgress = discStatus?.progress;
if (!activeDownloads.length && !tasks.length) return null; if (!activeDownloads.length && !tasks.length && !discRunning) return null;
const totalActive = activeDownloads.length + tasks.length; const totalActive = activeDownloads.length + tasks.length + (discRunning ? 1 : 0);
// Show download progress if there are active downloads, otherwise show task phase // Primary label: download % > task phase > discovery
let label; let label;
if (activeDownloads.length) { if (activeDownloads.length) {
const pct = activeDownloads[0].progress_percent ?? 0; const pct = activeDownloads[0].progress_percent ?? 0;
label = <span className="font-mono tabular-nums text-[11px]">{pct.toFixed(0)}%</span>; label = <span className="font-mono tabular-nums text-[11px]">{pct.toFixed(0)}%</span>;
} else { } else if (tasks.length) {
const task = tasks[0]; const task = tasks[0];
const pct = task.total > 0 ? Math.round((task.done / task.total) * 100) : null; const pct = task.total > 0 ? Math.round((task.done / task.total) * 100) : null;
label = ( label = (
@@ -112,12 +121,18 @@ function DownloadIndicator() {
{pct !== null ? `${pct}%` : task.phase || "…"} {pct !== null ? `${pct}%` : task.phase || "…"}
</span> </span>
); );
} else {
label = (
<span className="text-[11px] hidden sm:inline">
{discProgress ? `${discProgress.done}/${discProgress.total}` : "…"}
</span>
);
} }
return ( return (
<div className="relative group shrink-0"> <div className="relative group shrink-0">
<Link <Link
to="/downloads" to="/discovery"
className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors text-xs text-zinc-300" className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors text-xs text-zinc-300"
> >
<svg className="w-3 h-3 animate-spin text-zinc-400 shrink-0" fill="none" viewBox="0 0 24 24"> <svg className="w-3 h-3 animate-spin text-zinc-400 shrink-0" fill="none" viewBox="0 0 24 24">
@@ -163,6 +178,19 @@ function DownloadIndicator() {
</div> </div>
); );
})} })}
{discRunning && discProgress && (
<div>
<div className="flex items-center justify-between gap-2 mb-1">
<p className="text-xs text-zinc-200">Discovering channels</p>
<span className="text-[10px] text-zinc-400 tabular-nums shrink-0">{discProgress.done}/{discProgress.total}</span>
</div>
<p className="text-[10px] text-zinc-500 mb-1">Finding channels spaced over ~20 min</p>
<div className="h-0.5 bg-zinc-800 rounded-full overflow-hidden">
<div className="h-full bg-accent rounded-full transition-all duration-300"
style={{ width: `${Math.round((discProgress.done / discProgress.total) * 100)}%` }} />
</div>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -218,7 +218,7 @@ export default function DiscoveryPage() {
queryKey: ["discovery-status"], queryKey: ["discovery-status"],
queryFn: () => getDiscoveryStatus().then(r => r.data), queryFn: () => getDiscoveryStatus().then(r => r.data),
staleTime: 10_000, staleTime: 10_000,
refetchInterval: (query) => query.state.data?.progress?.running ? 10_000 : 60_000, refetchInterval: (query) => (query.state.data?.progress?.running ? 10_000 : 60_000),
}); });
const refreshMut = useMutation({ const refreshMut = useMutation({
@@ -248,13 +248,11 @@ export default function DiscoveryPage() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0">
<h1 className="font-display font-bold text-2xl text-zinc-100">Discover</h1> <h1 className="font-display font-bold text-2xl text-zinc-100">Discover</h1>
{discStatus && ( {discStatus && (
<p className="text-xs text-zinc-500 mt-0.5"> <p className="text-xs text-zinc-500 mt-0.5">
{discStatus.progress?.running {discStatus.pending_count > 0
? `Finding channels… ${discStatus.progress.done} / ${discStatus.progress.total}`
: discStatus.pending_count > 0
? `${discStatus.pending_count} channel${discStatus.pending_count !== 1 ? "s" : ""} queued` ? `${discStatus.pending_count} channel${discStatus.pending_count !== 1 ? "s" : ""} queued`
: "Queue empty"} : "Queue empty"}
{discStatus.last_run {discStatus.last_run
@@ -262,38 +260,25 @@ export default function DiscoveryPage() {
: " · never refreshed"} : " · never refreshed"}
</p> </p>
)} )}
{discStatus?.progress?.running && (
<div className="mt-1.5 h-1 w-48 rounded-full bg-zinc-800 overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all duration-500"
style={{ width: `${Math.round((discStatus.progress.done / discStatus.progress.total) * 100)}%` }}
/>
</div>
)}
</div> </div>
<button <button
onClick={() => refreshMut.mutate()} onClick={() => refreshMut.mutate()}
disabled={refreshMut.isPending || discStatus?.progress?.running} disabled={refreshMut.isPending || discStatus?.progress?.running}
className="shrink-0 flex items-center gap-2 text-sm font-medium px-4 py-2 bg-zinc-800 text-zinc-300 rounded-lg hover:bg-zinc-700 transition-colors disabled:opacity-60" className="shrink-0 flex items-center gap-2 text-sm font-medium px-4 py-2 bg-zinc-800 text-zinc-300 rounded-lg hover:bg-zinc-700 transition-colors disabled:opacity-60"
> >
{discStatus?.progress?.running ? ( {refreshMut.isPending && (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"> <svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg> </svg>
) : refreshMut.isPending ? ( )}
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
) : null}
{discStatus?.progress?.running ? "Running…" : refreshMut.isSuccess ? "Queued ✓" : refreshMut.isPending ? "Queueing…" : "Find more"} {discStatus?.progress?.running ? "Running…" : refreshMut.isSuccess ? "Queued ✓" : refreshMut.isPending ? "Queueing…" : "Find more"}
</button> </button>
</div> </div>
{(refreshMut.isSuccess || discStatus?.progress?.running) && ( {refreshMut.isSuccess && !discStatus?.progress?.running && (
<div className="px-4 py-3 rounded-xl bg-zinc-800/60 border border-zinc-700/50 text-sm text-zinc-400"> <div className="px-4 py-3 rounded-xl bg-zinc-800/60 border border-zinc-700/50 text-sm text-zinc-400">
Discovery is running in the background searches and channel fetches are spaced out over ~20 minutes to avoid hitting limits. New channels appear as each batch completes. Runs automatically every day. Discovery is running progress shows in the top bar. Searches are spaced out over ~20 minutes. Runs automatically every day.
</div> </div>
)} )}