diff --git a/backend/alembic/versions/0018_maintenance_batch_setting.py b/backend/alembic/versions/0018_maintenance_batch_setting.py new file mode 100644 index 0000000..07b9f9e --- /dev/null +++ b/backend/alembic/versions/0018_maintenance_batch_setting.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index dfe3e13..fca20f3 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -339,6 +339,9 @@ class AppState(Base): sync_paused: Mapped[bool] = mapped_column( 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): diff --git a/backend/app/routes/scheduler.py b/backend/app/routes/scheduler.py index 82a4c82..66e4f8f 100644 --- a/backend/app/routes/scheduler.py +++ b/backend/app/routes/scheduler.py @@ -71,6 +71,29 @@ def run_all_now(user: User = Depends(admin_user)) -> dict: 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("") def get_scheduler( _: User = Depends(admin_user), db: Session = Depends(get_db) @@ -138,4 +161,10 @@ def get_scheduler( "paused": state.is_sync_paused(db), "queue": queue, "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, + }, } diff --git a/backend/app/state.py b/backend/app/state.py index ad85e19..34f56fa 100644 --- a/backend/app/state.py +++ b/backend/app/state.py @@ -3,10 +3,15 @@ import logging from sqlalchemy.orm import Session +from app.config import settings from app.models import AppState 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: row = db.get(AppState, 1) @@ -27,3 +32,20 @@ def set_sync_paused(db: Session, paused: bool) -> None: db.add(row) db.commit() 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 diff --git a/backend/app/sync/maintenance.py b/backend/app/sync/maintenance.py index 7e308df..47e3569 100644 --- a/backend/app/sync/maintenance.py +++ b/backend/app/sync/maintenance.py @@ -29,6 +29,7 @@ from app import progress, quota from app.config import settings from app.models import Playlist, PlaylistItem, Video, VideoState from app.notifications import create_notification +from app.state import get_maintenance_batch from app.sync.runner import get_service_user from app.sync.videos import apply_video_details, parse_dt 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: """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).""" - batch = settings.maintenance_revalidate_batch + batch = get_maintenance_batch(db) # admin override, else config default flagged_new = 0 checked = 0 now = _now() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ccfe17a..037cc89 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -252,8 +252,6 @@ export default function App() { onOpenAbout={() => setAboutOpen(true)} onChangeLanguage={changeLanguage} language={i18n.language as LangCode} - filters={filters} - setFilters={setFilters} />
) : page === "notifications" ? ( - + ) : page === "settings" ? ( onState(id, "new") }, 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] diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index f862adb..cef12ec 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; @@ -17,13 +17,13 @@ import { Tv, UserPlus, } 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 { getUnreadCount, subscribe } from "../lib/notifications"; import type { Page } from "../lib/urlState"; import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; import LanguageSwitcher from "./LanguageSwitcher"; -import NotificationCenter from "./NotificationCenter"; // 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 @@ -35,8 +35,6 @@ export default function NavSidebar({ onOpenAbout, onChangeLanguage, language, - filters, - setFilters, }: { me: Me; page: Page; @@ -44,8 +42,6 @@ export default function NavSidebar({ onOpenAbout: () => void; onChangeLanguage: (code: LangCode) => void; language: LangCode; - filters: FeedFilters; - setFilters: (f: FeedFilters) => void; }) { const { t } = useTranslation(); const [collapsed, setCollapsed] = useState( @@ -125,7 +121,10 @@ export default function NavSidebar({ api.notificationUnreadCount, { 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 }; // User-facing content modules vs. admin/system modules, separated by a divider in the rail. @@ -243,7 +242,6 @@ export default function NavSidebar({ > -
- - {open && - renderPanel( -
-
-
{t("notifications.title")}
- {notifications.length > 0 && ( - - )} -
- -
- {notifications.length === 0 ? ( -
{t("notifications.empty")}
- ) : ( - 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 ( -
- -
- {n.title &&
{n.title}
} -
{n.message}
-
- {relTime(n.ts, t)} - {needsAction && ( - - {t("notifications.actionNeeded")} - - )} -
- - {hidden ? ( -
- - -
- ) : watched ? ( -
- -
- ) : ( - n.action && - !n.dismissed && ( - - ) - )} -
-
- ); - }) - )} -
-
- )} - - ); -} diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index d008f53..1fcadab 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -1,29 +1,56 @@ +import { useEffect, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Bell, Check, CheckCheck, Trash2, X } from "lucide-react"; -import { api, type AppNotification } from "../lib/api"; +import { Bell, Check, CheckCheck, Eye, RotateCcw, Search, Trash2, X } from "lucide-react"; +import { api, type AppNotification, type FeedFilters } from "../lib/api"; 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 -// client-side transient bell in the nav rail: the bell is a quick peek at recent toasts kept -// in localStorage, this is the full, filterable, clearable history synced from the server. +// The single notification center. "System" = durable, server-backed notifications (inbox +// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with +// 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; function relativeTime(iso: string | null, t: (k: string, o?: any) => string): string { if (!iso) return ""; - const then = new Date(iso).getTime(); - const secs = Math.max(0, Math.round((Date.now() - then) / 1000)); + return relativeFromMs(new Date(iso).getTime(), t); +} + +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. if (secs < 60) return t("notifications.time.justNow"); const mins = Math.round(secs / 60); if (mins < 60) return t("notifications.time.minutes", { count: mins }); const hours = Math.round(mins / 60); if (hours < 24) return t("notifications.time.hours", { count: hours }); - const days = Math.round(hours / 24); - return t("notifications.time.days", { count: days }); + return t("notifications.time.days", { count: Math.round(hours / 24) }); } -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 qc = useQueryClient(); const q = useLiveQuery<{ items: AppNotification[]; total: number }>( @@ -31,9 +58,16 @@ export default function NotificationsPanel() { () => api.notifications(), { 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 = () => { qc.invalidateQueries({ queryKey: ["notifications"] }); qc.invalidateQueries({ queryKey: ["notif-unread"] }); @@ -43,7 +77,41 @@ export default function NotificationsPanel() { const dismissMut = useMutation({ mutationFn: api.dismissNotification, 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 (
@@ -53,10 +121,10 @@ export default function NotificationsPanel() {
{t("inbox.title")}
{t("inbox.subtitle")}
- {items.length > 0 && ( + {hasAny && (
- {q.isLoading && !q.data ? ( + {q.isLoading && !q.data && clientItems.length === 0 ? (
{t("common.loading")}
- ) : items.length === 0 ? ( + ) : !hasAny ? (
{t("inbox.empty")}
) : ( -
- {items.map((n) => ( - readMut.mutate(n.id)} - onDismiss={() => dismissMut.mutate(n.id)} - /> - ))} +
+ {serverItems.length > 0 && ( +
+
+ {t("inbox.sectionSystem")} +
+
+ {serverItems.map((n) => ( + readMut.mutate(n.id)} + onDismiss={() => dismissMut.mutate(n.id)} + /> + ))} +
+
+ )} + {clientItems.length > 0 && ( +
+
+ {t("inbox.sectionActivity")} +
+
+ {clientItems.map((n) => ( + removeClient(n.id)} + /> + ))} +
+
+ )}
)}
@@ -185,3 +281,91 @@ function NotificationRow({ ); } + +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 ( +
+ +
+ {n.title &&
{n.title}
} +
{n.message}
+
+ {relativeFromMs(n.ts, t)} + {needsAction && ( + + {t("notifications.actionNeeded")} + + )} +
+ {hidden ? ( +
+ + +
+ ) : watched ? ( +
+ +
+ ) : ( + + n.action && + !n.dismissed && ( + + ) + )} +
+ +
+ ); +} diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 697722b..549916e 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -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 ( +
+
+ + + + {t("scheduler.maintenance.batchLabel")} + + + + 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" + /> + +
+
+ {t("scheduler.maintenance.batchNote", { default: m.revalidate_batch_default.toLocaleString() })} +
+
+ ); +} + function Stat({ icon: Icon, label, @@ -285,6 +339,10 @@ export default function Scheduler() { qc.invalidateQueries({ queryKey: ["scheduler"] }); }, }); + const batchMut = useMutation({ + mutationFn: (v: number) => api.updateMaintenanceBatch(v), + onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }), + }); if (q.isLoading && !data) return
{t("scheduler.loading")}
; @@ -432,6 +490,16 @@ export default function Scheduler() {
{t("scheduler.quotaNote")}
+ + {/* Maintenance settings */} +
+
{t("scheduler.maintenance.title")}
+ batchMut.mutate(batch)} + saving={batchMut.isPending} + /> +
); } diff --git a/frontend/src/i18n/locales/de/inbox.json b/frontend/src/i18n/locales/de/inbox.json index 807e91f..734869f 100644 --- a/frontend/src/i18n/locales/de/inbox.json +++ b/frontend/src/i18n/locales/de/inbox.json @@ -7,6 +7,8 @@ "clearAll": "Alle löschen", "markRead": "Als gelesen markieren", "dismiss": "Verwerfen", + "sectionSystem": "System", + "sectionActivity": "Aktivität", "andMore": "und {{count}} weitere", "maintenance": { "title": "Videos entfernt", diff --git a/frontend/src/i18n/locales/de/scheduler.json b/frontend/src/i18n/locales/de/scheduler.json index 28fa613..8c30032 100644 --- a/frontend/src/i18n/locales/de/scheduler.json +++ b/frontend/src/i18n/locales/de/scheduler.json @@ -40,6 +40,12 @@ "backfill_deep": "Ganze Historie nachladen", "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": { "running": "läuft gerade", "ok": "letzter Lauf OK", diff --git a/frontend/src/i18n/locales/en/inbox.json b/frontend/src/i18n/locales/en/inbox.json index 89d2fd8..8d7c7a6 100644 --- a/frontend/src/i18n/locales/en/inbox.json +++ b/frontend/src/i18n/locales/en/inbox.json @@ -7,6 +7,8 @@ "clearAll": "Clear all", "markRead": "Mark read", "dismiss": "Dismiss", + "sectionSystem": "System", + "sectionActivity": "Activity", "andMore": "and {{count}} more", "maintenance": { "title": "Videos removed", diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index 6c45ed5..b33bcf5 100644 --- a/frontend/src/i18n/locales/en/scheduler.json +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -40,6 +40,12 @@ "backfill_deep": "Backfilling full history", "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": { "running": "running now", "ok": "last run OK", diff --git a/frontend/src/i18n/locales/hu/inbox.json b/frontend/src/i18n/locales/hu/inbox.json index 9f144d3..3f6e6d8 100644 --- a/frontend/src/i18n/locales/hu/inbox.json +++ b/frontend/src/i18n/locales/hu/inbox.json @@ -7,6 +7,8 @@ "clearAll": "Összes törlése", "markRead": "Olvasottnak jelöl", "dismiss": "Elvetés", + "sectionSystem": "Rendszer", + "sectionActivity": "Tevékenység", "andMore": "és még {{count}}", "maintenance": { "title": "Videók eltávolítva", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index 7c7fac4..a3a1b9a 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -40,6 +40,12 @@ "backfill_deep": "Teljes előzmény behúzása", "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": { "running": "most fut", "ok": "utolsó futás OK", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4dbb9b6..c316c70 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -349,6 +349,12 @@ export interface SchedulerStatus { daily_budget: number; backfill_reserve: number; }; + maintenance: { + revalidate_batch: number; + revalidate_batch_default: number; + min: number; + max: number; + }; } export interface Account { @@ -474,6 +480,11 @@ export const api = { req(`/api/admin/scheduler/jobs/${jobId}/run`, { method: "POST" }), runAllSchedulerJobs: (): Promise<{ started: string[] }> => 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 --- requestAccess: (email: string): Promise<{ status: string }> => diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 530ce68..184d708 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -218,6 +218,21 @@ export function clearAll(): void { 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 getNotifications = (): Notification[] => cachedReversed; export const getUnreadCount = (): number => cachedUnread;