diff --git a/VERSION b/VERSION index 0d91a54..1d0ba9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 +0.4.0 diff --git a/backend/app/auth.py b/backend/app/auth.py index 53fa444..685a211 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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 diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 28312e2..40fc3f3 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 21b27e7..d74470c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(loadLayout); const [page, setPageState] = useState(loadInitialPage); - const [settingsOpen, setSettingsOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false); const [channelFilter, setChannelFilter] = useState("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 ( -
-
+ setSettingsOpen(true)} onOpenAbout={() => setAboutOpen(true)} - onChangeLanguage={changeLanguage} - onGoToFullHistory={() => { - setChannelFilter("needs_full"); - setPage("channels"); - }} /> - openReleaseNotes(CURRENT_VERSION)} /> -
- {page === "feed" && ( - - )} -
+
+
{ + setChannelFilter("needs_full"); + setPage("channels"); + }} + /> + openReleaseNotes(CURRENT_VERSION)} /> +
+ {page === "feed" && ( + + )} +
{page === "channels" ? ( ) : page === "playlists" ? ( + ) : page === "settings" ? ( + setWizardOpen(true)} + /> ) : ( setWizardOpen(true)} /> )} -
+
+
- {settingsOpen && ( - setSettingsOpen(false)} - onOpenWizard={() => { - setSettingsOpen(false); - setWizardOpen(true); - }} - /> - )} {wizardOpen && meQuery.data && ( setWizardOpen(false)} /> )} diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index 3124c97..5260163 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -139,7 +139,7 @@ export default function AddToPlaylist({
e.stopPropagation()} >
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 1d535f5..f5c3bdc 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -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 ( -
- - +
{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")}
)}
-
- -
); } - -function Avatar({ me, className = "" }: { me: Me; className?: string }) { - return ( - - ); -} - -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 | 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 ( -
- - - {open && ( -
-
- -
-
- {me.display_name ?? me.email.split("@")[0]} -
-
{me.email}
-
-
- - {me.role === "admin" && ( -
- - {t("header.account.admin")} -
- )} - -
- {page !== "feed" && ( - - )} - {page !== "channels" && ( - - )} - {page !== "playlists" && ( - - )} - {me.role === "admin" && page !== "stats" && ( - - )} - - - -
-
- )} -
- ); -} diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index f62a888..62e90cf 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -38,7 +38,7 @@ export default function LanguageSwitcher({ {open && (
diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index ff5d383..ba4fff7 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -35,7 +35,7 @@ export default function Modal({ aria-modal="true" >
e.stopPropagation()} >
diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx new file mode 100644 index 0000000..0d8c75b --- /dev/null +++ b/frontend/src/components/NavSidebar.tsx @@ -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( + () => localStorage.getItem("siftlode.navCollapsed") === "1" + ); + const [acctOpen, setAcctOpen] = useState(false); + const acctBtnRef = useRef(null); + const acctPanelRef = useRef(null); + // Popover position (fixed, viewport coords). It's portaled to 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 ( + + ); +} diff --git a/frontend/src/components/NotificationCenter.tsx b/frontend/src/components/NotificationCenter.tsx index 299ebb0..a50a5d3 100644 --- a/frontend/src/components/NotificationCenter.tsx +++ b/frontend/src/components/NotificationCenter.tsx @@ -97,7 +97,7 @@ export default function NotificationCenter({ {open && ( -
+
{t("notifications.title")}
{notifications.length > 0 && ( diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index b5c6ccc..1867af2 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -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("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 ( -
-
-
-
-
{t("settings.title")}
- -
- -
- {/* Vertical tab rail — never wraps; active is a clear accent pill. */} - - - {/* All tabs stacked in one grid cell so the panel sizes to the tallest tab - (stable height, no jump on switch); the active one is shown on top. */} -
- {TABS.map((tabItem) => ( -
+
+ {/* Vertical tab rail — never wraps; active is a clear accent pill. */} +
- ))} -
+ + {t(`settings.tabs.${tabItem.id}`)} + + ); + })} + + + {/* All tabs stacked in one grid cell so the panel sizes to the tallest tab + (stable height, no jump on switch); the active one is shown on top. */} +
+ {TABS.map((tabItem) => ( +
+ {tabItem.id === "appearance" && ( + + )} + {tabItem.id === "notifications" && } + {tabItem.id === "sync" && } + {tabItem.id === "account" && } +
+ ))}
diff --git a/frontend/src/i18n/locales/de/header.json b/frontend/src/i18n/locales/de/header.json index 482db47..e220b88 100644 --- a/frontend/src/i18n/locales/de/header.json +++ b/frontend/src/i18n/locales/de/header.json @@ -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", diff --git a/frontend/src/i18n/locales/de/nav.json b/frontend/src/i18n/locales/de/nav.json new file mode 100644 index 0000000..5892e3f --- /dev/null +++ b/frontend/src/i18n/locales/de/nav.json @@ -0,0 +1,5 @@ +{ + "primary": "Hauptnavigation", + "collapse": "Seitenleiste einklappen", + "expand": "Seitenleiste ausklappen" +} diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index da5a999..e6f29dc 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -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", diff --git a/frontend/src/i18n/locales/en/nav.json b/frontend/src/i18n/locales/en/nav.json new file mode 100644 index 0000000..621159b --- /dev/null +++ b/frontend/src/i18n/locales/en/nav.json @@ -0,0 +1,5 @@ +{ + "primary": "Primary navigation", + "collapse": "Collapse sidebar", + "expand": "Expand sidebar" +} diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index 5a3d559..16f3380 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/nav.json b/frontend/src/i18n/locales/hu/nav.json new file mode 100644 index 0000000..5c18135 --- /dev/null +++ b/frontend/src/i18n/locales/hu/nav.json @@ -0,0 +1,5 @@ +{ + "primary": "Fő navigáció", + "collapse": "Oldalsáv összecsukása", + "expand": "Oldalsáv kinyitása" +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 6eb89db..6225ce3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 350c270..98d8ed9 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 => req("/api/me"), + accounts: (): Promise => 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 => req("/api/version"), tags: (): Promise => req("/api/tags"), status: (): Promise => req("/api/sync/status"), diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 53d9bd7..4f89cea 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -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", diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 325f742..a0ad9d2 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -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