Merge: promote dev to prod
This commit is contained in:
commit
e4fbee448e
37 changed files with 738 additions and 205 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.20.2
|
||||
0.21.0
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -363,7 +363,19 @@ async def messages_ws(ws: WebSocket) -> None:
|
|||
"""Live push channel: authenticated by the session cookie, registers the connection so
|
||||
sent messages reach this user's open tabs instantly. We don't consume client→server frames
|
||||
(sending goes through POST /api/messages); the receive loop only detects disconnect."""
|
||||
uid = ws.session.get("user_id") if "session" in ws.scope else None
|
||||
# A browser WebSocket can't send custom headers, so the per-tab account (see
|
||||
# X-Siftlode-Account on HTTP) rides in the ?account= query param instead — honoured only for
|
||||
# an account already in this browser's wallet. Falls back to the session default.
|
||||
sess = ws.session if "session" in ws.scope else {}
|
||||
uid = sess.get("user_id")
|
||||
q = ws.query_params.get("account")
|
||||
if q:
|
||||
try:
|
||||
qid = int(q)
|
||||
except ValueError:
|
||||
qid = None
|
||||
if qid is not None and qid in (sess.get("account_ids") or []):
|
||||
uid = qid
|
||||
if not uid:
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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" : "")}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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")}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
{
|
||||
"primary": "Hauptnavigation",
|
||||
"collapse": "Seitenleiste einklappen",
|
||||
"expand": "Seitenleiste ausklappen"
|
||||
"expand": "Seitenleiste ausklappen",
|
||||
"adminSection": "Admin",
|
||||
"role": {
|
||||
"admin": "Admin",
|
||||
"user": "Benutzer",
|
||||
"demo": "Demo"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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ó"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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ő",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,21 @@ export interface 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",
|
||||
date: "2026-07-01",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue