merge: M5a — channel manager, settings panel, per-user sync, liquid-glass UI

Channel manager (priority/hide/user-tags, per-channel sync state, view-in-feed) with
a 'Channel priority' feed sort; a tabbed slide-in Settings panel (appearance, 6b
notification settings, per-user sync status + actions, account); per-user sync status
endpoint; an app-wide toggleable hint/tooltip system (portal-rendered); and a
theme-aware liquid-glass design system applied across panels, popovers, toasts and
cards (78% frosted, perf-mode opt-out).
This commit is contained in:
npeter83 2026-06-11 22:52:23 +02:00
commit bbcfd46ea9
21 changed files with 1458 additions and 172 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

@ -208,6 +208,8 @@ SORTS = {
"duration_asc": Video.duration_seconds.asc().nulls_last(), "duration_asc": Video.duration_seconds.asc().nulls_last(),
"title": func.lower(Video.title).asc().nulls_last(), "title": func.lower(Video.title).asc().nulls_last(),
"subscribers": Channel.subscriber_count.desc().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,6 +224,11 @@ def get_feed(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
query, _status = _filtered_query(db, user, **params) query, _status = _filtered_query(db, user, **params)
if sort == "priority":
query = query.order_by(
Subscription.priority.desc(), Video.published_at.desc().nulls_last()
)
else:
order = SORTS.get(sort) order = SORTS.get(sort)
if order is None and sort == "shuffle": if order is None and sort == "shuffle":
order = func.md5(func.concat(Video.id, str(seed))) order = func.md5(func.concat(Video.id, str(seed)))

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,21 @@ 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 { setHintsEnabled } from "./lib/hints";
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 +58,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 +80,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 +100,10 @@ export default function App() {
setSidebarLayoutState(l); setSidebarLayoutState(l);
saveLayoutLocal(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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.id]); }, [meQuery.data?.id]);
@ -118,24 +132,44 @@ export default function App() {
<div className="h-screen flex flex-col bg-bg text-fg"> <div className="h-screen flex flex-col bg-bg text-fg">
<Header <Header
me={meQuery.data!} me={meQuery.data!}
theme={theme}
setTheme={setTheme}
filters={filters} filters={filters}
setFilters={setFilters} setFilters={setFilters}
view={view} page={page}
setView={changeView} 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,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 (
<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} hint="Channels you're subscribed to." />
<Stat
label="Recent synced"
value={`${s.channels_recent_synced}/${s.channels_total}`}
hint="Channels whose recent uploads are in the local DB and show in your feed."
/>
<Stat
label="Full history"
value={`${s.channels_deep_done}/${s.channels_total}`}
hint="Channels whose entire back-catalog is fetched. The rest backfill gradually."
/>
<Stat label="My videos" value={s.my_videos.toLocaleString()} hint="Total videos available across your channels." />
<Stat
label="Quota left"
value={s.quota_remaining_today.toLocaleString()}
hint="Shared YouTube API budget left today (resets midnight US Pacific)."
/>
</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>
<Tooltip
side="bottom"
hint="Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them."
>
<button
onClick={() => syncSubs.mutate()}
disabled={syncSubs.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
Sync subscriptions
</button>
</Tooltip>
</div>
<p className="text-xs text-muted mb-4 leading-relaxed">
Set a channel's <b className="text-fg/80">priority</b> to push its videos up when you sort
by Channel priority, attach your own <b className="text-fg/80">tags</b> to filter the feed,
or <b className="text-fg/80">hide</b> a channel to drop it from the feed without unsubscribing.
</p>
{/* Your tags */}
<div className="flex flex-wrap items-center gap-1.5 mb-4">
<Tooltip hint="Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)">
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
Your tags
</span>
</Tooltip>
{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,
hint,
}: {
label: string;
value: string | number;
hint?: string;
}) {
return (
<Tooltip hint={hint ?? ""}>
<span className={hint ? "cursor-help" : ""}>
<span className="text-fg font-semibold">{value}</span> {label}
</span>
</Tooltip>
);
}
function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) {
return (
<Tooltip hint={hint ?? ""}>
<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>
</Tooltip>
);
}
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={`glass-card glass-hover flex items-center gap-3 p-2.5 rounded-xl transition ${
c.hidden ? "opacity-60" : ""
}`}
>
<Tooltip hint="Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.">
<div className="flex flex-col items-center cursor-help">
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label="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" aria-label="Lower priority">
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
</Tooltip>
{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"
hint={c.recent_synced ? "Recent uploads synced." : "Recent uploads not fetched yet."}
/>
<SyncBadge
ok={c.backfill_done}
label="full"
hint={c.backfill_done ? "Full history fetched." : "Only recent videos so far; full history pending."}
/>
{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>
<Tooltip
hint={
c.hidden
? "Hidden — this channel's videos are kept out of your feed. Click to show them again."
: "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube."
}
>
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" aria-label={c.hidden ? "Unhide" : "Hide from feed"}>
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</Tooltip>
</div>
);
}

View file

@ -1,101 +1,43 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { import { Home, LogOut, Search, Settings, Shield, Tv } from "lucide-react";
LayoutGrid,
List,
LogOut,
Moon,
Palette,
Search,
Shield,
Sun,
} from "lucide-react";
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";
function IconBtn(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
const { className = "", ...rest } = props;
return (
<button
{...rest}
className={`p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition ${className}`}
/>
);
}
function ThemeMenu({
theme,
setTheme,
}: {
theme: ThemePrefs;
setTheme: (t: ThemePrefs) => void;
}) {
return (
<div className="absolute right-0 mt-2 w-60 rounded-xl border border-border bg-surface shadow-2xl p-3 z-30">
<div className="text-xs uppercase tracking-wide text-muted mb-2">Color scheme</div>
<div className="grid grid-cols-4 gap-2 mb-3">
{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>
<div className="text-xs uppercase tracking-wide text-muted mb-2">Text size</div>
<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>
</div>
);
}
export default function Header({ export default function Header({
me, me,
theme,
setTheme,
filters, filters,
setFilters, setFilters,
view, page,
setView, setPage,
onOpenSettings,
}: { }: {
me: Me; me: Me;
theme: ThemePrefs;
setTheme: (t: ThemePrefs) => void;
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
view: "grid" | "list"; page: Page;
setView: (v: "grid" | "list") => void; setPage: (p: Page) => void;
onOpenSettings: () => void;
}) { }) {
const [menuOpen, setMenuOpen] = useState(false);
async function logout() { async function logout() {
await fetch("/auth/logout", { method: "POST", credentials: "include" }); await fetch("/auth/logout", { method: "POST", credentials: "include" });
location.reload(); location.reload();
} }
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/95 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,36 +47,20 @@ 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">
<IconBtn
onClick={() => setView(view === "grid" ? "list" : "grid")}
title={view === "grid" ? "List view" : "Grid view"}
>
{view === "grid" ? <List className="w-5 h-5" /> : <LayoutGrid className="w-5 h-5" />}
</IconBtn>
<IconBtn
onClick={() => setTheme({ ...theme, mode: theme.mode === "dark" ? "light" : "dark" })}
title="Toggle dark / light"
>
{theme.mode === "dark" ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</IconBtn>
<div className="relative">
<IconBtn onClick={() => setMenuOpen((o) => !o)} title="Theme">
<Palette className="w-5 h-5" />
</IconBtn>
{menuOpen && (
<>
<div className="fixed inset-0 z-20" onClick={() => setMenuOpen(false)} />
<ThemeMenu theme={theme} setTheme={setTheme} />
</>
)}
</div>
<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 +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 [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -163,12 +101,11 @@ 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" onMouseEnter={openNow} onMouseLeave={closeSoon}>
className="relative"
onMouseEnter={openNow}
onMouseLeave={closeSoon}
>
<button <button
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
title={me.email} title={me.email}
@ -178,7 +115,7 @@ function AccountMenu({ me, logout }: { me: Me; logout: () => void }) {
</button> </button>
{open && ( {open && (
<div className="absolute right-0 mt-2 w-64 rounded-xl border border-border bg-surface shadow-2xl p-3 z-30"> <div className="glass absolute right-0 mt-2 w-64 rounded-xl p-3 z-30 animate-[popIn_0.16s_ease]">
<div className="flex items-center gap-3 pb-3 border-b border-border"> <div className="flex items-center gap-3 pb-3 border-b border-border">
<Avatar me={me} className="w-10 h-10 text-sm shrink-0" /> <Avatar me={me} className="w-10 h-10 text-sm shrink-0" />
<div className="min-w-0"> <div className="min-w-0">
@ -196,14 +133,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

@ -1,7 +1,7 @@
export default function Login() { export default function Login() {
return ( return (
<div className="min-h-screen grid place-items-center bg-bg text-fg"> <div className="min-h-screen grid place-items-center bg-bg text-fg">
<div className="w-[min(92vw,420px)] rounded-2xl border border-border bg-card/70 backdrop-blur p-10 text-center shadow-2xl"> <div className="glass w-[min(92vw,420px)] rounded-2xl p-10 text-center">
<div className="text-3xl font-bold tracking-tight"> <div className="text-3xl font-bold tracking-tight">
Sub<span className="text-accent">feed</span> Sub<span className="text-accent">feed</span>
</div> </div>

View file

@ -90,7 +90,7 @@ export default function NotificationCenter({
</button> </button>
{open && ( {open && (
<div className="absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl border border-border bg-surface shadow-2xl z-30 flex flex-col max-h-[70vh]"> <div className="glass absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl z-30 flex flex-col max-h-[70vh] animate-[popIn_0.16s_ease]">
<div className="flex items-center justify-between px-3 py-2 border-b border-border"> <div className="flex items-center justify-between px-3 py-2 border-b border-border">
<div className="text-sm font-semibold">Notifications</div> <div className="text-sm font-semibold">Notifications</div>
{notifications.length > 0 && ( {notifications.length > 0 && (

View file

@ -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<TabId>("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 (
<div className="fixed inset-0 z-40 flex justify-end">
<div
className={`absolute inset-0 bg-black/50 ${
closing ? "animate-[overlayOut_0.19s_ease_forwards]" : "animate-[overlayIn_0.2s_ease]"
}`}
onClick={close}
/>
<div
className={`glass relative self-start m-3 w-[min(94vw,520px)] max-h-[calc(100vh-1.5rem)] rounded-2xl overflow-hidden flex flex-col ${
closing
? "animate-[panelOut_0.19s_ease-in_forwards]"
: "animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]"
}`}
>
<div className="flex items-center justify-between px-4 h-14 border-b border-border/60 shrink-0">
<div className="text-base font-semibold">Settings</div>
<button
onClick={close}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex flex-1 min-h-0">
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
<nav className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1">
{TABS.map((t) => {
const active = tab === t.id;
return (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
active
? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
: "text-muted hover:text-fg hover:bg-card/60"
}`}
>
<t.icon className="w-4 h-4 shrink-0" />
{t.label}
</button>
);
})}
</nav>
{/* 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. */}
<div className="grid flex-1 overflow-y-auto">
{TABS.map((t) => (
<div
key={t.id}
className={`[grid-area:1/1] p-4 ${
tab === t.id ? "" : "invisible pointer-events-none"
}`}
>
{t.id === "appearance" && (
<Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
)}
{t.id === "notifications" && <Notifications />}
{t.id === "sync" && <Sync me={me} />}
{t.id === "account" && <Account me={me} />}
</div>
))}
</div>
</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,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
<Tooltip hint={hint ?? ""}>
<span
className={
hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""
}
>
{label}
</span>
</Tooltip>
{children}
</div>
);
}
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 shadow 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");
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 (
<>
<Section title="Color scheme">
<div className="grid grid-cols-4 gap-2">
{SCHEMES.map((s) => (
<Tooltip key={s.id} hint={s.name}>
<button
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
className={`h-9 w-full rounded-lg border-2 transition ${
theme.scheme === s.id ? "border-fg" : "border-transparent"
}`}
style={{ background: s.swatch }}
/>
</Tooltip>
))}
</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" hint="Show the feed as a compact list instead of a grid of cards.">
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
</Row>
<Row
label="Performance mode"
hint="Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines."
>
<Switch checked={perf} onChange={togglePerf} />
</Row>
<Row
label="Show hints"
hint="These little hover explanations. Turn them off once you know your way around."
>
<Switch checked={hints} onChange={toggleHints} />
</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(() => {});
}
const autoOn = cfg.durationMs > 0;
return (
<Section title="Notifications">
<Row
label="Sound on alerts"
hint="Play a short tone when a notification needs your attention (e.g. errors, prompts)."
>
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
</Row>
<Row
label="Auto-dismiss toasts"
hint="When off, pop-up toasts stay until you close them. When on, they fade after the set time."
>
<Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} />
</Row>
{autoOn && (
<div className="py-1.5 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted text-xs">Dismiss after</span>
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
</div>
<input
type="range"
min={2000}
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",
title: "Test notification",
message: "This is what a notification looks like.",
sound: true,
})
}
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" hint="How many channels you're subscribed to.">
{s.channels_total}
</Row>
<Row
label="Recent synced"
hint="Channels whose recent uploads (~last year) are already in the local database, so they show in your feed."
>
{`${s.channels_recent_synced}/${s.channels_total}`}
</Row>
<Row
label="Full history"
hint="Channels whose entire back-catalog has been fetched. The rest backfill gradually (opt in per channel)."
>
{`${s.channels_deep_done}/${s.channels_total}`}
</Row>
<Row label="My videos" hint="Total videos available to you across your subscribed channels.">
{s.my_videos.toLocaleString()}
</Row>
<Row
label="Quota left today"
hint="Shared YouTube API budget remaining today (resets midnight US Pacific). Backfill yields to it."
>
{s.quota_remaining_today.toLocaleString()}
</Row>
</div>
) : (
<div className="text-muted text-sm">Loading</div>
)}
</Section>
<Section title="Actions">
<Tooltip hint="Re-import your YouTube subscriptions and pull in any newly-followed channels.">
<button
onClick={() => syncSubs.mutate()}
disabled={syncSubs.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
Sync subscriptions
</button>
</Tooltip>
</Section>
{me.role === "admin" && s && (
<Section title="Admin">
<Tooltip hint="Pause or resume the background sync for the whole app (all users).">
<button
onClick={() => pauseResume.mutate(s.paused)}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition"
>
{s.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
{s.paused ? "Resume background sync" : "Pause background sync"}
</button>
</Tooltip>
</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

@ -41,6 +41,7 @@ const SORTS = [
{ id: "duration_asc", label: "Shortest" }, { id: "duration_asc", label: "Shortest" },
{ id: "title", label: "Name (AZ)" }, { id: "title", label: "Name (AZ)" },
{ id: "subscribers", label: "Channel subscribers" }, { id: "subscribers", label: "Channel subscribers" },
{ id: "priority", label: "Channel priority" },
{ id: "shuffle", label: "Surprise me" }, { id: "shuffle", label: "Surprise me" },
]; ];
@ -401,7 +402,7 @@ export default function Sidebar({
)} )}
{filters.channelId && !editing && ( {filters.channelId && !editing && (
<div className="rounded-xl border border-border bg-card/30"> <div className="glass-card rounded-xl">
<div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted">Channel</div> <div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted">Channel</div>
<div className="px-3 pb-3 pt-2"> <div className="px-3 pb-3 pt-2">
<button <button
@ -474,7 +475,7 @@ function WidgetCard({
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={`rounded-xl border border-border bg-card/30 ${ className={`glass-card rounded-xl ${
isDragging ? "relative z-10 shadow-2xl ring-1 ring-accent" : "" isDragging ? "relative z-10 shadow-2xl ring-1 ring-accent" : ""
} ${hidden && editing ? "opacity-50" : ""}`} } ${hidden && editing ? "opacity-50" : ""}`}
> >

View file

@ -35,7 +35,7 @@ export default function Toaster() {
return ( return (
<div <div
key={t.id} key={t.id}
className="relative overflow-hidden bg-surface border border-border rounded-xl shadow-2xl px-3 py-3 flex items-start gap-3" className="glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
> >
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} /> <Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">

View file

@ -0,0 +1,70 @@
import { useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { hintsEnabled, subscribeHints } from "../lib/hints";
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. Rendered in a portal with
* fixed positioning so it is never clipped by an overflow/stacking ancestor. */
export default function Tooltip({
hint,
side = "top",
children,
}: {
hint: string;
side?: Side;
children: React.ReactNode;
}) {
const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
const ref = useRef<HTMLSpanElement>(null);
const [coords, setCoords] = useState<Coords | null>(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}</>;
return (
<span
ref={ref}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
className="inline-flex"
>
{children}
{coords &&
createPortal(
<div
role="tooltip"
style={{
position: "fixed",
left: coords.left,
top: coords.top,
transform: `translateX(-50%) translateY(${coords.placement === "top" ? "-100%" : "0"})`,
}}
className="glass pointer-events-none z-[100] w-max max-w-[240px] px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
>
{hint}
</div>,
document.body
)}
</span>
);
}

View file

@ -170,7 +170,7 @@ export default function VideoCard({
return ( return (
<div <div
className={clsx( className={clsx(
"group rounded-2xl border border-border bg-card/50 p-2.5 shadow-sm transition-all duration-150 hover:-translate-y-1 hover:shadow-2xl hover:bg-card hover:border-accent/40", "group glass-card glass-hover rounded-2xl p-2.5 transition-all duration-150 hover:-translate-y-1",
watched && "opacity-55" watched && "opacity-55"
)} )}
> >

View file

@ -21,8 +21,81 @@ html {
body { body {
margin: 0; margin: 0;
background: var(--bg);
color: var(--fg); color: var(--fg);
/* Ambient backdrop so translucent "glass" surfaces have soft color to refract. */
background:
radial-gradient(1100px 620px at 12% -8%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 60%),
radial-gradient(1000px 700px at 112% 8%, color-mix(in srgb, var(--accent) 9%, transparent), transparent 55%),
var(--bg);
background-attachment: fixed;
}
/* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */
.glass {
/* Frosted overlay surface: translucent enough that the blurred backdrop shows
through (the glassy look), opaque enough that the background isn't distracting.
The strong blur turns whatever is behind into soft colour, not a sharp image. */
background: color-mix(in srgb, var(--surface) 78%, transparent);
backdrop-filter: blur(30px) saturate(1.8);
-webkit-backdrop-filter: blur(30px) saturate(1.8);
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff 16%, transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6);
}
.glass-card {
/* 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 8%, transparent),
0 8px 22px -14px rgba(0, 0, 0, 0.45);
}
.glass-hover:hover {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff 12%, transparent),
0 14px 30px -14px rgba(0, 0, 0, 0.5);
}
/* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */
html[data-perf="1"] .glass,
html[data-perf="1"] .glass-card {
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: var(--surface);
box-shadow: 0 2px 8px -4px rgba(0, 0, 0, 0.4);
}
html[data-perf="1"] body {
background: var(--bg);
}
/* ===== Motion ===== */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlayIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlayOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes panelIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes panelOut {
from { transform: translateX(0); }
to { transform: translateX(100%); }
}
@keyframes popIn {
from { opacity: 0; transform: translateY(-6px) scale(0.97); }
to { opacity: 1; transform: none; }
} }
/* ===== Color schemes (accent + neutrals), each with dark + light ===== */ /* ===== Color schemes (accent + neutrals), each with dark + light ===== */

View file

@ -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<any> { async function req(url: string, opts: RequestInit = {}): Promise<any> {
const method = opts.method ?? "GET"; const method = opts.method ?? "GET";
let r: Response; let r: Response;
@ -78,22 +88,16 @@ async function req(url: string, opts: RequestInit = {}): Promise<any> {
...opts, ...opts,
}); });
} catch (e) { } catch (e) {
// Network/connection failure — surface it in the notification center. notifyErrorThrottled(
notify({ "Connection lost",
level: "error", "Couldn't reach the server — it may be restarting. This will clear once it's back."
title: "Network error", );
message: `Couldn't reach the server (${method} ${url}).`,
});
throw e; throw e;
} }
if (!r.ok) { 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) { if (r.status >= 500) {
notify({ notifyErrorThrottled(`Server error ${r.status}`, `${method} ${url}`);
level: "error",
title: `Server error ${r.status}`,
message: `${method} ${url}`,
});
} }
throw new HttpError(r.status); throw new HttpError(r.status);
} }
@ -133,6 +137,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 +180,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 };

36
frontend/src/lib/hints.ts Normal file
View file

@ -0,0 +1,36 @@
// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory
// captions on hover so the UI is somewhat self-documenting for new users. Experienced
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
const KEY = "subfeed.hints";
let enabled = load();
let listeners: Array<() => void> = [];
function load(): boolean {
try {
return localStorage.getItem(KEY) !== "0"; // default ON
} catch {
return true;
}
}
export function hintsEnabled(): boolean {
return enabled;
}
export function setHintsEnabled(v: boolean): void {
enabled = v;
try {
localStorage.setItem(KEY, v ? "1" : "0");
} catch {
/* ignore */
}
listeners.forEach((l) => l());
}
export function subscribeHints(listener: () => void): () => void {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
}

View file

@ -41,13 +41,63 @@ export interface NotifyInput {
action?: NotifAction; action?: NotifAction;
meta?: NotifMeta; meta?: NotifMeta;
requiresInteraction?: boolean; requiresInteraction?: boolean;
sound?: boolean; // force the alert tone (when sound is enabled) even for an info toast
} }
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();
if (audioCtx.state === "suspended") void audioCtx.resume();
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;
@ -103,11 +153,14 @@ export function notify(input: NotifyInput): number {
const id = counter++; const id = counter++;
const requiresInteraction = input.requiresInteraction ?? false; const requiresInteraction = input.requiresInteraction ?? false;
const level = input.level ?? "info"; const level = input.level ?? "info";
// config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed).
const duration = requiresInteraction const duration = requiresInteraction
? undefined ? undefined
: level === "error" || level === "fatal" : level === "error" || level === "fatal"
? ERROR_TTL ? ERROR_TTL
: DEFAULT_TTL; : config.durationMs > 0
? config.durationMs
: undefined;
items = [ items = [
...items, ...items,
{ {
@ -125,6 +178,9 @@ export function notify(input: NotifyInput): number {
}, },
]; ];
emit(); emit();
if (config.sound && (input.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);
} }

View file

@ -2,10 +2,13 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
// During `vite dev` (host with Node), proxy API calls to the backend container. // 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 = { const proxy = {
"/api": "http://localhost:8080", "/api": target,
"/auth": "http://localhost:8080", "/auth": target,
"/healthz": "http://localhost:8080", "/healthz": target,
}; };
export default defineConfig({ export default defineConfig({