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/App.tsx b/frontend/src/App.tsx index 037cc89..0a6b52b 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,79 @@ 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; + }); + // 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 (
{t("common.loading")}
@@ -307,10 +455,7 @@ export default function App() { ) : page === "settings" ? ( setWizardOpen(true)} /> ) : ( 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] ); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 47b44b1..6e65456 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,79 @@ export default function SettingsPanel({ return (
-
- {/* Vertical tab rail — never wraps; active is a clear accent pill. */} - +
+
+ {/* 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) => ( -
- {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" && } +
+ + {/* 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 +197,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 +235,13 @@ function Appearance({ label={t("settings.appearance.performanceMode")} hint={t("settings.appearance.performanceModeHint")} > - + - + @@ -243,15 +261,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/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/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/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/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/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/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 06ca59c..33aae14 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) { @@ -456,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"), 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(); 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",