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.
This commit is contained in:
npeter83 2026-06-11 20:45:48 +02:00
parent 1630a3d8c9
commit b46c8300d1
11 changed files with 1063 additions and 45 deletions

View file

@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app import auth from app import auth
from app.config import settings 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 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(tags.router)
app.include_router(feed.router) app.include_router(feed.router)
app.include_router(me.router) app.include_router(me.router)
app.include_router(channels.router)
# The built SPA (populated by the Docker frontend build stage). # The built SPA (populated by the Docker frontend build stage).
STATIC_DIR = Path(__file__).parent / "static_spa" STATIC_DIR = Path(__file__).parent / "static_spa"

View file

@ -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}

View file

@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select from sqlalchemy import and_, case, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import quota, state 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") @router.post("/pause")
def pause_sync( def pause_sync(
user: User = Depends(current_user), db: Session = Depends(get_db) user: User = Depends(current_user), db: Session = Depends(get_db)

View file

@ -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") @router.post("/recompute")
def recompute( def recompute(
user: User = Depends(current_user), user: User = Depends(current_user),

View file

@ -8,17 +8,20 @@ import {
saveLocalTheme, saveLocalTheme,
type ThemePrefs, type ThemePrefs,
} from "./lib/theme"; } from "./lib/theme";
import { hasFilterParams, paramsToFilters, syncUrl } from "./lib/urlState"; import { hasFilterParams, paramsToFilters, readPage, syncUrl, type Page } from "./lib/urlState";
import { import {
loadLayout, loadLayout,
normalizeLayout, normalizeLayout,
saveLayoutLocal, saveLayoutLocal,
type SidebarLayout, type SidebarLayout,
} from "./lib/sidebarLayout"; } from "./lib/sidebarLayout";
import { configureNotifications } from "./lib/notifications";
import Login from "./components/Login"; import Login from "./components/Login";
import Header from "./components/Header"; import Header from "./components/Header";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed"; import Feed from "./components/Feed";
import Channels from "./components/Channels";
import SettingsPanel from "./components/SettingsPanel";
import Toaster from "./components/Toaster"; import Toaster from "./components/Toaster";
const DEFAULT_FILTERS: FeedFilters = { const DEFAULT_FILTERS: FeedFilters = {
@ -54,11 +57,18 @@ export default function App() {
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters); const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
const [view, setView] = useState<"grid" | "list">("grid"); const [view, setView] = useState<"grid" | "list">("grid");
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout); const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
const [page, setPageState] = useState<Page>(readPage);
const [settingsOpen, setSettingsOpen] = useState(false);
function setFilters(next: FeedFilters) { function setFilters(next: FeedFilters) {
setFiltersState(next); setFiltersState(next);
localStorage.setItem(FILTERS_KEY, JSON.stringify(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) { function setSidebarLayout(next: SidebarLayout) {
@ -69,9 +79,8 @@ export default function App() {
useEffect(() => applyTheme(theme), [theme]); useEffect(() => applyTheme(theme), [theme]);
// Reflect the initial filters (from localStorage or URL) into the address bar // Reflect the initial filters + page into the address bar so the URL is always shareable.
// so the URL is always shareable, even before the first filter change. useEffect(() => syncUrl(filters, page), []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => syncUrl(filters), []); // eslint-disable-line react-hooks/exhaustive-deps
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me }); const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
@ -90,6 +99,7 @@ export default function App() {
setSidebarLayoutState(l); setSidebarLayoutState(l);
saveLayoutLocal(l); saveLayoutLocal(l);
} }
if (prefs.notifications) configureNotifications(prefs.notifications);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.id]); }, [meQuery.data?.id]);
@ -124,18 +134,42 @@ export default function App() {
setFilters={setFilters} setFilters={setFilters}
view={view} view={view}
setView={changeView} setView={changeView}
page={page}
setPage={setPage}
onOpenSettings={() => setSettingsOpen(true)}
/> />
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
{page === "feed" && (
<Sidebar <Sidebar
filters={filters} filters={filters}
setFilters={setFilters} setFilters={setFilters}
layout={sidebarLayout} layout={sidebarLayout}
setLayout={setSidebarLayout} setLayout={setSidebarLayout}
/> />
)}
<main className="flex-1 min-w-0 overflow-y-auto"> <main className="flex-1 min-w-0 overflow-y-auto">
{page === "feed" ? (
<Feed filters={filters} setFilters={setFilters} view={view} /> <Feed filters={filters} setFilters={setFilters} view={view} />
) : (
<Channels
onViewChannel={(id, name) => {
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
setPage("feed");
}}
/>
)}
</main> </main>
</div> </div>
{settingsOpen && (
<SettingsPanel
me={meQuery.data!}
theme={theme}
setTheme={setTheme}
view={view}
setView={changeView}
onClose={() => setSettingsOpen(false)}
/>
)}
<Toaster /> <Toaster />
</div> </div>
); );

View file

@ -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 (
<div className="p-4 max-w-4xl mx-auto">
{/* Per-user sync status */}
{s && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted mb-4">
<Stat label="Channels" value={s.channels_total} />
<Stat label="Recent synced" value={`${s.channels_recent_synced}/${s.channels_total}`} />
<Stat label="Full history" value={`${s.channels_deep_done}/${s.channels_total}`} />
<Stat label="My videos" value={s.my_videos.toLocaleString()} />
<Stat label="Quota left" value={s.quota_remaining_today.toLocaleString()} />
</div>
)}
{/* Toolbar */}
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input
value={q}
onChange={(e) => 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"
/>
</div>
<button
onClick={() => syncSubs.mutate()}
disabled={syncSubs.isPending}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-sm hover:border-accent disabled:opacity-50 transition"
>
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
Sync subscriptions
</button>
</div>
{/* Your tags */}
<div className="flex flex-wrap items-center gap-1.5 mb-4">
<span className="text-xs uppercase tracking-wide text-muted mr-1">Your tags</span>
{userTags.map((t) => (
<span
key={t.id}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-card border border-border"
>
{t.name}
<button onClick={() => deleteTag.mutate(t.id)} className="text-muted hover:text-red-400">
<X className="w-3 h-3" />
</button>
</span>
))}
<form
onSubmit={(e) => {
e.preventDefault();
if (newTag.trim()) createTag.mutate(newTag.trim());
}}
className="inline-flex items-center gap-1"
>
<input
value={newTag}
onChange={(e) => 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"
/>
<button type="submit" className="text-muted hover:text-accent" title="Create tag">
<Plus className="w-4 h-4" />
</button>
</form>
</div>
{/* Channel list */}
{channelsQuery.isLoading ? (
<div className="text-muted py-8">Loading channels</div>
) : channels.length === 0 ? (
<div className="text-muted py-8">No channels.</div>
) : (
<div className="flex flex-col gap-1.5">
{channels.map((c) => (
<ChannelRow
key={c.id}
c={c}
userTags={userTags}
onView={() => 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 })
}
/>
))}
</div>
)}
</div>
);
}
function Stat({ label, value }: { label: string; value: string | number }) {
return (
<span>
<span className="text-fg font-semibold">{value}</span> {label}
</span>
);
}
function SyncBadge({ ok, label }: { ok: boolean; label: string }) {
return (
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full border ${
ok ? "border-accent/40 text-accent" : "border-border text-muted"
}`}
>
{label}
</span>
);
}
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 (
<div
className={`flex items-center gap-3 p-2.5 rounded-xl border border-border bg-card/30 ${
c.hidden ? "opacity-60" : ""
}`}
>
<div className="flex flex-col items-center">
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" title="Raise priority">
<ArrowUp className="w-3.5 h-3.5" />
</button>
<span className="text-xs text-muted tabular-nums">{c.priority}</span>
<button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" title="Lower priority">
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
{c.thumbnail_url ? (
<img src={c.thumbnail_url} alt="" className="w-10 h-10 rounded-full shrink-0" />
) : (
<div className="w-10 h-10 rounded-full bg-card shrink-0" />
)}
<div className="min-w-0 flex-1">
<button onClick={onView} className="text-sm font-semibold truncate hover:text-accent block max-w-full text-left">
{c.title ?? c.id}
</button>
<div className="flex items-center gap-2 text-[11px] text-muted">
<span>{c.stored_videos.toLocaleString()} stored</span>
{c.subscriber_count != null && <span>· {c.subscriber_count.toLocaleString()} subs</span>}
</div>
<div className="flex flex-wrap items-center gap-1 mt-1">
<SyncBadge ok={c.recent_synced} label="recent" />
<SyncBadge ok={c.backfill_done} label="full" />
{userTags.map((t) => {
const on = c.tag_ids.includes(t.id);
return (
<button
key={t.id}
onClick={() => onToggleTag(t.id)}
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition ${
on
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{t.name}
</button>
);
})}
</div>
</div>
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" title={c.hidden ? "Unhide" : "Hide from feed"}>
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
);
}

View file

@ -1,16 +1,20 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { import {
Home,
LayoutGrid, LayoutGrid,
List, List,
LogOut, LogOut,
Moon, Moon,
Palette, Palette,
Search, Search,
Settings,
Shield, Shield,
Sun, Sun,
Tv,
} from "lucide-react"; } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import type { FeedFilters, Me } from "../lib/api"; import type { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState";
import SyncStatus from "./SyncStatus"; import SyncStatus from "./SyncStatus";
import NotificationCenter from "./NotificationCenter"; import NotificationCenter from "./NotificationCenter";
@ -72,6 +76,9 @@ export default function Header({
setFilters, setFilters,
view, view,
setView, setView,
page,
setPage,
onOpenSettings,
}: { }: {
me: Me; me: Me;
theme: ThemePrefs; theme: ThemePrefs;
@ -80,6 +87,9 @@ export default function Header({
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
view: "grid" | "list"; view: "grid" | "list";
setView: (v: "grid" | "list") => void; setView: (v: "grid" | "list") => void;
page: Page;
setPage: (p: Page) => void;
onOpenSettings: () => void;
}) { }) {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@ -90,12 +100,17 @@ export default function Header({
return ( return (
<header className="h-14 shrink-0 border-b border-border bg-surface/80 backdrop-blur flex items-center gap-3 px-4 z-20"> <header className="h-14 shrink-0 border-b border-border bg-surface/80 backdrop-blur flex items-center gap-3 px-4 z-20">
<div className="text-xl font-bold tracking-tight select-none"> <button
onClick={() => setPage("feed")}
className="text-xl font-bold tracking-tight select-none"
title="Feed"
>
Sub<span className="text-accent">feed</span> Sub<span className="text-accent">feed</span>
</div> </button>
<SyncStatus /> <SyncStatus />
{page === "feed" ? (
<div className="flex-1 max-w-xl mx-auto relative"> <div className="flex-1 max-w-xl mx-auto relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" /> <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input <input
@ -105,14 +120,19 @@ export default function Header({
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent" className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
/> />
</div> </div>
) : (
<div className="flex-1 text-center text-sm font-semibold">Channel manager</div>
)}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{page === "feed" && (
<IconBtn <IconBtn
onClick={() => setView(view === "grid" ? "list" : "grid")} onClick={() => setView(view === "grid" ? "list" : "grid")}
title={view === "grid" ? "List view" : "Grid view"} title={view === "grid" ? "List view" : "Grid view"}
> >
{view === "grid" ? <List className="w-5 h-5" /> : <LayoutGrid className="w-5 h-5" />} {view === "grid" ? <List className="w-5 h-5" /> : <LayoutGrid className="w-5 h-5" />}
</IconBtn> </IconBtn>
)}
<IconBtn <IconBtn
onClick={() => setTheme({ ...theme, mode: theme.mode === "dark" ? "light" : "dark" })} onClick={() => setTheme({ ...theme, mode: theme.mode === "dark" ? "light" : "dark" })}
title="Toggle dark / light" title="Toggle dark / light"
@ -134,7 +154,13 @@ export default function Header({
<NotificationCenter filters={filters} setFilters={setFilters} /> <NotificationCenter filters={filters} setFilters={setFilters} />
<div className="pl-1"> <div className="pl-1">
<AccountMenu me={me} logout={logout} /> <AccountMenu
me={me}
logout={logout}
page={page}
setPage={setPage}
onOpenSettings={onOpenSettings}
/>
</div> </div>
</div> </div>
</header> </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 [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -163,6 +201,9 @@ function AccountMenu({ me, logout }: { me: Me; logout: () => void }) {
closeTimer.current = setTimeout(() => setOpen(false), 220); 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 ( return (
<div <div
className="relative" className="relative"
@ -196,14 +237,28 @@ function AccountMenu({ me, logout }: { me: Me; logout: () => void }) {
</div> </div>
)} )}
<button <div className="mt-2 flex flex-col">
onClick={logout} {page === "channels" ? (
className="mt-2 w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition" <button onClick={() => { setPage("feed"); setOpen(false); }} className={itemClass}>
> <Home className="w-4 h-4" />
Feed
</button>
) : (
<button onClick={() => { setPage("channels"); setOpen(false); }} className={itemClass}>
<Tv className="w-4 h-4" />
Channels
</button>
)}
<button onClick={() => { onOpenSettings(); setOpen(false); }} className={itemClass}>
<Settings className="w-4 h-4" />
Settings
</button>
<button onClick={logout} className={itemClass}>
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
Sign out Sign out
</button> </button>
</div> </div>
</div>
)} )}
</div> </div>
); );

View file

@ -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<TabId>("appearance");
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div className="fixed inset-0 z-40 flex justify-end">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative w-[min(92vw,440px)] h-full bg-surface border-l border-border shadow-2xl flex flex-col animate-[fadeIn_0.15s_ease]">
<div className="flex items-center justify-between px-4 h-14 border-b border-border shrink-0">
<div className="text-base font-semibold">Settings</div>
<button onClick={onClose} className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex gap-1 px-3 py-2 border-b border-border shrink-0 overflow-x-auto">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm whitespace-nowrap transition ${
tab === t.id ? "bg-card text-fg" : "text-muted hover:text-fg"
}`}
>
<t.icon className="w-4 h-4" />
{t.label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-4">
{tab === "appearance" && (
<Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
)}
{tab === "notifications" && <Notifications />}
{tab === "sync" && <Sync me={me} />}
{tab === "account" && <Account me={me} />}
</div>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-6">
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
{children}
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex items-center justify-between gap-3 py-1.5 text-sm">
<span>{label}</span>
{children}
</label>
);
}
function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
);
}
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 (
<>
<Section title="Color scheme">
<div className="grid grid-cols-4 gap-2">
{SCHEMES.map((s) => (
<button
key={s.id}
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
title={s.name}
className={`h-9 rounded-lg border-2 transition ${
theme.scheme === s.id ? "border-fg" : "border-transparent"
}`}
style={{ background: s.swatch }}
/>
))}
</div>
</Section>
<Section title="Display">
<Row label="Dark mode">
<Switch
checked={theme.mode === "dark"}
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/>
</Row>
<Row label="List view">
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
</Row>
<Row label="Performance mode">
<Switch checked={perf} onChange={togglePerf} />
</Row>
</Section>
<Section title="Text size">
<input
type="range"
min={0.9}
max={1.3}
step={0.02}
value={theme.fontScale}
onChange={(e) => setTheme({ ...theme, fontScale: Number(e.target.value) })}
className="w-full accent-accent"
/>
<div className="text-right text-xs text-muted">{Math.round(theme.fontScale * 100)}%</div>
</Section>
</>
);
}
function Notifications() {
const [cfg, setCfg] = useState<NotifSettings>(() => getNotifSettings());
function update(p: Partial<NotifSettings>) {
const next = { ...cfg, ...p };
setCfg(next);
configureNotifications(next);
api.savePrefs({ notifications: next }).catch(() => {});
}
return (
<Section title="Notifications">
<Row label="Sound on attention-needing alerts">
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
</Row>
<div className="py-1.5 text-sm">
<div className="flex items-center justify-between">
<span>Auto-dismiss</span>
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
</div>
<input
type="range"
min={3000}
max={15000}
step={1000}
value={cfg.durationMs}
onChange={(e) => update({ durationMs: Number(e.target.value) })}
className="w-full accent-accent mt-1"
/>
</div>
<button
onClick={() => notify({ level: "info", message: "This is a test notification." })}
className="mt-2 text-sm text-accent hover:underline"
>
Send a test notification
</button>
</Section>
);
}
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 (
<>
<Section title="My sync status">
{s ? (
<div className="space-y-1.5 text-sm">
<Row label="Channels">{s.channels_total}</Row>
<Row label="Recent synced">{`${s.channels_recent_synced}/${s.channels_total}`}</Row>
<Row label="Full history">{`${s.channels_deep_done}/${s.channels_total}`}</Row>
<Row label="My videos">{s.my_videos.toLocaleString()}</Row>
<Row label="Quota left today">{s.quota_remaining_today.toLocaleString()}</Row>
</div>
) : (
<div className="text-muted text-sm">Loading</div>
)}
</Section>
<Section title="Actions">
<button
onClick={() => syncSubs.mutate()}
disabled={syncSubs.isPending}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-sm hover:border-accent disabled:opacity-50 transition"
>
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
Sync subscriptions
</button>
</Section>
{me.role === "admin" && s && (
<Section title="Admin">
<button
onClick={() => pauseResume.mutate(s.paused)}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-sm hover:border-accent transition"
>
{s.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
{s.paused ? "Resume background sync" : "Pause background sync"}
</button>
</Section>
)}
</>
);
}
function Account({ me }: { me: Me }) {
return (
<Section title="Account">
<div className="flex items-center gap-3 mb-3">
{me.avatar_url ? (
<img src={me.avatar_url} alt="" className="w-12 h-12 rounded-full" />
) : (
<div className="w-12 h-12 rounded-full bg-card grid place-items-center font-semibold">
{(me.display_name?.[0] ?? me.email[0] ?? "?").toUpperCase()}
</div>
)}
<div className="min-w-0">
<div className="font-semibold truncate">{me.display_name ?? me.email.split("@")[0]}</div>
<div className="text-xs text-muted truncate">{me.email}</div>
<div className="text-xs text-muted capitalize">{me.role}</div>
</div>
</div>
<p className="text-xs text-muted leading-relaxed">
Playlist editing &amp; YouTube export, and the admin approval queue, arrive in the next
phases of this milestone.
</p>
</Section>
);
}

