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/feed.py b/backend/app/routes/feed.py index 10af4de..2fc5044 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -208,6 +208,8 @@ SORTS = { "duration_asc": Video.duration_seconds.asc().nulls_last(), "title": func.lower(Video.title).asc().nulls_last(), "subscribers": Channel.subscriber_count.desc().nulls_last(), + # Your per-channel priority (set in the channel manager), newest first within a tier. + "priority": Subscription.priority.desc(), } @@ -222,10 +224,15 @@ def get_feed( db: Session = Depends(get_db), ) -> dict: query, _status = _filtered_query(db, user, **params) - order = SORTS.get(sort) - if order is None and sort == "shuffle": - order = func.md5(func.concat(Video.id, str(seed))) - query = query.order_by(order if order is not None else SORTS["newest"]) + if sort == "priority": + query = query.order_by( + Subscription.priority.desc(), Video.published_at.desc().nulls_last() + ) + else: + order = SORTS.get(sort) + if order is None and sort == "shuffle": + order = func.md5(func.concat(Video.id, str(seed))) + query = query.order_by(order if order is not None else SORTS["newest"]) rows = db.execute(query.offset(offset).limit(limit + 1)).all() has_more = len(rows) > limit 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..3569a9f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,17 +8,21 @@ 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 { setHintsEnabled } from "./lib/hints"; 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 +58,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 +80,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 +100,10 @@ export default function App() { setSidebarLayoutState(l); 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]); @@ -118,24 +132,44 @@ export default function App() {
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..4540535 --- /dev/null +++ b/frontend/src/components/Channels.tsx @@ -0,0 +1,319 @@ +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"; +import Tooltip from "./Tooltip"; + +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" + /> +
+ + + +
+ +

+ Set a channel's priority to push its videos up when you sort + by “Channel priority”, attach your own tags to filter the feed, + or hide a channel to drop it from the feed without unsubscribing. +

+ + {/* 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, + hint, +}: { + label: string; + value: string | number; + hint?: string; +}) { + return ( + + + {value} {label} + + + ); +} + +function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: 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..bd5ca99 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,140 +1,66 @@ import { useRef, useState } from "react"; -import { - LayoutGrid, - List, - LogOut, - Moon, - Palette, - Search, - Shield, - Sun, -} 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(); } 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" ? : } - - setTheme({ ...theme, mode: theme.mode === "dark" ? "light" : "dark" })} - title="Toggle dark / light" - > - {theme.mode === "dark" ? : } - -
- setMenuOpen((o) => !o)} title="Theme"> - - - {menuOpen && ( - <> -
setMenuOpen(false)} /> - - - )} -
- -
- +
@@ -151,7 +77,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,12 +101,11 @@ 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 ( -
+
{open && ( -
+
@@ -196,13 +133,27 @@ function AccountMenu({ me, logout }: { me: Me; logout: () => void }) {
)} - +
+ {page === "channels" ? ( + + ) : ( + + )} + + +
)}
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 new file mode 100644 index 0000000..51e01a2 --- /dev/null +++ b/frontend/src/components/SettingsPanel.tsx @@ -0,0 +1,427 @@ +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"; +import { api, type Me } from "../lib/api"; +import { + configureNotifications, + getNotifSettings, + 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 }[] = [ + { 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"); + const [closing, setClosing] = useState(false); + + const close = useCallback(() => { + setClosing(true); + setTimeout(onClose, 190); + }, [onClose]); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") close(); + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [close]); + + return ( +
+
+
+
+
Settings
+ +
+ +
+ {/* Vertical tab rail — never wraps; active is a clear accent pill. */} + + + {/* All tabs stacked in one grid cell so the panel sizes to the tallest tab + (stable height, no jump on switch); the active one is shown on top. */} +
+ {TABS.map((t) => ( +
+ {t.id === "appearance" && ( + + )} + {t.id === "notifications" && } + {t.id === "sync" && } + {t.id === "account" && } +
+ ))} +
+
+
+
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function Row({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+ + + {label} + + + {children} +
+ ); +} + +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"); + const hints = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled); + + 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(() => {}); + } + function toggleHints(v: boolean) { + setHintsEnabled(v); + api.savePrefs({ hints: 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(() => {}); + } + const autoOn = cfg.durationMs > 0; + + return ( +
+ + update({ sound: v })} /> + + + update({ durationMs: v ? 6000 : 0 })} /> + + {autoOn && ( +
+
+ Dismiss after + {(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/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d7258e6..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" }, ]; @@ -401,7 +402,7 @@ export default function Sidebar({ )} {filters.channelId && !editing && ( -
+
Channel