feat: sync status indicator, admin pause/resume, filtered video count

- Header shows a live sync status (total videos + channels still backfilling, or
  "paused"), polled every 30s
- Admins can pause/resume the background sync; a paused flag in app_state makes
  scheduled jobs skip (migration 0006)
- GET /api/feed/count returns the number of videos matching the current filters;
  shared filter builder keeps it in sync with /api/feed; shown above the feed
- /api/sync/status reports backfill progress, pending enrichment and paused state
This commit is contained in:
npeter83 2026-06-11 04:15:25 +02:00
parent f73cbdb490
commit dc73b43b71
10 changed files with 298 additions and 69 deletions

View file

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type FeedFilters, type Video } from "../lib/api";
import { toast } from "../lib/toast";
import VideoCard from "./VideoCard";
@ -42,6 +42,12 @@ export default function Feed({
useEffect(() => setOverrides({}), [filters]);
const countQuery = useQuery({
queryKey: ["feed-count", filters],
queryFn: () => api.feedCount(filters),
staleTime: 30_000,
});
const sentinel = useRef<HTMLDivElement | null>(null);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
useEffect(() => {
@ -87,6 +93,11 @@ export default function Feed({
return (
<div className="p-4">
<div className="pb-3 text-sm text-muted">
{countQuery.data
? `${countQuery.data.count.toLocaleString()} video${countQuery.data.count === 1 ? "" : "s"}`
: " "}
</div>
{view === "grid" ? (
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
{items.map((v) => (

View file

@ -10,6 +10,7 @@ import {
} from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import type { FeedFilters, Me } from "../lib/api";
import SyncStatus from "./SyncStatus";
function IconBtn(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
const { className = "", ...rest } = props;
@ -91,6 +92,8 @@ export default function Header({
Sub<span className="text-accent">feed</span>
</div>
<SyncStatus />
<div className="flex-1 max-w-xl mx-auto relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input

View file

@ -0,0 +1,49 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Database, Loader2, Pause, Play } from "lucide-react";
import { api } from "../lib/api";
import { formatViews } from "../lib/format";
export default function SyncStatus() {
const qc = useQueryClient();
const { data } = useQuery({
queryKey: ["sync-status"],
queryFn: api.status,
refetchInterval: 30_000,
staleTime: 25_000,
});
const toggle = useMutation({
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: ["sync-status"] }),
});
if (!data) return null;
return (
<div className="hidden lg:flex items-center gap-2 text-xs text-muted">
<Database className="w-3.5 h-3.5" />
<span>{formatViews(data.videos_total)} videos</span>
<span className="opacity-40">·</span>
{data.paused ? (
<span className="text-accent font-medium">paused</span>
) : data.channels_backfilling > 0 ? (
<span className="flex items-center gap-1">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{data.channels_backfilling} syncing
</span>
) : (
<span>all synced</span>
)}
{data.is_admin && (
<button
onClick={() => toggle.mutate()}
disabled={toggle.isPending}
title={data.paused ? "Resume background sync" : "Pause background sync"}
className="ml-1 p-1 rounded-md hover:bg-card hover:text-fg transition"
>
{data.paused ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />}
</button>
)}
</div>
);
}

View file

@ -97,14 +97,30 @@ function feedQuery(f: FeedFilters, offset: number, limit: number): string {
return p.toString();
}
export interface SyncStatus {
subscriptions: number;
channels_total: number;
channels_backfilling: number;
videos_total: number;
pending_enrich: number;
quota_used_today: number;
quota_remaining_today: number;
paused: boolean;
is_admin: boolean;
}
export const api = {
me: (): Promise<Me> => req("/api/me"),
tags: (): Promise<Tag[]> => req("/api/tags"),
status: (): Promise<any> => req("/api/sync/status"),
status: (): Promise<SyncStatus> => req("/api/sync/status"),
feed: (f: FeedFilters, offset: number, limit: number): Promise<FeedResponse> =>
req(`/api/feed?${feedQuery(f, offset, limit)}`),
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
setState: (id: string, status: string) =>
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
savePrefs: (p: Record<string, any>) =>
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }),
};