Merge: promote dev to prod
This commit is contained in:
commit
f97b4aefe1
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]+$")
|
_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
|
# 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
|
# 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
|
# 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.add(tok)
|
||||||
db.commit()
|
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
|
request.session["user_id"] = user.id
|
||||||
log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role)
|
log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role)
|
||||||
return RedirectResponse(url="/")
|
return RedirectResponse(url="/")
|
||||||
|
|
@ -182,8 +191,16 @@ async def callback(
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
async def logout(request: Request):
|
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()
|
request.session.clear()
|
||||||
return JSONResponse({"ok": True})
|
return JSONResponse({"ok": True, "switched": False})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/request-access")
|
@router.post("/request-access")
|
||||||
|
|
@ -215,6 +232,12 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||||
if user is None:
|
if user is None:
|
||||||
request.session.clear()
|
request.session.clear()
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
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
|
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 import func, select
|
||||||
from sqlalchemy.orm import Session
|
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.db import get_db
|
||||||
from app.models import Invite, User
|
from app.models import Invite, User
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/me", tags=["me"])
|
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("")
|
@router.get("")
|
||||||
def get_me(
|
def get_me(
|
||||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
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 { setHintsEnabled } from "./lib/hints";
|
||||||
import Login from "./components/Login";
|
import Login from "./components/Login";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
|
import NavSidebar from "./components/NavSidebar";
|
||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
import Feed from "./components/Feed";
|
import Feed from "./components/Feed";
|
||||||
import Channels, { type ChannelStatusFilter } from "./components/Channels";
|
import Channels, { type ChannelStatusFilter } from "./components/Channels";
|
||||||
|
|
@ -56,7 +57,13 @@ function loadInitialPage(): Page {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (params.has("page")) return readPage();
|
if (params.has("page")) return readPage();
|
||||||
const stored = localStorage.getItem(PAGE_KEY);
|
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";
|
return "feed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,7 +89,6 @@ export default function App() {
|
||||||
const [view, setView] = useState<"grid" | "list">("grid");
|
const [view, setView] = useState<"grid" | "list">("grid");
|
||||||
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
||||||
const [page, setPageState] = useState<Page>(loadInitialPage);
|
const [page, setPageState] = useState<Page>(loadInitialPage);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all");
|
const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all");
|
||||||
const [aboutOpen, setAboutOpen] = useState(false);
|
const [aboutOpen, setAboutOpen] = useState(false);
|
||||||
|
|
@ -100,8 +106,13 @@ export default function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPage(next: Page) {
|
function setPage(next: Page) {
|
||||||
|
if (next === page) return;
|
||||||
setPageState(next);
|
setPageState(next);
|
||||||
localStorage.setItem(PAGE_KEY, 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) {
|
function setSidebarLayout(next: SidebarLayout) {
|
||||||
|
|
@ -123,6 +134,23 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // 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 });
|
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
|
||||||
|
|
||||||
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after
|
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after
|
||||||
|
|
@ -192,15 +220,19 @@ export default function App() {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-bg text-fg">
|
<div className="h-screen flex bg-bg text-fg">
|
||||||
|
<NavSidebar
|
||||||
|
me={meQuery.data!}
|
||||||
|
page={page}
|
||||||
|
setPage={setPage}
|
||||||
|
onOpenAbout={() => setAboutOpen(true)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
<Header
|
<Header
|
||||||
me={meQuery.data!}
|
me={meQuery.data!}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
page={page}
|
page={page}
|
||||||
setPage={setPage}
|
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
|
||||||
onOpenAbout={() => setAboutOpen(true)}
|
|
||||||
onChangeLanguage={changeLanguage}
|
onChangeLanguage={changeLanguage}
|
||||||
onGoToFullHistory={() => {
|
onGoToFullHistory={() => {
|
||||||
setChannelFilter("needs_full");
|
setChannelFilter("needs_full");
|
||||||
|
|
@ -233,6 +265,15 @@ export default function App() {
|
||||||
<Stats />
|
<Stats />
|
||||||
) : page === "playlists" ? (
|
) : page === "playlists" ? (
|
||||||
<Playlists canWrite={meQuery.data!.can_write} />
|
<Playlists canWrite={meQuery.data!.can_write} />
|
||||||
|
) : page === "settings" ? (
|
||||||
|
<SettingsPanel
|
||||||
|
me={meQuery.data!}
|
||||||
|
theme={theme}
|
||||||
|
setTheme={setTheme}
|
||||||
|
view={view}
|
||||||
|
setView={changeView}
|
||||||
|
onOpenWizard={() => setWizardOpen(true)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Feed
|
<Feed
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|
@ -244,20 +285,7 @@ export default function App() {
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{settingsOpen && (
|
</div>
|
||||||
<SettingsPanel
|
|
||||||
me={meQuery.data!}
|
|
||||||
theme={theme}
|
|
||||||
setTheme={setTheme}
|
|
||||||
view={view}
|
|
||||||
setView={changeView}
|
|
||||||
onClose={() => setSettingsOpen(false)}
|
|
||||||
onOpenWizard={() => {
|
|
||||||
setSettingsOpen(false);
|
|
||||||
setWizardOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{wizardOpen && meQuery.data && (
|
{wizardOpen && meQuery.data && (
|
||||||
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ export default function AddToPlaylist({
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
style={{ position: "fixed", top: coords.top, left: coords.left, width: 240 }}
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="text-xs text-muted px-1.5 pb-1.5">
|
<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 { 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 { FeedFilters, Me } from "../lib/api";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
import { type LangCode } from "../i18n";
|
import { type LangCode } from "../i18n";
|
||||||
import SyncStatus from "./SyncStatus";
|
import SyncStatus from "./SyncStatus";
|
||||||
import NotificationCenter from "./NotificationCenter";
|
import NotificationCenter from "./NotificationCenter";
|
||||||
import LanguageSwitcher from "./LanguageSwitcher";
|
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({
|
export default function Header({
|
||||||
me,
|
me,
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
page,
|
page,
|
||||||
setPage,
|
|
||||||
onOpenSettings,
|
|
||||||
onOpenAbout,
|
|
||||||
onChangeLanguage,
|
onChangeLanguage,
|
||||||
onGoToFullHistory,
|
onGoToFullHistory,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -24,29 +22,13 @@ export default function Header({
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
page: Page;
|
page: Page;
|
||||||
setPage: (p: Page) => void;
|
|
||||||
onOpenSettings: () => void;
|
|
||||||
onOpenAbout: () => void;
|
|
||||||
onChangeLanguage: (code: LangCode) => void;
|
onChangeLanguage: (code: LangCode) => void;
|
||||||
onGoToFullHistory: () => void;
|
onGoToFullHistory: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
await fetch("/auth/logout", { method: "POST", credentials: "include" });
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-14 shrink-0 border-b border-border bg-surface/95 flex items-center gap-3 px-4 z-20">
|
<header className="glass h-14 shrink-0 border-b border-border 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>
|
|
||||||
|
|
||||||
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
|
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
|
||||||
|
|
||||||
{page === "feed" && (
|
{page === "feed" && (
|
||||||
|
|
@ -94,6 +76,8 @@ export default function Header({
|
||||||
? t("header.usageStats")
|
? t("header.usageStats")
|
||||||
: page === "playlists"
|
: page === "playlists"
|
||||||
? t("header.account.playlists")
|
? t("header.account.playlists")
|
||||||
|
: page === "settings"
|
||||||
|
? t("settings.title")
|
||||||
: t("header.channelManager")}
|
: t("header.channelManager")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -101,130 +85,7 @@ export default function Header({
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<LanguageSwitcher value={i18n.language as LangCode} onChange={onChangeLanguage} />
|
<LanguageSwitcher value={i18n.language as LangCode} onChange={onChangeLanguage} />
|
||||||
<NotificationCenter filters={filters} setFilters={setFilters} />
|
<NotificationCenter filters={filters} setFilters={setFilters} />
|
||||||
<div className="pl-1">
|
|
||||||
<AccountMenu
|
|
||||||
me={me}
|
|
||||||
logout={logout}
|
|
||||||
page={page}
|
|
||||||
setPage={setPage}
|
|
||||||
onOpenSettings={onOpenSettings}
|
|
||||||
onOpenAbout={onOpenAbout}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
className={`glass absolute ${
|
className={`glass-menu absolute ${
|
||||||
align === "right" ? "right-0" : "left-0"
|
align === "right" ? "right-0" : "left-0"
|
||||||
} mt-1 w-40 rounded-xl p-1.5 z-40 animate-[popIn_0.16s_ease]`}
|
} 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"
|
aria-modal="true"
|
||||||
>
|
>
|
||||||
<div
|
<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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3 p-5 pb-3">
|
<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>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{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="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||||
<div className="text-sm font-semibold">{t("notifications.title")}</div>
|
<div className="text-sm font-semibold">{t("notifications.title")}</div>
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,15 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||||
{ id: "account", icon: User },
|
{ 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({
|
export default function SettingsPanel({
|
||||||
me,
|
me,
|
||||||
theme,
|
theme,
|
||||||
setTheme,
|
setTheme,
|
||||||
view,
|
view,
|
||||||
setView,
|
setView,
|
||||||
onClose,
|
|
||||||
onOpenWizard,
|
onOpenWizard,
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
me: Me;
|
||||||
|
|
@ -37,52 +39,14 @@ export default function SettingsPanel({
|
||||||
setTheme: (t: ThemePrefs) => void;
|
setTheme: (t: ThemePrefs) => void;
|
||||||
view: "grid" | "list";
|
view: "grid" | "list";
|
||||||
setView: (v: "grid" | "list") => void;
|
setView: (v: "grid" | "list") => void;
|
||||||
onClose: () => void;
|
|
||||||
onOpenWizard: () => void;
|
onOpenWizard: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [tab, setTab] = useState<TabId>("appearance");
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex justify-end">
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||||
<div
|
<div className="glass rounded-2xl overflow-hidden flex">
|
||||||
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. */}
|
{/* 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">
|
<nav className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1">
|
||||||
{TABS.map((tabItem) => {
|
{TABS.map((tabItem) => {
|
||||||
|
|
@ -106,7 +70,7 @@ export default function SettingsPanel({
|
||||||
|
|
||||||
{/* All tabs stacked in one grid cell so the panel sizes to the tallest tab
|
{/* 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. */}
|
(stable height, no jump on switch); the active one is shown on top. */}
|
||||||
<div className="grid flex-1 overflow-y-auto">
|
<div className="grid flex-1 min-w-0">
|
||||||
{TABS.map((tabItem) => (
|
{TABS.map((tabItem) => (
|
||||||
<div
|
<div
|
||||||
key={tabItem.id}
|
key={tabItem.id}
|
||||||
|
|
@ -125,7 +89,6 @@ export default function SettingsPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"about": "Über",
|
"about": "Über",
|
||||||
"signOut": "Abmelden"
|
"signOut": "Abmelden",
|
||||||
|
"addAccount": "Weiteres Konto hinzufügen",
|
||||||
|
"switchTo": "Zu {{name}} wechseln"
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"yours": "deine",
|
"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",
|
"stats": "Stats",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"signOut": "Sign out"
|
"signOut": "Sign out",
|
||||||
|
"addAccount": "Add another account",
|
||||||
|
"switchTo": "Switch to {{name}}"
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"yours": "yours",
|
"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",
|
"stats": "Statisztika",
|
||||||
"settings": "Beállítások",
|
"settings": "Beállítások",
|
||||||
"about": "Névjegy",
|
"about": "Névjegy",
|
||||||
"signOut": "Kijelentkezés"
|
"signOut": "Kijelentkezés",
|
||||||
|
"addAccount": "Másik fiók hozzáadása",
|
||||||
|
"switchTo": "Váltás erre: {{name}}"
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"yours": "tiéd",
|
"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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--fg);
|
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:
|
background:
|
||||||
radial-gradient(1100px 620px at 12% -8%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 60%),
|
radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 60%),
|
||||||
radial-gradient(1000px 700px at 112% 8%, color-mix(in srgb, var(--accent) 9%, transparent), transparent 55%),
|
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);
|
var(--bg);
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
}
|
}
|
||||||
|
|
@ -53,6 +56,17 @@ body {
|
||||||
inset 0 1px 0 color-mix(in srgb, #fff 8%, transparent),
|
inset 0 1px 0 color-mix(in srgb, #fff 8%, transparent),
|
||||||
0 8px 22px -14px rgba(0, 0, 0, 0.45);
|
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 {
|
.glass-hover:hover {
|
||||||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
|
|
@ -60,8 +74,29 @@ body {
|
||||||
0 14px 30px -14px rgba(0, 0, 0, 0.5);
|
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. */
|
/* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */
|
||||||
html[data-perf="1"] .glass,
|
html[data-perf="1"] .glass,
|
||||||
|
html[data-perf="1"] .glass-menu,
|
||||||
html[data-perf="1"] .glass-card {
|
html[data-perf="1"] .glass-card {
|
||||||
backdrop-filter: none;
|
backdrop-filter: none;
|
||||||
-webkit-backdrop-filter: none;
|
-webkit-backdrop-filter: none;
|
||||||
|
|
|
||||||
|
|
@ -294,8 +294,19 @@ export interface ManagedChannel {
|
||||||
backfill_done: boolean;
|
backfill_done: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
me: (): Promise<Me> => req("/api/me"),
|
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"),
|
version: (): Promise<VersionInfo> => req("/api/version"),
|
||||||
tags: (): Promise<Tag[]> => req("/api/tags"),
|
tags: (): Promise<Tag[]> => req("/api/tags"),
|
||||||
status: (): Promise<SyncStatus> => req("/api/sync/status"),
|
status: (): Promise<SyncStatus> => req("/api/sync/status"),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,18 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: 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",
|
version: "0.3.0",
|
||||||
date: "2026-06-16",
|
date: "2026-06-16",
|
||||||
|
|
|
||||||
|
|
@ -78,11 +78,13 @@ export function hasFilterParams(params: URLSearchParams): boolean {
|
||||||
return KEYS.some((k) => params.has(k));
|
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 {
|
export function readPage(): Page {
|
||||||
const p = new URLSearchParams(window.location.search).get("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
|
/** 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