Merge: promote dev to prod
Some checks failed
CI / frontend (push) Failing after 43s
CI / backend (push) Failing after 40s

This commit is contained in:
npeter83 2026-06-16 02:16:33 +02:00
commit f97b4aefe1
21 changed files with 565 additions and 279 deletions

View file

@ -1 +1 @@
0.3.0
0.4.0

View file

@ -16,6 +16,9 @@ from app.security import encrypt
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
# How many recently-used accounts to keep switchable in one browser session.
MAX_SESSION_ACCOUNTS = 6
# Base sign-in requests only the non-sensitive OpenID scopes (profile/email). These do
# NOT trigger Google's "unverified app" warning and do NOT expire after 7 days, so a new
# user gets a clean, familiar consent. YouTube access is granted later, one step at a
@ -175,6 +178,12 @@ async def callback(
db.add(tok)
db.commit()
# Multi-session: remember every account that has authenticated in this browser so the
# user can switch between them without going through Google again. Only accounts that
# actually completed OAuth here land in this list (it's the proof they may be switched to).
accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id]
accounts.append(user.id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
request.session["user_id"] = user.id
log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role)
return RedirectResponse(url="/")
@ -182,8 +191,16 @@ async def callback(
@router.post("/logout")
async def logout(request: Request):
"""Sign out the active account. If other accounts have authenticated in this browser,
switch to the most recent one instead of fully logging out."""
active = request.session.get("user_id")
remaining = [a for a in (request.session.get("account_ids") or []) if a != active]
if remaining:
request.session["account_ids"] = remaining
request.session["user_id"] = remaining[-1]
return JSONResponse({"ok": True, "switched": True})
request.session.clear()
return JSONResponse({"ok": True})
return JSONResponse({"ok": True, "switched": False})
@router.post("/request-access")
@ -215,6 +232,12 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
if user is None:
request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated")
# Always keep the active account in the switchable list — covers sessions created before
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
accounts = request.session.get("account_ids") or []
if user_id not in accounts:
accounts.append(user_id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
return user

View file

@ -1,14 +1,62 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth import current_user, has_read_scope, has_write_scope
from app.auth import current_user, has_read_scope, has_write_scope, is_allowed
from app.db import get_db
from app.models import Invite, User
router = APIRouter(prefix="/api/me", tags=["me"])
@router.get("/accounts")
def my_accounts(
request: Request,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> list[dict]:
"""The accounts that have authenticated in this browser session (switchable without a
new Google sign-in). The active one is flagged."""
ids = request.session.get("account_ids") or [user.id]
rows = {u.id: u for u in db.query(User).filter(User.id.in_(ids)).all()}
out = []
for uid in ids: # preserve recency order from the session
u = rows.get(uid)
if u is not None:
out.append(
{
"id": u.id,
"email": u.email,
"display_name": u.display_name,
"avatar_url": u.avatar_url,
"active": u.id == user.id,
}
)
return out
@router.post("/switch")
def switch_account(
payload: dict,
request: Request,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Switch the active account to another one already authenticated in this browser. No
Google round-trip but only accounts present in the session list (proof they signed in
here) are allowed, and access is re-checked in case the invite was revoked since."""
target = payload.get("user_id")
if target not in (request.session.get("account_ids") or []):
raise HTTPException(status_code=403, detail="Not an account you've signed into here.")
u = db.get(User, target)
if u is None:
raise HTTPException(status_code=404, detail="That account no longer exists.")
if not is_allowed(db, u.email):
raise HTTPException(status_code=403, detail="That account no longer has access.")
request.session["user_id"] = target
return {"ok": True, "user_id": target}
@router.get("")
def get_me(
user: User = Depends(current_user), db: Session = Depends(get_db)

View file

@ -21,6 +21,7 @@ import { configureNotifications, notify } from "./lib/notifications";
import { setHintsEnabled } from "./lib/hints";
import Login from "./components/Login";
import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed";
import Channels, { type ChannelStatusFilter } from "./components/Channels";
@ -56,7 +57,13 @@ function loadInitialPage(): Page {
const params = new URLSearchParams(window.location.search);
if (params.has("page")) return readPage();
const stored = localStorage.getItem(PAGE_KEY);
if (stored === "channels" || stored === "stats" || stored === "playlists") return stored;
if (
stored === "channels" ||
stored === "stats" ||
stored === "playlists" ||
stored === "settings"
)
return stored;
return "feed";
}
@ -82,7 +89,6 @@ export default function App() {
const [view, setView] = useState<"grid" | "list">("grid");
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
const [page, setPageState] = useState<Page>(loadInitialPage);
const [settingsOpen, setSettingsOpen] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all");
const [aboutOpen, setAboutOpen] = useState(false);
@ -100,8 +106,13 @@ export default function App() {
}
function setPage(next: Page) {
if (next === page) return;
setPageState(next);
localStorage.setItem(PAGE_KEY, next);
// Push an in-app history entry so the browser/mouse Back button steps through pages
// instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean —
// the page rides in history.state, not the query string (filters never go in the URL).
window.history.pushState({ ...window.history.state, sfPage: next }, "");
}
function setSidebarLayout(next: SidebarLayout) {
@ -123,6 +134,23 @@ export default function App() {
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// In-app Back/Forward: stamp the initial entry with the current page, then sync `page`
// from history.state on popstate. Runs after the strip-params effect above (which resets
// history.state to null), so the initial stamp isn't clobbered.
useEffect(() => {
window.history.replaceState(
{ ...window.history.state, sfPage: page },
""
);
function onPop(e: PopStateEvent) {
const p = (e.state?.sfPage as Page) ?? "feed";
setPageState(p);
localStorage.setItem(PAGE_KEY, p);
}
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after
@ -192,32 +220,36 @@ export default function App() {
);
return (
<div className="h-screen flex flex-col bg-bg text-fg">
<Header
<div className="h-screen flex bg-bg text-fg">
<NavSidebar
me={meQuery.data!}
filters={filters}
setFilters={setFilters}
page={page}
setPage={setPage}
onOpenSettings={() => setSettingsOpen(true)}
onOpenAbout={() => setAboutOpen(true)}
onChangeLanguage={changeLanguage}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setPage("channels");
}}
/>
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
<div className="flex flex-1 min-h-0">
{page === "feed" && (
<Sidebar
filters={filters}
setFilters={setFilters}
layout={sidebarLayout}
setLayout={setSidebarLayout}
/>
)}
<main className="flex-1 min-w-0 overflow-y-auto">
<div className="flex-1 min-w-0 flex flex-col">
<Header
me={meQuery.data!}
filters={filters}
setFilters={setFilters}
page={page}
onChangeLanguage={changeLanguage}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setPage("channels");
}}
/>
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
<div className="flex flex-1 min-h-0">
{page === "feed" && (
<Sidebar
filters={filters}
setFilters={setFilters}
layout={sidebarLayout}
setLayout={setSidebarLayout}
/>
)}
<main className="flex-1 min-w-0 overflow-y-auto">
{page === "channels" ? (
<Channels
canWrite={meQuery.data!.can_write}
@ -233,6 +265,15 @@ export default function App() {
<Stats />
) : page === "playlists" ? (
<Playlists canWrite={meQuery.data!.can_write} />
) : page === "settings" ? (
<SettingsPanel
me={meQuery.data!}
theme={theme}
setTheme={setTheme}
view={view}
setView={changeView}
onOpenWizard={() => setWizardOpen(true)}
/>
) : (
<Feed
filters={filters}
@ -242,22 +283,9 @@ export default function App() {
onOpenWizard={() => setWizardOpen(true)}
/>
)}
</main>
</main>
</div>
</div>
{settingsOpen && (
<SettingsPanel
me={meQuery.data!}
theme={theme}
setTheme={setTheme}
view={view}
setView={changeView}
onClose={() => setSettingsOpen(false)}
onOpenWizard={() => {
setSettingsOpen(false);
setWizardOpen(true);
}}
/>
)}
{wizardOpen && meQuery.data && (
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
)}

View file

@ -139,7 +139,7 @@ export default function AddToPlaylist({
<div
ref={panelRef}
style={{ position: "fixed", top: coords.top, left: coords.left, width: 240 }}
className="z-50 glass rounded-xl p-2 shadow-2xl"
className="z-50 glass-menu rounded-xl p-2 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="text-xs text-muted px-1.5 pb-1.5">

View file

@ -1,22 +1,20 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3, Home, Info, Library, ListVideo, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react";
import { Library, Search, User } from "lucide-react";
import type { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState";
import { type LangCode } from "../i18n";
import SyncStatus from "./SyncStatus";
import NotificationCenter from "./NotificationCenter";
import LanguageSwitcher from "./LanguageSwitcher";
import AvatarImg from "./Avatar";
// Contextual top bar. Primary navigation + account now live in the left NavSidebar; the
// header carries the global sync status, the feed's scope toggle + search (or the current
// page title), the language switcher and the notification bell.
export default function Header({
me,
filters,
setFilters,
page,
setPage,
onOpenSettings,
onOpenAbout,
onChangeLanguage,
onGoToFullHistory,
}: {
@ -24,29 +22,13 @@ export default function Header({
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
page: Page;
setPage: (p: Page) => void;
onOpenSettings: () => void;
onOpenAbout: () => void;
onChangeLanguage: (code: LangCode) => void;
onGoToFullHistory: () => void;
}) {
const { t, i18n } = useTranslation();
async function logout() {
await fetch("/auth/logout", { method: "POST", credentials: "include" });
location.reload();
}
return (
<header className="h-14 shrink-0 border-b border-border bg-surface/95 flex items-center gap-3 px-4 z-20">
<button
onClick={() => setPage("feed")}
className="text-xl font-bold tracking-tight select-none"
title={t("header.feed")}
>
Sift<span className="text-accent">lode</span>
</button>
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
{page === "feed" && (
@ -94,137 +76,16 @@ export default function Header({
? t("header.usageStats")
: page === "playlists"
? t("header.account.playlists")
: t("header.channelManager")}
: page === "settings"
? t("settings.title")
: t("header.channelManager")}
</div>
)}
<div className="flex items-center gap-1">
<LanguageSwitcher value={i18n.language as LangCode} onChange={onChangeLanguage} />
<NotificationCenter filters={filters} setFilters={setFilters} />
<div className="pl-1">
<AccountMenu
me={me}
logout={logout}
page={page}
setPage={setPage}
onOpenSettings={onOpenSettings}
onOpenAbout={onOpenAbout}
/>
</div>
</div>
</header>
);
}
function Avatar({ me, className = "" }: { me: Me; className?: string }) {
return (
<AvatarImg
src={me.avatar_url}
fallback={me.display_name ?? me.email}
className={`rounded-full ${className}`}
/>
);
}
function AccountMenu({
me,
logout,
page,
setPage,
onOpenSettings,
onOpenAbout,
}: {
me: Me;
logout: () => void;
page: Page;
setPage: (p: Page) => void;
onOpenSettings: () => void;
onOpenAbout: () => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
function openNow() {
if (closeTimer.current) clearTimeout(closeTimer.current);
setOpen(true);
}
function closeSoon() {
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 (
<div className="relative" onMouseEnter={openNow} onMouseLeave={closeSoon}>
<button
onClick={() => setOpen((o) => !o)}
title={me.email}
className="block rounded-full ring-2 ring-transparent hover:ring-border focus:outline-none focus:ring-accent transition"
>
<Avatar me={me} className="w-8 h-8 text-xs" />
</button>
{open && (
<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">
<Avatar me={me} className="w-10 h-10 text-sm shrink-0" />
<div className="min-w-0">
<div className="text-sm font-semibold truncate">
{me.display_name ?? me.email.split("@")[0]}
</div>
<div className="text-xs text-muted truncate">{me.email}</div>
</div>
</div>
{me.role === "admin" && (
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
<Shield className="w-3.5 h-3.5" />
{t("header.account.admin")}
</div>
)}
<div className="mt-2 flex flex-col">
{page !== "feed" && (
<button onClick={() => { setPage("feed"); setOpen(false); }} className={itemClass}>
<Home className="w-4 h-4" />
{t("header.account.feed")}
</button>
)}
{page !== "channels" && (
<button onClick={() => { setPage("channels"); setOpen(false); }} className={itemClass}>
<Tv className="w-4 h-4" />
{t("header.account.channels")}
</button>
)}
{page !== "playlists" && (
<button onClick={() => { setPage("playlists"); setOpen(false); }} className={itemClass}>
<ListVideo className="w-4 h-4" />
{t("header.account.playlists")}
</button>
)}
{me.role === "admin" && page !== "stats" && (
<button onClick={() => { setPage("stats"); setOpen(false); }} className={itemClass}>
<BarChart3 className="w-4 h-4" />
{t("header.account.stats")}
</button>
)}
<button onClick={() => { onOpenSettings(); setOpen(false); }} className={itemClass}>
<Settings className="w-4 h-4" />
{t("header.account.settings")}
</button>
<button onClick={() => { onOpenAbout(); setOpen(false); }} className={itemClass}>
<Info className="w-4 h-4" />
{t("header.account.about")}
</button>
<button onClick={logout} className={itemClass}>
<LogOut className="w-4 h-4" />
{t("header.account.signOut")}
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -38,7 +38,7 @@ export default function LanguageSwitcher({
{open && (
<div
className={`glass absolute ${
className={`glass-menu absolute ${
align === "right" ? "right-0" : "left-0"
} mt-1 w-40 rounded-xl p-1.5 z-40 animate-[popIn_0.16s_ease]`}
>

View file

@ -35,7 +35,7 @@ export default function Modal({
aria-modal="true"
>
<div
className={`glass-card relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl shadow-2xl`}
className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl shadow-2xl`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-3 p-5 pb-3">

View file

@ -0,0 +1,282 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import {
BarChart3,
ChevronLeft,
ChevronRight,
Home,
Info,
ListVideo,
LogOut,
Settings,
Shield,
Tv,
UserPlus,
} from "lucide-react";
import { api, type Me } from "../lib/api";
import type { Page } from "../lib/urlState";
import AvatarImg from "./Avatar";
// Primary app navigation: a collapsible left rail with icon+label entries (Design C). The
// modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes
// a thin icon-only rail. Account actions sit at the bottom in a popover.
export default function NavSidebar({
me,
page,
setPage,
onOpenAbout,
}: {
me: Me;
page: Page;
setPage: (p: Page) => void;
onOpenAbout: () => void;
}) {
const { t } = useTranslation();
const [collapsed, setCollapsed] = useState<boolean>(
() => localStorage.getItem("siftlode.navCollapsed") === "1"
);
const [acctOpen, setAcctOpen] = useState(false);
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
const acctPanelRef = useRef<HTMLDivElement | null>(null);
// Popover position (fixed, viewport coords). It's portaled to <body> so it escapes the
// nav's backdrop-filter, which would otherwise trap fixed positioning + stacking and let
// clicks fall through to the controls behind it.
const [acctPos, setAcctPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
function toggleAccount() {
if (!acctOpen) {
const r = acctBtnRef.current?.getBoundingClientRect();
if (r) setAcctPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom });
}
setAcctOpen((o) => !o);
}
// Dismiss on outside click / Escape via document listeners — NOT a full-screen backdrop
// div, which (sitting between the popover and the page) would break the popover's
// backdrop-filter and make it look solid.
useEffect(() => {
if (!acctOpen) return;
function onDoc(e: MouseEvent) {
const t = e.target as Node;
if (acctPanelRef.current?.contains(t) || acctBtnRef.current?.contains(t)) return;
setAcctOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setAcctOpen(false);
}
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, [acctOpen]);
function toggleCollapsed() {
setCollapsed((c) => {
const next = !c;
localStorage.setItem("siftlode.navCollapsed", next ? "1" : "0");
return next;
});
}
async function logout() {
await fetch("/auth/logout", { method: "POST", credentials: "include" });
location.reload();
}
// Accounts that have signed in on this browser (loaded only while the popover is open).
const accountsQuery = useQuery({
queryKey: ["accounts"],
queryFn: api.accounts,
enabled: acctOpen,
});
const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active);
async function switchTo(id: number) {
try {
await api.switchAccount(id);
location.reload(); // cleanest way to reload all per-user state for the new account
} catch {
/* a revoked/removed account — leave the popover open */
}
}
const items: { page: Page; icon: typeof Home; label: string }[] = [
{ page: "feed", icon: Home, label: t("header.account.feed") },
{ page: "channels", icon: Tv, label: t("header.account.channels") },
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
];
if (me.role === "admin")
items.push({ page: "stats", icon: BarChart3, label: t("header.account.stats") });
const rowBase =
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const name = me.display_name ?? me.email.split("@")[0];
return (
<nav
className={`glass relative shrink-0 border-r border-border flex flex-col py-3 transition-[width] ${
collapsed ? "w-[56px] px-2" : "w-52 px-3"
}`}
aria-label={t("nav.primary")}
>
<div className={`flex items-center mb-3 ${collapsed ? "justify-center" : "justify-between"}`}>
{!collapsed && (
<button
onClick={() => setPage("feed")}
className="text-lg font-bold tracking-tight select-none"
title={t("header.feed")}
>
Sift<span className="text-accent">lode</span>
</button>
)}
<button
onClick={toggleCollapsed}
title={collapsed ? t("nav.expand") : t("nav.collapse")}
aria-label={collapsed ? t("nav.expand") : t("nav.collapse")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
{collapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
{items.map(({ page: p, icon: Icon, label }) => (
<button
key={p}
onClick={() => setPage(p)}
title={collapsed ? label : undefined}
aria-current={page === p ? "page" : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${
page === p
? "bg-accent text-accent-fg"
: "text-muted hover:text-fg hover:bg-card"
}`}
>
<Icon className="w-[18px] h-[18px] shrink-0" />
{!collapsed && <span className="truncate">{label}</span>}
</button>
))}
</div>
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
<button
onClick={() => setPage("settings")}
title={collapsed ? t("header.account.settings") : undefined}
aria-current={page === "settings" ? "page" : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${
page === "settings"
? "bg-accent text-accent-fg"
: "text-muted hover:text-fg hover:bg-card"
}`}
>
<Settings className="w-[18px] h-[18px] shrink-0" />
{!collapsed && <span className="truncate">{t("header.account.settings")}</span>}
</button>
<div className="relative">
<button
ref={acctBtnRef}
onClick={toggleAccount}
title={collapsed ? name : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
>
<AvatarImg
src={me.avatar_url}
fallback={name}
className="w-[22px] h-[22px] rounded-full text-[10px] shrink-0"
/>
{!collapsed && <span className="truncate flex-1 text-left">{name}</span>}
</button>
{acctOpen &&
createPortal(
<div
ref={acctPanelRef}
style={{ position: "fixed", left: acctPos.left, bottom: acctPos.bottom }}
className="glass w-60 rounded-xl p-3 z-50 animate-[popIn_0.16s_ease]"
>
<div className="flex items-center gap-3 pb-3 border-b border-border">
<AvatarImg
src={me.avatar_url}
fallback={name}
className="w-10 h-10 rounded-full text-sm shrink-0"
/>
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{name}</div>
<div className="text-xs text-muted truncate">{me.email}</div>
</div>
</div>
{me.role === "admin" && (
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
<Shield className="w-3.5 h-3.5" />
{t("header.account.admin")}
</div>
)}
{otherAccounts.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
{otherAccounts.map((a) => (
<button
key={a.id}
onClick={() => switchTo(a.id)}
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
>
<AvatarImg
src={a.avatar_url}
fallback={a.display_name ?? a.email}
className="w-6 h-6 rounded-full text-[10px] shrink-0"
/>
<span className="min-w-0 text-left">
<span className="block text-[13px] text-fg truncate">
{a.display_name ?? a.email.split("@")[0]}
</span>
<span className="block text-[11px] text-muted truncate">{a.email}</span>
</span>
</button>
))}
</div>
)}
<div className="mt-2 pt-2 border-t border-border flex flex-col">
<button
onClick={() => {
window.location.href = "/auth/login";
}}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<UserPlus className="w-4 h-4" />
{t("header.account.addAccount")}
</button>
<button
onClick={() => {
onOpenAbout();
setAcctOpen(false);
}}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Info className="w-4 h-4" />
{t("header.account.about")}
</button>
<button
onClick={logout}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-4 h-4" />
{t("header.account.signOut")}
</button>
</div>
</div>,
document.body
)}
</div>
</div>
</nav>
);
}

View file

@ -97,7 +97,7 @@ export default function NotificationCenter({
</button>
{open && (
<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="glass-menu 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="text-sm font-semibold">{t("notifications.title")}</div>
{notifications.length > 0 && (

View file

@ -23,13 +23,15 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [
{ id: "account", icon: User },
];
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
// the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is
// shown by the Header; the tab rail stays as in-page sub-navigation.
export default function SettingsPanel({
me,
theme,
setTheme,
view,
setView,
onClose,
onOpenWizard,
}: {
me: Me;
@ -37,92 +39,53 @@ export default function SettingsPanel({
setTheme: (t: ThemePrefs) => void;
view: "grid" | "list";
setView: (v: "grid" | "list") => void;
onClose: () => void;
onOpenWizard: () => void;
}) {
const { t } = useTranslation();
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">{t("settings.title")}</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((tabItem) => {
const active = tab === tabItem.id;
return (
<button
key={tabItem.id}
onClick={() => setTab(tabItem.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"
}`}
>
<tabItem.icon className="w-4 h-4 shrink-0" />
{t(`settings.tabs.${tabItem.id}`)}
</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((tabItem) => (
<div
<div className="p-4 max-w-3xl w-full mx-auto">
<div className="glass rounded-2xl overflow-hidden flex">
{/* 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((tabItem) => {
const active = tab === tabItem.id;
return (
<button
key={tabItem.id}
className={`[grid-area:1/1] p-4 ${
tab === tabItem.id ? "" : "invisible pointer-events-none"
onClick={() => setTab(tabItem.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"
}`}
>
{tabItem.id === "appearance" && (
<Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
)}
{tabItem.id === "notifications" && <Notifications />}
{tabItem.id === "sync" && <Sync me={me} />}
{tabItem.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
</div>
))}
</div>
<tabItem.icon className="w-4 h-4 shrink-0" />
{t(`settings.tabs.${tabItem.id}`)}
</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 min-w-0">
{TABS.map((tabItem) => (
<div
key={tabItem.id}
className={`[grid-area:1/1] p-4 ${
tab === tabItem.id ? "" : "invisible pointer-events-none"
}`}
>
{tabItem.id === "appearance" && (
<Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
)}
{tabItem.id === "notifications" && <Notifications />}
{tabItem.id === "sync" && <Sync me={me} />}
{tabItem.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
</div>
))}
</div>
</div>
</div>

View file

@ -18,7 +18,9 @@
"stats": "Statistik",
"settings": "Einstellungen",
"about": "Über",
"signOut": "Abmelden"
"signOut": "Abmelden",
"addAccount": "Weiteres Konto hinzufügen",
"switchTo": "Zu {{name}} wechseln"
},
"sync": {
"yours": "deine",

View file

@ -0,0 +1,5 @@
{
"primary": "Hauptnavigation",
"collapse": "Seitenleiste einklappen",
"expand": "Seitenleiste ausklappen"
}

View file

@ -18,7 +18,9 @@
"stats": "Stats",
"settings": "Settings",
"about": "About",
"signOut": "Sign out"
"signOut": "Sign out",
"addAccount": "Add another account",
"switchTo": "Switch to {{name}}"
},
"sync": {
"yours": "yours",

View file

@ -0,0 +1,5 @@
{
"primary": "Primary navigation",
"collapse": "Collapse sidebar",
"expand": "Expand sidebar"
}

View file

@ -18,7 +18,9 @@
"stats": "Statisztika",
"settings": "Beállítások",
"about": "Névjegy",
"signOut": "Kijelentkezés"
"signOut": "Kijelentkezés",
"addAccount": "Másik fiók hozzáadása",
"switchTo": "Váltás erre: {{name}}"
},
"sync": {
"yours": "tiéd",

View file

@ -0,0 +1,5 @@
{
"primary": "Fő navigáció",
"collapse": "Oldalsáv összecsukása",
"expand": "Oldalsáv kinyitása"
}

View file

@ -22,10 +22,13 @@ html {
body {
margin: 0;
color: var(--fg);
/* Ambient backdrop so translucent "glass" surfaces have soft color to refract. */
/* Ambient backdrop so translucent "glass" surfaces have soft color to refract. Kept
subtle but a touch richer (three soft pools, incl. a bottom glow) now that the nav,
header and dialogs are all frosted glass and refract it. */
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%),
radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 60%),
radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 55%),
radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) 11%, transparent), transparent 60%),
var(--bg);
background-attachment: fixed;
}
@ -53,6 +56,17 @@ body {
inset 0 1px 0 color-mix(in srgb, #fff 8%, transparent),
0 8px 22px -14px rgba(0, 0, 0, 0.45);
}
.glass-menu {
/* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs),
so they must be near-opaque to stay readable only a hint of translucency + the blur. */
background: color-mix(in srgb, var(--surface) 92%, 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) 80%, transparent);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff 14%, transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6);
}
.glass-hover:hover {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
box-shadow:
@ -60,8 +74,29 @@ body {
0 14px 30px -14px rgba(0, 0, 0, 0.5);
}
/* Dark mode: the ambient accent pools are faint over a near-black bg, so the docked frosted
chrome (nav/header) barely reads there's no content behind it to refract, unlike dialogs
which sit over the feed. Strengthen the pools and let a touch more bleed through the glass.
(The full "videos behind glass everywhere" effect is Phase 2 / end-of-project polish.) */
html[data-theme="dark"] body {
background:
radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) 34%, transparent), transparent 60%),
radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) 24%, transparent), transparent 55%),
radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 60%),
var(--bg);
background-attachment: fixed;
}
/* Dark glass reads faint because the blurred backdrop is itself dark; lift its brightness so
the frosted sheen shows even over dark UI (over colourful content it just looks richer). */
html[data-theme="dark"] .glass,
html[data-theme="dark"] .glass-menu {
backdrop-filter: blur(30px) saturate(1.7) brightness(1.4);
-webkit-backdrop-filter: blur(30px) saturate(1.7) brightness(1.4);
}
/* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */
html[data-perf="1"] .glass,
html[data-perf="1"] .glass-menu,
html[data-perf="1"] .glass-card {
backdrop-filter: none;
-webkit-backdrop-filter: none;

View file

@ -294,8 +294,19 @@ export interface ManagedChannel {
backfill_done: boolean;
}
export interface Account {
id: number;
email: string;
display_name: string | null;
avatar_url: string | null;
active: boolean;
}
export const api = {
me: (): Promise<Me> => req("/api/me"),
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
version: (): Promise<VersionInfo> => req("/api/version"),
tags: (): Promise<Tag[]> => req("/api/tags"),
status: (): Promise<SyncStatus> => req("/api/sync/status"),

View file

@ -14,6 +14,18 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.4.0",
date: "2026-06-16",
summary: "A clearer left-hand navigation, a glassier look, and account switching.",
features: [
"New left navigation sidebar: Feed, Channels, Playlists and Stats are now always visible (no longer tucked under your avatar), and the rail collapses to icons when you want more room.",
"Switch accounts without signing in again: the account menu lists everyone signed in on this browser — pick one to switch instantly, or “Add another account”. Signing out drops you to the next account if you have one.",
"Settings is now its own page that opens right where you are, instead of a panel sliding in from the far side.",
"The browser/back button now steps back through the app (Feed, Channels, …) instead of leaving the app.",
"A frosted-glass refresh across the navigation, header and dialogs.",
],
},
{
version: "0.3.0",
date: "2026-06-16",

View file

@ -78,11 +78,13 @@ export function hasFilterParams(params: URLSearchParams): boolean {
return KEYS.some((k) => params.has(k));
}
export type Page = "feed" | "channels" | "stats" | "playlists";
export type Page = "feed" | "channels" | "stats" | "playlists" | "settings";
export function readPage(): Page {
const p = new URLSearchParams(window.location.search).get("page");
return p === "channels" || p === "stats" || p === "playlists" ? p : "feed";
return p === "channels" || p === "stats" || p === "playlists" || p === "settings"
? p
: "feed";
}
/** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the