Merge feature/followups-batch-and-bell-unify: maintenance batch knob + unified notification inbox

This commit is contained in:
npeter83 2026-06-18 04:37:27 +02:00
commit 310e9b63b6
19 changed files with 429 additions and 273 deletions

View file

@ -0,0 +1,30 @@
"""admin override for the maintenance re-validation batch size
Revision ID: 0018_maintenance_batch_setting
Revises: 0017_notifications
Create Date: 2026-06-18
Adds app_state.maintenance_revalidate_batch: an admin-tunable override (from the Scheduler
dashboard) for how many videos the maintenance job re-validates per run. NULL = use the
env/config default. Purely additive.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0018_maintenance_batch_setting"
down_revision: Union[str, None] = "0017_notifications"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"app_state",
sa.Column("maintenance_revalidate_batch", sa.Integer(), nullable=True),
)
def downgrade() -> None:
op.drop_column("app_state", "maintenance_revalidate_batch")

View file

@ -339,6 +339,9 @@ class AppState(Base):
sync_paused: Mapped[bool] = mapped_column( sync_paused: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false" Boolean, default=False, server_default="false"
) )
# Admin override for the maintenance job's rolling re-validation batch (videos re-checked
# per run). NULL = use the config/env default (settings.maintenance_revalidate_batch).
maintenance_revalidate_batch: Mapped[int | None] = mapped_column(Integer)
class SchedulerSetting(Base): class SchedulerSetting(Base):

View file

@ -71,6 +71,29 @@ def run_all_now(user: User = Depends(admin_user)) -> dict:
return {"started": trigger_all(actor_id=user.id)} return {"started": trigger_all(actor_id=user.id)}
@router.patch("/maintenance")
def set_maintenance_settings(
payload: dict,
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Admin-tune the maintenance job's rolling re-validation batch (videos re-checked per
run). Persisted to app_state; the job reads it each run."""
try:
value = int(payload.get("revalidate_batch"))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="revalidate_batch must be a number")
if not (state.MAINTENANCE_BATCH_MIN <= value <= state.MAINTENANCE_BATCH_MAX):
raise HTTPException(
status_code=400,
detail=(
f"revalidate_batch must be between {state.MAINTENANCE_BATCH_MIN} and "
f"{state.MAINTENANCE_BATCH_MAX}"
),
)
return {"revalidate_batch": state.set_maintenance_batch(db, value)}
@router.get("") @router.get("")
def get_scheduler( def get_scheduler(
_: User = Depends(admin_user), db: Session = Depends(get_db) _: User = Depends(admin_user), db: Session = Depends(get_db)
@ -138,4 +161,10 @@ def get_scheduler(
"paused": state.is_sync_paused(db), "paused": state.is_sync_paused(db),
"queue": queue, "queue": queue,
"quota": quota_info, "quota": quota_info,
"maintenance": {
"revalidate_batch": state.get_maintenance_batch(db),
"revalidate_batch_default": settings.maintenance_revalidate_batch,
"min": state.MAINTENANCE_BATCH_MIN,
"max": state.MAINTENANCE_BATCH_MAX,
},
} }

View file

@ -3,10 +3,15 @@ import logging
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import settings
from app.models import AppState from app.models import AppState
log = logging.getLogger("subfeed.state") log = logging.getLogger("subfeed.state")
# Bounds for the admin-set maintenance re-validation batch (videos re-checked per run).
MAINTENANCE_BATCH_MIN = 100
MAINTENANCE_BATCH_MAX = 100_000
def _row(db: Session) -> AppState: def _row(db: Session) -> AppState:
row = db.get(AppState, 1) row = db.get(AppState, 1)
@ -27,3 +32,20 @@ def set_sync_paused(db: Session, paused: bool) -> None:
db.add(row) db.add(row)
db.commit() db.commit()
log.info("Background sync %s", "paused" if paused else "resumed") log.info("Background sync %s", "paused" if paused else "resumed")
def get_maintenance_batch(db: Session) -> int:
"""Effective maintenance re-validation batch: the admin override if set, else the
env/config default."""
return _row(db).maintenance_revalidate_batch or settings.maintenance_revalidate_batch
def set_maintenance_batch(db: Session, value: int) -> int:
"""Clamp and persist the maintenance batch override. Returns the applied value."""
value = max(MAINTENANCE_BATCH_MIN, min(MAINTENANCE_BATCH_MAX, int(value)))
row = _row(db)
row.maintenance_revalidate_batch = value
db.add(row)
db.commit()
log.info("Maintenance re-validation batch set to %s", value)
return value

View file

@ -29,6 +29,7 @@ from app import progress, quota
from app.config import settings from app.config import settings
from app.models import Playlist, PlaylistItem, Video, VideoState from app.models import Playlist, PlaylistItem, Video, VideoState
from app.notifications import create_notification from app.notifications import create_notification
from app.state import get_maintenance_batch
from app.sync.runner import get_service_user from app.sync.runner import get_service_user
from app.sync.videos import apply_video_details, parse_dt from app.sync.videos import apply_video_details, parse_dt
from app.youtube.client import YouTubeClient from app.youtube.client import YouTubeClient
@ -189,7 +190,7 @@ def _recheck_flagged(db: Session, yt: YouTubeClient) -> dict:
def _revalidate_rolling(db: Session, yt: YouTubeClient) -> dict: def _revalidate_rolling(db: Session, yt: YouTubeClient) -> dict:
"""Phase 2: re-check the least-recently-checked currently-available videos and flag any """Phase 2: re-check the least-recently-checked currently-available videos and flag any
that have become unplayable (hidden immediately; deleted later if they don't recover).""" that have become unplayable (hidden immediately; deleted later if they don't recover)."""
batch = settings.maintenance_revalidate_batch batch = get_maintenance_batch(db) # admin override, else config default
flagged_new = 0 flagged_new = 0
checked = 0 checked = 0
now = _now() now = _now()

View file

@ -252,8 +252,6 @@ export default function App() {
onOpenAbout={() => setAboutOpen(true)} onOpenAbout={() => setAboutOpen(true)}
onChangeLanguage={changeLanguage} onChangeLanguage={changeLanguage}
language={i18n.language as LangCode} language={i18n.language as LangCode}
filters={filters}
setFilters={setFilters}
/> />
<div className="relative flex-1 min-w-0 flex flex-col"> <div className="relative flex-1 min-w-0 flex flex-col">
<Header <Header
@ -305,7 +303,7 @@ export default function App() {
) : page === "playlists" ? ( ) : page === "playlists" ? (
<Playlists canWrite={meQuery.data!.can_write} /> <Playlists canWrite={meQuery.data!.can_write} />
) : page === "notifications" ? ( ) : page === "notifications" ? (
<NotificationsPanel /> <NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} />
) : page === "settings" ? ( ) : page === "settings" ? (
<SettingsPanel <SettingsPanel
me={meQuery.data!} me={meQuery.data!}

View file

@ -4,7 +4,7 @@ import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-quer
import { ArrowDown, ArrowUp, RefreshCw } from "lucide-react"; import { ArrowDown, ArrowUp, RefreshCw } from "lucide-react";
import { api, type FeedFilters, type Video } from "../lib/api"; import { api, type FeedFilters, type Video } from "../lib/api";
import i18n from "../i18n"; import i18n from "../i18n";
import { notify } from "../lib/notifications"; import { notify, resolveVideo } from "../lib/notifications";
import VideoCard from "./VideoCard"; import VideoCard from "./VideoCard";
import PlayerModal from "./PlayerModal"; import PlayerModal from "./PlayerModal";
@ -180,6 +180,10 @@ export default function Feed({
action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") }, action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") },
meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" }, meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" },
}); });
} else if (status === "new") {
// Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve any
// stale hide/watch notice for this video so it doesn't linger with a dead action.
resolveVideo(id);
} }
}, },
[qc] [qc]

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@ -17,13 +17,13 @@ import {
Tv, Tv,
UserPlus, UserPlus,
} from "lucide-react"; } from "lucide-react";
import { api, type FeedFilters, type Me } from "../lib/api"; import { api, type Me } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
import { type LangCode } from "../i18n"; import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar"; import AvatarImg from "./Avatar";
import LanguageSwitcher from "./LanguageSwitcher"; import LanguageSwitcher from "./LanguageSwitcher";
import NotificationCenter from "./NotificationCenter";
// Primary app navigation: a collapsible left rail with icon+label entries (Design C). The // Primary app navigation: a collapsible left rail with icon+label entries (Design C). The
// modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes // modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes
@ -35,8 +35,6 @@ export default function NavSidebar({
onOpenAbout, onOpenAbout,
onChangeLanguage, onChangeLanguage,
language, language,
filters,
setFilters,
}: { }: {
me: Me; me: Me;
page: Page; page: Page;
@ -44,8 +42,6 @@ export default function NavSidebar({
onOpenAbout: () => void; onOpenAbout: () => void;
onChangeLanguage: (code: LangCode) => void; onChangeLanguage: (code: LangCode) => void;
language: LangCode; language: LangCode;
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [collapsed, setCollapsed] = useState<boolean>( const [collapsed, setCollapsed] = useState<boolean>(
@ -125,7 +121,10 @@ export default function NavSidebar({
api.notificationUnreadCount, api.notificationUnreadCount,
{ intervalMs: 30000 } { intervalMs: 30000 }
); );
const unread = unreadQuery.data?.count ?? 0; // One indicator for both layers: durable server notifications + the client-side transient
// events (the former separate bell is now folded into the inbox page).
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number }; type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
// User-facing content modules vs. admin/system modules, separated by a divider in the rail. // User-facing content modules vs. admin/system modules, separated by a divider in the rail.
@ -243,7 +242,6 @@ export default function NavSidebar({
> >
<Info className="w-5 h-5" /> <Info className="w-5 h-5" />
</button> </button>
<NotificationCenter variant="rail" filters={filters} setFilters={setFilters} />
</div> </div>
<button <button
onClick={() => setPage("settings")} onClick={() => setPage("settings")}

View file

@ -1,231 +0,0 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Bell, Eye, RotateCcw, Search, Trash2 } from "lucide-react";
import { api, type FeedFilters } from "../lib/api";
import {
clearAll,
dismiss,
getNotifications,
getUnreadCount,
markAllRead,
notify,
subscribe,
type VideoHiddenMeta,
type VideoWatchedMeta,
} from "../lib/notifications";
import { LEVEL_STYLE } from "./Toaster";
function relTime(ts: number, t: TFunction): string {
const s = Math.round((Date.now() - ts) / 1000);
if (s < 60) return t("notifications.time.justNow");
const m = Math.round(s / 60);
if (m < 60) return t("notifications.time.minutes", { count: m });
const h = Math.round(m / 60);
if (h < 24) return t("notifications.time.hours", { count: h });
const d = Math.round(h / 24);
return t("notifications.time.days", { count: d });
}
export default function NotificationCenter({
filters,
setFilters,
variant = "header",
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
variant?: "header" | "rail";
}) {
const { t } = useTranslation();
const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications);
const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
const [open, setOpen] = useState(false);
const wrap = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
// Rail variant anchors the panel right + above the button (fixed, viewport coords) and
// portals it to <body> so it escapes the nav's backdrop-filter.
const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
const qc = useQueryClient();
// Close when clicking anywhere outside the button + panel.
useEffect(() => {
if (!open) return;
function onDown(e: MouseEvent) {
const target = e.target as Node;
if (btnRef.current?.contains(target) || panelRef.current?.contains(target)) return;
if (variant === "header" && wrap.current?.contains(target)) return;
setOpen(false);
}
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open, variant]);
function toggle() {
if (!open && variant === "rail") {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom });
}
setOpen((o) => !o);
}
// Opening the center marks everything read.
useEffect(() => {
if (open) markAllRead();
}, [open, notifications.length]);
function locateHidden(meta: VideoHiddenMeta) {
setFilters({
...filters,
show: "hidden",
channelId: meta.channelId || undefined,
channelName: meta.channelName || undefined,
});
setOpen(false);
}
function revertState(
meta: VideoHiddenMeta | VideoWatchedMeta,
messageKey: "notifications.unhidden" | "notifications.unwatched"
) {
api
.setState(meta.videoId, "new")
.then(() => {
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["feed-count"] });
notify({ level: "success", message: t(messageKey, { title: meta.title }) });
})
.catch(() => {});
}
function renderPanel(el: JSX.Element) {
if (variant !== "rail") return el;
return createPortal(
<div style={{ position: "fixed", left: pos.left, bottom: pos.bottom, zIndex: 40 }}>
{el}
</div>,
document.body
);
}
return (
<div className="relative" ref={wrap}>
<button
ref={btnRef}
onClick={toggle}
title={t("notifications.title")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Bell className="w-5 h-5" />
{unread > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold grid place-items-center">
{unread > 9 ? "9+" : unread}
</span>
)}
</button>
{open &&
renderPanel(
<div
ref={panelRef}
className={`glass-menu w-80 max-w-[calc(100vw-2rem)] rounded-xl flex flex-col max-h-[70vh] animate-[popIn_0.16s_ease] ${
variant === "rail" ? "" : "absolute right-0 mt-2 z-30"
}`}
>
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<div className="text-sm font-semibold">{t("notifications.title")}</div>
{notifications.length > 0 && (
<button
onClick={clearAll}
className="flex items-center gap-1 text-xs text-muted hover:text-accent transition"
title={t("notifications.clearAll")}
>
<Trash2 className="w-3.5 h-3.5" />
{t("notifications.clear")}
</button>
)}
</div>
<div className="overflow-y-auto">
{notifications.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-muted">{t("notifications.empty")}</div>
) : (
notifications.map((n) => {
const { icon: Icon, color } = LEVEL_STYLE[n.level];
const needsAction = n.requiresInteraction && !n.dismissed;
const hidden = n.meta?.kind === "video-hidden" ? n.meta : null;
const watched = n.meta?.kind === "video-watched" ? n.meta : null;
return (
<div
key={n.id}
className={`flex items-start gap-2.5 px-3 py-2.5 border-b border-border/60 ${
needsAction ? "bg-accent/5" : ""
}`}
>
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
<div className="text-sm break-words">{n.message}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[11px] text-muted">{relTime(n.ts, t)}</span>
{needsAction && (
<span className="text-[10px] uppercase tracking-wide font-semibold text-accent">
{t("notifications.actionNeeded")}
</span>
)}
</div>
{hidden ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => locateHidden(hidden)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Search className="w-3.5 h-3.5" />
{t("notifications.findInFeed")}
</button>
<button
onClick={() => revertState(hidden, "notifications.unhidden")}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Eye className="w-3.5 h-3.5" />
{t("notifications.unhide")}
</button>
</div>
) : watched ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => revertState(watched, "notifications.unwatched")}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("notifications.unwatch")}
</button>
</div>
) : (
n.action &&
!n.dismissed && (
<button
onClick={() => {
n.action!.onClick();
dismiss(n.id);
}}
className="mt-1 text-accent text-sm font-semibold hover:underline"
>
{n.action.label}
</button>
)
)}
</div>
</div>
);
})
)}
</div>
</div>
)}
</div>
);
}

View file

@ -1,29 +1,56 @@
import { useEffect, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, CheckCheck, Trash2, X } from "lucide-react"; import { Bell, Check, CheckCheck, Eye, RotateCcw, Search, Trash2, X } from "lucide-react";
import { api, type AppNotification } from "../lib/api"; import { api, type AppNotification, type FeedFilters } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import {
clearAll as clearClient,
dismiss as dismissClient,
getNotifications,
getUnreadCount,
markAllRead as markAllClientRead,
remove as removeClient,
resolveVideo,
subscribe,
type Notification as ClientNotif,
type VideoHiddenMeta,
type VideoWatchedMeta,
} from "../lib/notifications";
import type { Page } from "../lib/urlState";
import { LEVEL_STYLE } from "./Toaster";
// The durable, server-backed notification center (inbox phase 1). It COEXISTS with the // The single notification center. "System" = durable, server-backed notifications (inbox
// client-side transient bell in the nav rail: the bell is a quick peek at recent toasts kept // phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
// in localStorage, this is the full, filterable, clearable history synced from the server. // their inline undo/find actions) that used to live behind a separate bell — folded in here so
// there's one indicator and one place to look. Transient toasts still pop via the Toaster.
const POLL_MS = 8000; const POLL_MS = 8000;
function relativeTime(iso: string | null, t: (k: string, o?: any) => string): string { function relativeTime(iso: string | null, t: (k: string, o?: any) => string): string {
if (!iso) return ""; if (!iso) return "";
const then = new Date(iso).getTime(); return relativeFromMs(new Date(iso).getTime(), t);
const secs = Math.max(0, Math.round((Date.now() - then) / 1000)); }
function relativeFromMs(ms: number, t: (k: string, o?: any) => string): string {
const secs = Math.max(0, Math.round((Date.now() - ms) / 1000));
// Reuse the bell's relative-time strings (notifications.time.*) — same format, no dupes. // Reuse the bell's relative-time strings (notifications.time.*) — same format, no dupes.
if (secs < 60) return t("notifications.time.justNow"); if (secs < 60) return t("notifications.time.justNow");
const mins = Math.round(secs / 60); const mins = Math.round(secs / 60);
if (mins < 60) return t("notifications.time.minutes", { count: mins }); if (mins < 60) return t("notifications.time.minutes", { count: mins });
const hours = Math.round(mins / 60); const hours = Math.round(mins / 60);
if (hours < 24) return t("notifications.time.hours", { count: hours }); if (hours < 24) return t("notifications.time.hours", { count: hours });
const days = Math.round(hours / 24); return t("notifications.time.days", { count: Math.round(hours / 24) });
return t("notifications.time.days", { count: days });
} }
export default function NotificationsPanel() { export default function NotificationsPanel({
filters,
setFilters,
setPage,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
setPage: (p: Page) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const q = useLiveQuery<{ items: AppNotification[]; total: number }>( const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
@ -31,9 +58,16 @@ export default function NotificationsPanel() {
() => api.notifications(), () => api.notifications(),
{ intervalMs: POLL_MS } { intervalMs: POLL_MS }
); );
const items = q.data?.items ?? []; const serverItems = q.data?.items ?? [];
const clientItems = useSyncExternalStore(subscribe, getNotifications, getNotifications);
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
// Opening the center marks the (ephemeral) client items read, matching the old bell. Server
// items stay unread until acted on individually.
useEffect(() => {
markAllClientRead();
}, []);
// Any mutation refreshes both the list and the nav badge's unread count.
const refresh = () => { const refresh = () => {
qc.invalidateQueries({ queryKey: ["notifications"] }); qc.invalidateQueries({ queryKey: ["notifications"] });
qc.invalidateQueries({ queryKey: ["notif-unread"] }); qc.invalidateQueries({ queryKey: ["notif-unread"] });
@ -43,7 +77,41 @@ export default function NotificationsPanel() {
const dismissMut = useMutation({ mutationFn: api.dismissNotification, onSuccess: refresh }); const dismissMut = useMutation({ mutationFn: api.dismissNotification, onSuccess: refresh });
const clearMut = useMutation({ mutationFn: api.clearNotifications, onSuccess: refresh }); const clearMut = useMutation({ mutationFn: api.clearNotifications, onSuccess: refresh });
const hasUnread = items.some((n) => !n.read); const serverUnread = serverItems.some((n) => !n.read);
const hasAny = serverItems.length > 0 || clientItems.length > 0;
const hasUnread = serverUnread || clientUnread > 0;
function markAll() {
markAllClientRead();
if (serverUnread) readAllMut.mutate();
else refresh();
}
function clearEverything() {
clearClient();
clearMut.mutate();
}
// "Find in feed" / undo, ported from the old bell.
function locateHidden(meta: VideoHiddenMeta) {
setFilters({
...filters,
show: "hidden",
channelId: meta.channelId || undefined,
channelName: meta.channelName || undefined,
});
setPage("feed");
}
function revertState(meta: VideoHiddenMeta | VideoWatchedMeta) {
api
.setState(meta.videoId, "new")
.then(() => {
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["feed-count"] });
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
resolveVideo(meta.videoId);
})
.catch(() => {});
}
return ( return (
<div className="p-4 max-w-3xl w-full mx-auto space-y-4"> <div className="p-4 max-w-3xl w-full mx-auto space-y-4">
@ -53,10 +121,10 @@ export default function NotificationsPanel() {
<div className="font-semibold">{t("inbox.title")}</div> <div className="font-semibold">{t("inbox.title")}</div>
<div className="text-xs text-muted">{t("inbox.subtitle")}</div> <div className="text-xs text-muted">{t("inbox.subtitle")}</div>
</div> </div>
{items.length > 0 && ( {hasAny && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => readAllMut.mutate()} onClick={markAll}
disabled={!hasUnread || readAllMut.isPending} disabled={!hasUnread || readAllMut.isPending}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40" className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
> >
@ -64,7 +132,7 @@ export default function NotificationsPanel() {
{t("inbox.markAllRead")} {t("inbox.markAllRead")}
</button> </button>
<button <button
onClick={() => clearMut.mutate()} onClick={clearEverything}
disabled={clearMut.isPending} disabled={clearMut.isPending}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40" className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
> >
@ -75,16 +143,22 @@ export default function NotificationsPanel() {
)} )}
</div> </div>
{q.isLoading && !q.data ? ( {q.isLoading && !q.data && clientItems.length === 0 ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div> <div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : items.length === 0 ? ( ) : !hasAny ? (
<div className="glass rounded-2xl p-10 text-center text-muted"> <div className="glass rounded-2xl p-10 text-center text-muted">
<Bell className="w-8 h-8 mx-auto mb-3 opacity-40" /> <Bell className="w-8 h-8 mx-auto mb-3 opacity-40" />
{t("inbox.empty")} {t("inbox.empty")}
</div> </div>
) : ( ) : (
<div className="space-y-4">
{serverItems.length > 0 && (
<section>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("inbox.sectionSystem")}
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{items.map((n) => ( {serverItems.map((n) => (
<NotificationRow <NotificationRow
key={n.id} key={n.id}
n={n} n={n}
@ -94,6 +168,28 @@ export default function NotificationsPanel() {
/> />
))} ))}
</div> </div>
</section>
)}
{clientItems.length > 0 && (
<section>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("inbox.sectionActivity")}
</div>
<div className="flex flex-col gap-2">
{clientItems.map((n) => (
<ClientActivityRow
key={n.id}
n={n}
t={t}
onFind={locateHidden}
onRevert={revertState}
onRemove={() => removeClient(n.id)}
/>
))}
</div>
</section>
)}
</div>
)} )}
</div> </div>
); );
@ -185,3 +281,91 @@ function NotificationRow({
</div> </div>
); );
} }
function ClientActivityRow({
n,
t,
onFind,
onRevert,
onRemove,
}: {
n: ClientNotif;
t: (k: string, o?: any) => string;
onFind: (meta: VideoHiddenMeta) => void;
onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onRemove: () => void;
}) {
const { icon: Icon, color } = LEVEL_STYLE[n.level];
const needsAction = n.requiresInteraction && !n.dismissed;
const hidden = n.meta?.kind === "video-hidden" ? n.meta : null;
const watched = n.meta?.kind === "video-watched" ? n.meta : null;
return (
<div
className={`glass rounded-xl p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
>
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
<div className="text-sm break-words">{n.message}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[11px] text-muted">{relativeFromMs(n.ts, t)}</span>
{needsAction && (
<span className="text-[10px] uppercase tracking-wide font-semibold text-accent">
{t("notifications.actionNeeded")}
</span>
)}
</div>
{hidden ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => onFind(hidden)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Search className="w-3.5 h-3.5" />
{t("notifications.findInFeed")}
</button>
<button
onClick={() => onRevert(hidden)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Eye className="w-3.5 h-3.5" />
{t("notifications.unhide")}
</button>
</div>
) : watched ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => onRevert(watched)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("notifications.unwatch")}
</button>
</div>
) : (
n.action &&
!n.dismissed && (
<button
onClick={() => {
n.action!.onClick();
dismissClient(n.id);
}}
className="mt-1 text-accent text-sm font-semibold hover:underline"
>
{n.action.label}
</button>
)
)}
</div>
<button
onClick={onRemove}
title={t("inbox.dismiss")}
aria-label={t("inbox.dismiss")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
);
}

View file

@ -212,6 +212,60 @@ function JobRow({
); );
} }
function MaintenanceSettings({
m,
onSave,
saving,
}: {
m: SchedulerStatus["maintenance"];
onSave: (batch: number) => void;
saving: boolean;
}) {
const { t } = useTranslation();
const [val, setVal] = useState(String(m.revalidate_batch));
useEffect(() => setVal(String(m.revalidate_batch)), [m.revalidate_batch]);
function save() {
const n = parseInt(val, 10);
if (Number.isFinite(n) && n >= m.min && n <= m.max && n !== m.revalidate_batch) onSave(n);
}
const dirty = String(m.revalidate_batch) !== val;
return (
<div className="glass-card rounded-xl p-4">
<div className="flex items-center gap-2 text-sm flex-wrap">
<Database className="w-4 h-4 text-muted shrink-0" />
<Tooltip hint={t("scheduler.maintenance.batchHint")}>
<span className="underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help">
{t("scheduler.maintenance.batchLabel")}
</span>
</Tooltip>
<span className="flex-1" />
<input
type="number"
min={m.min}
max={m.max}
step={1000}
value={val}
onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && save()}
className="w-28 bg-card border border-border rounded px-2 py-1 text-sm tabular-nums outline-none focus:border-accent"
/>
<button
onClick={save}
disabled={!dirty || saving}
className="glass-card glass-hover px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
>
{t("common.save")}
</button>
</div>
<div className="text-[11px] text-muted mt-1.5">
{t("scheduler.maintenance.batchNote", { default: m.revalidate_batch_default.toLocaleString() })}
</div>
</div>
);
}
function Stat({ function Stat({
icon: Icon, icon: Icon,
label, label,
@ -285,6 +339,10 @@ export default function Scheduler() {
qc.invalidateQueries({ queryKey: ["scheduler"] }); qc.invalidateQueries({ queryKey: ["scheduler"] });
}, },
}); });
const batchMut = useMutation({
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
});
if (q.isLoading && !data) if (q.isLoading && !data)
return <div className="p-8 text-muted">{t("scheduler.loading")}</div>; return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
@ -432,6 +490,16 @@ export default function Scheduler() {
<div className="text-[11px] text-muted mt-1.5">{t("scheduler.quotaNote")}</div> <div className="text-[11px] text-muted mt-1.5">{t("scheduler.quotaNote")}</div>
</div> </div>
</div> </div>
{/* Maintenance settings */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.maintenance.title")}</div>
<MaintenanceSettings
m={data.maintenance}
onSave={(batch) => batchMut.mutate(batch)}
saving={batchMut.isPending}
/>
</div>
</div> </div>
); );
} }

View file

@ -7,6 +7,8 @@
"clearAll": "Alle löschen", "clearAll": "Alle löschen",
"markRead": "Als gelesen markieren", "markRead": "Als gelesen markieren",
"dismiss": "Verwerfen", "dismiss": "Verwerfen",
"sectionSystem": "System",
"sectionActivity": "Aktivität",
"andMore": "und {{count}} weitere", "andMore": "und {{count}} weitere",
"maintenance": { "maintenance": {
"title": "Videos entfernt", "title": "Videos entfernt",

View file

@ -40,6 +40,12 @@
"backfill_deep": "Ganze Historie nachladen", "backfill_deep": "Ganze Historie nachladen",
"probe": "Shorts klassifizieren" "probe": "Shorts klassifizieren"
}, },
"maintenance": {
"title": "Wartung",
"batchLabel": "Pro Lauf erneut geprüfte Videos",
"batchHint": "Wie viele am längsten nicht geprüfte Videos der Wartungs-Job pro Lauf erneut validiert. Höher = schnellerer Gesamtzyklus, aber mehr API-Kontingent (1 Einheit je 50 Videos).",
"batchNote": "Standard: {{default}}. ~233k Videos ÷ dieser Wert = Tage für einen vollen Prüfzyklus."
},
"dot": { "dot": {
"running": "läuft gerade", "running": "läuft gerade",
"ok": "letzter Lauf OK", "ok": "letzter Lauf OK",

View file

@ -7,6 +7,8 @@
"clearAll": "Clear all", "clearAll": "Clear all",
"markRead": "Mark read", "markRead": "Mark read",
"dismiss": "Dismiss", "dismiss": "Dismiss",
"sectionSystem": "System",
"sectionActivity": "Activity",
"andMore": "and {{count}} more", "andMore": "and {{count}} more",
"maintenance": { "maintenance": {
"title": "Videos removed", "title": "Videos removed",

View file

@ -40,6 +40,12 @@
"backfill_deep": "Backfilling full history", "backfill_deep": "Backfilling full history",
"probe": "Classifying Shorts" "probe": "Classifying Shorts"
}, },
"maintenance": {
"title": "Maintenance",
"batchLabel": "Videos re-checked per run",
"batchHint": "How many least-recently-checked videos the maintenance job re-validates each run. Higher = faster full cycle but more API quota (1 unit per 50 videos).",
"batchNote": "Default: {{default}}. ~233k videos ÷ this = days for a full re-check cycle."
},
"dot": { "dot": {
"running": "running now", "running": "running now",
"ok": "last run OK", "ok": "last run OK",

View file

@ -7,6 +7,8 @@
"clearAll": "Összes törlése", "clearAll": "Összes törlése",
"markRead": "Olvasottnak jelöl", "markRead": "Olvasottnak jelöl",
"dismiss": "Elvetés", "dismiss": "Elvetés",
"sectionSystem": "Rendszer",
"sectionActivity": "Tevékenység",
"andMore": "és még {{count}}", "andMore": "és még {{count}}",
"maintenance": { "maintenance": {
"title": "Videók eltávolítva", "title": "Videók eltávolítva",

View file

@ -40,6 +40,12 @@
"backfill_deep": "Teljes előzmény behúzása", "backfill_deep": "Teljes előzmény behúzása",
"probe": "Shorts besorolás" "probe": "Shorts besorolás"
}, },
"maintenance": {
"title": "Karbantartás",
"batchLabel": "Futásonként újraellenőrzött videók",
"batchHint": "Hány, legrégebben ellenőrzött videót ellenőriz újra a karbantartó job futásonként. Magasabb = gyorsabb teljes ciklus, de több API-kvóta (50 videónként 1 egység).",
"batchNote": "Alapérték: {{default}}. ~233k videó ÷ ez = a teljes újraellenőrzési ciklus napokban."
},
"dot": { "dot": {
"running": "most fut", "running": "most fut",
"ok": "utolsó futás OK", "ok": "utolsó futás OK",

View file

@ -349,6 +349,12 @@ export interface SchedulerStatus {
daily_budget: number; daily_budget: number;
backfill_reserve: number; backfill_reserve: number;
}; };
maintenance: {
revalidate_batch: number;
revalidate_batch_default: number;
min: number;
max: number;
};
} }
export interface Account { export interface Account {
@ -474,6 +480,11 @@ export const api = {
req(`/api/admin/scheduler/jobs/${jobId}/run`, { method: "POST" }), req(`/api/admin/scheduler/jobs/${jobId}/run`, { method: "POST" }),
runAllSchedulerJobs: (): Promise<{ started: string[] }> => runAllSchedulerJobs: (): Promise<{ started: string[] }> =>
req("/api/admin/scheduler/run-all", { method: "POST" }), req("/api/admin/scheduler/run-all", { method: "POST" }),
updateMaintenanceBatch: (revalidateBatch: number): Promise<{ revalidate_batch: number }> =>
req("/api/admin/scheduler/maintenance", {
method: "PATCH",
body: JSON.stringify({ revalidate_batch: revalidateBatch }),
}),
// --- onboarding / admin --- // --- onboarding / admin ---
requestAccess: (email: string): Promise<{ status: string }> => requestAccess: (email: string): Promise<{ status: string }> =>

View file

@ -218,6 +218,21 @@ export function clearAll(): void {
emit(); emit();
} }
/** Remove a single history entry entirely (per-item clear in the notification center). */
export function remove(id: number): void {
const before = items.length;
items = items.filter((n) => n.id !== id);
if (items.length !== before) emit();
}
/** Resolve any history entries tied to a video called when it's unhidden/unwatched so the
* now-stale "Hidden/Watched X" notice disappears instead of lingering with a dead action. */
export function resolveVideo(videoId: string): void {
const before = items.length;
items = items.filter((n) => n.meta?.videoId !== videoId);
if (items.length !== before) emit();
}
export const getActiveToasts = (): Notification[] => cachedActive; export const getActiveToasts = (): Notification[] => cachedActive;
export const getNotifications = (): Notification[] => cachedReversed; export const getNotifications = (): Notification[] => cachedReversed;
export const getUnreadCount = (): number => cachedUnread; export const getUnreadCount = (): number => cachedUnread;