From bf54b4b5a505e927af990e39651650dfb9cf14c6 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 11 Jun 2026 20:45:48 +0200 Subject: [PATCH 1/9] 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); } From f8d2a11226a1a066a66ab54a111a015ec4ae64da Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 11 Jun 2026 21:08:35 +0200 Subject: [PATCH 2/9] feat(ui): liquid-glass design system, settings polish, hints, notif fixes - Add a theme-aware glass surface system (.glass/.glass-card + ambient backdrop, performance-mode opt-out) and apply it across panels, popovers, toasts, cards, sidebar widgets, channel rows, video cards and login. - SettingsPanel: slide in/out animation, glass styling, wrapping pill tabs (no horizontal scrollbar) with a prominent active state. - Notifications: auto-dismiss can be switched off (stays until closed); the test notification now also triggers the alert sound; resume a suspended AudioContext. - Add an app-wide, toggleable hint/tooltip system (lib/hints + Tooltip) and wire hints across the settings and channel-manager surfaces; persisted per account. --- frontend/src/App.tsx | 4 + frontend/src/components/Channels.tsx | 73 ++++-- frontend/src/components/Header.tsx | 4 +- frontend/src/components/Login.tsx | 2 +- .../src/components/NotificationCenter.tsx | 2 +- frontend/src/components/SettingsPanel.tsx | 246 +++++++++++++----- frontend/src/components/Sidebar.tsx | 4 +- frontend/src/components/Toaster.tsx | 2 +- frontend/src/components/Tooltip.tsx | 38 +++ frontend/src/components/VideoCard.tsx | 2 +- frontend/src/index.css | 71 ++++- frontend/src/lib/hints.ts | 36 +++ frontend/src/lib/notifications.ts | 6 +- 13 files changed, 388 insertions(+), 102 deletions(-) create mode 100644 frontend/src/components/Tooltip.tsx create mode 100644 frontend/src/lib/hints.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 23d4f13..039c216 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import { type SidebarLayout, } from "./lib/sidebarLayout"; import { configureNotifications } from "./lib/notifications"; +import { setHintsEnabled } from "./lib/hints"; import Login from "./components/Login"; import Header from "./components/Header"; import Sidebar from "./components/Sidebar"; @@ -100,6 +101,9 @@ export default function App() { saveLayoutLocal(l); } if (prefs.notifications) configureNotifications(prefs.notifications); + if (typeof prefs.hints === "boolean") setHintsEnabled(prefs.hints); + if (typeof prefs.performanceMode === "boolean") + document.documentElement.dataset.perf = prefs.performanceMode ? "1" : ""; // eslint-disable-next-line react-hooks/exhaustive-deps }, [meQuery.data?.id]); diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index a5e30b6..61f811b 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -12,6 +12,7 @@ import { } from "lucide-react"; import { api, type ManagedChannel, type Tag } from "../lib/api"; import { notify } from "../lib/notifications"; +import Tooltip from "./Tooltip"; export default function Channels({ onViewChannel, @@ -76,11 +77,23 @@ export default function Channels({ {/* Per-user sync status */} {s && (
- - - - - + + + + +
)} @@ -166,23 +179,35 @@ export default function Channels({ ); } -function Stat({ label, value }: { label: string; value: string | number }) { +function Stat({ + label, + value, + hint, +}: { + label: string; + value: string | number; + hint?: string; +}) { return ( - - {value} {label} - + + + {value} {label} + + ); } -function SyncBadge({ ok, label }: { ok: boolean; label: string }) { +function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) { return ( - - {label} - + + + {label} + + ); } @@ -203,7 +228,7 @@ function ChannelRow({ }) { return (
@@ -232,8 +257,16 @@ function ChannelRow({ {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 a5f2bd2..22f2dc9 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -36,7 +36,7 @@ function ThemeMenu({ setTheme: (t: ThemePrefs) => void; }) { return ( -
+
Color scheme
{SCHEMES.map((s) => ( @@ -219,7 +219,7 @@ function AccountMenu({ {open && ( -
+
diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index a79f418..3c20228 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -1,7 +1,7 @@ export default function Login() { return (
-
+
Subfeed
diff --git a/frontend/src/components/NotificationCenter.tsx b/frontend/src/components/NotificationCenter.tsx index 38bf67c..5ba96e4 100644 --- a/frontend/src/components/NotificationCenter.tsx +++ b/frontend/src/components/NotificationCenter.tsx @@ -90,7 +90,7 @@ export default function NotificationCenter({ {open && ( -
+
Notifications
{notifications.length > 0 && ( diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 553af8a..0959912 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState, useSyncExternalStore } 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"; @@ -9,6 +9,8 @@ import { notify, type NotifSettings, } from "../lib/notifications"; +import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints"; +import Tooltip from "./Tooltip"; type TabId = "appearance" | "notifications" | "sync" | "account"; const TABS: { id: TabId; label: string; icon: typeof Monitor }[] = [ @@ -34,39 +36,65 @@ export default function SettingsPanel({ onClose: () => void; }) { const [tab, setTab] = useState("appearance"); + const [closing, setClosing] = useState(false); + + const close = useCallback(() => { + setClosing(true); + setTimeout(onClose, 190); + }, [onClose]); useEffect(() => { function onKey(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") close(); } document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); - }, [onClose]); + }, [close]); return (
-
-
-
+
+
+
Settings
-
-
- {TABS.map((t) => ( - - ))} + {/* Wrapping pill tabs — no horizontal scrollbar, prominent active state. */} +
+ {TABS.map((t) => { + const active = tab === t.id; + return ( + + ); + })}
@@ -91,12 +119,28 @@ function Section({ title, children }: { title: string; children: React.ReactNode ); } -function Row({ label, children }: { label: string; children: React.ReactNode }) { +function Row({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { return ( -
@@ -294,7 +307,7 @@ function Notifications() { level: "info", title: "Test notification", message: "This is what a notification looks like.", - requiresInteraction: true, + sound: true, }) } className="mt-2 text-sm text-accent hover:underline" diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 8f01561..af58adb 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -41,6 +41,7 @@ const SORTS = [ { id: "duration_asc", label: "Shortest" }, { id: "title", label: "Name (A–Z)" }, { id: "subscribers", label: "Channel subscribers" }, + { id: "priority", label: "Channel priority" }, { id: "shuffle", label: "Surprise me" }, ]; diff --git a/frontend/src/components/Tooltip.tsx b/frontend/src/components/Tooltip.tsx index dfc3b75..3c77373 100644 --- a/frontend/src/components/Tooltip.tsx +++ b/frontend/src/components/Tooltip.tsx @@ -1,10 +1,13 @@ -import { useSyncExternalStore } from "react"; +import { useRef, useState, useSyncExternalStore } from "react"; +import { createPortal } from "react-dom"; import { hintsEnabled, subscribeHints } from "../lib/hints"; -type Side = "top" | "bottom" | "left"; +type Side = "top" | "bottom"; +type Coords = { left: number; top: number; placement: Side }; /** Wrap any element to show a short glass hint caption on hover — but only while the - * app-wide hints toggle (Settings → Appearance) is on. */ + * app-wide hints toggle (Settings → Appearance) is on. Rendered in a portal with + * fixed positioning so it is never clipped by an overflow/stacking ancestor. */ export default function Tooltip({ hint, side = "top", @@ -15,24 +18,53 @@ export default function Tooltip({ children: React.ReactNode; }) { const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled); + const ref = useRef(null); + const [coords, setCoords] = useState(null); + + function show() { + const el = ref.current; + if (!el) return; + const r = el.getBoundingClientRect(); + // Prefer the requested side; flip to bottom if there's no room above. + const placement: Side = side === "bottom" || r.top < 90 ? "bottom" : "top"; + setCoords({ + left: Math.min(Math.max(r.left + r.width / 2, 92), window.innerWidth - 92), + top: placement === "top" ? r.top - 8 : r.bottom + 8, + placement, + }); + } + function hide() { + setCoords(null); + } + if (!enabled || !hint) return <>{children}; - const place = - side === "bottom" - ? "top-full mt-2 left-1/2 -translate-x-1/2" - : side === "left" - ? "right-full mr-2 top-1/2 -translate-y-1/2" - : "bottom-full mb-2 left-1/2 -translate-x-1/2"; - return ( - + {children} - - {hint} - + {coords && + createPortal( +
+ {hint} +
, + document.body + )}
); } diff --git a/frontend/src/index.css b/frontend/src/index.css index e831bde..c26a98f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -32,19 +32,21 @@ body { /* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */ .glass { - background: color-mix(in srgb, var(--surface) 72%, transparent); - backdrop-filter: blur(18px) saturate(1.6); - -webkit-backdrop-filter: blur(18px) saturate(1.6); - border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); + /* Mostly opaque so overlay menus/panels stay readable over arbitrary content; + the blur + slight translucency keep the glassy feel. */ + background: color-mix(in srgb, var(--surface) 90%, transparent); + backdrop-filter: blur(24px) saturate(1.7); + -webkit-backdrop-filter: blur(24px) saturate(1.7); + border: 1px solid color-mix(in srgb, var(--border) 75%, transparent); box-shadow: - inset 0 1px 0 color-mix(in srgb, #fff 14%, transparent), - 0 18px 40px -16px rgba(0, 0, 0, 0.55); + inset 0 1px 0 color-mix(in srgb, #fff 15%, transparent), + 0 18px 44px -16px rgba(0, 0, 0, 0.6); } .glass-card { - background: color-mix(in srgb, var(--card) 58%, transparent); - backdrop-filter: blur(10px) saturate(1.3); - -webkit-backdrop-filter: blur(10px) saturate(1.3); - border: 1px solid color-mix(in srgb, var(--border) 55%, transparent); + background: color-mix(in srgb, var(--card) 78%, transparent); + backdrop-filter: blur(12px) saturate(1.3); + -webkit-backdrop-filter: blur(12px) saturate(1.3); + border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 9%, transparent), 0 8px 22px -14px rgba(0, 0, 0, 0.45); diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 09aa4b8..f131d39 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -41,6 +41,7 @@ export interface NotifyInput { action?: NotifAction; meta?: NotifMeta; requiresInteraction?: boolean; + sound?: boolean; // force the alert tone (when sound is enabled) even for an info toast } const HISTORY_KEY = "subfeed.notifications"; @@ -177,7 +178,7 @@ export function notify(input: NotifyInput): number { }, ]; emit(); - if (config.sound && (requiresInteraction || level === "error" || level === "fatal")) { + if (config.sound && (input.sound || requiresInteraction || level === "error" || level === "fatal")) { beep(); } if (duration) setTimeout(() => dismiss(id), duration); From 6245722aa274ee2f8c9c9d72a38ebf3a60f3df31 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 11 Jun 2026 22:00:57 +0200 Subject: [PATCH 4/9] fix(dev): vite proxy to 127.0.0.1 + throttle error notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev proxy targeted 'localhost', which Node can resolve to IPv6 ::1 that the Docker publish doesn't answer after a container recreate — every /api call failed, spamming 'Network error'. Pin the proxy to 127.0.0.1. Also collapse bursts of connection/5xx failures into one notification per 30s so a brief restart no longer floods the notification center. --- frontend/src/lib/api.ts | 28 ++++++++++++++++------------ frontend/vite.config.ts | 9 ++++++--- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6ebd9fe..ea3426a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -68,6 +68,16 @@ class HttpError extends Error { } } +// Collapse bursts of connection failures (e.g. while the server restarts) into a +// single notification rather than one per failed request. +let lastErrorNotifiedAt = 0; +function notifyErrorThrottled(title: string, message: string): void { + const now = Date.now(); + if (now - lastErrorNotifiedAt < 30_000) return; + lastErrorNotifiedAt = now; + notify({ level: "error", title, message }); +} + async function req(url: string, opts: RequestInit = {}): Promise { const method = opts.method ?? "GET"; let r: Response; @@ -78,22 +88,16 @@ async function req(url: string, opts: RequestInit = {}): Promise { ...opts, }); } catch (e) { - // Network/connection failure — surface it in the notification center. - notify({ - level: "error", - title: "Network error", - message: `Couldn't reach the server (${method} ${url}).`, - }); + notifyErrorThrottled( + "Connection lost", + "Couldn't reach the server — it may be restarting. This will clear once it's back." + ); throw e; } if (!r.ok) { - // Server faults are worth logging; 401/403/404 etc. are handled by callers. + // Server faults are worth surfacing; 401/403/404 etc. are handled by callers. if (r.status >= 500) { - notify({ - level: "error", - title: `Server error ${r.status}`, - message: `${method} ${url}`, - }); + notifyErrorThrottled(`Server error ${r.status}`, `${method} ${url}`); } throw new HttpError(r.status); } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 77d2204..1026675 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,10 +2,13 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; // During `vite dev` (host with Node), proxy API calls to the backend container. +// Use 127.0.0.1 (not "localhost") so Node doesn't resolve to IPv6 ::1, which the +// Docker port publish may not answer — that surfaces as proxy connection failures. +const target = "http://127.0.0.1:8080"; const proxy = { - "/api": "http://localhost:8080", - "/auth": "http://localhost:8080", - "/healthz": "http://localhost:8080", + "/api": target, + "/auth": target, + "/healthz": target, }; export default defineConfig({ From 38a6e132b031e8f46d0ce8ae2667cec5e1ad61fb Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 11 Jun 2026 22:04:01 +0200 Subject: [PATCH 5/9] perf(ui): drop backdrop-filter from bulk glass cards Video cards / channel rows / sidebar widgets render in bulk over a solid background where backdrop blur adds little but is GPU-expensive and triggers forced reflow (browser perf 'Violation' logs). Keep translucency + depth; reserve blur for the few .glass overlay surfaces (menus, panels, toasts, tooltips). --- frontend/src/index.css | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index c26a98f..6420ca0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -43,12 +43,13 @@ body { 0 18px 44px -16px rgba(0, 0, 0, 0.6); } .glass-card { - background: color-mix(in srgb, var(--card) 78%, transparent); - backdrop-filter: blur(12px) saturate(1.3); - -webkit-backdrop-filter: blur(12px) saturate(1.3); + /* No backdrop-filter here: cards render in bulk (feed grid) over a solid background + where blur adds almost nothing visually but is GPU-expensive and triggers reflow. + Translucency + border + depth keep the look; blur is reserved for .glass overlays. */ + background: color-mix(in srgb, var(--card) 84%, transparent); border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); box-shadow: - inset 0 1px 0 color-mix(in srgb, #fff 9%, transparent), + inset 0 1px 0 color-mix(in srgb, #fff 8%, transparent), 0 8px 22px -14px rgba(0, 0, 0, 0.45); } .glass-hover:hover { From a6a1052d3040c40b5882709440ff9785761fbdb3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 11 Jun 2026 22:16:07 +0200 Subject: [PATCH 6/9] fix(ui): stronger frosted glass + frosted settings backdrop; declutter header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increase .glass blur (32px) so overlay menus (account) read as proper frosted glass. - Blur the settings modal backdrop (overlay backdrop-blur) so the panel reliably frosts the content behind it instead of showing a sharp video through. - Remove the now-redundant header buttons (dark/light, color scheme, grid/list) — they live in Settings → Appearance. Header keeps search, sync, bell, account. --- frontend/src/App.tsx | 4 - frontend/src/components/Header.tsx | 108 +--------------------- frontend/src/components/SettingsPanel.tsx | 2 +- frontend/src/index.css | 10 +- 4 files changed, 8 insertions(+), 116 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 039c216..3569a9f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -132,12 +132,8 @@ export default function App() {
setSettingsOpen(true)} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 22f2dc9..2f7a5a8 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,98 +1,25 @@ 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 { Home, LogOut, Search, Settings, Shield, Tv } from "lucide-react"; import type { FeedFilters, Me } from "../lib/api"; import type { Page } from "../lib/urlState"; import SyncStatus from "./SyncStatus"; import NotificationCenter from "./NotificationCenter"; -function IconBtn(props: React.ButtonHTMLAttributes) { - const { className = "", ...rest } = props; - return ( -
-
Text size
- setTheme({ ...theme, fontScale: Number(e.target.value) })} - className="w-full accent-accent" - /> -
- {Math.round(theme.fontScale * 100)}% -
-
- ); -} - export default function Header({ me, - theme, - setTheme, filters, setFilters, - view, - setView, page, setPage, onOpenSettings, }: { me: Me; - theme: ThemePrefs; - setTheme: (t: ThemePrefs) => void; filters: FeedFilters; 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); - async function logout() { await fetch("/auth/logout", { method: "POST", credentials: "include" }); location.reload(); @@ -125,34 +52,7 @@ export default function Header({ )}
- {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" - > - {theme.mode === "dark" ? : } - -
- setMenuOpen((o) => !o)} title="Theme"> - - - {menuOpen && ( - <> -
setMenuOpen(false)} /> - - - )} -
- -
+