View file

@ -133,6 +133,35 @@ export interface SyncStatus {
is_admin: boolean; 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 = { export const api = {
me: (): Promise<Me> => req("/api/me"), me: (): Promise<Me> => req("/api/me"),
tags: (): Promise<Tag[]> => req("/api/tags"), tags: (): Promise<Tag[]> => req("/api/tags"),
@ -147,6 +176,24 @@ export const api = {
resumeSync: () => req("/api/sync/resume", { method: "POST" }), resumeSync: () => req("/api/sync/resume", { method: "POST" }),
savePrefs: (p: Record<string, any>) => savePrefs: (p: Record<string, any>) =>
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }), req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }),
// --- channel manager ---
myStatus: (): Promise<MyStatus> => req("/api/sync/my-status"),
channels: (): Promise<ManagedChannel[]> => 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 }; export { HttpError };

View file

@ -44,10 +44,58 @@ export interface NotifyInput {
} }
const HISTORY_KEY = "subfeed.notifications"; const HISTORY_KEY = "subfeed.notifications";
const SETTINGS_KEY = "subfeed.notifSettings";
const MAX_HISTORY = 100; const MAX_HISTORY = 100;
const DEFAULT_TTL = 6000;
const ERROR_TTL = 15000; 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<NotifSettings>): 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 items: Notification[] = load();
let listeners: Array<() => void> = []; let listeners: Array<() => void> = [];
let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1; let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1;
@ -107,7 +155,7 @@ export function notify(input: NotifyInput): number {
? undefined ? undefined
: level === "error" || level === "fatal" : level === "error" || level === "fatal"
? ERROR_TTL ? ERROR_TTL
: DEFAULT_TTL; : config.durationMs;
items = [ items = [
...items, ...items,
{ {
@ -125,6 +173,9 @@ export function notify(input: NotifyInput): number {
}, },
]; ];
emit(); emit();
if (config.sound && (requiresInteraction || level === "error" || level === "fatal")) {
beep();
}
if (duration) setTimeout(() => dismiss(id), duration); if (duration) setTimeout(() => dismiss(id), duration);
return id; return id;
} }

View file

@ -74,9 +74,19 @@ export function hasFilterParams(params: URLSearchParams): boolean {
return KEYS.some((k) => params.has(k)); return KEYS.some((k) => params.has(k));
} }
/** Reflect the current filters into the address bar without adding history entries. */ export type Page = "feed" | "channels";
export function syncUrl(f: FeedFilters): void {
const qs = filtersToParams(f).toString(); 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; const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
window.history.replaceState(null, "", url); window.history.replaceState(null, "", url);
} }