Merge: facelift epic — per-tab accounts, full-height collapsible layout, per-account state, nav polish, open-source Welcome link

This commit is contained in:
npeter83 2026-07-02 02:18:40 +02:00
commit f56328a437
35 changed files with 722 additions and 204 deletions

View file

@ -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

View file

@ -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)

View file

@ -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 clientserver 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

View file

@ -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<FeedFilters | null>(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<FeedFilters | null>(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<Page>("feed");
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [filters, setFiltersState] = useState<FeedFilters>(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<NotifSettings>(() => getNotifSettings());
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
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>(() =>
Boolean(readAccount(LS.navCollapsed, false))
);
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
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 && (
<Sidebar
filters={filters}
setFilters={setFilters}
layout={sidebarLayout}
setLayout={setSidebarLayout}
onFocusChannel={focusChannel}
isDemo={meQuery.data!.is_demo}
collapsed={filterCollapsed}
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
/>
)}
<div className="relative flex-1 min-w-0 flex flex-col">
{channelView ? (
<ChannelPage
@ -584,26 +689,9 @@ export default function App() {
setFilters={setFilters}
page={page}
onYtSearch={enterYtSearch}
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");
}}
/>
{meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
<div className="flex flex-1 min-h-0">
{page === "feed" && (
<Sidebar
filters={filters}
setFilters={setFilters}
layout={sidebarLayout}
setLayout={setSidebarLayout}
onFocusChannel={focusChannel}
isDemo={meQuery.data!.is_demo}
/>
)}
<main className="flex-1 min-w-0 overflow-y-auto">
{page === "channels" ? (
<Channels
@ -661,7 +749,6 @@ export default function App() {
/>
)}
</main>
</div>
</>
)}
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.

View file

@ -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 }) {

View file

@ -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")}
/>

View file

@ -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" : "")}

View file

@ -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 (
<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" && (
<div
className="flex items-center rounded-full border border-border bg-card p-0.5 text-xs shrink-0"
role="group"
aria-label={t("header.scope.label")}
>
{(["my", "all"] as const).map((s) => (
<button
key={s}
onClick={() => setFilters({ ...filters, scope: s })}
title={t("header.scope." + s + "Tip")}
aria-pressed={filters.scope === s}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full transition ${
filters.scope === s
? "bg-accent text-accent-fg"
: "text-muted hover:text-fg"
}`}
>
{s === "my" ? (
<User className="w-3.5 h-3.5" />
) : (
<Library className="w-3.5 h-3.5" />
)}
<span className="hidden sm:inline">{t("header.scope." + s)}</span>
</button>
))}
</div>
)}
{page === "feed" ? (
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
<div className="flex-1 relative">

View file

@ -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"
>
<Globe className="w-5 h-5" />
{/* 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. */}
<span className="absolute -bottom-1 -right-1 text-[8px] font-bold leading-none px-1 py-[1px] rounded bg-accent text-accent-fg ring-2 ring-bg">
{current.code.toUpperCase()}
</span>
</button>
{open &&
createPortal(

View file

@ -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<boolean>(
() => localStorage.getItem(LS.navCollapsed) === "1"
);
const [acctOpen, setAcctOpen] = useState(false);
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
const acctPanelRef = useRef<HTMLDivElement | null>(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({
</button>
)}
<button
onClick={toggleCollapsed}
onClick={onToggleCollapse}
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"
@ -245,11 +263,31 @@ export default function NavSidebar({
</button>
</div>
{/* Per-user sync status (video counts + live sync state), moved out of the old top bar. */}
<div className="mb-3 pb-3 border-b border-border">
<SyncStatus
isAdmin={me.role === "admin"}
onGoToFullHistory={onGoToFullHistory}
variant="rail"
collapsed={collapsed}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
{userItems.map(renderItem)}
{systemItems.length > 0 && (
<div className="my-1.5 border-t border-border/70" />
)}
{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.
<div className="my-2 mx-auto w-7 border-t-2 border-border" />
) : (
<div className="mt-4 mb-1 px-2.5 flex items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted/70">
{t("nav.adminSection")}
</span>
<span className="flex-1 border-t border-border" />
</div>
))}
{systemItems.map(renderItem)}
</div>
@ -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`}
>
<AvatarImg
src={me.avatar_url}
fallback={name}
className="w-[22px] h-[22px] rounded-full text-[10px] shrink-0"
/>
<span className="relative shrink-0">
<AvatarImg
src={me.avatar_url}
fallback={name}
className="w-[22px] h-[22px] rounded-full text-[10px]"
/>
{collapsed && (
<span
className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full ring-2 ring-bg ${roleDotCls}`}
title={t(`nav.role.${roleKey}`)}
/>
)}
</span>
{!collapsed && <span className="truncate flex-1 text-left">{name}</span>}
{!collapsed && (
<span
className={`shrink-0 text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${roleChipCls}`}
>
{t(`nav.role.${roleKey}`)}
</span>
)}
</button>
{acctOpen &&
@ -349,6 +402,10 @@ export default function NavSidebar({
<div className="mt-2 pt-2 border-t border-border flex flex-col">
<button
onClick={() => {
// Adding an account switches THIS tab to it: drop the tab's pin so that on
// return from Google it adopts the freshly-added account (the new default)
// and pins that. Other tabs keep their own pinned account.
clearActiveAccount();
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"

View file

@ -37,7 +37,7 @@ import {
import { api, type Playlist, type Video } from "../lib/api";
import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications";
import { LS, readMerged, writeJSON } from "../lib/storage";
import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage";
import { useUndoable } from "../lib/useUndoable";
import PlayerModal from "./PlayerModal";
import UndoToolbar from "./UndoToolbar";
@ -188,18 +188,18 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
const confirm = useConfirm();
// Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL).
const [selectedId, setSelectedId] = useState<number | null>(() => {
const s = localStorage.getItem(LS.playlist);
const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null;
});
useEffect(() => {
if (selectedId != null) localStorage.setItem(LS.playlist, String(selectedId));
if (selectedId != null) setAccountRaw(LS.playlist, String(selectedId));
}, [selectedId]);
const selectedRef = useRef<HTMLButtonElement | null>(null);
const scrolledRef = useRef(false);
// How the left playlist rail is ordered (persisted).
const [plSort, setPlSort] = useState<PlSort>(() => readMerged(LS.plSort, PL_SORT_DEFAULT));
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
useEffect(() => {
writeJSON(LS.plSort, plSort);
writeAccount(LS.plSort, plSort);
}, [plSort]);
const [newName, setNewName] = useState("");
const [renaming, setRenaming] = useState(false);

View file

@ -17,10 +17,10 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { api, type FeedFilters, type SavedView } from "../lib/api";
import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
import { filtersToParams, shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications";
import { LS, readJSON, writeJSON } from "../lib/storage";
import { accountKey, LS, readJSON, writeJSON } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider";
// Compact, order-independent signature of a filter set (reuses the share serializer, which
@ -28,15 +28,19 @@ import { useConfirm } from "./ConfirmProvider";
// saved view that matches the feed's current filters.
const keyOf = (f: FeedFilters) => filtersToParams(f).toString();
/** The default view's filters, or null. Read synchronously on load (App.loadInitialFilters)
* from a localStorage mirror the widget keeps in sync avoids a flash before the query loads. */
/** The default view's filters, or null. Read synchronously on load (App.loadAccountFilters) from a
* per-account localStorage mirror the widget keeps in sync avoids a flash before the query loads.
* Per-account so one account's default view never seeds another account's feed. */
export function loadDefaultViewFilters(): FeedFilters | null {
return readJSON<FeedFilters | null>(LS.defaultViewFilters, null);
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
return key ? readJSON<FeedFilters | null>(key, null) : null;
}
function syncDefaultMirror(views: SavedView[]): void {
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
if (!key) return;
const def = views.find((v) => v.is_default);
writeJSON(LS.defaultViewFilters, def ? def.filters : null);
writeJSON(key, def ? def.filters : null);
}
export default function SavedViewsWidget({

View file

@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { Bell, Monitor, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api";
import { LS, usePersistedState } from "../lib/storage";
import { LS, useAccountPersistedState } from "../lib/storage";
import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip";
@ -54,7 +54,7 @@ export default function SettingsPanel({
onOpenWizard: () => void;
}) {
const { t } = useTranslation();
const [tabRaw, setTab] = usePersistedState(LS.settingsTab, "appearance");
const [tabRaw, setTab] = useAccountPersistedState(LS.settingsTab, "appearance");
// Clamp at render so a stale/removed tab id falls back to Appearance.
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
? (tabRaw as TabId)

View file

@ -4,13 +4,17 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Eye,
EyeOff,
GripVertical,
Library,
Pencil,
RefreshCw,
RotateCcw,
Share2,
SlidersHorizontal,
User,
X,
} from "lucide-react";
import {
@ -120,6 +124,8 @@ export default function Sidebar({
setLayout,
onFocusChannel,
isDemo = false,
collapsed,
onToggleCollapse,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
@ -127,6 +133,10 @@ export default function Sidebar({
setLayout: (l: SidebarLayout) => void;
onFocusChannel: (name: string) => void;
isDemo?: boolean;
// Full-height collapse (state owned by App, persisted to the user's preferences), mirroring
// the left nav rail.
collapsed: boolean;
onToggleCollapse: () => void;
}) {
const { t } = useTranslation();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
@ -464,23 +474,91 @@ export default function Sidebar({
? orderedAvailable
: orderedAvailable.filter((id) => !layout.hidden[id]);
return (
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-3 hidden md:block">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold">
{t("sidebar.filters")}
// Collapsed: a thin rail (mirroring the nav) — an expand control plus a filter icon that
// carries the active-filter count so you can tell filters are on without opening it.
if (collapsed) {
return (
<aside className="hidden md:flex w-[46px] shrink-0 flex-col items-center gap-2 border-r border-border bg-surface/40 py-3">
<button
onClick={onToggleCollapse}
title={t("sidebar.expandPanel")}
aria-label={t("sidebar.expandPanel")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={onToggleCollapse}
title={t("sidebar.filters")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<SlidersHorizontal className="w-5 h-5" />
{activeCount > 0 && (
<span className="ml-1.5 text-xs font-medium text-accent">
{t("sidebar.activeCount", { count: activeCount })}
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold leading-none grid place-items-center ring-2 ring-bg">
{activeCount > 9 ? "9+" : activeCount}
</span>
)}
</button>
</aside>
);
}
return (
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-3">
{/* Scope: your own subscriptions (Mine) vs the shared Library — moved out of the top bar. */}
<div
className="flex items-center rounded-full border border-border bg-card p-0.5 text-xs"
role="group"
aria-label={t("header.scope.label")}
>
{(["my", "all"] as const).map((s) => (
<button
key={s}
onClick={() => setFilters({ ...filters, scope: s })}
title={t("header.scope." + s + "Tip")}
aria-pressed={filters.scope === s}
className={`flex-1 inline-flex items-center justify-center gap-1 px-2.5 py-1 rounded-full transition ${
filters.scope === s ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{s === "my" ? (
<User className="w-3.5 h-3.5" />
) : (
<Library className="w-3.5 h-3.5" />
)}
<span>{t("header.scope." + s)}</span>
</button>
))}
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0">
<button
onClick={onToggleCollapse}
title={t("sidebar.collapsePanel")}
aria-label={t("sidebar.collapsePanel")}
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-semibold shrink-0">{t("sidebar.filters")}</span>
{activeCount > 0 && (
// Compact count pill (not "{n} active" text) so it never truncates or forces the
// action buttons to wrap in a narrow / zoomed-in sidebar.
<span
title={t("sidebar.activeCount", { count: activeCount })}
className="shrink-0 min-w-[18px] h-[18px] px-1.5 rounded-full bg-accent/15 text-accent text-[11px] font-semibold inline-flex items-center justify-center tabular-nums"
>
{activeCount}
</span>
)}
</div>
<div className="flex items-center gap-0.5">
<div className="flex items-center gap-0.5 shrink-0">
{!editing && (
<button
onClick={clearAll}
disabled={!active}
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1"
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1 whitespace-nowrap"
>
{t("sidebar.clearAll")}
</button>

View file

@ -5,7 +5,7 @@ import { History, Pause, Play, RefreshCw } from "lucide-react";
import { api, type AdminQuotaRow, type Me } from "../lib/api";
import { formatEta, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications";
import { LS, usePersistedState } from "../lib/storage";
import { LS, useAccountPersistedState } from "../lib/storage";
import Tooltip from "./Tooltip";
import { Section, SettingRow } from "./ui/form";
@ -18,7 +18,7 @@ type StatsTab = "overview" | "system";
export default function Stats({ me }: { me: Me }) {
const { t } = useTranslation();
const isAdmin = me.role === "admin";
const [tabRaw, setTab] = usePersistedState(LS.statsTab, "overview");
const [tabRaw, setTab] = useAccountPersistedState(LS.statsTab, "overview");
// Clamp at render: only admins may land on the System tab (covers a stale stored value).
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";

View file

@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api";
import { formatViews } from "../lib/format";
import Tooltip from "./Tooltip";
@ -11,9 +11,15 @@ import Tooltip from "./Tooltip";
export default function SyncStatus({
isAdmin,
onGoToFullHistory,
variant = "bar",
collapsed = false,
}: {
isAdmin: boolean;
onGoToFullHistory: () => void;
// "bar" = the legacy horizontal top-bar row. "rail" = a compact block for the top of the
// left nav sidebar (stacked; icon-only with a tooltip when the rail is collapsed).
variant?: "bar" | "rail";
collapsed?: boolean;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
@ -51,6 +57,94 @@ export default function SyncStatus({
const active = data.sync_active;
const showMain = data.paused || active || syncing > 0 || notFull === 0;
// Calm one-liner describing the current sync state; shared by the bar and rail layouts.
const stateNode = data.paused ? (
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
) : active ? (
<span className="flex items-center gap-1">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{syncing > 0
? t("header.sync.syncing", { count: syncing })
: notFull > 0
? t("header.sync.backfillingHistory")
: t("header.sync.working")}
</span>
) : syncing > 0 ? (
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{t("header.sync.recentQueued", { count: syncing })}
</span>
) : (
// A small check anchors the "all synced" state so it doesn't read as orphaned text.
<span className="flex items-center gap-1 text-emerald-500">
<CheckCircle2 className="w-3.5 h-3.5" />
<span className="text-muted">{t("header.sync.allSynced")}</span>
</span>
);
const pauseBtn = isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
<button
onClick={() => toggle.mutate()}
disabled={toggle.isPending}
title={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
className="p-1 rounded-md hover:bg-card hover:text-fg transition"
>
{data.paused ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />}
</button>
);
// Rail: a compact block at the top of the left nav. Collapsed → a single icon (spinner while
// syncing) with the counts in a tooltip; a small accent dot flags paused / missing-history.
if (variant === "rail") {
const countsText = `${formatViews(data.my_videos)} ${t("header.sync.yours")} / ${formatViews(
data.total_videos
)} ${t("header.sync.total")}`;
if (collapsed) {
return (
<Tooltip hint={countsText}>
<div className="relative grid place-items-center py-1 text-muted">
{active ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Database className="w-4 h-4" />
)}
{(data.paused || notFull > 0) && (
<span className="absolute top-0 right-1 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
)}
</div>
</Tooltip>
);
}
return (
<div className="text-[11px] text-muted leading-snug space-y-1">
<Tooltip hint={t("header.sync.countTooltip")}>
<span className="flex items-center gap-1.5 cursor-default">
<Database className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
{t("header.sync.yours")}
<span className="opacity-40"> / </span>
{formatViews(data.total_videos)} {t("header.sync.total")}
</span>
</span>
</Tooltip>
{showMain && <div className="flex items-center gap-1.5">{stateNode}</div>}
{notFull > 0 && (
<Tooltip hint={t("header.sync.fullHistoryTooltip")}>
<button
onClick={onGoToFullHistory}
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
>
<History className="w-3.5 h-3.5" />
{t("header.sync.withoutFullHistory", { count: notFull })}
</button>
</Tooltip>
)}
{pauseBtn && <div className="pt-0.5">{pauseBtn}</div>}
</div>
);
}
return (
<div className="hidden lg:flex items-center gap-2 text-xs text-muted">
<Tooltip hint={t("header.sync.countTooltip")}>

View file

@ -1,4 +1,4 @@
import { usePersistedState } from "../lib/storage";
import { useAccountPersistedState } from "../lib/storage";
// Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…).
// The active tab is persisted to localStorage so a reload (F5) keeps the user where they were,
@ -10,9 +10,9 @@ export interface TabDef {
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
}
/** Persisted active-tab state the canonical helper now lives in lib/storage as
* `usePersistedState`; re-exported here under its original name for existing call sites. */
export const usePersistedTab = usePersistedState;
/** Persisted active-tab state, scoped to the current account (so one account's last tab doesn't
* carry into another in the same browser) re-exported under its original name for call sites. */
export const usePersistedTab = useAccountPersistedState;
export default function Tabs({
tabs,

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowRight,
Code2,
Expand,
Filter,
@ -64,6 +65,29 @@ export default function Welcome() {
onOpen={open}
/>
{/* Open source Siftlode's code is public; nudge visitors to read it or self-host. The
footer keeps a compact link too. */}
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="group mt-8 flex flex-col items-center gap-x-4 gap-y-2 rounded-2xl border border-accent/25 bg-accent/[0.06] px-5 py-4 text-center sm:flex-row sm:text-left hover:border-accent/50 transition"
>
<span className="grid place-items-center w-10 h-10 rounded-xl bg-accent/15 text-accent shrink-0">
<Code2 className="w-5 h-5" />
</span>
<span className="flex-1">
<span className="block font-semibold">{t("welcome.openSource.title")}</span>
<span className="block text-sm text-muted leading-relaxed">
{t("welcome.openSource.body")}
</span>
</span>
<span className="inline-flex items-center gap-1 text-sm font-medium text-accent whitespace-nowrap">
{t("welcome.openSource.link")}
<ArrowRight className="w-4 h-4 transition group-hover:translate-x-0.5" />
</span>
</a>
{/* Features */}
<section className="mt-14">
<h2 className="text-2xl font-semibold text-center">{t("welcome.features.heading")}</h2>

View file

@ -1,5 +1,11 @@
{
"primary": "Hauptnavigation",
"collapse": "Seitenleiste einklappen",
"expand": "Seitenleiste ausklappen"
"expand": "Seitenleiste ausklappen",
"adminSection": "Admin",
"role": {
"admin": "Admin",
"user": "Benutzer",
"demo": "Demo"
}
}

View file

@ -16,6 +16,8 @@
"hideWidget": "Widget ausblenden",
"expand": "Ausklappen",
"collapse": "Einklappen",
"collapsePanel": "Filter einklappen",
"expandPanel": "Filter ausklappen",
"any": "Beliebig",
"all": "Alle",
"tagModeTooltip": "Beliebige vs. alle ausgewählten Tags treffen",

View file

@ -4,6 +4,11 @@
"subtitle": "Jeder Upload der Kanäle, denen du folgst, in einem aufgeräumten, filterbaren Feed — kein Algorithmus entscheidet, was du siehst, und kein Shorts- oder Livestream-Lärm, außer du willst es.",
"trust": "Selbst gehostet und privat. Melde dich per E-Mail an oder fahre mit Google fort."
},
"openSource": {
"title": "Kostenlos und quelloffen",
"body": "Der Code von Siftlode ist öffentlich — sieh nach, wie es funktioniert, betreibe deine eigene private Instanz oder melde ein Problem.",
"link": "Quellcode ansehen"
},
"preview": {
"feed": "Der Feed — filtern nach Kanal, Sprache, Thema, Länge und Datum.",
"channels": "Kanal-Verwaltung",

View file

@ -1,5 +1,11 @@
{
"primary": "Primary navigation",
"collapse": "Collapse sidebar",
"expand": "Expand sidebar"
"expand": "Expand sidebar",
"adminSection": "Admin",
"role": {
"admin": "Admin",
"user": "User",
"demo": "Demo"
}
}

View file

@ -16,6 +16,8 @@
"hideWidget": "Hide widget",
"expand": "Expand",
"collapse": "Collapse",
"collapsePanel": "Collapse filters",
"expandPanel": "Expand filters",
"any": "Any",
"all": "All",
"tagModeTooltip": "Match any vs all selected tags",

View file

@ -4,6 +4,11 @@
"subtitle": "Every upload from the channels you follow in one clean, filterable feed — no algorithm deciding what you see, and no Shorts or livestream noise unless you want it.",
"trust": "Self-hosted and private. Sign in with email, or continue with Google."
},
"openSource": {
"title": "Free and open source",
"body": "Siftlode's code is public — see how it works, run your own private instance, or open an issue.",
"link": "View the source"
},
"preview": {
"feed": "The feed — filter by channel, language, topic, length and date.",
"channels": "Channel manager",

View file

@ -1,5 +1,11 @@
{
"primary": "Fő navigáció",
"collapse": "Oldalsáv összecsukása",
"expand": "Oldalsáv kinyitása"
"expand": "Oldalsáv kinyitása",
"adminSection": "Admin",
"role": {
"admin": "Admin",
"user": "Felhasználó",
"demo": "Demó"
}
}

View file

@ -16,6 +16,8 @@
"hideWidget": "Modul elrejtése",
"expand": "Kibontás",
"collapse": "Összecsukás",
"collapsePanel": "Szűrők összecsukása",
"expandPanel": "Szűrők kibontása",
"any": "Bármelyik",
"all": "Összes",
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",

View file

@ -4,6 +4,11 @@
"subtitle": "Minden feltöltés a követett csatornáidról egyetlen tiszta, szűrhető hírfolyamban — nincs algoritmus, ami eldönti, mit látsz, és nincs Shorts- vagy élő-zaj, hacsak nem kéred.",
"trust": "Self-hosted és privát. Lépj be e-maillel, vagy folytasd Google-fiókkal."
},
"openSource": {
"title": "Ingyenes és nyílt forráskódú",
"body": "A Siftlode kódja nyilvános — nézd meg, hogyan működik, futtass saját privát példányt, vagy nyiss egy hibajegyet.",
"link": "Forráskód megtekintése"
},
"preview": {
"feed": "A hírfolyam — szűrés csatorna, nyelv, téma, hossz és dátum szerint.",
"channels": "Csatorna-kezelő",

View file

@ -1,6 +1,7 @@
import { notify, remove } from "./notifications";
import i18n from "../i18n";
import { reportError } from "./errorDialog";
import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "./storage";
export interface Me {
id: number;
@ -266,6 +267,29 @@ const setupHeaders = (token: string) => ({
"X-Setup-Token": token,
});
// --- Per-tab active account --------------------------------------------------------------
// The signed session cookie is the browser's account "wallet"; which account a given TAB acts
// as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default.
// The storage key + reader live in storage.ts (so its per-account helpers can use them too).
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
export const getActiveAccount = activeAccountId;
export function setActiveAccount(id: number): void {
try {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
} catch {
/* ignore */
}
}
export function clearActiveAccount(): void {
try {
sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
} catch {
/* ignore */
}
}
async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
const method = opts.method ?? "GET";
const canRetry = cfg.idempotent ?? method === "GET";
@ -274,9 +298,15 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
for (;;) {
let r: Response;
try {
const active = getActiveAccount();
r = await fetch(url, {
credentials: "include",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
// Per-tab identity override (only setup calls pass their own headers, and those are
// pre-auth, so this is safe to put in the defaults that `...opts` may replace).
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
},
...opts,
});
} catch (e) {
@ -600,6 +630,10 @@ export const api = {
req("/api/me/account", { method: "DELETE" }),
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
// Signs the current tab's active account out of the browser wallet (per-tab aware via the
// X-Siftlode-Account header that req() attaches).
logout: (): Promise<{ ok: boolean; switched: boolean }> =>
req("/auth/logout", { method: "POST" }),
version: (): Promise<VersionInfo> => req("/api/version"),
tags: (): Promise<Tag[]> => req("/api/tags"),
status: (): Promise<SyncStatus> => req("/api/sync/status"),

View file

@ -2,14 +2,10 @@
// captions on hover so the UI is somewhat self-documenting for new users. Experienced
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
import { createStore } from "./store";
import { LS } from "./storage";
import { getAccountRaw, LS, setAccountRaw } from "./storage";
function load(): boolean {
try {
return localStorage.getItem(LS.hints) !== "0"; // default ON
} catch {
return true;
}
return getAccountRaw(LS.hints) !== "0"; // default ON (also when the account isn't known yet)
}
const store = createStore<boolean>(load());
@ -19,9 +15,5 @@ export const subscribeHints = store.subscribe;
export function setHintsEnabled(v: boolean): void {
store.set(v);
try {
localStorage.setItem(LS.hints, v ? "1" : "0");
} catch {
/* ignore */
}
setAccountRaw(LS.hints, v ? "1" : "0");
}

View file

@ -2,7 +2,7 @@
// to all of a user's open tabs when a message is stored; subscribers react (e.g. refetch the
// thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the
// safety net if the socket is down.
import type { Message, MessageUser } from "./api";
import { getActiveAccount, type Message, type MessageUser } from "./api";
// A pushed message plus both parties, so the dock can open/flash the right window.
export interface IncomingMessage {
@ -20,7 +20,11 @@ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function url(): string {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}/api/messages/ws`;
// A WebSocket can't carry the X-Siftlode-Account header, so the per-tab account rides in the
// query string (validated against the browser wallet server-side).
const account = getActiveAccount();
const qs = account != null ? `?account=${account}` : "";
return `${proto}://${location.host}/api/messages/ws${qs}`;
}
function open() {

View file

@ -2,7 +2,7 @@
// the Notification Center shows (info events, actions awaiting interaction, and app
// errors). History is persisted to localStorage so it survives reloads; action
// callbacks are live-only and dropped on reload.
import { LS, readJSON, readMerged, writeJSON } from "./storage";
import { LS, readAccount, readAccountMerged, writeAccount } from "./storage";
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
@ -87,7 +87,7 @@ const DEFAULT_SETTINGS: NotifSettings = { sound: false, durationMs: 6000 };
let config: NotifSettings = loadSettings();
function loadSettings(): NotifSettings {
return readMerged(SETTINGS_KEY, DEFAULT_SETTINGS);
return readAccountMerged(SETTINGS_KEY, DEFAULT_SETTINGS);
}
export function getNotifSettings(): NotifSettings {
@ -96,7 +96,7 @@ export function getNotifSettings(): NotifSettings {
export function configureNotifications(p: Partial<NotifSettings>): void {
config = { ...config, ...p };
writeJSON(SETTINGS_KEY, config);
writeAccount(SETTINGS_KEY, config);
}
let audioCtx: AudioContext | null = null;
@ -130,7 +130,7 @@ let cachedUnread = 0;
function load(): Notification[] {
try {
const raw = readJSON<unknown>(HISTORY_KEY, []);
const raw = readAccount<unknown>(HISTORY_KEY, []);
if (!Array.isArray(raw)) return [];
// Restored entries are history only. Mark them dismissed so they don't
// resurrect as active toasts on reload — their auto-dismiss timers aren't
@ -147,7 +147,7 @@ function persist() {
// Transient status notices are live-session only — never write them to history,
// so a reload can't leave one stranded with no producer left to clear it.
const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n);
writeJSON(HISTORY_KEY, slim);
writeAccount(HISTORY_KEY, slim);
} catch {
/* ignore quota / serialization errors */
}

View file

@ -12,9 +12,11 @@
// suppresses the auto-popup on future logins (they
// can still reopen it from Settings → Account).
import { LS } from "./storage";
import { getAccountRaw, LS, setAccountRaw } from "./storage";
export const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage)
// Per-account (see accountKey): a fresh account should still get the onboarding nudge even if a
// different account in the same browser dismissed it.
export const ONBOARD_DISMISSED = LS.onboardingDismissed;
export function shouldAutoOpenOnboarding(me: {
@ -28,7 +30,7 @@ export function shouldAutoOpenOnboarding(me: {
// email+password only — then there's nothing to connect, so don't nudge.
if (me.google_enabled === false) return false;
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
return !me.can_read && !localStorage.getItem(ONBOARD_DISMISSED);
return !me.can_read && !getAccountRaw(ONBOARD_DISMISSED);
}
/** Mark a grant as in progress, then send the user to Google's consent screen. */
@ -40,5 +42,5 @@ export function beginGrant(access: "read" | "write"): void {
/** Leave the wizard. `dismiss` suppresses the future auto-popup (used when read is skipped). */
export function endOnboarding(dismiss: boolean): void {
sessionStorage.removeItem(ONBOARD_ACTIVE);
if (dismiss) localStorage.setItem(ONBOARD_DISMISSED, "1");
if (dismiss) setAccountRaw(ONBOARD_DISMISSED, "1");
}

View file

@ -4,7 +4,7 @@
// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
import { LS, readJSON, writeJSON } from "./storage";
import { LS, readAccount, writeAccount } from "./storage";
export type WidgetId = "savedviews" | "date" | "language" | "topic" | "tags";
@ -46,9 +46,9 @@ export function normalizeLayout(raw: unknown): SidebarLayout {
}
export function loadLayout(): SidebarLayout {
return normalizeLayout(readJSON<unknown>(LS.sidebarLayout, {}));
return normalizeLayout(readAccount<unknown>(LS.sidebarLayout, {}));
}
export function saveLayoutLocal(l: SidebarLayout): void {
writeJSON(LS.sidebarLayout, l);
writeAccount(LS.sidebarLayout, l);
}

View file

@ -20,6 +20,7 @@ export const LS = {
statsTab: "siftlode.statsTab",
adminUsersTab: "siftlode.adminUsersTab",
navCollapsed: "siftlode.navCollapsed",
filterCollapsed: "siftlode.filterCollapsed",
playlist: "siftlode.playlist",
plSort: "siftlode.plSort",
notifHistory: "siftlode.notifications",
@ -29,6 +30,62 @@ export const LS = {
chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
} as const;
// --- per-account scoping -----------------------------------------------------------------
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts). Duplicated
// here (rather than imported from api.ts) so storage.ts stays dependency-free and usable by the
// low-level helpers below.
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
export function activeAccountId(): number | null {
try {
const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
/** Suffix a base localStorage key with an account id so per-account UI state (filters, selected
* playlist, notifications, tab positions, cached prefs) is NOT shared across the accounts signed
* into one browser a bare shared key leaks one account's state into another (and, with per-tab
* accounts, two tabs would fight over it). Defaults to the current tab's active account; returns
* null when no account is known yet, so callers fall back to defaults / skip persistence instead
* of touching another account's data. */
export function accountKey(base: string, id: number | null | undefined = activeAccountId()): string | null {
return id != null ? `${base}.${id}` : null;
}
/** Per-account variants of the read/write helpers: key by the current account, no-op / return the
* fallback when the account isn't known (avoids reading or writing a shared/other-account key). */
export function readAccount<T>(base: string, fallback: T): T {
const k = accountKey(base);
return k ? readJSON(k, fallback) : fallback;
}
export function readAccountMerged<T extends object>(base: string, defaults: T): T {
const k = accountKey(base);
return k ? readMerged(k, defaults) : { ...defaults };
}
export function writeAccount(base: string, value: unknown): void {
const k = accountKey(base);
if (k) writeJSON(k, value);
}
export function getAccountRaw(base: string): string | null {
const k = accountKey(base);
return k ? localStorage.getItem(k) : null;
}
export function setAccountRaw(base: string, value: string): void {
const k = accountKey(base);
if (k) {
try {
localStorage.setItem(k, value);
} catch {
/* ignore */
}
}
}
// --- typed read/write helpers (do the JSON + try/catch once) ----------------------------
/** Read an object-shaped value merged over `defaults` (so missing/added keys are tolerated),
@ -81,3 +138,18 @@ export function usePersistedState(
};
return [value, set];
}
/** Like usePersistedState but scoped to the current account (see accountKey), so one account's
* persisted tab/view position doesn't carry over to another in the same browser. These hooks run
* in components that only mount once signed in, so the account is known. */
export function useAccountPersistedState(
base: string,
fallback = "",
): [string, (value: string) => void] {
const [value, setValue] = useState<string>(() => getAccountRaw(base) ?? fallback);
const set = (next: string) => {
setValue(next);
setAccountRaw(base, next);
};
return [value, set];
}

View file

@ -1,4 +1,4 @@
import { LS, readMerged, writeJSON } from "./storage";
import { LS, readAccountMerged, writeAccount } from "./storage";
export type Mode = "dark" | "light";
export type Scheme = "midnight" | "forest" | "slate" | "youtube";
@ -30,9 +30,9 @@ export function applyTheme(t: ThemePrefs): void {
}
export function loadLocalTheme(): ThemePrefs {
return readMerged(LS.theme, DEFAULT_THEME);
return readAccountMerged(LS.theme, DEFAULT_THEME);
}
export function saveLocalTheme(t: ThemePrefs): void {
writeJSON(LS.theme, t);
writeAccount(LS.theme, t);
}