From b9a3a9012d8618e609461ee8372c2bc53e6c2dec Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 18 Jun 2026 03:20:17 +0200 Subject: [PATCH] feat(notifications): durable per-user inbox (P1) + maintenance schema Add a server-backed notification center that coexists with the client-side transient bell: a per-user `notifications` table (type/title/body/data JSON/ read/dismissed), a `/api/me/notifications` CRUD API (list, unread_count, read, read_all, dismiss, clear), and a left-nav inbox module with a live unread badge polled via useLiveQuery. Known types render trilingual text from type+data (English stored text is the fallback); read rows are trimmed past a soft cap. Also adds the schema the maintenance job builds on: videos.list?part=status columns (embeddable/privacy_status/upload_status) and the validation lifecycle columns (last_checked_at, unavailable_since, unavailable_reason). Page-id validation is centralized in one PAGES source of truth (isPage) so the new page survives reload without a second allowlist to keep in sync. --- .../versions/0016_video_maintenance.py | 43 +++++ .../alembic/versions/0017_notifications.py | 59 ++++++ backend/app/main.py | 2 + backend/app/models.py | 49 +++++ backend/app/notifications.py | 58 ++++++ backend/app/routes/notifications.py | 136 ++++++++++++++ frontend/src/App.tsx | 15 +- frontend/src/components/NavSidebar.tsx | 31 ++- .../src/components/NotificationsPanel.tsx | 176 ++++++++++++++++++ frontend/src/i18n/locales/de/inbox.json | 16 ++ frontend/src/i18n/locales/en/inbox.json | 16 ++ frontend/src/i18n/locales/hu/inbox.json | 16 ++ frontend/src/lib/api.ts | 26 +++ frontend/src/lib/urlState.ts | 28 ++- 14 files changed, 649 insertions(+), 22 deletions(-) create mode 100644 backend/alembic/versions/0016_video_maintenance.py create mode 100644 backend/alembic/versions/0017_notifications.py create mode 100644 backend/app/notifications.py create mode 100644 backend/app/routes/notifications.py create mode 100644 frontend/src/components/NotificationsPanel.tsx create mode 100644 frontend/src/i18n/locales/de/inbox.json create mode 100644 frontend/src/i18n/locales/en/inbox.json create mode 100644 frontend/src/i18n/locales/hu/inbox.json diff --git a/backend/alembic/versions/0016_video_maintenance.py b/backend/alembic/versions/0016_video_maintenance.py new file mode 100644 index 0000000..7eff8ca --- /dev/null +++ b/backend/alembic/versions/0016_video_maintenance.py @@ -0,0 +1,43 @@ +"""video maintenance / validation columns + +Revision ID: 0016_video_maintenance +Revises: 0015_scheduler_settings +Create Date: 2026-06-18 + +Adds the columns the maintenance/validation job needs to detect and retire unplayable +videos: videos.list?part=status fields (embeddable / privacy_status / upload_status) plus +the validation lifecycle (last_checked_at for rolling re-validation; unavailable_since + +unavailable_reason to flag a video as currently unplayable — hidden from the feed +immediately, hard-deleted after a grace period). Purely additive; all columns nullable. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0016_video_maintenance" +down_revision: Union[str, None] = "0015_scheduler_settings" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("videos", sa.Column("embeddable", sa.Boolean(), nullable=True)) + op.add_column("videos", sa.Column("privacy_status", sa.String(length=16), nullable=True)) + op.add_column("videos", sa.Column("upload_status", sa.String(length=16), nullable=True)) + op.add_column("videos", sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("videos", sa.Column("unavailable_since", sa.DateTime(timezone=True), nullable=True)) + op.add_column("videos", sa.Column("unavailable_reason", sa.String(length=24), nullable=True)) + op.create_index("ix_videos_last_checked_at", "videos", ["last_checked_at"]) + op.create_index("ix_videos_unavailable_since", "videos", ["unavailable_since"]) + + +def downgrade() -> None: + op.drop_index("ix_videos_unavailable_since", table_name="videos") + op.drop_index("ix_videos_last_checked_at", table_name="videos") + op.drop_column("videos", "unavailable_reason") + op.drop_column("videos", "unavailable_since") + op.drop_column("videos", "last_checked_at") + op.drop_column("videos", "upload_status") + op.drop_column("videos", "privacy_status") + op.drop_column("videos", "embeddable") diff --git a/backend/alembic/versions/0017_notifications.py b/backend/alembic/versions/0017_notifications.py new file mode 100644 index 0000000..057544e --- /dev/null +++ b/backend/alembic/versions/0017_notifications.py @@ -0,0 +1,59 @@ +"""per-user notification inbox (phase 1) + +Revision ID: 0017_notifications +Revises: 0016_video_maintenance +Create Date: 2026-06-18 + +Adds the durable, server-backed per-user notification table behind the inbox module. The +JSON payload column is named `data` (SQLAlchemy's declarative Base reserves `metadata`). +First producer is the maintenance job's batched "videos removed" notice. Coexists with the +existing client-side transient bell. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0017_notifications" +down_revision: Union[str, None] = "0016_video_maintenance" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "notifications", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("type", sa.String(length=32), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("body", sa.Text(), nullable=True), + sa.Column("data", sa.JSON(), nullable=True), + sa.Column("read", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("dismissed", sa.Boolean(), nullable=False, server_default="false"), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now() + ), + ) + op.create_index("ix_notifications_user_id", "notifications", ["user_id"]) + op.create_index("ix_notifications_type", "notifications", ["type"]) + op.create_index("ix_notifications_read", "notifications", ["read"]) + op.create_index("ix_notifications_created_at", "notifications", ["created_at"]) + # Fast unread-badge count + inbox listing per user. + op.create_index( + "ix_notifications_user_read", "notifications", ["user_id", "read"] + ) + + +def downgrade() -> None: + op.drop_index("ix_notifications_user_read", table_name="notifications") + op.drop_index("ix_notifications_created_at", table_name="notifications") + op.drop_index("ix_notifications_read", table_name="notifications") + op.drop_index("ix_notifications_type", table_name="notifications") + op.drop_index("ix_notifications_user_id", table_name="notifications") + op.drop_table("notifications") diff --git a/backend/app/main.py b/backend/app/main.py index ca0b01d..d134beb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -32,6 +32,7 @@ from app.routes import ( feed, health, me, + notifications, playlists, quota, scheduler as scheduler_routes, @@ -77,6 +78,7 @@ app.include_router(sync.router) app.include_router(tags.router) app.include_router(feed.router) app.include_router(me.router) +app.include_router(notifications.router) app.include_router(channels.router) app.include_router(playlists.router) app.include_router(admin.router) diff --git a/backend/app/models.py b/backend/app/models.py index f99e8ec..dfe3e13 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -203,6 +203,22 @@ class Video(Base): String(16), default="none", server_default="none", index=True ) enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # videos.list?part=status fields (populated during enrichment); used by the + # maintenance/validation job to decide whether a video is playable anywhere. + embeddable: Mapped[bool | None] = mapped_column(Boolean) + privacy_status: Mapped[str | None] = mapped_column(String(16)) # public|unlisted|private + upload_status: Mapped[str | None] = mapped_column(String(16)) # processed|failed|rejected|deleted + # Maintenance/validation lifecycle. last_checked_at drives the rolling re-validation + # (least-recently-checked first); unavailable_since marks a video as currently + # unplayable (hidden from the feed immediately, hard-deleted after a grace period); + # unavailable_reason records why (deleted|private|paywalled|abandoned|stuck_live). + last_checked_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), index=True + ) + unavailable_since: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), index=True + ) + unavailable_reason: Mapped[str | None] = mapped_column(String(24)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) @@ -399,3 +415,36 @@ class PlaylistItem(Base): ) playlist: Mapped["Playlist"] = relationship(back_populates="items") + + +class Notification(Base): + """A durable, server-backed per-user notification (the inbox center, phase 1). + + Distinct from the client-side transient "bell" (toasts kept only in localStorage): + these survive reloads and devices, and are produced server-side (first producer = the + maintenance/validation job's batched "N saved/playlisted videos removed" notice). The + column is named `data` (not `metadata`, which SQLAlchemy's declarative Base reserves): + a free-form JSON payload (e.g. the list of removed videos) the UI can render. `read` + and `dismissed` are separate: read clears the unread badge but keeps the row in the + inbox; dismissed removes it from the default view. Unread rows never auto-expire; read + ones are trimmed past a soft per-user cap (see app.notifications.trim_read).""" + + __tablename__ = "notifications" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + type: Mapped[str] = mapped_column(String(32), index=True) # e.g. "maintenance" + title: Mapped[str] = mapped_column(String(255)) + body: Mapped[str | None] = mapped_column(Text) + data: Mapped[dict | None] = mapped_column(JSON) + read: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false", index=True + ) + dismissed: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True + ) diff --git a/backend/app/notifications.py b/backend/app/notifications.py new file mode 100644 index 0000000..c4ce120 --- /dev/null +++ b/backend/app/notifications.py @@ -0,0 +1,58 @@ +"""Helpers for the durable, server-backed per-user notification inbox (phase 1). + +The inbox COEXISTS with the client-side transient "bell" (localStorage toasts): these rows +survive reloads/devices and are produced server-side. First producer is the maintenance +job. Unread rows never auto-expire; read rows are trimmed past a soft per-user cap so the +table can't grow without bound for a heavy user. +""" +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import Notification + +# Keep at most this many READ notifications per user; older read ones are pruned. Unread +# rows are never pruned (the user hasn't seen them yet). +READ_SOFT_CAP = 200 + + +def create_notification( + db: Session, + user_id: int, + type: str, + title: str, + body: str | None = None, + data: dict | None = None, + *, + commit: bool = True, +) -> Notification: + """Insert one notification for a user. Callers that batch many inserts in a single + transaction can pass commit=False and commit once themselves.""" + notif = Notification( + user_id=user_id, type=type, title=title, body=body, data=data + ) + db.add(notif) + if commit: + db.commit() + db.refresh(notif) + return notif + + +def trim_read(db: Session, user_id: int, cap: int = READ_SOFT_CAP) -> int: + """Delete a user's oldest READ notifications beyond `cap`. Returns the number removed. + Idempotent and cheap; safe to call after marking things read.""" + ids = ( + db.execute( + select(Notification.id) + .where(Notification.user_id == user_id, Notification.read.is_(True)) + .order_by(Notification.created_at.desc()) + .offset(cap) + ) + .scalars() + .all() + ) + if not ids: + return 0 + for nid in ids: + db.delete(db.get(Notification, nid)) + db.commit() + return len(ids) diff --git a/backend/app/routes/notifications.py b/backend/app/routes/notifications.py new file mode 100644 index 0000000..224a7f7 --- /dev/null +++ b/backend/app/routes/notifications.py @@ -0,0 +1,136 @@ +"""Per-user notification inbox (phase 1): the durable, server-backed center that coexists +with the client-side transient bell. Read-only listing plus read/dismiss/clear mutations. + +All endpoints use `current_user` (not `require_human`): notifications and their read/dismiss +state are personal UI state with no quota or YouTube involvement, so the shared demo account +manages its own inbox normally.""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func, select, update +from sqlalchemy.orm import Session + +from app.auth import current_user +from app.db import get_db +from app.models import Notification, User +from app.notifications import trim_read + +router = APIRouter(prefix="/api/me/notifications", tags=["notifications"]) + + +def _serialize(n: Notification) -> dict: + return { + "id": n.id, + "type": n.type, + "title": n.title, + "body": n.body, + "data": n.data, + "read": n.read, + "dismissed": n.dismissed, + "created_at": n.created_at.isoformat() if n.created_at else None, + } + + +@router.get("") +def list_notifications( + include_dismissed: bool = False, + limit: int = Query(default=100, le=500), + offset: int = 0, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + where = [Notification.user_id == user.id] + if not include_dismissed: + where.append(Notification.dismissed.is_(False)) + total = db.scalar( + select(func.count()).select_from(Notification).where(*where) + ) or 0 + rows = ( + db.execute( + select(Notification) + .where(*where) + .order_by(Notification.created_at.desc()) + .offset(offset) + .limit(limit) + ) + .scalars() + .all() + ) + return {"items": [_serialize(n) for n in rows], "total": total} + + +@router.get("/unread_count") +def unread_count( + user: User = Depends(current_user), db: Session = Depends(get_db) +) -> dict: + """Cheap poll target for the nav badge (uses the (user_id, read) index).""" + count = db.scalar( + select(func.count()) + .select_from(Notification) + .where( + Notification.user_id == user.id, + Notification.read.is_(False), + Notification.dismissed.is_(False), + ) + ) + return {"count": count or 0} + + +def _owned(db: Session, notification_id: int, user: User) -> Notification: + n = db.get(Notification, notification_id) + if n is None or n.user_id != user.id: + raise HTTPException(status_code=404, detail="Notification not found") + return n + + +@router.post("/{notification_id}/read") +def mark_read( + notification_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + n = _owned(db, notification_id, user) + n.read = True + db.commit() + trim_read(db, user.id) + return {"ok": True} + + +@router.post("/read_all") +def mark_all_read( + user: User = Depends(current_user), db: Session = Depends(get_db) +) -> dict: + db.execute( + update(Notification) + .where(Notification.user_id == user.id, Notification.read.is_(False)) + .values(read=True) + ) + db.commit() + trim_read(db, user.id) + return {"ok": True} + + +@router.post("/{notification_id}/dismiss") +def dismiss( + notification_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + n = _owned(db, notification_id, user) + # Dismiss implies read (it no longer counts toward the unread badge). + n.dismissed = True + n.read = True + db.commit() + return {"ok": True} + + +@router.post("/clear") +def clear_all( + user: User = Depends(current_user), db: Session = Depends(get_db) +) -> dict: + """Dismiss every (still-visible) notification for this user.""" + db.execute( + update(Notification) + .where(Notification.user_id == user.id, Notification.dismissed.is_(False)) + .values(dismissed=True, read=True) + ) + db.commit() + return {"ok": True} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4bf2686..ccfe17a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,7 +10,7 @@ import { saveLocalTheme, type ThemePrefs, } from "./lib/theme"; -import { hasFilterParams, paramsToFilters, readPage, stripUrlParams, type Page } from "./lib/urlState"; +import { hasFilterParams, isPage, paramsToFilters, readPage, stripUrlParams, type Page } from "./lib/urlState"; import { loadLayout, normalizeLayout, @@ -29,6 +29,7 @@ import Playlists from "./components/Playlists"; import Stats from "./components/Stats"; import Scheduler from "./components/Scheduler"; import SettingsPanel from "./components/SettingsPanel"; +import NotificationsPanel from "./components/NotificationsPanel"; import OnboardingWizard from "./components/OnboardingWizard"; import { shouldAutoOpenOnboarding } from "./lib/onboarding"; import Toaster from "./components/Toaster"; @@ -60,15 +61,7 @@ function loadInitialPage(): Page { const params = new URLSearchParams(window.location.search); if (params.has("page")) return readPage(); const stored = localStorage.getItem(PAGE_KEY); - if ( - stored === "channels" || - stored === "stats" || - stored === "playlists" || - stored === "settings" || - stored === "scheduler" - ) - return stored; - return "feed"; + return isPage(stored) ? stored : "feed"; } function loadStoredFilters(): FeedFilters { @@ -311,6 +304,8 @@ export default function App() { ) : page === "playlists" ? ( + ) : page === "notifications" ? ( + ) : page === "settings" ? ( ( + const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => ( ); diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx new file mode 100644 index 0000000..273e1f4 --- /dev/null +++ b/frontend/src/components/NotificationsPanel.tsx @@ -0,0 +1,176 @@ +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 { useLiveQuery } from "../lib/useLiveQuery"; + +// 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. +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)); + // 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 }); +} + +export default function NotificationsPanel() { + const { t } = useTranslation(); + const qc = useQueryClient(); + const q = useLiveQuery<{ items: AppNotification[]; total: number }>( + ["notifications"], + () => api.notifications(), + { intervalMs: POLL_MS } + ); + const items = q.data?.items ?? []; + + // Any mutation refreshes both the list and the nav badge's unread count. + const refresh = () => { + qc.invalidateQueries({ queryKey: ["notifications"] }); + qc.invalidateQueries({ queryKey: ["notif-unread"] }); + }; + const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh }); + const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh }); + const dismissMut = useMutation({ mutationFn: api.dismissNotification, onSuccess: refresh }); + const clearMut = useMutation({ mutationFn: api.clearNotifications, onSuccess: refresh }); + + const hasUnread = items.some((n) => !n.read); + + return ( +
+
+ +
+
{t("inbox.title")}
+
{t("inbox.subtitle")}
+
+ {items.length > 0 && ( +
+ + +
+ )} +
+ + {q.isLoading && !q.data ? ( +
{t("common.loading")}
+ ) : items.length === 0 ? ( +
+ + {t("inbox.empty")} +
+ ) : ( +
+ {items.map((n) => ( + readMut.mutate(n.id)} + onDismiss={() => dismissMut.mutate(n.id)} + /> + ))} +
+ )} +
+ ); +} + +function NotificationRow({ + n, + t, + onRead, + onDismiss, +}: { + n: AppNotification; + t: (k: string, o?: any) => string; + onRead: () => void; + onDismiss: () => void; +}) { + // The maintenance producer ships the affected videos in `data.videos`; list a few titles. + const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos) + ? n.data!.videos + : []; + // Known notification types are rendered from i18n (so they're trilingual) using the typed + // payload; the server-stored title/body are an English fallback for any unknown type. + const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number"; + const title = isMaintenance ? t("inbox.maintenance.title") : n.title; + const body = isMaintenance + ? t("inbox.maintenance.body", { count: n.data!.count }) + : n.body; + return ( +
+ {!n.read && } +
+
+ {title} + + {relativeTime(n.created_at, t)} + +
+ {body &&
{body}
} + {videos.length > 0 && ( +
    + {videos.slice(0, 8).map((v) => ( +
  • + {v.title || v.id} +
  • + ))} + {videos.length > 8 && ( +
  • + {t("inbox.andMore", { count: videos.length - 8 })} +
  • + )} +
+ )} +
+
+ {!n.read && ( + + )} + +
+
+ ); +} diff --git a/frontend/src/i18n/locales/de/inbox.json b/frontend/src/i18n/locales/de/inbox.json new file mode 100644 index 0000000..39a2ddb --- /dev/null +++ b/frontend/src/i18n/locales/de/inbox.json @@ -0,0 +1,16 @@ +{ + "navLabel": "Benachrichtigungen", + "title": "Benachrichtigungen", + "subtitle": "Neuigkeiten aus deiner Bibliothek und vom System.", + "empty": "Alles erledigt.", + "markAllRead": "Alle als gelesen markieren", + "clearAll": "Alle löschen", + "markRead": "Als gelesen markieren", + "dismiss": "Verwerfen", + "andMore": "und {{count}} weitere", + "maintenance": { + "title": "Videos entfernt", + "body_one": "{{count}} gespeichertes oder in einer Playlist befindliches Video wurde entfernt, weil es auf YouTube nicht mehr verfügbar ist.", + "body_other": "{{count}} gespeicherte oder in Playlists befindliche Videos wurden entfernt, weil sie auf YouTube nicht mehr verfügbar sind." + } +} diff --git a/frontend/src/i18n/locales/en/inbox.json b/frontend/src/i18n/locales/en/inbox.json new file mode 100644 index 0000000..7f972a4 --- /dev/null +++ b/frontend/src/i18n/locales/en/inbox.json @@ -0,0 +1,16 @@ +{ + "navLabel": "Notifications", + "title": "Notifications", + "subtitle": "Updates from your library and the system.", + "empty": "You're all caught up.", + "markAllRead": "Mark all read", + "clearAll": "Clear all", + "markRead": "Mark read", + "dismiss": "Dismiss", + "andMore": "and {{count}} more", + "maintenance": { + "title": "Videos removed", + "body_one": "{{count}} saved or playlisted video was removed because it's no longer available on YouTube.", + "body_other": "{{count}} saved or playlisted videos were removed because they're no longer available on YouTube." + } +} diff --git a/frontend/src/i18n/locales/hu/inbox.json b/frontend/src/i18n/locales/hu/inbox.json new file mode 100644 index 0000000..9167954 --- /dev/null +++ b/frontend/src/i18n/locales/hu/inbox.json @@ -0,0 +1,16 @@ +{ + "navLabel": "Értesítések", + "title": "Értesítések", + "subtitle": "Frissítések a könyvtáradból és a rendszertől.", + "empty": "Nincs új értesítés.", + "markAllRead": "Összes olvasott", + "clearAll": "Összes törlése", + "markRead": "Olvasottnak jelöl", + "dismiss": "Elvetés", + "andMore": "és még {{count}}", + "maintenance": { + "title": "Videók eltávolítva", + "body_one": "{{count}} mentett vagy lejátszási listás videó eltávolítva, mert már nem elérhető a YouTube-on.", + "body_other": "{{count}} mentett vagy lejátszási listás videó eltávolítva, mert már nem elérhetők a YouTube-on." + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 12d4d82..006a834 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -356,6 +356,19 @@ export interface Account { active: boolean; } +// A durable, server-backed notification (the inbox center). Distinct from the client-side +// transient toast/bell, which lives only in localStorage. +export interface AppNotification { + id: number; + type: string; + title: string; + body: string | null; + data: Record | null; + read: boolean; + dismissed: boolean; + created_at: string | null; +} + export const api = { me: (): Promise => req("/api/me"), accounts: (): Promise => req("/api/me/accounts"), @@ -478,6 +491,19 @@ export const api = { addInvite: (email: string) => req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }), + // --- notification inbox (durable, server-backed) --- + notifications: (includeDismissed = false): Promise<{ items: AppNotification[]; total: number }> => + req(`/api/me/notifications?include_dismissed=${includeDismissed}`), + notificationUnreadCount: (): Promise<{ count: number }> => + req("/api/me/notifications/unread_count"), + markNotificationRead: (id: number) => + req(`/api/me/notifications/${id}/read`, { method: "POST" }), + markAllNotificationsRead: () => + req("/api/me/notifications/read_all", { method: "POST" }), + dismissNotification: (id: number) => + req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }), + clearNotifications: () => req("/api/me/notifications/clear", { method: "POST" }), + // --- user tags --- createTag: (t: { name: string; color?: string; category?: string }) => req("/api/tags", { method: "POST", body: JSON.stringify(t) }), diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index ab2a38b..8483ee7 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -78,17 +78,29 @@ export function hasFilterParams(params: URLSearchParams): boolean { return KEYS.some((k) => params.has(k)); } -export type Page = "feed" | "channels" | "stats" | "playlists" | "settings" | "scheduler"; +// The single source of truth for valid page ids; `Page` and the runtime validator both +// derive from it, so adding a page is a one-line change (no parallel allowlists to keep in +// sync). "feed" is the default/fallback. +export const PAGES = [ + "feed", + "channels", + "stats", + "playlists", + "settings", + "scheduler", + "notifications", +] as const; + +export type Page = (typeof PAGES)[number]; + +/** Narrow an arbitrary string to a known Page (else null). */ +export function isPage(p: string | null | undefined): p is Page { + return !!p && (PAGES as readonly string[]).includes(p); +} export function readPage(): Page { const p = new URLSearchParams(window.location.search).get("page"); - return p === "channels" || - p === "stats" || - p === "playlists" || - p === "settings" || - p === "scheduler" - ? p - : "feed"; + return isPage(p) ? p : "feed"; } /** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the