diff --git a/VERSION b/VERSION index 727d97b..8854156 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.20.2 +0.21.0 diff --git a/backend/app/auth.py b/backend/app/auth.py index 68b235f..c654dca 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -419,15 +419,22 @@ def _complete_link( @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") + """Sign the requesting tab's active account out of this browser (removing it from the wallet). + Per-tab aware: the account is taken from the X-Siftlode-Account header when present, so logging + out in one tab doesn't disturb another tab running a different account. If the account being + removed was the session default, promote the most recent remaining account (or clear the + session when none remain).""" + active, is_default = resolved_user_id(request) 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["account_ids"] = remaining + if is_default: + if remaining: + request.session["user_id"] = remaining[-1] + return JSONResponse({"ok": True, "switched": True}) + request.session.clear() + return JSONResponse({"ok": True, "switched": False}) + # A non-default (per-tab) account signed out: it's gone from the wallet and the session default + # is untouched. The tab drops its own override and falls back to the default on reload. return JSONResponse({"ok": True, "switched": False}) @@ -659,20 +666,52 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict return {"ok": True} +# A tab can act as a different signed-in account than the session default by sending this header +# (per-tab identity: two tabs in one browser, two accounts). Lowercased — Starlette header lookup +# is case-insensitive, but we compare explicitly below. +ACTIVE_ACCOUNT_HEADER = "x-siftlode-account" + + +def resolved_user_id(request: Request) -> tuple[int | None, bool]: + """Which account this request acts as, and whether that's the session's default account. + + The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser) + plus a default `user_id`. A request may override the default with the X-Siftlode-Account + header, but ONLY for an account already in the wallet — so a tab can't impersonate an account + that never authenticated here. Everything else (WebSocket, plain navigations) keeps using the + cookie default. + """ + default_id = request.session.get("user_id") + wallet = request.session.get("account_ids") or [] + hdr = request.headers.get(ACTIVE_ACCOUNT_HEADER) + if hdr: + try: + hid = int(hdr) + except ValueError: + hid = None + if hid is not None and hid in wallet: + return hid, hid == default_id + return default_id, True + + def current_user(request: Request, db: Session = Depends(get_db)) -> User: - user_id = request.session.get("user_id") + user_id, is_default = resolved_user_id(request) if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") user = db.get(User, user_id) if user is None or not user.is_active or user.is_suspended: # Suspended/deactivated mid-session → drop the session so the block takes effect at once. - request.session.clear() + # But only nuke the whole session when the SESSION DEFAULT identity is gone; a stale + # per-tab header account just 401s that one tab (other tabs' default may still be valid). + if is_default: + request.session.clear() raise HTTPException(status_code=401, detail="Not authenticated") - # Always keep the active account in the switchable list — covers sessions created before + # Always keep the session default in the switchable list — covers sessions created before # multi-session existed (their account_ids was never seeded), so the switcher isn't empty. + default_id = request.session.get("user_id") accounts = request.session.get("account_ids") or [] - if user_id not in accounts: - accounts.append(user_id) + if default_id and default_id not in accounts: + accounts.append(default_id) request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] return user diff --git a/backend/app/main.py b/backend/app/main.py index 944e294..7115870 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -131,6 +131,13 @@ app.include_router(setup_routes.router) # The built SPA (populated by the Docker frontend build stage). STATIC_DIR = Path(__file__).parent / "static_spa" +# index.html is unhashed and references the content-hashed /assets bundles, so it MUST NOT be +# heuristically cached — otherwise, after a deploy, a browser keeps serving the old index.html +# (pointing at the previous bundle) and runs stale code until a hard refresh. `no-cache` lets it +# stay cached but forces revalidation (cheap 304 via the FileResponse ETag) on every load. The +# hashed bundles under /assets can cache forever (a new build changes their filename). +INDEX_HTML = STATIC_DIR / "index.html" +INDEX_HEADERS = {"Cache-Control": "no-cache"} app.mount( "/assets", StaticFiles(directory=STATIC_DIR / "assets", check_dir=False), @@ -140,7 +147,7 @@ app.mount( @app.get("/") async def index() -> FileResponse: - return FileResponse(STATIC_DIR / "index.html") + return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) @app.get("/{full_path:path}") @@ -155,4 +162,4 @@ async def spa_fallback(full_path: str) -> FileResponse: candidate = (STATIC_DIR / full_path).resolve() if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents: return FileResponse(candidate) - return FileResponse(STATIC_DIR / "index.html") + return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index 9718e1d..53a68e8 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -363,7 +363,19 @@ async def messages_ws(ws: WebSocket) -> None: """Live push channel: authenticated by the session cookie, registers the connection so sent messages reach this user's open tabs instantly. We don't consume client→server frames (sending goes through POST /api/messages); the receive loop only detects disconnect.""" - uid = ws.session.get("user_id") if "session" in ws.scope else None + # A browser WebSocket can't send custom headers, so the per-tab account (see + # X-Siftlode-Account on HTTP) rides in the ?account= query param instead — honoured only for + # an account already in this browser's wallet. Falls back to the session default. + sess = ws.session if "session" in ws.scope else {} + uid = sess.get("user_id") + q = ws.query_params.get("account") + if q: + try: + qid = int(q) + except ValueError: + qid = None + if qid is not None and qid in (sess.get("account_ids") or []): + uid = qid if not uid: await ws.close(code=1008) return diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5897f07..e05e41e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,14 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { api, HttpError, setUnauthorizedHandler, type FeedFilters } from "./lib/api"; +import { + api, + getActiveAccount, + HttpError, + setActiveAccount, + setUnauthorizedHandler, + type FeedFilters, +} from "./lib/api"; import { setLanguage, isSupported, type LangCode } from "./i18n"; import { applyTheme, @@ -19,7 +26,18 @@ import { } from "./lib/sidebarLayout"; import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications"; import { hintsEnabled, setHintsEnabled } from "./lib/hints"; -import { LS, readJSON, readMerged, usePersistedState } from "./lib/storage"; +import { + accountKey, + getAccountRaw, + LS, + readAccount, + readJSON, + readMerged, + setAccountRaw, + useAccountPersistedState, + writeAccount, + writeJSON, +} from "./lib/storage"; import { useConfirm } from "./components/ConfirmProvider"; import type { PrefsController } from "./components/SettingsPanel"; import Welcome from "./components/Welcome"; @@ -82,23 +100,27 @@ type EditablePrefs = { function loadInitialPage(): Page { const params = new URLSearchParams(window.location.search); if (params.has("page")) return readPage(); - const stored = localStorage.getItem(PAGE_KEY); + const stored = getAccountRaw(PAGE_KEY); return isPage(stored) ? stored : "feed"; } -function loadStoredFilters(): FeedFilters { - return readMerged(FILTERS_KEY, DEFAULT_FILTERS); +// This account's own persisted filters (per-account keys — never another account's). A chosen +// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When +// the account isn't known yet (first load, before the tab is pinned), start from defaults; the +// post-login effect loads the real ones once the id arrives. +function loadAccountFilters(id: number | null): FeedFilters { + const dvKey = accountKey(LS.defaultViewFilters, id); + const def = dvKey ? readJSON(dvKey, null) : null; + if (def) return { ...DEFAULT_FILTERS, ...def }; + const fKey = accountKey(LS.filters, id); + return fKey ? readMerged(fKey, DEFAULT_FILTERS) : { ...DEFAULT_FILTERS }; } // URL wins over localStorage so a pasted link reproduces the exact view. function loadInitialFilters(): FeedFilters { const params = new URLSearchParams(window.location.search); if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS); - // A user-chosen default saved view takes precedence over the last-session filters on a fresh - // load / F5 (SavedViewsWidget mirrors its filters here). No default set → last-session filters. - const def = readJSON(LS.defaultViewFilters, null); - if (def) return { ...DEFAULT_FILTERS, ...def }; - return loadStoredFilters(); + return loadAccountFilters(getActiveAccount()); } export default function App() { @@ -112,15 +134,20 @@ export default function App() { const pageRef = useRef("feed"); const [theme, setThemeState] = useState(() => loadLocalTheme()); const [filters, setFiltersState] = useState(loadInitialFilters); + // Whether this load arrived via a "Share view" link (captured once, before the URL is stripped) + // — such filters win over the account's stored view and are persisted to the account instead. + const [initialHadUrlFilters] = useState(() => + hasFilterParams(new URLSearchParams(window.location.search)) + ); const [view, setView] = useState<"grid" | "list">("grid"); // Settings-page prefs draft (apply live, persist on Save — see EditablePrefs). - const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1"); + const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1"); const [hints, setHints] = useState(() => hintsEnabled()); const [notif, setNotif] = useState(() => getNotifSettings()); const [savedPrefs, setSavedPrefs] = useState(() => ({ theme: loadLocalTheme(), view: "grid", - performanceMode: localStorage.getItem(PERF_KEY) === "1", + performanceMode: getAccountRaw(PERF_KEY) === "1", hints: hintsEnabled(), notifications: getNotifSettings(), })); @@ -177,7 +204,7 @@ export default function App() { }, []); const [wizardOpen, setWizardOpen] = useState(false); // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render. - const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all"); + const [channelFilterRaw, setChannelFilter] = useAccountPersistedState(LS.channelFilter, "all"); const channelFilter: ChannelStatusFilter = ( ["needs_full", "fully_synced", "hidden", "all"] as const ).includes(channelFilterRaw as ChannelStatusFilter) @@ -190,7 +217,7 @@ export default function App() { // persisted so navigation intents that target the subscriptions table — the header's // "without full history" link, focus-channel — can force it back to "subscribed" instead // of dumping the user on whatever tab they last left open. - const [channelsViewRaw, setChannelsView] = usePersistedState(LS.channelsView, "subscribed"); + const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed"); const channelsView: ChannelsView = channelsViewRaw === "discovery" ? "discovery" : "subscribed"; // Bumped to tell the channel manager to drop a stale column filter when we send the user @@ -217,7 +244,9 @@ export default function App() { function setFilters(next: FeedFilters) { setFiltersState(next); - localStorage.setItem(FILTERS_KEY, JSON.stringify(next)); + // Persist under THIS tab's active account so filters don't bleed between accounts. + const key = accountKey(FILTERS_KEY, getActiveAccount()); + if (key) writeJSON(key, next); } function setPage(next: Page) { @@ -227,7 +256,7 @@ export default function App() { const go = () => { setChannelView(null); // leave any open channel page when navigating via the rail setPageState(next); - localStorage.setItem(PAGE_KEY, next); + setAccountRaw(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). @@ -257,13 +286,33 @@ export default function App() { api.savePrefs({ sidebarLayout: next }).catch(() => {}); } + // Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the + // user's preferences so it follows the account across devices; a localStorage cache seeds the + // initial render synchronously so nothing flashes open before the server prefs arrive. + const [navCollapsed, setNavCollapsedState] = useState(() => + Boolean(readAccount(LS.navCollapsed, false)) + ); + const [filterCollapsed, setFilterCollapsedState] = useState(() => + Boolean(readAccount(LS.filterCollapsed, false)) + ); + function setNavCollapsed(next: boolean) { + setNavCollapsedState(next); + writeAccount(LS.navCollapsed, next); + api.savePrefs({ navCollapsed: next }).catch(() => {}); + } + function setFilterCollapsed(next: boolean) { + setFilterCollapsedState(next); + writeAccount(LS.filterCollapsed, next); + api.savePrefs({ filterCollapsed: next }).catch(() => {}); + } + useEffect(() => applyTheme(theme), [theme]); // Apply the draft prefs locally for instant preview (their localStorage mirrors update via // the stores too); persistence to the server is deferred to an explicit Save. useEffect(() => { document.documentElement.dataset.perf = perf ? "1" : ""; - localStorage.setItem(PERF_KEY, perf ? "1" : "0"); + setAccountRaw(PERF_KEY, perf ? "1" : "0"); }, [perf]); useEffect(() => setHintsEnabled(hints), [hints]); useEffect(() => configureNotifications(notif), [notif]); @@ -274,7 +323,8 @@ export default function App() { useEffect(() => { const params = new URLSearchParams(window.location.search); if (hasFilterParams(params) || params.has("page")) { - localStorage.setItem(FILTERS_KEY, JSON.stringify(filters)); + // A share link's filters are held in state; they're persisted to the account's key by the + // post-login effect (which knows the account id). Here we only clean the address bar. stripUrlParams(); } }, []); // eslint-disable-line react-hooks/exhaustive-deps @@ -309,12 +359,12 @@ export default function App() { if (!ok) return; discardRef.current(); setPageState(p); - localStorage.setItem(PAGE_KEY, p); + setAccountRaw(PAGE_KEY, p); }); return; } setPageState(p); - localStorage.setItem(PAGE_KEY, p); + setAccountRaw(PAGE_KEY, p); // The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or // clears it to match the entry we landed on (and a player popped first leaves it intact). setYtSearch((e.state?._yt as string) ?? null); @@ -347,6 +397,35 @@ export default function App() { refetchInterval: (query) => (query.state.data ? 60_000 : false), }); + // Pin this tab to whatever account it first loaded as. Without this, a tab that never picked + // an account (it was riding the session's default) would silently swap identity when ANOTHER + // tab changes the default — e.g. that other tab adds/switches an account. Once pinned, this + // tab always sends its own account header, so cross-tab changes can't reach it. An explicit + // switch on THIS tab sets the override itself (so this no-ops then). + useEffect(() => { + if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id); + }, [meQuery.data?.id]); + + // Load THIS account's own filters when the account first resolves or changes (login / switch / + // add). Filters are per-account localStorage state, so another account's last view (incl. its + // default saved view) must never carry over. A share link's filters (captured at mount) win and + // are persisted to the account instead. Runs on id change only, so a 60s background refetch of + // the same account never clobbers the filters you're editing. + useEffect(() => { + const id = meQuery.data?.id; + if (id == null) return; + if (initialHadUrlFilters) { + const key = accountKey(FILTERS_KEY, id); + if (key) writeJSON(key, filters); + return; + } + let f = loadAccountFilters(id); + // The shared demo account has no subscriptions, so default it to the whole library. + if (meQuery.data?.is_demo) f = { ...f, scope: "all" }; + setFiltersState(f); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [meQuery.data?.id]); + // Once signed in, drop any leftover query string from the pre-login flow (e.g. // ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the // address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params. @@ -376,7 +455,7 @@ export default function App() { if (result === "ok") { notify({ level: "success", message: t("settings.account.googleLink.linked") }); void meQuery.refetch(); - localStorage.setItem(LS.settingsTab, "account"); + setAccountRaw(LS.settingsTab, "account"); setPage("settings"); } else { const key = result === "conflict" || result === "mismatch" ? result : "error"; @@ -404,7 +483,7 @@ export default function App() { performanceMode: typeof prefs.performanceMode === "boolean" ? prefs.performanceMode - : localStorage.getItem(PERF_KEY) === "1", + : getAccountRaw(PERF_KEY) === "1", hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(), notifications: prefs.notifications ? { ...getNotifSettings(), ...prefs.notifications } @@ -423,13 +502,17 @@ export default function App() { setSidebarLayoutState(l); saveLayoutLocal(l); } - if (isSupported(prefs.language)) setLanguage(prefs.language); - // The demo account is shared: there are no subscriptions, so default it to the whole - // library (the "my" feed would be empty). The communal-state warning is a permanent - // banner (see DemoBanner below), not a toast that re-pops on every reload. - if (meQuery.data?.is_demo) { - setFilters({ ...loadStoredFilters(), scope: "all" }); + if (typeof prefs.navCollapsed === "boolean") { + setNavCollapsedState(prefs.navCollapsed); + writeAccount(LS.navCollapsed, prefs.navCollapsed); } + if (typeof prefs.filterCollapsed === "boolean") { + setFilterCollapsedState(prefs.filterCollapsed); + writeAccount(LS.filterCollapsed, prefs.filterCollapsed); + } + if (isSupported(prefs.language)) setLanguage(prefs.language); + // (Per-account filters — incl. the demo account's whole-library default — are loaded by the + // dedicated effect above, which also covers accounts that have no stored preferences.) // eslint-disable-next-line react-hooks/exhaustive-deps }, [meQuery.data?.id]); @@ -564,7 +647,29 @@ export default function App() { onOpenAbout={() => setAboutOpen(true)} onChangeLanguage={changeLanguage} language={i18n.language as LangCode} + collapsed={navCollapsed} + onToggleCollapse={() => setNavCollapsed(!navCollapsed)} + onGoToFullHistory={() => { + setChannelFilter("needs_full"); + setChannelsView("subscribed"); // the status filter applies to the subscriptions tab + setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows + setPage("channels"); + }} /> + {/* Full-height filter sidebar: feed only, and hidden while a channel page overlays the + content column. Sits beside the nav rail as its own collapsible column. */} + {page === "feed" && !channelView && ( + setFilterCollapsed(!filterCollapsed)} + /> + )}
{channelView ? ( { - setChannelFilter("needs_full"); - setChannelsView("subscribed"); // the status filter applies to the subscriptions tab - setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows - setPage("channels"); - }} /> {meQuery.data!.is_demo && } openReleaseNotes(CURRENT_VERSION)} /> -
- {page === "feed" && ( - - )}
{page === "channels" ? ( )}
-
)} {/* Toasts rise from the bottom-left, by the notification bell in the nav rail. diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 356b99d..9e43dec 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react"; import { api, type AdminUserRow, type Invite, type Me } from "../lib/api"; import { notify } from "../lib/notifications"; -import { LS } from "../lib/storage"; +import { LS, setAccountRaw } from "../lib/storage"; import Tooltip from "./Tooltip"; import { Section } from "./ui/form"; import { useConfirm } from "./ConfirmProvider"; @@ -20,7 +20,7 @@ export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab; export const ADMIN_USERS_ACCESS_TAB = "access"; export function focusAccessRequestsTab(): void { - localStorage.setItem(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB); + setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB); } export default function AdminUsers({ me }: { me: Me }) { diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 62deae1..f51a7fe 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { UserPlus } from "lucide-react"; import { api, HttpError, type DiscoveredChannel } from "../lib/api"; +import { accountKey } from "../lib/storage"; import { formatViews } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; @@ -173,7 +174,7 @@ export default function ChannelDiscovery({ rows={rows} columns={columns} rowKey={(c) => c.id} - persistKey="siftlode.channelDiscoveryTable" + persistKey={accountKey("siftlode.channelDiscoveryTable") ?? undefined} controlsPosition="top" emptyText={t("channels.discovery.empty")} /> diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 26e4fa7..0729a4d 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -17,6 +17,7 @@ import { X, } from "lucide-react"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; +import { accountKey } from "../lib/storage"; import { useDismiss } from "../lib/useDismiss"; import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; @@ -569,7 +570,7 @@ export default function Channels({ rows={visibleChannels} columns={columns} rowKey={(c) => c.id} - persistKey="siftlode.channelsTable" + persistKey={accountKey("siftlode.channelsTable") ?? undefined} controlsPosition="top" controlsLeading={statusChips} rowClassName={(c) => (c.hidden ? "opacity-60" : "")} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 9938a48..2dc8024 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,25 +1,22 @@ import { useTranslation } from "react-i18next"; -import { Library, Search, User, X, Youtube } from "lucide-react"; +import { Search, X, Youtube } from "lucide-react"; import type { FeedFilters, Me } from "../lib/api"; import type { Page } from "../lib/urlState"; -import SyncStatus from "./SyncStatus"; -// 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. +// Contextual top bar over the content column. Primary navigation, account and the per-user sync +// status live in the left NavSidebar; the feed's scope toggle now lives in the filter sidebar. +// The header carries just the feed search (or the current page title). export default function Header({ me, filters, setFilters, page, - onGoToFullHistory, onYtSearch, }: { me: Me; filters: FeedFilters; setFilters: (f: FeedFilters) => void; page: Page; - onGoToFullHistory: () => void; // Trigger a live YouTube search for the current term (hidden for the demo account, which // can't spend the shared quota). onYtSearch: (q: string) => void; @@ -30,37 +27,6 @@ export default function Header({ return (
- - - {page === "feed" && ( -
- {(["my", "all"] as const).map((s) => ( - - ))} -
- )} - {page === "feed" ? (
diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index 598f3d6..eaaf244 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -92,9 +92,14 @@ export default function LanguageSwitcher({ onClick={toggleRail} title={current.label} aria-label={current.label} - className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition" + className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition" > + {/* Current-language badge (HU/EN/DE) so the active language reads at a glance without + opening the menu. The ring matches the page background to detach it from the icon. */} + + {current.code.toUpperCase()} + {open && createPortal( diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index c3ef325..db5dde3 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -20,14 +20,14 @@ import { Tv, UserPlus, } from "lucide-react"; -import { api, type Me } from "../lib/api"; +import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { getUnreadCount, subscribe } from "../lib/notifications"; -import { LS } from "../lib/storage"; import type { Page } from "../lib/urlState"; import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; import LanguageSwitcher from "./LanguageSwitcher"; +import SyncStatus from "./SyncStatus"; // 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 @@ -39,6 +39,9 @@ export default function NavSidebar({ onOpenAbout, onChangeLanguage, language, + collapsed, + onToggleCollapse, + onGoToFullHistory, }: { me: Me; page: Page; @@ -46,11 +49,13 @@ export default function NavSidebar({ onOpenAbout: () => void; onChangeLanguage: (code: LangCode) => void; language: LangCode; + // Collapse state is owned by App (persisted to the user's preferences); the rail just reflects + // it and asks App to flip it. + collapsed: boolean; + onToggleCollapse: () => void; + onGoToFullHistory: () => void; }) { const { t } = useTranslation(); - const [collapsed, setCollapsed] = useState( - () => localStorage.getItem(LS.navCollapsed) === "1" - ); const [acctOpen, setAcctOpen] = useState(false); const acctBtnRef = useRef(null); const acctPanelRef = useRef(null); @@ -88,16 +93,15 @@ export default function NavSidebar({ }; }, [acctOpen]); - function toggleCollapsed() { - setCollapsed((c) => { - const next = !c; - localStorage.setItem(LS.navCollapsed, next ? "1" : "0"); - return next; - }); - } - async function logout() { - await fetch("/auth/logout", { method: "POST", credentials: "include" }); + // Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then + // drops this tab's override and reloads onto whatever account remains the default. + try { + await api.logout(); + } catch { + /* clear locally and reload regardless */ + } + clearActiveAccount(); location.reload(); } @@ -109,21 +113,20 @@ export default function NavSidebar({ }); const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active); - async function switchTo(id: number) { - try { - await api.switchAccount(id); - // Drop the previous account's in-module sub-view/overlay before the reload (which would - // otherwise preserve history.state across the swap). Otherwise the new account lands on a - // stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity - // (often the new account's own id → an empty self-thread). Start at the module root instead. - const st = { ...(window.history.state || {}) }; - delete st._sub; - delete st._ov; - window.history.replaceState(st, ""); - location.reload(); // cleanest way to reload all per-user state for the new account - } catch { - /* a revoked/removed account — leave the popover open */ - } + function switchTo(id: number) { + // Per-tab switch: point THIS tab at the account and reload; other tabs keep their own + // identity. No server round-trip — req() sends the account header (validated against the + // browser wallet server-side), so the cookie's default account is left untouched. + setActiveAccount(id); + // Drop the previous account's in-module sub-view/overlay before the reload (which would + // otherwise preserve history.state across the swap). Otherwise the new account lands on a + // stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity + // (often the new account's own id → an empty self-thread). Start at the module root instead. + const st = { ...(window.history.state || {}) }; + delete st._sub; + delete st._ov; + window.history.replaceState(st, ""); + location.reload(); // cleanest way to reload all per-user state for the new account } // Durable, server-backed unread count for the inbox badge. Polled live (pauses when the @@ -171,6 +174,21 @@ export default function NavSidebar({ 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]; + // Role shown next to the name (expanded) / as an avatar dot (collapsed). Demo isn't a real + // role (it's a flag on a `user` row), so surface it first. + const roleKey: "admin" | "user" | "demo" = me.is_demo + ? "demo" + : me.role === "admin" + ? "admin" + : "user"; + const roleChipCls = + roleKey === "admin" + ? "bg-accent/15 text-accent" + : roleKey === "demo" + ? "bg-amber-500/20 text-amber-500" + : "bg-muted/15 text-muted"; + const roleDotCls = + roleKey === "admin" ? "bg-accent" : roleKey === "demo" ? "bg-amber-500" : "bg-muted"; const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => { const active = page === p; @@ -232,7 +250,7 @@ export default function NavSidebar({ )}
+ {/* Per-user sync status (video counts + live sync state), moved out of the old top bar. */} +
+ +
+
{userItems.map(renderItem)} - {systemItems.length > 0 && ( -
- )} + {systemItems.length > 0 && + (collapsed ? ( + // Collapsed rail: a short, thicker centred rule is the clearest "new section" cue + // when there's no room for a label. +
+ ) : ( +
+ + {t("nav.adminSection")} + + +
+ ))} {systemItems.map(renderItem)}
@@ -290,12 +328,27 @@ export default function NavSidebar({ title={collapsed ? name : undefined} className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`} > - + + + {collapsed && ( + + )} + {!collapsed && {name}} + {!collapsed && ( + + {t(`nav.role.${roleKey}`)} + + )} {acctOpen && @@ -349,6 +402,10 @@ export default function NavSidebar({
+ + + ); + } + + return ( +