Merge: promote dev to prod
This commit is contained in:
commit
079c8182b4
21 changed files with 565 additions and 279 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.3.0
|
||||
0.4.0
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
282
frontend/src/components/NavSidebar.tsx
Normal file
282
frontend/src/components/NavSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
5
frontend/src/i18n/locales/de/nav.json
Normal file
5
frontend/src/i18n/locales/de/nav.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"primary": "Hauptnavigation",
|
||||
"collapse": "Seitenleiste einklappen",
|
||||
"expand": "Seitenleiste ausklappen"
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
5
frontend/src/i18n/locales/en/nav.json
Normal file
5
frontend/src/i18n/locales/en/nav.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"primary": "Primary navigation",
|
||||
"collapse": "Collapse sidebar",
|
||||
"expand": "Expand sidebar"
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
5
frontend/src/i18n/locales/hu/nav.json
Normal file
5
frontend/src/i18n/locales/hu/nav.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"primary": "Fő navigáció",
|
||||
"collapse": "Oldalsáv összecsukása",
|
||||
"expand": "Oldalsáv kinyitása"
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue