Merge: promote dev to prod

This commit is contained in:
npeter83 2026-07-02 02:20:39 +02:00
commit e4fbee448e
37 changed files with 738 additions and 205 deletions

View file

@ -1 +1 @@
0.20.2 0.21.0

View file

@ -419,16 +419,23 @@ def _complete_link(
@router.post("/logout") @router.post("/logout")
async def logout(request: Request): async def logout(request: Request):
"""Sign out the active account. If other accounts have authenticated in this browser, """Sign the requesting tab's active account out of this browser (removing it from the wallet).
switch to the most recent one instead of fully logging out.""" Per-tab aware: the account is taken from the X-Siftlode-Account header when present, so logging
active = request.session.get("user_id") 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] remaining = [a for a in (request.session.get("account_ids") or []) if a != active]
if remaining:
request.session["account_ids"] = remaining request.session["account_ids"] = remaining
if is_default:
if remaining:
request.session["user_id"] = remaining[-1] request.session["user_id"] = remaining[-1]
return JSONResponse({"ok": True, "switched": True}) return JSONResponse({"ok": True, "switched": True})
request.session.clear() request.session.clear()
return JSONResponse({"ok": True, "switched": False}) 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})
@router.post("/request-access") @router.post("/request-access")
@ -659,20 +666,52 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict
return {"ok": True} 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: 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: if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
user = db.get(User, user_id) user = db.get(User, user_id)
if user is None or not user.is_active or user.is_suspended: 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. # Suspended/deactivated mid-session → drop the session so the block takes effect at once.
# 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() request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
# Always keep the active account in the switchable list — covers sessions created before # 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. # 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 [] accounts = request.session.get("account_ids") or []
if user_id not in accounts: if default_id and default_id not in accounts:
accounts.append(user_id) accounts.append(default_id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
return user return user

View file

@ -131,6 +131,13 @@ app.include_router(setup_routes.router)
# The built SPA (populated by the Docker frontend build stage). # The built SPA (populated by the Docker frontend build stage).
STATIC_DIR = Path(__file__).parent / "static_spa" 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( app.mount(
"/assets", "/assets",
StaticFiles(directory=STATIC_DIR / "assets", check_dir=False), StaticFiles(directory=STATIC_DIR / "assets", check_dir=False),
@ -140,7 +147,7 @@ app.mount(
@app.get("/") @app.get("/")
async def index() -> FileResponse: async def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html") return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
@ -155,4 +162,4 @@ async def spa_fallback(full_path: str) -> FileResponse:
candidate = (STATIC_DIR / full_path).resolve() candidate = (STATIC_DIR / full_path).resolve()
if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents: if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents:
return FileResponse(candidate) 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 """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 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.""" (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: if not uid:
await ws.close(code=1008) await ws.close(code=1008)
return return

View file

@ -1,7 +1,14 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; 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 { setLanguage, isSupported, type LangCode } from "./i18n";
import { import {
applyTheme, applyTheme,
@ -19,7 +26,18 @@ import {
} from "./lib/sidebarLayout"; } from "./lib/sidebarLayout";
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications"; import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints"; 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 { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel"; import type { PrefsController } from "./components/SettingsPanel";
import Welcome from "./components/Welcome"; import Welcome from "./components/Welcome";
@ -82,23 +100,27 @@ type EditablePrefs = {
function loadInitialPage(): Page { function loadInitialPage(): Page {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (params.has("page")) return readPage(); if (params.has("page")) return readPage();
const stored = localStorage.getItem(PAGE_KEY); const stored = getAccountRaw(PAGE_KEY);
return isPage(stored) ? stored : "feed"; return isPage(stored) ? stored : "feed";
} }
function loadStoredFilters(): FeedFilters { // This account's own persisted filters (per-account keys — never another account's). A chosen
return readMerged(FILTERS_KEY, DEFAULT_FILTERS); // 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. // URL wins over localStorage so a pasted link reproduces the exact view.
function loadInitialFilters(): FeedFilters { function loadInitialFilters(): FeedFilters {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS); if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
// A user-chosen default saved view takes precedence over the last-session filters on a fresh return loadAccountFilters(getActiveAccount());
// 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();
} }
export default function App() { export default function App() {
@ -112,15 +134,20 @@ export default function App() {
const pageRef = useRef<Page>("feed"); const pageRef = useRef<Page>("feed");
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme()); const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters); 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"); const [view, setView] = useState<"grid" | "list">("grid");
// Settings-page prefs draft (apply live, persist on Save — see EditablePrefs). // 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 [hints, setHints] = useState(() => hintsEnabled());
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings()); const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({ const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
theme: loadLocalTheme(), theme: loadLocalTheme(),
view: "grid", view: "grid",
performanceMode: localStorage.getItem(PERF_KEY) === "1", performanceMode: getAccountRaw(PERF_KEY) === "1",
hints: hintsEnabled(), hints: hintsEnabled(),
notifications: getNotifSettings(), notifications: getNotifSettings(),
})); }));
@ -177,7 +204,7 @@ export default function App() {
}, []); }, []);
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render. // 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 = ( const channelFilter: ChannelStatusFilter = (
["needs_full", "fully_synced", "hidden", "all"] as const ["needs_full", "fully_synced", "hidden", "all"] as const
).includes(channelFilterRaw as ChannelStatusFilter) ).includes(channelFilterRaw as ChannelStatusFilter)
@ -190,7 +217,7 @@ export default function App() {
// persisted so navigation intents that target the subscriptions table — the header's // 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 // "without full history" link, focus-channel — can force it back to "subscribed" instead
// of dumping the user on whatever tab they last left open. // 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 = const channelsView: ChannelsView =
channelsViewRaw === "discovery" ? "discovery" : "subscribed"; channelsViewRaw === "discovery" ? "discovery" : "subscribed";
// Bumped to tell the channel manager to drop a stale column filter when we send the user // 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) { function setFilters(next: FeedFilters) {
setFiltersState(next); 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) { function setPage(next: Page) {
@ -227,7 +256,7 @@ export default function App() {
const go = () => { const go = () => {
setChannelView(null); // leave any open channel page when navigating via the rail setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next); 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 // 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 — // 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). // 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(() => {}); 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]); useEffect(() => applyTheme(theme), [theme]);
// Apply the draft prefs locally for instant preview (their localStorage mirrors update via // 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. // the stores too); persistence to the server is deferred to an explicit Save.
useEffect(() => { useEffect(() => {
document.documentElement.dataset.perf = perf ? "1" : ""; document.documentElement.dataset.perf = perf ? "1" : "";
localStorage.setItem(PERF_KEY, perf ? "1" : "0"); setAccountRaw(PERF_KEY, perf ? "1" : "0");
}, [perf]); }, [perf]);
useEffect(() => setHintsEnabled(hints), [hints]); useEffect(() => setHintsEnabled(hints), [hints]);
useEffect(() => configureNotifications(notif), [notif]); useEffect(() => configureNotifications(notif), [notif]);
@ -274,7 +323,8 @@ export default function App() {
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (hasFilterParams(params) || params.has("page")) { 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(); stripUrlParams();
} }
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
@ -309,12 +359,12 @@ export default function App() {
if (!ok) return; if (!ok) return;
discardRef.current(); discardRef.current();
setPageState(p); setPageState(p);
localStorage.setItem(PAGE_KEY, p); setAccountRaw(PAGE_KEY, p);
}); });
return; return;
} }
setPageState(p); 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 // 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). // clears it to match the entry we landed on (and a player popped first leaves it intact).
setYtSearch((e.state?._yt as string) ?? null); setYtSearch((e.state?._yt as string) ?? null);
@ -347,6 +397,35 @@ export default function App() {
refetchInterval: (query) => (query.state.data ? 60_000 : false), 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. // 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 // ?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. // 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") { if (result === "ok") {
notify({ level: "success", message: t("settings.account.googleLink.linked") }); notify({ level: "success", message: t("settings.account.googleLink.linked") });
void meQuery.refetch(); void meQuery.refetch();
localStorage.setItem(LS.settingsTab, "account"); setAccountRaw(LS.settingsTab, "account");
setPage("settings"); setPage("settings");
} else { } else {
const key = result === "conflict" || result === "mismatch" ? result : "error"; const key = result === "conflict" || result === "mismatch" ? result : "error";
@ -404,7 +483,7 @@ export default function App() {
performanceMode: performanceMode:
typeof prefs.performanceMode === "boolean" typeof prefs.performanceMode === "boolean"
? prefs.performanceMode ? prefs.performanceMode
: localStorage.getItem(PERF_KEY) === "1", : getAccountRaw(PERF_KEY) === "1",
hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(), hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
notifications: prefs.notifications notifications: prefs.notifications
? { ...getNotifSettings(), ...prefs.notifications } ? { ...getNotifSettings(), ...prefs.notifications }
@ -423,13 +502,17 @@ export default function App() {
setSidebarLayoutState(l); setSidebarLayoutState(l);
saveLayoutLocal(l); saveLayoutLocal(l);
} }
if (isSupported(prefs.language)) setLanguage(prefs.language); if (typeof prefs.navCollapsed === "boolean") {
// The demo account is shared: there are no subscriptions, so default it to the whole setNavCollapsedState(prefs.navCollapsed);
// library (the "my" feed would be empty). The communal-state warning is a permanent writeAccount(LS.navCollapsed, prefs.navCollapsed);
// 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.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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.id]); }, [meQuery.data?.id]);
@ -564,7 +647,29 @@ export default function App() {
onOpenAbout={() => setAboutOpen(true)} onOpenAbout={() => setAboutOpen(true)}
onChangeLanguage={changeLanguage} onChangeLanguage={changeLanguage}
language={i18n.language as LangCode} 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"> <div className="relative flex-1 min-w-0 flex flex-col">
{channelView ? ( {channelView ? (
<ChannelPage <ChannelPage
@ -584,26 +689,9 @@ export default function App() {
setFilters={setFilters} setFilters={setFilters}
page={page} page={page}
onYtSearch={enterYtSearch} 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 />} {meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} /> <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"> <main className="flex-1 min-w-0 overflow-y-auto">
{page === "channels" ? ( {page === "channels" ? (
<Channels <Channels
@ -661,7 +749,6 @@ export default function App() {
/> />
)} )}
</main> </main>
</div>
</> </>
)} )}
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail. {/* 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 { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api"; import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { LS } from "../lib/storage"; import { LS, setAccountRaw } from "../lib/storage";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { Section } from "./ui/form"; import { Section } from "./ui/form";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
@ -20,7 +20,7 @@ export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
export const ADMIN_USERS_ACCESS_TAB = "access"; export const ADMIN_USERS_ACCESS_TAB = "access";
export function focusAccessRequestsTab(): void { 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 }) { 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UserPlus } from "lucide-react"; import { UserPlus } from "lucide-react";
import { api, HttpError, type DiscoveredChannel } from "../lib/api"; import { api, HttpError, type DiscoveredChannel } from "../lib/api";
import { accountKey } from "../lib/storage";
import { formatViews } from "../lib/format"; import { formatViews } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@ -173,7 +174,7 @@ export default function ChannelDiscovery({
rows={rows} rows={rows}
columns={columns} columns={columns}
rowKey={(c) => c.id} rowKey={(c) => c.id}
persistKey="siftlode.channelDiscoveryTable" persistKey={accountKey("siftlode.channelDiscoveryTable") ?? undefined}
controlsPosition="top" controlsPosition="top"
emptyText={t("channels.discovery.empty")} emptyText={t("channels.discovery.empty")}
/> />

View file

@ -17,6 +17,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
import { accountKey } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss"; import { useDismiss } from "../lib/useDismiss";
import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format"; import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
@ -569,7 +570,7 @@ export default function Channels({
rows={visibleChannels} rows={visibleChannels}
columns={columns} columns={columns}
rowKey={(c) => c.id} rowKey={(c) => c.id}
persistKey="siftlode.channelsTable" persistKey={accountKey("siftlode.channelsTable") ?? undefined}
controlsPosition="top" controlsPosition="top"
controlsLeading={statusChips} controlsLeading={statusChips}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")} rowClassName={(c) => (c.hidden ? "opacity-60" : "")}

View file

@ -1,25 +1,22 @@
import { useTranslation } from "react-i18next"; 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 { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
import SyncStatus from "./SyncStatus";
// Contextual top bar. Primary navigation + account now live in the left NavSidebar; the // Contextual top bar over the content column. Primary navigation, account and the per-user sync
// header carries the global sync status, the feed's scope toggle + search (or the current // status live in the left NavSidebar; the feed's scope toggle now lives in the filter sidebar.
// page title), the language switcher and the notification bell. // The header carries just the feed search (or the current page title).
export default function Header({ export default function Header({
me, me,
filters, filters,
setFilters, setFilters,
page, page,
onGoToFullHistory,
onYtSearch, onYtSearch,
}: { }: {
me: Me; me: Me;
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
page: Page; page: Page;
onGoToFullHistory: () => void;
// Trigger a live YouTube search for the current term (hidden for the demo account, which // Trigger a live YouTube search for the current term (hidden for the demo account, which
// can't spend the shared quota). // can't spend the shared quota).
onYtSearch: (q: string) => void; onYtSearch: (q: string) => void;
@ -30,37 +27,6 @@ export default function Header({
return ( return (
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20"> <header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
<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" ? ( {page === "feed" ? (
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2"> <div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
<div className="flex-1 relative"> <div className="flex-1 relative">

View file

@ -92,9 +92,14 @@ export default function LanguageSwitcher({
onClick={toggleRail} onClick={toggleRail}
title={current.label} title={current.label}
aria-label={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" /> <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> </button>
{open && {open &&
createPortal( createPortal(

View file

@ -20,14 +20,14 @@ import {
Tv, Tv,
UserPlus, UserPlus,
} from "lucide-react"; } 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 { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications"; import { getUnreadCount, subscribe } from "../lib/notifications";
import { LS } from "../lib/storage";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
import { type LangCode } from "../i18n"; import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar"; import AvatarImg from "./Avatar";
import LanguageSwitcher from "./LanguageSwitcher"; import LanguageSwitcher from "./LanguageSwitcher";
import SyncStatus from "./SyncStatus";
// Primary app navigation: a collapsible left rail with icon+label entries (Design C). The // 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 // modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes
@ -39,6 +39,9 @@ export default function NavSidebar({
onOpenAbout, onOpenAbout,
onChangeLanguage, onChangeLanguage,
language, language,
collapsed,
onToggleCollapse,
onGoToFullHistory,
}: { }: {
me: Me; me: Me;
page: Page; page: Page;
@ -46,11 +49,13 @@ export default function NavSidebar({
onOpenAbout: () => void; onOpenAbout: () => void;
onChangeLanguage: (code: LangCode) => void; onChangeLanguage: (code: LangCode) => void;
language: LangCode; 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 { t } = useTranslation();
const [collapsed, setCollapsed] = useState<boolean>(
() => localStorage.getItem(LS.navCollapsed) === "1"
);
const [acctOpen, setAcctOpen] = useState(false); const [acctOpen, setAcctOpen] = useState(false);
const acctBtnRef = useRef<HTMLButtonElement | null>(null); const acctBtnRef = useRef<HTMLButtonElement | null>(null);
const acctPanelRef = useRef<HTMLDivElement | null>(null); const acctPanelRef = useRef<HTMLDivElement | null>(null);
@ -88,16 +93,15 @@ export default function NavSidebar({
}; };
}, [acctOpen]); }, [acctOpen]);
function toggleCollapsed() {
setCollapsed((c) => {
const next = !c;
localStorage.setItem(LS.navCollapsed, next ? "1" : "0");
return next;
});
}
async function logout() { 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(); location.reload();
} }
@ -109,9 +113,11 @@ export default function NavSidebar({
}); });
const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active); const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active);
async function switchTo(id: number) { function switchTo(id: number) {
try { // Per-tab switch: point THIS tab at the account and reload; other tabs keep their own
await api.switchAccount(id); // 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 // 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 // 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 // stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity
@ -121,9 +127,6 @@ export default function NavSidebar({
delete st._ov; delete st._ov;
window.history.replaceState(st, ""); window.history.replaceState(st, "");
location.reload(); // cleanest way to reload all per-user state for the new account location.reload(); // cleanest way to reload all per-user state for the new account
} catch {
/* a revoked/removed account — leave the popover open */
}
} }
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the // Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
@ -171,6 +174,21 @@ export default function NavSidebar({
const rowBase = const rowBase =
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; "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]; 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 renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => {
const active = page === p; const active = page === p;
@ -232,7 +250,7 @@ export default function NavSidebar({
</button> </button>
)} )}
<button <button
onClick={toggleCollapsed} onClick={onToggleCollapse}
title={collapsed ? t("nav.expand") : t("nav.collapse")} title={collapsed ? t("nav.expand") : t("nav.collapse")}
aria-label={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" className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
@ -245,11 +263,31 @@ export default function NavSidebar({
</button> </button>
</div> </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"> <div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
{userItems.map(renderItem)} {userItems.map(renderItem)}
{systemItems.length > 0 && ( {systemItems.length > 0 &&
<div className="my-1.5 border-t border-border/70" /> (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)} {systemItems.map(renderItem)}
</div> </div>
@ -290,12 +328,27 @@ export default function NavSidebar({
title={collapsed ? name : undefined} title={collapsed ? name : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`} className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
> >
<span className="relative shrink-0">
<AvatarImg <AvatarImg
src={me.avatar_url} src={me.avatar_url}
fallback={name} fallback={name}
className="w-[22px] h-[22px] rounded-full text-[10px] shrink-0" 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="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> </button>
{acctOpen && {acctOpen &&
@ -349,6 +402,10 @@ export default function NavSidebar({
<div className="mt-2 pt-2 border-t border-border flex flex-col"> <div className="mt-2 pt-2 border-t border-border flex flex-col">
<button <button
onClick={() => { 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"; 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" 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 { api, type Playlist, type Video } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications"; 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 { useUndoable } from "../lib/useUndoable";
import PlayerModal from "./PlayerModal"; import PlayerModal from "./PlayerModal";
import UndoToolbar from "./UndoToolbar"; import UndoToolbar from "./UndoToolbar";
@ -188,18 +188,18 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
const confirm = useConfirm(); const confirm = useConfirm();
// Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL). // Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL).
const [selectedId, setSelectedId] = useState<number | null>(() => { const [selectedId, setSelectedId] = useState<number | null>(() => {
const s = localStorage.getItem(LS.playlist); const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null; return s ? Number(s) : null;
}); });
useEffect(() => { useEffect(() => {
if (selectedId != null) localStorage.setItem(LS.playlist, String(selectedId)); if (selectedId != null) setAccountRaw(LS.playlist, String(selectedId));
}, [selectedId]); }, [selectedId]);
const selectedRef = useRef<HTMLButtonElement | null>(null); const selectedRef = useRef<HTMLButtonElement | null>(null);
const scrolledRef = useRef(false); const scrolledRef = useRef(false);
// How the left playlist rail is ordered (persisted). // 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(() => { useEffect(() => {
writeJSON(LS.plSort, plSort); writeAccount(LS.plSort, plSort);
}, [plSort]); }, [plSort]);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);

View file

@ -17,10 +17,10 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; 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 { filtersToParams, shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { LS, readJSON, writeJSON } from "../lib/storage"; import { accountKey, LS, readJSON, writeJSON } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
// Compact, order-independent signature of a filter set (reuses the share serializer, which // 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. // saved view that matches the feed's current filters.
const keyOf = (f: FeedFilters) => filtersToParams(f).toString(); const keyOf = (f: FeedFilters) => filtersToParams(f).toString();
/** The default view's filters, or null. Read synchronously on load (App.loadInitialFilters) /** The default view's filters, or null. Read synchronously on load (App.loadAccountFilters) from a
* from a localStorage mirror the widget keeps in sync avoids a flash before the query loads. */ * 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 { 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 { function syncDefaultMirror(views: SavedView[]): void {
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
if (!key) return;
const def = views.find((v) => v.is_default); 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({ 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 { Bell, Monitor, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api"; import { api, type Me } from "../lib/api";
import { LS, usePersistedState } from "../lib/storage"; import { LS, useAccountPersistedState } from "../lib/storage";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications"; import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@ -54,7 +54,7 @@ export default function SettingsPanel({
onOpenWizard: () => void; onOpenWizard: () => void;
}) { }) {
const { t } = useTranslation(); 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. // Clamp at render so a stale/removed tab id falls back to Appearance.
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw) const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
? (tabRaw as TabId) ? (tabRaw as TabId)

View file

@ -4,13 +4,17 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { import {
Check, Check,
ChevronDown, ChevronDown,
ChevronLeft,
ChevronRight,
Eye, Eye,
EyeOff, EyeOff,
GripVertical, GripVertical,
Library,
Pencil, Pencil,
RefreshCw,
RotateCcw, RotateCcw,
Share2, Share2,
SlidersHorizontal,
User,
X, X,
} from "lucide-react"; } from "lucide-react";
import { import {
@ -120,6 +124,8 @@ export default function Sidebar({
setLayout, setLayout,
onFocusChannel, onFocusChannel,
isDemo = false, isDemo = false,
collapsed,
onToggleCollapse,
}: { }: {
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
@ -127,6 +133,10 @@ export default function Sidebar({
setLayout: (l: SidebarLayout) => void; setLayout: (l: SidebarLayout) => void;
onFocusChannel: (name: string) => void; onFocusChannel: (name: string) => void;
isDemo?: boolean; 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 { t } = useTranslation();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
@ -464,23 +474,91 @@ export default function Sidebar({
? orderedAvailable ? orderedAvailable
: orderedAvailable.filter((id) => !layout.hidden[id]); : orderedAvailable.filter((id) => !layout.hidden[id]);
// 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 ( 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"> <aside className="hidden md:flex w-[46px] shrink-0 flex-col items-center gap-2 border-r border-border bg-surface/40 py-3">
<div className="flex items-center justify-between"> <button
<div className="text-sm font-semibold"> onClick={onToggleCollapse}
{t("sidebar.filters")} 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 && ( {activeCount > 0 && (
<span className="ml-1.5 text-xs font-medium text-accent"> <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">
{t("sidebar.activeCount", { count: activeCount })} {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> </span>
)} )}
</div> </div>
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5 shrink-0">
{!editing && ( {!editing && (
<button <button
onClick={clearAll} onClick={clearAll}
disabled={!active} 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")} {t("sidebar.clearAll")}
</button> </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 { api, type AdminQuotaRow, type Me } from "../lib/api";
import { formatEta, quotaActionLabel } from "../lib/format"; import { formatEta, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { LS, usePersistedState } from "../lib/storage"; import { LS, useAccountPersistedState } from "../lib/storage";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { Section, SettingRow } from "./ui/form"; import { Section, SettingRow } from "./ui/form";
@ -18,7 +18,7 @@ type StatsTab = "overview" | "system";
export default function Stats({ me }: { me: Me }) { export default function Stats({ me }: { me: Me }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isAdmin = me.role === "admin"; 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). // Clamp at render: only admins may land on the System tab (covers a stale stored value).
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview"; const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";

View file

@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; 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 { api, type MyStatus } from "../lib/api";
import { formatViews } from "../lib/format"; import { formatViews } from "../lib/format";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@ -11,9 +11,15 @@ import Tooltip from "./Tooltip";
export default function SyncStatus({ export default function SyncStatus({
isAdmin, isAdmin,
onGoToFullHistory, onGoToFullHistory,
variant = "bar",
collapsed = false,
}: { }: {
isAdmin: boolean; isAdmin: boolean;
onGoToFullHistory: () => void; 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 { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
@ -51,6 +57,94 @@ export default function SyncStatus({
const active = data.sync_active; const active = data.sync_active;
const showMain = data.paused || active || syncing > 0 || notFull === 0; 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 ( return (
<div className="hidden lg:flex items-center gap-2 text-xs text-muted"> <div className="hidden lg:flex items-center gap-2 text-xs text-muted">
<Tooltip hint={t("header.sync.countTooltip")}> <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…). // 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, // 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 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 /** Persisted active-tab state, scoped to the current account (so one account's last tab doesn't
* `usePersistedState`; re-exported here under its original name for existing call sites. */ * carry into another in the same browser) re-exported under its original name for call sites. */
export const usePersistedTab = usePersistedState; export const usePersistedTab = useAccountPersistedState;
export default function Tabs({ export default function Tabs({
tabs, tabs,

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ArrowRight,
Code2, Code2,
Expand, Expand,
Filter, Filter,
@ -64,6 +65,29 @@ export default function Welcome() {
onOpen={open} 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 */} {/* Features */}
<section className="mt-14"> <section className="mt-14">
<h2 className="text-2xl font-semibold text-center">{t("welcome.features.heading")}</h2> <h2 className="text-2xl font-semibold text-center">{t("welcome.features.heading")}</h2>

View file

@ -1,5 +1,11 @@
{ {
"primary": "Hauptnavigation", "primary": "Hauptnavigation",
"collapse": "Seitenleiste einklappen", "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", "hideWidget": "Widget ausblenden",
"expand": "Ausklappen", "expand": "Ausklappen",
"collapse": "Einklappen", "collapse": "Einklappen",
"collapsePanel": "Filter einklappen",
"expandPanel": "Filter ausklappen",
"any": "Beliebig", "any": "Beliebig",
"all": "Alle", "all": "Alle",
"tagModeTooltip": "Beliebige vs. alle ausgewählten Tags treffen", "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.", "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." "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": { "preview": {
"feed": "Der Feed — filtern nach Kanal, Sprache, Thema, Länge und Datum.", "feed": "Der Feed — filtern nach Kanal, Sprache, Thema, Länge und Datum.",
"channels": "Kanal-Verwaltung", "channels": "Kanal-Verwaltung",

View file

@ -1,5 +1,11 @@
{ {
"primary": "Primary navigation", "primary": "Primary navigation",
"collapse": "Collapse sidebar", "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", "hideWidget": "Hide widget",
"expand": "Expand", "expand": "Expand",
"collapse": "Collapse", "collapse": "Collapse",
"collapsePanel": "Collapse filters",
"expandPanel": "Expand filters",
"any": "Any", "any": "Any",
"all": "All", "all": "All",
"tagModeTooltip": "Match any vs all selected tags", "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.", "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." "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": { "preview": {
"feed": "The feed — filter by channel, language, topic, length and date.", "feed": "The feed — filter by channel, language, topic, length and date.",
"channels": "Channel manager", "channels": "Channel manager",

View file

@ -1,5 +1,11 @@
{ {
"primary": "Fő navigáció", "primary": "Fő navigáció",
"collapse": "Oldalsáv összecsukása", "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", "hideWidget": "Modul elrejtése",
"expand": "Kibontás", "expand": "Kibontás",
"collapse": "Összecsukás", "collapse": "Összecsukás",
"collapsePanel": "Szűrők összecsukása",
"expandPanel": "Szűrők kibontása",
"any": "Bármelyik", "any": "Bármelyik",
"all": "Összes", "all": "Összes",
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen", "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.", "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." "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": { "preview": {
"feed": "A hírfolyam — szűrés csatorna, nyelv, téma, hossz és dátum szerint.", "feed": "A hírfolyam — szűrés csatorna, nyelv, téma, hossz és dátum szerint.",
"channels": "Csatorna-kezelő", "channels": "Csatorna-kezelő",

View file

@ -1,6 +1,7 @@
import { notify, remove } from "./notifications"; import { notify, remove } from "./notifications";
import i18n from "../i18n"; import i18n from "../i18n";
import { reportError } from "./errorDialog"; import { reportError } from "./errorDialog";
import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "./storage";
export interface Me { export interface Me {
id: number; id: number;
@ -266,6 +267,29 @@ const setupHeaders = (token: string) => ({
"X-Setup-Token": token, "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> { async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
const method = opts.method ?? "GET"; const method = opts.method ?? "GET";
const canRetry = cfg.idempotent ?? method === "GET"; const canRetry = cfg.idempotent ?? method === "GET";
@ -274,9 +298,15 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
for (;;) { for (;;) {
let r: Response; let r: Response;
try { try {
const active = getActiveAccount();
r = await fetch(url, { r = await fetch(url, {
credentials: "include", 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, ...opts,
}); });
} catch (e) { } catch (e) {
@ -600,6 +630,10 @@ export const api = {
req("/api/me/account", { method: "DELETE" }), req("/api/me/account", { method: "DELETE" }),
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> => switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }), 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"), version: (): Promise<VersionInfo> => req("/api/version"),
tags: (): Promise<Tag[]> => req("/api/tags"), tags: (): Promise<Tag[]> => req("/api/tags"),
status: (): Promise<SyncStatus> => req("/api/sync/status"), status: (): Promise<SyncStatus> => req("/api/sync/status"),

View file

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

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 // 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 // thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the
// safety net if the socket is down. // 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. // A pushed message plus both parties, so the dock can open/flash the right window.
export interface IncomingMessage { export interface IncomingMessage {
@ -20,7 +20,11 @@ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function url(): string { function url(): string {
const proto = location.protocol === "https:" ? "wss" : "ws"; 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() { function open() {

View file

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

View file

@ -12,9 +12,11 @@
// suppresses the auto-popup on future logins (they // suppresses the auto-popup on future logins (they
// can still reopen it from Settings → Account). // 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) 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 const ONBOARD_DISMISSED = LS.onboardingDismissed;
export function shouldAutoOpenOnboarding(me: { 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. // email+password only — then there's nothing to connect, so don't nudge.
if (me.google_enabled === false) return false; if (me.google_enabled === false) return false;
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true; 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. */ /** 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). */ /** Leave the wizard. `dismiss` suppresses the future auto-popup (used when read is skipped). */
export function endOnboarding(dismiss: boolean): void { export function endOnboarding(dismiss: boolean): void {
sessionStorage.removeItem(ONBOARD_ACTIVE); sessionStorage.removeItem(ONBOARD_ACTIVE);
if (dismiss) localStorage.setItem(ONBOARD_DISMISSED, "1"); if (dismiss) setAccountRaw(ONBOARD_DISMISSED, "1");
} }

View file

@ -14,6 +14,21 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.21.0",
date: "2026-07-02",
summary: "Run a different account in each browser tab, a roomier collapsible layout, and nav polish.",
features: [
"Multi-account, per tab: open two tabs and sign into a different account in each — each tab stays on its own account. Switching or adding an account in one tab no longer changes what the others show. Every tab keeps its own filters, theme and settings.",
"Roomier layout: the filter sidebar is now a full-height column you can collapse like the left nav (a thin rail keeps the active-filter count). Your video counts and sync status moved to the top of the left rail, and the Mine/Library switch sits with the filters. Both panels remember whether you left them open or collapsed.",
"Nav touches: a role badge (admin / user / demo) next to your name, the current language (HU/EN/DE) shown on the language button, and a clearer “Admin” divider above the admin tools.",
"The sign-in page now highlights that Siftlode is open source, with a link to the code.",
],
fixes: [
"Your filters, selected playlist, theme and other per-browser settings no longer carry over between different accounts signed into the same browser.",
"Opening one pop-up from inside another (e.g. Release notes from the About box) no longer makes it flash and vanish.",
],
},
{ {
version: "0.20.2", version: "0.20.2",
date: "2026-07-01", date: "2026-07-01",

View file

@ -4,7 +4,7 @@
// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no // `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. // 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"; export type WidgetId = "savedviews" | "date" | "language" | "topic" | "tags";
@ -46,9 +46,9 @@ export function normalizeLayout(raw: unknown): SidebarLayout {
} }
export function loadLayout(): SidebarLayout { export function loadLayout(): SidebarLayout {
return normalizeLayout(readJSON<unknown>(LS.sidebarLayout, {})); return normalizeLayout(readAccount<unknown>(LS.sidebarLayout, {}));
} }
export function saveLayoutLocal(l: SidebarLayout): void { 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", statsTab: "siftlode.statsTab",
adminUsersTab: "siftlode.adminUsersTab", adminUsersTab: "siftlode.adminUsersTab",
navCollapsed: "siftlode.navCollapsed", navCollapsed: "siftlode.navCollapsed",
filterCollapsed: "siftlode.filterCollapsed",
playlist: "siftlode.playlist", playlist: "siftlode.playlist",
plSort: "siftlode.plSort", plSort: "siftlode.plSort",
notifHistory: "siftlode.notifications", notifHistory: "siftlode.notifications",
@ -29,6 +30,62 @@ export const LS = {
chatDock: (userId: number) => `siftlode.chatDock.${userId}`, chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
} as const; } 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) ---------------------------- // --- typed read/write helpers (do the JSON + try/catch once) ----------------------------
/** Read an object-shaped value merged over `defaults` (so missing/added keys are tolerated), /** Read an object-shaped value merged over `defaults` (so missing/added keys are tolerated),
@ -81,3 +138,18 @@ export function usePersistedState(
}; };
return [value, set]; 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 Mode = "dark" | "light";
export type Scheme = "midnight" | "forest" | "slate" | "youtube"; export type Scheme = "midnight" | "forest" | "slate" | "youtube";
@ -30,9 +30,9 @@ export function applyTheme(t: ThemePrefs): void {
} }
export function loadLocalTheme(): ThemePrefs { export function loadLocalTheme(): ThemePrefs {
return readMerged(LS.theme, DEFAULT_THEME); return readAccountMerged(LS.theme, DEFAULT_THEME);
} }
export function saveLocalTheme(t: ThemePrefs): void { export function saveLocalTheme(t: ThemePrefs): void {
writeJSON(LS.theme, t); writeAccount(LS.theme, t);
} }