From a8822d39359005f4308e2f527537ecd4c4055d96 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 11 Jun 2026 20:45:48 +0200 Subject: [PATCH] feat(m5a): channel manager, tabbed settings panel, per-user sync status Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags), user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts). Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status + actions + admin pause/resume, Account); a channel manager with priority, hide, per-channel user tags, sync badges and 'view in feed'. Notifications now honor the configurable sound + auto-dismiss settings. --- backend/app/main.py | 3 +- backend/app/routes/channels.py | 143 ++++++++++ backend/app/routes/sync.py | 46 +++- backend/app/routes/tags.py | 60 +++++ frontend/src/App.tsx | 58 +++- frontend/src/components/Channels.tsx | 261 ++++++++++++++++++ frontend/src/components/Header.tsx | 107 ++++++-- frontend/src/components/SettingsPanel.tsx | 312 ++++++++++++++++++++++ frontend/src/lib/api.ts | 47 ++++ frontend/src/lib/notifications.ts | 55 +++- frontend/src/lib/urlState.ts | 16 +- 11 files changed, 1063 insertions(+), 45 deletions(-) create mode 100644 backend/app/routes/channels.py create mode 100644 frontend/src/components/Channels.tsx create mode 100644 frontend/src/components/SettingsPanel.tsx diff --git a/backend/app/main.py b/backend/app/main.py index 9690916..bdce514 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware from app import auth from app.config import settings -from app.routes import feed, health, me, sync, tags +from app.routes import channels, feed, health, me, sync, tags from app.scheduler import shutdown_scheduler, start_scheduler @@ -65,6 +65,7 @@ app.include_router(sync.router) app.include_router(tags.router) app.include_router(feed.router) app.include_router(me.router) +app.include_router(channels.router) # The built SPA (populated by the Docker frontend build stage). STATIC_DIR = Path(__file__).parent / "static_spa" diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py new file mode 100644 index 0000000..32c80b4 --- /dev/null +++ b/backend/app/routes/channels.py @@ -0,0 +1,143 @@ +"""Channel manager: the user's subscriptions with their personal overrides (priority, +hidden, tags) and per-channel sync state. Read + local-write only — YouTube-side writes +(unsubscribe) live behind the optional write scope and are added in a later phase.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import and_, func, select +from sqlalchemy.orm import Session + +from app.auth import current_user +from app.db import get_db +from app.models import Channel, ChannelTag, Subscription, Tag, User, Video + +router = APIRouter(prefix="/api/channels", tags=["channels"]) + + +@router.get("") +def list_channels( + user: User = Depends(current_user), db: Session = Depends(get_db) +) -> list[dict]: + rows = db.execute( + select(Subscription, Channel) + .join(Channel, Channel.id == Subscription.channel_id) + .where(Subscription.user_id == user.id) + .order_by(Subscription.priority.desc(), func.lower(Channel.title)) + ).all() + + # Per-channel stored video count (shared data) in one grouped query. + channel_ids = [c.id for _, c in rows] + stored: dict[str, int] = {} + if channel_ids: + for cid, count in db.execute( + select(Video.channel_id, func.count(Video.id)) + .where(Video.channel_id.in_(channel_ids)) + .group_by(Video.channel_id) + ).all(): + stored[cid] = count + + # The user's personal tag links, grouped by channel. + tags_by_channel: dict[str, list[int]] = {} + for cid, tag_id in db.execute( + select(ChannelTag.channel_id, ChannelTag.tag_id).where( + ChannelTag.user_id == user.id + ) + ).all(): + tags_by_channel.setdefault(cid, []).append(tag_id) + + return [ + { + "id": ch.id, + "title": ch.title, + "handle": ch.handle, + "thumbnail_url": ch.thumbnail_url, + "subscriber_count": ch.subscriber_count, + "video_count": ch.video_count, + "stored_videos": stored.get(ch.id, 0), + "priority": sub.priority, + "hidden": sub.hidden, + "tag_ids": tags_by_channel.get(ch.id, []), + "details_synced": ch.details_synced_at is not None, + "recent_synced": ch.recent_synced_at is not None, + "backfill_done": ch.backfill_done, + } + for sub, ch in rows + ] + + +def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription: + sub = db.execute( + select(Subscription).where( + Subscription.user_id == user.id, Subscription.channel_id == channel_id + ) + ).scalar_one_or_none() + if sub is None: + raise HTTPException(status_code=404, detail="Not subscribed to this channel") + return sub + + +@router.patch("/{channel_id}") +def update_channel( + channel_id: str, + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + sub = _user_subscription(db, user, channel_id) + if "priority" in payload: + sub.priority = int(payload["priority"]) + if "hidden" in payload: + sub.hidden = bool(payload["hidden"]) + db.commit() + return {"id": channel_id, "priority": sub.priority, "hidden": sub.hidden} + + +@router.post("/{channel_id}/tags") +def attach_tag( + channel_id: str, + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + _user_subscription(db, user, channel_id) + tag_id = payload.get("tag_id") + tag = db.get(Tag, tag_id) if tag_id is not None else None + # Only the user's own tags may be attached as personal links. + if tag is None or tag.user_id != user.id: + raise HTTPException(status_code=404, detail="Unknown tag") + exists = db.execute( + select(ChannelTag).where( + ChannelTag.channel_id == channel_id, + ChannelTag.tag_id == tag_id, + ChannelTag.user_id == user.id, + ) + ).scalar_one_or_none() + if exists is None: + db.add( + ChannelTag( + channel_id=channel_id, + tag_id=tag_id, + user_id=user.id, + source="user", + ) + ) + db.commit() + return {"channel_id": channel_id, "tag_id": tag_id} + + +@router.delete("/{channel_id}/tags/{tag_id}") +def detach_tag( + channel_id: str, + tag_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + row = db.execute( + select(ChannelTag).where( + ChannelTag.channel_id == channel_id, + ChannelTag.tag_id == tag_id, + ChannelTag.user_id == user.id, + ) + ).scalar_one_or_none() + if row is not None: + db.delete(row) + db.commit() + return {"channel_id": channel_id, "tag_id": tag_id} diff --git a/backend/app/routes/sync.py b/backend/app/routes/sync.py index 4efd941..a6cb902 100644 --- a/backend/app/routes/sync.py +++ b/backend/app/routes/sync.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import func, select +from sqlalchemy import and_, case, func, select from sqlalchemy.orm import Session from app import quota, state @@ -93,6 +93,50 @@ def sync_status( } +@router.get("/my-status") +def my_status( + user: User = Depends(current_user), db: Session = Depends(get_db) +) -> dict: + """Per-user sync progress: how far the channels *this* user subscribes to have synced.""" + sub_join = and_( + Subscription.channel_id == Channel.id, Subscription.user_id == user.id + ) + total, details_synced, recent_synced, deep_done = db.execute( + select( + func.count(Channel.id), + func.count(Channel.details_synced_at), + func.count(Channel.recent_synced_at), + func.sum(case((Channel.backfill_done.is_(True), 1), else_=0)), + ) + .select_from(Channel) + .join(Subscription, sub_join) + ).one() + total = total or 0 + deep_done = int(deep_done or 0) + my_videos = db.scalar( + select(func.count(Video.id)).join( + Subscription, + and_( + Subscription.channel_id == Video.channel_id, + Subscription.user_id == user.id, + Subscription.hidden.is_(False), + ), + ) + ) + return { + "channels_total": total, + "channels_details_synced": details_synced, + "channels_recent_synced": recent_synced, + "channels_deep_done": deep_done, + "channels_recent_pending": total - recent_synced, + "channels_deep_pending": total - deep_done, + "my_videos": my_videos or 0, + "quota_used_today": quota.units_used_today(db), + "quota_remaining_today": quota.remaining_today(db), + "paused": state.is_sync_paused(db), + } + + @router.post("/pause") def pause_sync( user: User = Depends(current_user), db: Session = Depends(get_db) diff --git a/backend/app/routes/tags.py b/backend/app/routes/tags.py index 0269f20..1eb8546 100644 --- a/backend/app/routes/tags.py +++ b/backend/app/routes/tags.py @@ -32,6 +32,66 @@ def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db)) ] +@router.post("") +def create_tag( + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + name = (payload.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name is required") + category = payload.get("category") or "other" + tag = Tag( + user_id=user.id, + name=name, + color=payload.get("color"), + category=category if category in ("language", "topic", "other") else "other", + ) + db.add(tag) + db.commit() + return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category} + + +def _own_tag(db: Session, user: User, tag_id: int) -> Tag: + tag = db.get(Tag, tag_id) + if tag is None or tag.user_id != user.id: + # System tags (user_id NULL) are not user-editable. + raise HTTPException(status_code=404, detail="Unknown tag") + return tag + + +@router.patch("/{tag_id}") +def update_tag( + tag_id: int, + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + tag = _own_tag(db, user, tag_id) + if "name" in payload: + name = (payload.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name cannot be empty") + tag.name = name + if "color" in payload: + tag.color = payload.get("color") + db.commit() + return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category} + + +@router.delete("/{tag_id}") +def delete_tag( + tag_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + tag = _own_tag(db, user, tag_id) + db.delete(tag) # ChannelTag rows cascade on tag delete. + db.commit() + return {"deleted": tag_id} + + @router.post("/recompute") def recompute( user: User = Depends(current_user), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9dd4d3c..23d4f13 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,17 +8,20 @@ import { saveLocalTheme, type ThemePrefs, } from "./lib/theme"; -import { hasFilterParams, paramsToFilters, syncUrl } from "./lib/urlState"; +import { hasFilterParams, paramsToFilters, readPage, syncUrl, type Page } from "./lib/urlState"; import { loadLayout, normalizeLayout, saveLayoutLocal, type SidebarLayout, } from "./lib/sidebarLayout"; +import { configureNotifications } from "./lib/notifications"; import Login from "./components/Login"; import Header from "./components/Header"; import Sidebar from "./components/Sidebar"; import Feed from "./components/Feed"; +import Channels from "./components/Channels"; +import SettingsPanel from "./components/SettingsPanel"; import Toaster from "./components/Toaster"; const DEFAULT_FILTERS: FeedFilters = { @@ -54,11 +57,18 @@ export default function App() { const [filters, setFiltersState] = useState(loadInitialFilters); const [view, setView] = useState<"grid" | "list">("grid"); const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout); + const [page, setPageState] = useState(readPage); + const [settingsOpen, setSettingsOpen] = useState(false); function setFilters(next: FeedFilters) { setFiltersState(next); localStorage.setItem(FILTERS_KEY, JSON.stringify(next)); - syncUrl(next); + syncUrl(next, page); + } + + function setPage(next: Page) { + setPageState(next); + syncUrl(filters, next); } function setSidebarLayout(next: SidebarLayout) { @@ -69,9 +79,8 @@ export default function App() { useEffect(() => applyTheme(theme), [theme]); - // Reflect the initial filters (from localStorage or URL) into the address bar - // so the URL is always shareable, even before the first filter change. - useEffect(() => syncUrl(filters), []); // eslint-disable-line react-hooks/exhaustive-deps + // Reflect the initial filters + page into the address bar so the URL is always shareable. + useEffect(() => syncUrl(filters, page), []); // eslint-disable-line react-hooks/exhaustive-deps const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me }); @@ -90,6 +99,7 @@ export default function App() { setSidebarLayoutState(l); saveLayoutLocal(l); } + if (prefs.notifications) configureNotifications(prefs.notifications); // eslint-disable-next-line react-hooks/exhaustive-deps }, [meQuery.data?.id]); @@ -124,18 +134,42 @@ export default function App() { setFilters={setFilters} view={view} setView={changeView} + page={page} + setPage={setPage} + onOpenSettings={() => setSettingsOpen(true)} />
- + {page === "feed" && ( + + )}
- + {page === "feed" ? ( + + ) : ( + { + setFilters({ ...filters, channelId: id, channelName: name, show: "all" }); + setPage("feed"); + }} + /> + )}
+ {settingsOpen && ( + setSettingsOpen(false)} + /> + )} ); diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx new file mode 100644 index 0000000..a5e30b6 --- /dev/null +++ b/frontend/src/components/Channels.tsx @@ -0,0 +1,261 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ArrowDown, + ArrowUp, + Eye, + EyeOff, + Plus, + RefreshCw, + Search, + X, +} from "lucide-react"; +import { api, type ManagedChannel, type Tag } from "../lib/api"; +import { notify } from "../lib/notifications"; + +export default function Channels({ + onViewChannel, +}: { + onViewChannel: (id: string, name: string) => void; +}) { + const qc = useQueryClient(); + const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); + const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); + const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); + const [q, setQ] = useState(""); + const [newTag, setNewTag] = useState(""); + + const invalidate = () => { + qc.invalidateQueries({ queryKey: ["channels"] }); + qc.invalidateQueries({ queryKey: ["tags"] }); + qc.invalidateQueries({ queryKey: ["my-status"] }); + }; + + const userTags = (tagsQuery.data ?? []).filter((t) => !t.system); + + const patch = useMutation({ + mutationFn: (v: { id: string; body: { priority?: number; hidden?: boolean } }) => + api.updateChannel(v.id, v.body), + onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }), + }); + const attach = useMutation({ + mutationFn: (v: { id: string; tagId: number }) => api.attachChannelTag(v.id, v.tagId), + onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }), + }); + const detach = useMutation({ + mutationFn: (v: { id: string; tagId: number }) => api.detachChannelTag(v.id, v.tagId), + onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }), + }); + const syncSubs = useMutation({ + mutationFn: () => api.syncSubscriptions(), + onSuccess: (r: { subscriptions?: number }) => { + invalidate(); + notify({ level: "success", message: `Synced ${r.subscriptions ?? 0} subscriptions` }); + }, + onError: () => notify({ level: "error", message: "Subscription sync failed" }), + }); + const createTag = useMutation({ + mutationFn: (name: string) => api.createTag({ name, category: "other" }), + onSuccess: () => { + setNewTag(""); + qc.invalidateQueries({ queryKey: ["tags"] }); + }, + }); + const deleteTag = useMutation({ + mutationFn: (id: number) => api.deleteTag(id), + onSuccess: () => invalidate(), + }); + + const channels = (channelsQuery.data ?? []).filter( + (c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase()) + ); + const s = statusQuery.data; + + return ( +
+ {/* Per-user sync status */} + {s && ( +
+ + + + + +
+ )} + + {/* Toolbar */} +
+
+ + setQ(e.target.value)} + placeholder="Filter channels…" + className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent" + /> +
+ +
+ + {/* Your tags */} +
+ Your tags + {userTags.map((t) => ( + + {t.name} + + + ))} +
{ + e.preventDefault(); + if (newTag.trim()) createTag.mutate(newTag.trim()); + }} + className="inline-flex items-center gap-1" + > + setNewTag(e.target.value)} + placeholder="new tag" + className="w-24 bg-card border border-border rounded-full px-2.5 py-1 text-xs outline-none focus:border-accent" + /> + +
+
+ + {/* Channel list */} + {channelsQuery.isLoading ? ( +
Loading channels…
+ ) : channels.length === 0 ? ( +
No channels.
+ ) : ( +
+ {channels.map((c) => ( + onViewChannel(c.id, c.title ?? "This channel")} + onPriority={(d) => patch.mutate({ id: c.id, body: { priority: c.priority + d } })} + onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })} + onToggleTag={(tagId) => + c.tag_ids.includes(tagId) + ? detach.mutate({ id: c.id, tagId }) + : attach.mutate({ id: c.id, tagId }) + } + /> + ))} +
+ )} +
+ ); +} + +function Stat({ label, value }: { label: string; value: string | number }) { + return ( + + {value} {label} + + ); +} + +function SyncBadge({ ok, label }: { ok: boolean; label: string }) { + return ( + + {label} + + ); +} + +function ChannelRow({ + c, + userTags, + onView, + onPriority, + onHide, + onToggleTag, +}: { + c: ManagedChannel; + userTags: Tag[]; + onView: () => void; + onPriority: (delta: number) => void; + onHide: () => void; + onToggleTag: (tagId: number) => void; +}) { + return ( +
+
+ + {c.priority} + +
+ + {c.thumbnail_url ? ( + + ) : ( +
+ )} + +
+ +
+ {c.stored_videos.toLocaleString()} stored + {c.subscriber_count != null && · {c.subscriber_count.toLocaleString()} subs} +
+
+ + + {userTags.map((t) => { + const on = c.tag_ids.includes(t.id); + return ( + + ); + })} +
+
+ + +
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 46f73c0..a5f2bd2 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,16 +1,20 @@ import { useRef, useState } from "react"; import { + Home, LayoutGrid, List, LogOut, Moon, Palette, Search, + Settings, Shield, Sun, + Tv, } from "lucide-react"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import type { FeedFilters, Me } from "../lib/api"; +import type { Page } from "../lib/urlState"; import SyncStatus from "./SyncStatus"; import NotificationCenter from "./NotificationCenter"; @@ -72,6 +76,9 @@ export default function Header({ setFilters, view, setView, + page, + setPage, + onOpenSettings, }: { me: Me; theme: ThemePrefs; @@ -80,6 +87,9 @@ export default function Header({ setFilters: (f: FeedFilters) => void; view: "grid" | "list"; setView: (v: "grid" | "list") => void; + page: Page; + setPage: (p: Page) => void; + onOpenSettings: () => void; }) { const [menuOpen, setMenuOpen] = useState(false); @@ -90,29 +100,39 @@ export default function Header({ return (
-
+
+ -
- - setFilters({ ...filters, q: e.target.value })} - placeholder="Search your subscriptions…" - className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent" - /> -
+ {page === "feed" ? ( +
+ + setFilters({ ...filters, q: e.target.value })} + placeholder="Search your subscriptions…" + className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent" + /> +
+ ) : ( +
Channel manager
+ )}
- setView(view === "grid" ? "list" : "grid")} - title={view === "grid" ? "List view" : "Grid view"} - > - {view === "grid" ? : } - + {page === "feed" && ( + setView(view === "grid" ? "list" : "grid")} + title={view === "grid" ? "List view" : "Grid view"} + > + {view === "grid" ? : } + + )} setTheme({ ...theme, mode: theme.mode === "dark" ? "light" : "dark" })} title="Toggle dark / light" @@ -134,7 +154,13 @@ export default function Header({
- +
@@ -151,7 +177,19 @@ function Avatar({ me, className = "" }: { me: Me; className?: string }) { ); } -function AccountMenu({ me, logout }: { me: Me; logout: () => void }) { +function AccountMenu({ + me, + logout, + page, + setPage, + onOpenSettings, +}: { + me: Me; + logout: () => void; + page: Page; + setPage: (p: Page) => void; + onOpenSettings: () => void; +}) { const [open, setOpen] = useState(false); const closeTimer = useRef | null>(null); @@ -163,6 +201,9 @@ function AccountMenu({ me, logout }: { me: Me; logout: () => void }) { closeTimer.current = setTimeout(() => setOpen(false), 220); } + const itemClass = + "w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"; + return (
void }) {
)} - +
+ {page === "channels" ? ( + + ) : ( + + )} + + +
)} diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..553af8a --- /dev/null +++ b/frontend/src/components/SettingsPanel.tsx @@ -0,0 +1,312 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Bell, Monitor, Pause, Play, RefreshCw, User, X } from "lucide-react"; +import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; +import { api, type Me } from "../lib/api"; +import { + configureNotifications, + getNotifSettings, + notify, + type NotifSettings, +} from "../lib/notifications"; + +type TabId = "appearance" | "notifications" | "sync" | "account"; +const TABS: { id: TabId; label: string; icon: typeof Monitor }[] = [ + { id: "appearance", label: "Appearance", icon: Monitor }, + { id: "notifications", label: "Notifications", icon: Bell }, + { id: "sync", label: "Sync", icon: RefreshCw }, + { id: "account", label: "Account", icon: User }, +]; + +export default function SettingsPanel({ + me, + theme, + setTheme, + view, + setView, + onClose, +}: { + me: Me; + theme: ThemePrefs; + setTheme: (t: ThemePrefs) => void; + view: "grid" | "list"; + setView: (v: "grid" | "list") => void; + onClose: () => void; +}) { + const [tab, setTab] = useState("appearance"); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onClose]); + + return ( +
+
+
+
+
Settings
+ +
+ +
+ {TABS.map((t) => ( + + ))} +
+ +
+ {tab === "appearance" && ( + + )} + {tab === "notifications" && } + {tab === "sync" && } + {tab === "account" && } +
+
+
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ); +} + +function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ); +} + +const PERF_KEY = "subfeed.perfMode"; + +function Appearance({ + theme, + setTheme, + view, + setView, +}: { + theme: ThemePrefs; + setTheme: (t: ThemePrefs) => void; + view: "grid" | "list"; + setView: (v: "grid" | "list") => void; +}) { + const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1"); + useEffect(() => { + document.documentElement.dataset.perf = perf ? "1" : ""; + }, [perf]); + function togglePerf(v: boolean) { + setPerf(v); + localStorage.setItem(PERF_KEY, v ? "1" : "0"); + api.savePrefs({ performanceMode: v }).catch(() => {}); + } + + return ( + <> +
+
+ {SCHEMES.map((s) => ( +
+
+ +
+ + setTheme({ ...theme, mode: v ? "dark" : "light" })} + /> + + + setView(v ? "list" : "grid")} /> + + + + +
+ +
+ setTheme({ ...theme, fontScale: Number(e.target.value) })} + className="w-full accent-accent" + /> +
{Math.round(theme.fontScale * 100)}%
+
+ + ); +} + +function Notifications() { + const [cfg, setCfg] = useState(() => getNotifSettings()); + function update(p: Partial) { + const next = { ...cfg, ...p }; + setCfg(next); + configureNotifications(next); + api.savePrefs({ notifications: next }).catch(() => {}); + } + return ( +
+ + update({ sound: v })} /> + +
+
+ Auto-dismiss + {(cfg.durationMs / 1000).toFixed(0)}s +
+ update({ durationMs: Number(e.target.value) })} + className="w-full accent-accent mt-1" + /> +
+ +
+ ); +} + +function Sync({ me }: { me: Me }) { + const qc = useQueryClient(); + const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); + const s = status.data; + const syncSubs = useMutation({ + mutationFn: () => api.syncSubscriptions(), + onSuccess: (r: { subscriptions?: number }) => { + qc.invalidateQueries({ queryKey: ["my-status"] }); + qc.invalidateQueries({ queryKey: ["channels"] }); + notify({ level: "success", message: `Synced ${r.subscriptions ?? 0} subscriptions` }); + }, + onError: () => notify({ level: "error", message: "Sync failed" }), + }); + const pauseResume = useMutation({ + mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), + onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }), + }); + + return ( + <> +
+ {s ? ( +
+ {s.channels_total} + {`${s.channels_recent_synced}/${s.channels_total}`} + {`${s.channels_deep_done}/${s.channels_total}`} + {s.my_videos.toLocaleString()} + {s.quota_remaining_today.toLocaleString()} +
+ ) : ( +
Loading…
+ )} +
+ +
+ +
+ + {me.role === "admin" && s && ( +
+ +
+ )} + + ); +} + +function Account({ me }: { me: Me }) { + return ( +
+
+ {me.avatar_url ? ( + + ) : ( +
+ {(me.display_name?.[0] ?? me.email[0] ?? "?").toUpperCase()} +
+ )} +
+
{me.display_name ?? me.email.split("@")[0]}
+
{me.email}
+
{me.role}
+
+
+

+ Playlist editing & YouTube export, and the admin approval queue, arrive in the next + phases of this milestone. +

+
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 093ca15..6ebd9fe 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -133,6 +133,35 @@ export interface SyncStatus { is_admin: boolean; } +export interface MyStatus { + channels_total: number; + channels_details_synced: number; + channels_recent_synced: number; + channels_deep_done: number; + channels_recent_pending: number; + channels_deep_pending: number; + my_videos: number; + quota_used_today: number; + quota_remaining_today: number; + paused: boolean; +} + +export interface ManagedChannel { + id: string; + title: string | null; + handle: string | null; + thumbnail_url: string | null; + subscriber_count: number | null; + video_count: number | null; + stored_videos: number; + priority: number; + hidden: boolean; + tag_ids: number[]; + details_synced: boolean; + recent_synced: boolean; + backfill_done: boolean; +} + export const api = { me: (): Promise => req("/api/me"), tags: (): Promise => req("/api/tags"), @@ -147,6 +176,24 @@ export const api = { resumeSync: () => req("/api/sync/resume", { method: "POST" }), savePrefs: (p: Record) => req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }), + + // --- channel manager --- + myStatus: (): Promise => req("/api/sync/my-status"), + channels: (): Promise => req("/api/channels"), + updateChannel: (id: string, patch: { priority?: number; hidden?: boolean }) => + req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), + attachChannelTag: (id: string, tagId: number) => + req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }), + detachChannelTag: (id: string, tagId: number) => + req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }), + syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }), + + // --- user tags --- + createTag: (t: { name: string; color?: string; category?: string }) => + req("/api/tags", { method: "POST", body: JSON.stringify(t) }), + updateTag: (id: number, patch: { name?: string; color?: string }) => + req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), + deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }), }; export { HttpError }; diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 71f25a2..c00e4ab 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -44,10 +44,58 @@ export interface NotifyInput { } const HISTORY_KEY = "subfeed.notifications"; +const SETTINGS_KEY = "subfeed.notifSettings"; const MAX_HISTORY = 100; -const DEFAULT_TTL = 6000; const ERROR_TTL = 15000; +// 6b: per-account notification settings (persisted by the Settings panel into the +// server `preferences`, mirrored to localStorage so they apply before login). +export interface NotifSettings { + sound: boolean; // play a short tone on attention-needing notifications + durationMs: number; // auto-dismiss time for info/success toasts +} +const DEFAULT_SETTINGS: NotifSettings = { sound: false, durationMs: 6000 }; +let config: NotifSettings = loadSettings(); + +function loadSettings(): NotifSettings { + try { + return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}") }; + } catch { + return DEFAULT_SETTINGS; + } +} + +export function getNotifSettings(): NotifSettings { + return config; +} + +export function configureNotifications(p: Partial): void { + config = { ...config, ...p }; + try { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(config)); + } catch { + /* ignore */ + } +} + +let audioCtx: AudioContext | null = null; +function beep(): void { + try { + const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + audioCtx = audioCtx || new Ctx(); + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.connect(gain); + gain.connect(audioCtx.destination); + osc.frequency.value = 660; + gain.gain.value = 0.04; + osc.start(); + osc.stop(audioCtx.currentTime + 0.12); + } catch { + /* autoplay blocked or unsupported — ignore */ + } +} + let items: Notification[] = load(); let listeners: Array<() => void> = []; let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1; @@ -107,7 +155,7 @@ export function notify(input: NotifyInput): number { ? undefined : level === "error" || level === "fatal" ? ERROR_TTL - : DEFAULT_TTL; + : config.durationMs; items = [ ...items, { @@ -125,6 +173,9 @@ export function notify(input: NotifyInput): number { }, ]; emit(); + if (config.sound && (requiresInteraction || level === "error" || level === "fatal")) { + beep(); + } if (duration) setTimeout(() => dismiss(id), duration); return id; } diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 3db19e2..c4fcb4f 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -74,9 +74,19 @@ export function hasFilterParams(params: URLSearchParams): boolean { return KEYS.some((k) => params.has(k)); } -/** Reflect the current filters into the address bar without adding history entries. */ -export function syncUrl(f: FeedFilters): void { - const qs = filtersToParams(f).toString(); +export type Page = "feed" | "channels"; + +export function readPage(): Page { + return new URLSearchParams(window.location.search).get("page") === "channels" + ? "channels" + : "feed"; +} + +/** Reflect the current filters + page into the address bar without adding history entries. */ +export function syncUrl(f: FeedFilters, page: Page = "feed"): void { + const params = filtersToParams(f); + if (page !== "feed") params.set("page", page); + const qs = params.toString(); const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname; window.history.replaceState(null, "", url); }