From 37878068ea051afc9c12eff345801a27022f4497 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 18 Jun 2026 23:17:32 +0200 Subject: [PATCH 1/5] fix(web): make the 'connection lost' notice self-resolving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notice promised 'this will clear once it's back', but nothing ever removed it: the toast auto-hid on a 15s timer (not on recovery) and the v0.9.0 unified inbox kept every notify() in persistent history, so the notice lingered there forever. Model it as a single live status instead: one sticky, transient (never persisted) notice while the server is unreachable, removed the moment any request reaches the server again. Adds a 'transient' notification flag (sticky toast + excluded from history so a reload can't orphan it) and replaces the throttled-toast helper with a connectivity lost/restored pair. i18n the strings (errors.offline.*) in EN/HU/DE — they were hardcoded English. --- frontend/src/i18n/locales/de/errors.json | 4 +++ frontend/src/i18n/locales/en/errors.json | 4 +++ frontend/src/i18n/locales/hu/errors.json | 4 +++ frontend/src/lib/api.ts | 42 ++++++++++++++---------- frontend/src/lib/notifications.ts | 16 +++++++-- 5 files changed, 50 insertions(+), 20 deletions(-) diff --git a/frontend/src/i18n/locales/de/errors.json b/frontend/src/i18n/locales/de/errors.json index 799a006..8b09923 100644 --- a/frontend/src/i18n/locales/de/errors.json +++ b/frontend/src/i18n/locales/de/errors.json @@ -3,6 +3,10 @@ "generic": "Der Server konnte die Aktion nicht ausführen. Bitte erneut versuchen.", "server": "Serverfehler", "ok": "OK", + "offline": { + "title": "Verbindung verloren", + "body": "Server nicht erreichbar — er startet möglicherweise gerade neu. Dieser Hinweis verschwindet, sobald die Verbindung wieder steht." + }, "boundary": { "title": "Etwas ist schiefgelaufen.", "subtitle": "Die App ist auf einen unerwarteten Fehler gestoßen.", diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json index f22b32a..56f38c7 100644 --- a/frontend/src/i18n/locales/en/errors.json +++ b/frontend/src/i18n/locales/en/errors.json @@ -3,6 +3,10 @@ "generic": "The server couldn't carry out that action. Please try again.", "server": "Server error", "ok": "OK", + "offline": { + "title": "Connection lost", + "body": "Couldn't reach the server — it may be restarting. This notice will clear once the connection is back." + }, "boundary": { "title": "Something went wrong.", "subtitle": "The app hit an unexpected error.", diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index b3c7bc3..43f5311 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -3,6 +3,10 @@ "generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.", "server": "Szerverhiba", "ok": "OK", + "offline": { + "title": "Megszakadt a kapcsolat", + "body": "Nem sikerült elérni a szervert — lehet, hogy épp újraindul. Ez az üzenet eltűnik, amint helyreáll a kapcsolat." + }, "boundary": { "title": "Hiba történt.", "subtitle": "Az alkalmazás váratlan hibába ütközött.", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 06ca59c..be31fd8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,4 +1,4 @@ -import { notify } from "./notifications"; +import { notify, remove } from "./notifications"; import i18n from "../i18n"; import { reportError } from "./errorDialog"; @@ -163,14 +163,24 @@ class HttpError extends Error { } } -// Collapse bursts of connection failures (e.g. while the server restarts) into a -// single notification rather than one per failed request. -let lastErrorNotifiedAt = 0; -function notifyErrorThrottled(title: string, message: string): void { - const now = Date.now(); - if (now - lastErrorNotifiedAt < 30_000) return; - lastErrorNotifiedAt = now; - notify({ level: "error", title, message }); +// A single, self-resolving "connection lost" status: while the server is unreachable we +// show one sticky, transient (non-persisted) notice; the moment any request reaches the +// server again we remove it. This makes good on its "clears once it's back" copy and keeps +// bursts of concurrent failures from stacking up — there's only ever one live handle. +let connectivityNotifId: number | null = null; +function markConnectivityLost(): void { + if (connectivityNotifId !== null) return; + connectivityNotifId = notify({ + level: "error", + title: i18n.t("errors.offline.title"), + message: i18n.t("errors.offline.body"), + transient: true, + }); +} +function markConnectivityRestored(): void { + if (connectivityNotifId === null) return; + remove(connectivityNotifId); + connectivityNotifId = null; } // Gateway statuses that mean "the upstream connection failed", not "the app rejected the @@ -206,10 +216,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr await delay(250); continue; } - notifyErrorThrottled( - "Connection lost", - "Couldn't reach the server — it may be restarting. This will clear once it's back." - ); + markConnectivityLost(); throw e; } @@ -221,6 +228,10 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr continue; } + // The server answered (anything that isn't a gateway error means it's reachable) — + // clear a standing "connection lost" status if one is showing. + if (!RETRIABLE_GATEWAY.has(r.status)) markConnectivityRestored(); + if (!r.ok) { // Capture the server's reason (FastAPI returns `{ detail }`) so callers can react. let detail: string | undefined; @@ -236,10 +247,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr // A genuine 500 (real fault, carries a JSON detail) still gets the modal, as do // 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow. if (RETRIABLE_GATEWAY.has(r.status)) { - notifyErrorThrottled( - "Connection lost", - "Couldn't reach the server — it may be restarting. This will clear once it's back." - ); + markConnectivityLost(); } else if (r.status >= 500) { reportError(detail || `${i18n.t("errors.server")} (${r.status})`); } else if (r.status === 400 || r.status === 409 || r.status === 422) { diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 184d708..717533b 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -38,6 +38,10 @@ export interface Notification { ts: number; read: boolean; dismissed: boolean; // transient toast surface closed (still kept in history) + // Live session-only status (e.g. "connection lost"): sticky toast (no auto-dismiss + // timer) that the producer removes itself when the condition resolves. Never persisted + // to history, so a reload can't orphan it with no live handle to clear it. + transient: boolean; } export interface NotifyInput { @@ -48,6 +52,7 @@ export interface NotifyInput { meta?: NotifMeta; requiresInteraction?: boolean; sound?: boolean; // force the alert tone (when sound is enabled) even for an info toast + transient?: boolean; // live status: sticky toast, not persisted (see Notification.transient) } const HISTORY_KEY = "subfeed.notifications"; @@ -122,7 +127,7 @@ function load(): Notification[] { // resurrect as active toasts on reload — their auto-dismiss timers aren't // re-armed across a reload, so otherwise they'd stick forever. Also drop the // live `action` callback, which can't survive serialization. - return raw.map((n) => ({ ...n, action: undefined, dismissed: true }) as Notification); + return raw.map((n) => ({ ...n, action: undefined, dismissed: true, transient: false }) as Notification); } catch { return []; } @@ -130,7 +135,9 @@ function load(): Notification[] { function persist() { try { - const slim = items.map(({ action: _action, ...n }) => n); + // 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); localStorage.setItem(HISTORY_KEY, JSON.stringify(slim)); } catch { /* ignore quota / serialization errors */ @@ -164,7 +171,9 @@ export function notify(input: NotifyInput): number { const requiresInteraction = input.requiresInteraction ?? false; const level = input.level ?? "info"; // config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed). - const duration = requiresInteraction + // A transient status stays put until its producer clears it (no auto-dismiss timer), + // same as an interaction-awaiting notice. + const duration = requiresInteraction || input.transient ? undefined : level === "error" || level === "fatal" ? ERROR_TTL @@ -185,6 +194,7 @@ export function notify(input: NotifyInput): number { ts: Date.now(), read: false, dismissed: false, + transient: input.transient ?? false, }, ]; emit(); From 3f2fb339d380e679a43fef13b23ff95be059b4fe Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 18 Jun 2026 23:27:59 +0200 Subject: [PATCH 2/5] fix(web): only announce a state change after the server confirms it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Marked watched' / 'Hidden' notices (and resolving a stale notice on unwatch/unhide) fired synchronously, before the api.setState call resolved. When the write failed — e.g. the API was unreachable — the optimistic card override was rolled back in .catch, but the notice had already been emitted and lingered, falsely claiming a change that never persisted. Move all three branches into the .then() so the notice is produced only once the server has actually applied the change; on failure the card reverts and nothing is announced (the connection-lost status already explains why). --- frontend/src/components/Feed.tsx | 66 +++++++++++++++++--------------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 860e3e3..423be85 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -144,9 +144,44 @@ export default function Feed({ (id: string, status: string) => { setOverrides((o) => ({ ...o, [id]: status })); // Refetch once the server has the change so other views (e.g. Hidden) are in sync. + // Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden" + // notice (or resolving a stale one) is a claim of success, so firing it optimistically + // would falsely report a change that the .catch below is about to roll back (e.g. when + // the API is unreachable). api .setState(id, status) - .then(() => qc.invalidateQueries({ queryKey: ["feed"] })) + .then(() => { + qc.invalidateQueries({ queryKey: ["feed"] }); + if (status === "hidden") { + const v = loadedRef.current.find((x) => x.id === id); + notify({ + message: v?.title + ? i18n.t("feed.hiddenNamed", { title: v.title }) + : i18n.t("feed.hidden"), + action: { label: i18n.t("feed.undo"), onClick: () => onState(id, "new") }, + meta: { + kind: "video-hidden", + videoId: id, + title: v?.title ?? "this video", + channelId: v?.channel_id ?? "", + channelName: v?.channel_title ?? "", + }, + }); + } else if (status === "watched") { + const v = loadedRef.current.find((x) => x.id === id); + notify({ + message: v?.title + ? i18n.t("feed.markedWatchedNamed", { title: v.title }) + : i18n.t("feed.markedWatched"), + action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") }, + meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" }, + }); + } else if (status === "new") { + // Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve + // any stale hide/watch notice for this video so it doesn't linger with a dead action. + resolveVideo(id); + } + }) .catch(() => // Server rejected the change — drop the optimistic override so the card // reverts to the real (unchanged) state instead of staying phantom-hidden. @@ -156,35 +191,6 @@ export default function Feed({ return next; }) ); - if (status === "hidden") { - const v = loadedRef.current.find((x) => x.id === id); - notify({ - message: v?.title - ? i18n.t("feed.hiddenNamed", { title: v.title }) - : i18n.t("feed.hidden"), - action: { label: i18n.t("feed.undo"), onClick: () => onState(id, "new") }, - meta: { - kind: "video-hidden", - videoId: id, - title: v?.title ?? "this video", - channelId: v?.channel_id ?? "", - channelName: v?.channel_title ?? "", - }, - }); - } else if (status === "watched") { - const v = loadedRef.current.find((x) => x.id === id); - notify({ - message: v?.title - ? i18n.t("feed.markedWatchedNamed", { title: v.title }) - : i18n.t("feed.markedWatched"), - action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") }, - meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" }, - }); - } else if (status === "new") { - // Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve any - // stale hide/watch notice for this video so it doesn't linger with a dead action. - resolveVideo(id); - } }, [qc] ); From d24ff9c0f0fe52f7738b8199170d7a51479f3c11 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 18 Jun 2026 23:59:40 +0200 Subject: [PATCH 3/5] feat(settings): explicit Save/Discard for preferences with dirty tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings-page prefs (theme/scheme/dark-mode/list-view/perf/hints/font + the notification settings) were each auto-saved to the server on every toggle via fire-and-forget savePrefs().catch(() => {}) — silent on failure, and no positive confirmation on success, so the user had zero feedback either way. Make them a draft instead: changes apply locally for instant preview but persist only on an explicit Save (or revert on Discard). App owns the live draft + the last-saved baseline, computes dirty, and exposes a controller to the panel. The panel shows a Save/Discard bar with 'Saving…' → 'Settings saved' (auto-clearing) / 'Couldn't save' feedback. Leaving the page with unsaved changes prompts a confirm (in-app nav + browser Back), and a beforeunload guards reload/close. savePrefs is now idempotent so the Save survives a transient gateway blip; failures surface via the connection-lost status. Language & sidebar layout stay instant (edited outside this page). New i18n keys settings.save.* / settings.unsaved.* in EN/HU/DE. --- frontend/src/App.tsx | 209 ++++++++++++++++++--- frontend/src/components/SettingsPanel.tsx | 205 +++++++++++--------- frontend/src/i18n/locales/de/settings.json | 13 ++ frontend/src/i18n/locales/en/settings.json | 13 ++ frontend/src/i18n/locales/hu/settings.json | 13 ++ frontend/src/lib/api.ts | 3 +- 6 files changed, 336 insertions(+), 120 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 037cc89..cc1e2d5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { api, HttpError, type FeedFilters } from "./lib/api"; @@ -17,8 +17,10 @@ import { saveLayoutLocal, type SidebarLayout, } from "./lib/sidebarLayout"; -import { configureNotifications, notify } from "./lib/notifications"; -import { setHintsEnabled } from "./lib/hints"; +import { configureNotifications, getNotifSettings, notify, type NotifSettings } from "./lib/notifications"; +import { hintsEnabled, setHintsEnabled } from "./lib/hints"; +import { useConfirm } from "./components/ConfirmProvider"; +import type { PrefsController } from "./components/SettingsPanel"; import Login from "./components/Login"; import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; @@ -54,6 +56,18 @@ const DEFAULT_FILTERS: FeedFilters = { const FILTERS_KEY = "subfeed.filters"; const PAGE_KEY = "siftlode.page"; +const PERF_KEY = "subfeed.perfMode"; + +// The preferences edited on the Settings page. They apply locally for instant preview but +// persist to the server only on an explicit Save — so App holds both the live "draft" (the +// individual states below) and the last-saved "baseline" to compute dirty / revert on discard. +type EditablePrefs = { + theme: ThemePrefs; + view: "grid" | "list"; + performanceMode: boolean; + hints: boolean; + notifications: NotifSettings; +}; // Page is navigation state, not a filter, but it's also kept out of the address bar — so // persist it to localStorage to survive a reload. A share link's ?page= still wins. @@ -81,9 +95,29 @@ function loadInitialFilters(): FeedFilters { export default function App() { const { t, i18n } = useTranslation(); + const confirm = useConfirm(); + // Refs the (stable) navigation handlers read so they always see the latest unsaved-prefs + // state without being recreated; populated by an effect each render (below). + const prefsDirtyRef = useRef(false); + const discardRef = useRef<() => void>(() => {}); + const confirmRef = useRef(confirm); + const pageRef = useRef("feed"); const [theme, setThemeState] = useState(() => loadLocalTheme()); const [filters, setFiltersState] = useState(loadInitialFilters); 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 [hints, setHints] = useState(() => hintsEnabled()); + const [notif, setNotif] = useState(() => getNotifSettings()); + const [savedPrefs, setSavedPrefs] = useState(() => ({ + theme: loadLocalTheme(), + view: "grid", + performanceMode: localStorage.getItem(PERF_KEY) === "1", + hints: hintsEnabled(), + notifications: getNotifSettings(), + })); + const [prefsSaveState, setPrefsSaveState] = useState("idle"); + const saveMsgTimer = useRef(undefined); const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout); const [page, setPageState] = useState(loadInitialPage); const [wizardOpen, setWizardOpen] = useState(false); @@ -125,12 +159,29 @@ export default function App() { function setPage(next: Page) { if (next === page) return; - setPageState(next); - localStorage.setItem(PAGE_KEY, next); - // Push an in-app history entry so the browser/mouse Back button steps through pages - // instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean — - // the page rides in history.state, not the query string (filters never go in the URL). - window.history.pushState({ ...window.history.state, sfPage: next }, ""); + const go = () => { + setPageState(next); + localStorage.setItem(PAGE_KEY, next); + // Push an in-app history entry so the browser/mouse Back button steps through pages + // instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean — + // the page rides in history.state, not the query string (filters never go in the URL). + window.history.pushState({ ...window.history.state, sfPage: next }, ""); + }; + // Guard leaving the Settings page with unsaved preference changes. + if (page === "settings" && prefsDirtyRef.current) { + void confirmRef.current({ + title: t("settings.unsaved.title"), + message: t("settings.unsaved.message"), + confirmLabel: t("settings.unsaved.discard"), + danger: true, + }).then((ok) => { + if (!ok) return; + discardRef.current(); + go(); + }); + return; + } + go(); } function setSidebarLayout(next: SidebarLayout) { @@ -141,6 +192,15 @@ export default function App() { 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"); + }, [perf]); + useEffect(() => setHintsEnabled(hints), [hints]); + useEffect(() => configureNotifications(notif), [notif]); + // Filters live in localStorage, not the address bar. If we arrived via a "Share view" // link, its params have already hydrated the initial state — persist them and strip the // query so the URL stays clean from here on. @@ -162,6 +222,23 @@ export default function App() { ); function onPop(e: PopStateEvent) { const p = (e.state?.sfPage as Page) ?? "feed"; + // Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings + // entry so nothing is silently stranded, then ask; on discard, go where Back headed. + if (pageRef.current === "settings" && p !== "settings" && prefsDirtyRef.current) { + window.history.pushState({ ...window.history.state, sfPage: "settings" }, ""); + void confirmRef.current({ + title: t("settings.unsaved.title"), + message: t("settings.unsaved.message"), + confirmLabel: t("settings.unsaved.discard"), + danger: true, + }).then((ok) => { + if (!ok) return; + discardRef.current(); + setPageState(p); + localStorage.setItem(PAGE_KEY, p); + }); + return; + } setPageState(p); localStorage.setItem(PAGE_KEY, p); } @@ -182,22 +259,34 @@ export default function App() { useEffect(() => { const prefs = meQuery.data?.preferences; if (!prefs) return; - if (prefs.theme) { - const merged = { ...DEFAULT_THEME, ...prefs.theme }; - setThemeState(merged); - saveLocalTheme(merged); - } - if (prefs.view === "grid" || prefs.view === "list") setView(prefs.view); + // Adopt the server prefs as both the saved baseline and the live draft. The runtime + // effects above apply the draft (theme/perf/hints/notifications) for display. + const adopted: EditablePrefs = { + theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(), + view: prefs.view === "grid" || prefs.view === "list" ? prefs.view : "grid", + performanceMode: + typeof prefs.performanceMode === "boolean" + ? prefs.performanceMode + : localStorage.getItem(PERF_KEY) === "1", + hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(), + notifications: prefs.notifications + ? { ...getNotifSettings(), ...prefs.notifications } + : getNotifSettings(), + }; + setThemeState(adopted.theme); + saveLocalTheme(adopted.theme); + setView(adopted.view); + setPerf(adopted.performanceMode); + setHints(adopted.hints); + setNotif(adopted.notifications); + setSavedPrefs(adopted); + // Out-of-scope prefs stay instant (edited outside the Settings page). if (prefs.sidebarLayout) { const l = normalizeLayout(prefs.sidebarLayout); setSidebarLayoutState(l); saveLayoutLocal(l); } if (isSupported(prefs.language)) setLanguage(prefs.language); - if (prefs.notifications) configureNotifications(prefs.notifications); - if (typeof prefs.hints === "boolean") setHintsEnabled(prefs.hints); - if (typeof prefs.performanceMode === "boolean") - document.documentElement.dataset.perf = prefs.performanceMode ? "1" : ""; // Nudge admins when access requests are waiting (in lieu of a server-push bell). const pending = meQuery.data?.pending_invites ?? 0; if (meQuery.data?.role === "admin" && pending > 0) { @@ -216,20 +305,87 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [meQuery.data?.id]); + // Theme is part of the Settings draft: apply locally for preview, persist on Save. function setTheme(next: ThemePrefs) { setThemeState(next); saveLocalTheme(next); - api.savePrefs({ theme: next }).catch(() => {}); - } - function changeView(v: "grid" | "list") { - setView(v); - api.savePrefs({ view: v }).catch(() => {}); } + // Language is edited outside the Settings page (sidebar/login), so it stays instant. function changeLanguage(code: LangCode) { setLanguage(code); api.savePrefs({ language: code }).catch(() => {}); } + const prefsDirty = + JSON.stringify({ theme, view, performanceMode: perf, hints, notifications: notif }) !== + JSON.stringify(savedPrefs); + + const savePrefs = useCallback(() => { + const payload: EditablePrefs = { theme, view, performanceMode: perf, hints, notifications: notif }; + window.clearTimeout(saveMsgTimer.current); + setPrefsSaveState("saving"); + api + .savePrefs(payload) + .then(() => { + setSavedPrefs(payload); + setPrefsSaveState("saved"); + saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 2500); + }) + .catch(() => { + // api.req() already surfaces the failure (connection-lost status / error dialog); + // just flag the bar so the user sees the save didn't take, then clear it. + setPrefsSaveState("error"); + saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000); + }); + }, [theme, view, perf, hints, notif]); + + const discardPrefs = useCallback(() => { + setThemeState(savedPrefs.theme); + saveLocalTheme(savedPrefs.theme); + setView(savedPrefs.view); + setPerf(savedPrefs.performanceMode); + setHints(savedPrefs.hints); + setNotif(savedPrefs.notifications); + window.clearTimeout(saveMsgTimer.current); + setPrefsSaveState("idle"); + }, [savedPrefs]); + + const prefsCtl: PrefsController = { + theme, + setTheme, + view, + setView, + perf, + setPerf, + hints, + setHints, + notif, + setNotif, + dirty: prefsDirty, + save: savePrefs, + discard: discardPrefs, + saveState: prefsSaveState, + }; + + // Keep the navigation guards (setPage / popstate) reading the latest values. + useEffect(() => { + prefsDirtyRef.current = prefsDirty; + discardRef.current = discardPrefs; + confirmRef.current = confirm; + pageRef.current = page; + }); + + // Warn on a hard navigation (reload / tab close) while preference changes are unsaved. + useEffect(() => { + if (!prefsDirty) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [prefsDirty]); + if (meQuery.isLoading) return (
{t("common.loading")}
@@ -307,10 +463,7 @@ export default function App() { ) : page === "settings" ? ( setWizardOpen(true)} /> ) : ( diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 47b44b1..9e1b5d1 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1,21 +1,37 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, RotateCcw, Trash2, User, UserPlus, X } from "lucide-react"; +import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, RotateCcw, Save, Trash2, User, UserPlus, X } from "lucide-react"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, type Invite, type Me } from "../lib/api"; import { formatEta, quotaActionLabel } from "../lib/format"; import Avatar from "./Avatar"; -import { - configureNotifications, - getNotifSettings, - notify, - type NotifSettings, -} from "../lib/notifications"; -import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints"; +import { notify, type NotifSettings } from "../lib/notifications"; import Tooltip from "./Tooltip"; import { useConfirm } from "./ConfirmProvider"; +// The Settings page edits server-persisted preferences as a draft: changes apply locally +// for instant preview but reach the server only on an explicit Save (or revert on Discard). +// App owns the draft + baseline (so the leave-the-page guard can see it); this controller is +// how the panel reads/writes it. See App.tsx. +export type PrefsSaveState = "idle" | "saving" | "saved" | "error"; +export interface PrefsController { + theme: ThemePrefs; + setTheme: (t: ThemePrefs) => void; + view: "grid" | "list"; + setView: (v: "grid" | "list") => void; + perf: boolean; + setPerf: (v: boolean) => void; + hints: boolean; + setHints: (v: boolean) => void; + notif: NotifSettings; + setNotif: (n: NotifSettings) => void; + dirty: boolean; + save: () => void; + discard: () => void; + saveState: PrefsSaveState; +} + type TabId = "appearance" | "notifications" | "sync" | "account"; const TABS: { id: TabId; icon: typeof Monitor }[] = [ { id: "appearance", icon: Monitor }, @@ -37,17 +53,11 @@ function loadSettingsTab(): TabId { // shown by the Header; the tab rail stays as in-page sub-navigation. export default function SettingsPanel({ me, - theme, - setTheme, - view, - setView, + prefs, onOpenWizard, }: { me: Me; - theme: ThemePrefs; - setTheme: (t: ThemePrefs) => void; - view: "grid" | "list"; - setView: (v: "grid" | "list") => void; + prefs: PrefsController; onOpenWizard: () => void; }) { const { t } = useTranslation(); @@ -59,47 +69,87 @@ export default function SettingsPanel({ return (
-
- {/* Vertical tab rail — never wraps; active is a clear accent pill. */} - + + {/* All tabs stacked in one grid cell so the panel sizes to the tallest tab + (stable height, no jump on switch); the active one is shown on top. */} +
+ {TABS.map((tabItem) => ( +
setTab(tabItem.id)} - className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${ - active - ? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20" - : "text-muted hover:text-fg hover:bg-card/60" + className={`[grid-area:1/1] p-4 ${ + tab === tabItem.id ? "" : "invisible pointer-events-none" }`} > - - {t(`settings.tabs.${tabItem.id}`)} - - ); - })} - - - {/* All tabs stacked in one grid cell so the panel sizes to the tallest tab - (stable height, no jump on switch); the active one is shown on top. */} -
- {TABS.map((tabItem) => ( -
- {tabItem.id === "appearance" && ( - - )} - {tabItem.id === "notifications" && } - {tabItem.id === "sync" && } - {tabItem.id === "account" && } -
- ))} + {tabItem.id === "appearance" && } + {tabItem.id === "notifications" && } + {tabItem.id === "sync" && } + {tabItem.id === "account" && } +
+ ))} +
+ + {/* Save/Discard bar — spans the whole card (a change can come from any tab). Only the + Appearance/Notifications prefs are drafted; the Sync/Account tabs act immediately. */} + +
+
+ ); +} + +function PrefsSaveBar({ prefs }: { prefs: PrefsController }) { + const { t } = useTranslation(); + // Stay mounted while a transient "saved"/"error" message is fading, even after dirty clears. + if (!prefs.dirty && prefs.saveState === "idle") return null; + const saving = prefs.saveState === "saving"; + return ( +
+ + {prefs.saveState === "saved" + ? {t("settings.save.saved")} + : prefs.saveState === "error" + ? {t("settings.save.failed")} + : t("settings.save.unsaved")} + +
+ +
); @@ -155,35 +205,11 @@ function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean ); } -const PERF_KEY = "subfeed.perfMode"; - -function Appearance({ - theme, - setTheme, - view, - setView, -}: { - theme: ThemePrefs; - setTheme: (t: ThemePrefs) => void; - view: "grid" | "list"; - setView: (v: "grid" | "list") => void; -}) { +function Appearance({ prefs }: { prefs: PrefsController }) { const { t } = useTranslation(); - const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1"); - const hints = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled); - - useEffect(() => { - document.documentElement.dataset.perf = perf ? "1" : ""; - }, [perf]); - function togglePerf(v: boolean) { - setPerf(v); - localStorage.setItem(PERF_KEY, v ? "1" : "0"); - api.savePrefs({ performanceMode: v }).catch(() => {}); - } - function toggleHints(v: boolean) { - setHintsEnabled(v); - api.savePrefs({ hints: v }).catch(() => {}); - } + // Controlled by App's prefs draft: each change applies locally for preview but is only + // persisted on an explicit Save (handled by the panel's Save/Discard bar). + const { theme, setTheme, view, setView, perf, setPerf, hints, setHints } = prefs; return ( <> @@ -217,13 +243,13 @@ function Appearance({ label={t("settings.appearance.performanceMode")} hint={t("settings.appearance.performanceModeHint")} > - + - + @@ -243,15 +269,12 @@ function Appearance({ ); } -function Notifications() { +function Notifications({ prefs }: { prefs: PrefsController }) { const { t } = useTranslation(); - const [cfg, setCfg] = useState(() => getNotifSettings()); - function update(p: Partial) { - const next = { ...cfg, ...p }; - setCfg(next); - configureNotifications(next); - api.savePrefs({ notifications: next }).catch(() => {}); - } + // Controlled by App's prefs draft (live preview via configureNotifications there); the + // panel's Save bar persists it. The "send test" button below still fires immediately. + const { notif: cfg, setNotif } = prefs; + const update = (p: Partial) => setNotif({ ...cfg, ...p }); const autoOn = cfg.durationMs > 0; return ( diff --git a/frontend/src/i18n/locales/de/settings.json b/frontend/src/i18n/locales/de/settings.json index 26230d7..0b9677f 100644 --- a/frontend/src/i18n/locales/de/settings.json +++ b/frontend/src/i18n/locales/de/settings.json @@ -6,6 +6,19 @@ "sync": "Synchronisierung", "account": "Konto" }, + "save": { + "unsaved": "Nicht gespeicherte Änderungen", + "saving": "Speichern…", + "saved": "Einstellungen gespeichert", + "failed": "Speichern fehlgeschlagen", + "save": "Speichern", + "discard": "Verwerfen" + }, + "unsaved": { + "title": "Nicht gespeicherte Änderungen", + "message": "Du hast nicht gespeicherte Einstellungen. Verwerfen und verlassen?", + "discard": "Änderungen verwerfen" + }, "appearance": { "colorScheme": "Farbschema", "display": "Anzeige", diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 0b39c94..b7e837f 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -6,6 +6,19 @@ "sync": "Sync", "account": "Account" }, + "save": { + "unsaved": "Unsaved changes", + "saving": "Saving…", + "saved": "Settings saved", + "failed": "Couldn't save", + "save": "Save", + "discard": "Discard" + }, + "unsaved": { + "title": "Unsaved changes", + "message": "You have unsaved settings. Discard them and leave?", + "discard": "Discard changes" + }, "appearance": { "colorScheme": "Color scheme", "display": "Display", diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index f58012b..0212e51 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -6,6 +6,19 @@ "sync": "Szinkronizálás", "account": "Fiók" }, + "save": { + "unsaved": "Nem mentett változások", + "saving": "Mentés…", + "saved": "Beállítások elmentve", + "failed": "Nem sikerült menteni", + "save": "Mentés", + "discard": "Elvetés" + }, + "unsaved": { + "title": "Nem mentett változások", + "message": "Vannak nem mentett beállításaid. Elveted őket és továbblépsz?", + "discard": "Változások elvetése" + }, "appearance": { "colorScheme": "Színséma", "display": "Megjelenítés", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index be31fd8..33aae14 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -464,8 +464,9 @@ export const api = { videoDetail: (id: string): Promise => req(`/api/videos/${id}`), pauseSync: () => req("/api/sync/pause", { method: "POST" }), resumeSync: () => req("/api/sync/resume", { method: "POST" }), + // Overwriting preferences is idempotent, so let it retry on a transient gateway blip. savePrefs: (p: Record) => - req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }), + req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }), // --- channel manager --- myStatus: (): Promise => req("/api/sync/my-status"), From 989ac65474cf7c96415744b2c64071e57cd849b2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 00:04:19 +0200 Subject: [PATCH 4/5] fix(settings): size panel to the active tab + drop the native unload prompt - Render only the active settings tab instead of stacking all four in one grid cell. The stack forced the whole panel to the tallest tab's (Account) height, leaving the short tabs (Appearance/Notifications) with dead space and a pointless scrollbar. Now the panel sizes to its actual content. - Remove the beforeunload guard: it could only raise the browser's own native 'Reload site?' prompt (no in-app dialog possible there), which we avoid. Unsaved prefs are local-only and revert to the saved server baseline on the next load, so a reload/close loses nothing. The in-app confirm still guards in-app navigation and browser Back. --- frontend/src/App.tsx | 14 +++---------- frontend/src/components/SettingsPanel.tsx | 24 ++++++++--------------- 2 files changed, 11 insertions(+), 27 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cc1e2d5..0a6b52b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -374,17 +374,9 @@ export default function App() { confirmRef.current = confirm; pageRef.current = page; }); - - // Warn on a hard navigation (reload / tab close) while preference changes are unsaved. - useEffect(() => { - if (!prefsDirty) return; - const handler = (e: BeforeUnloadEvent) => { - e.preventDefault(); - e.returnValue = ""; - }; - window.addEventListener("beforeunload", handler); - return () => window.removeEventListener("beforeunload", handler); - }, [prefsDirty]); + // No beforeunload guard: a reload/close can only raise the browser's own native prompt + // (we can't show our in-app confirm there), and unsaved prefs are local-only — they + // revert to the saved server baseline on the next load — so there's nothing to lose. if (meQuery.isLoading) return ( diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 9e1b5d1..6e65456 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -92,22 +92,14 @@ export default function SettingsPanel({ })} - {/* All tabs stacked in one grid cell so the panel sizes to the tallest tab - (stable height, no jump on switch); the active one is shown on top. */} -
- {TABS.map((tabItem) => ( -
- {tabItem.id === "appearance" && } - {tabItem.id === "notifications" && } - {tabItem.id === "sync" && } - {tabItem.id === "account" && } -
- ))} + {/* Render only the active tab so the panel sizes to its actual content. (Stacking + every tab in one grid cell made the whole panel as tall as the tallest tab — + Account — leaving the short tabs with dead space and a needless scrollbar.) */} +
+ {tab === "appearance" && } + {tab === "notifications" && } + {tab === "sync" && } + {tab === "account" && }
From 470ddf484f29a6ca45f23327f5db104384a85118 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 00:07:25 +0200 Subject: [PATCH 5/5] release: 0.10.0 Explicit Save/Discard for Settings; self-resolving connection-lost notice + automatic retry of transient gateway 502s; state-change notices gated on server confirmation; Settings panel height tidy-up. --- VERSION | 2 +- frontend/src/lib/releaseNotes.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index ac39a10..78bc1ab 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0 +0.10.0 diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 5265d71..056b709 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,19 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.10.0", + date: "2026-06-19", + summary: "Settings you save explicitly, plus a calmer, more honest playback experience.", + features: [ + "Settings now save explicitly: your changes preview instantly, but only apply to your account when you press Save — with a clear “Settings saved” confirmation. A Discard button reverts everything, and leaving the page with unsaved changes asks first.", + ], + fixes: [ + "The in-app player no longer throws a spurious “Server error (502)” when a video finishes or while the server is briefly busy: passing hiccups are retried automatically, and if the server is momentarily unreachable you get a single “connection lost” notice that clears itself the moment it's back — instead of an error you had to dismiss.", + "Confirmations like “Marked watched” or “Hidden” now appear only after the server has actually applied the change, so you'll never see a success message for something that didn't go through.", + "Tidied the Settings page height — no more unnecessary empty space and scrolling.", + ], + }, { version: "0.9.0", date: "2026-06-18",