feat(settings): auto-save preferences instead of draft + Save

Every Settings change now persists on its own (debounced ~500ms so a slider drag
is one PUT), with a small transient indicator; the draft/dirty-save UI and the
navigation leave-guard are gone. Also unblocks the navigation extraction — there
is no longer a cross-page "unsaved settings" guard to thread through routing.
This commit is contained in:
2026-07-18 01:44:25 +02:00
parent 49739da925
commit c855924fdc
5 changed files with 126 additions and 188 deletions
+26 -37
View File
@@ -1,62 +1,51 @@
import { test, expect } from "@playwright/test"; import { test, expect, type Page } from "@playwright/test";
import { openApp, gotoPage } from "./helpers"; import { openApp, gotoPage } from "./helpers";
// Locks the Settings draft/dirty-save machine: an edit raises the save bar, Save persists via // Locks the Settings AUTO-SAVE contract (the draft + Save button + leave-guard were removed): an
// PUT /me/preferences, and leaving with unsaved changes is guarded. The dirty-guard is one of // Appearance change persists on its own via PUT /me/preferences and a transient indicator confirms
// the behaviors the god-component refactor must preserve exactly. // it; leaving Settings after a change is no longer guarded.
test.describe("settings dirty-save", () => { test.describe("settings auto-save", () => {
const TOGGLE = "Performance mode"; // an Appearance switch (locale pinned to en) const TOGGLE = "Performance mode"; // an Appearance switch (locale pinned to en)
async function putPrefs(page: import("@playwright/test").Page) { // The save is debounced (~500ms), so wait for the PUT rather than assuming it's synchronous.
return page.waitForResponse( const putPrefs = (page: Page) =>
page.waitForResponse(
(r) => r.url().includes("/me/preferences") && r.request().method() === "PUT", (r) => r.url().includes("/me/preferences") && r.request().method() === "PUT",
); );
}
test("edit raises the bar and Save persists via PUT /me/preferences", async ({ page }) => { test("changing a setting auto-saves and shows the saved indicator", async ({ page }) => {
await openApp(page); await openApp(page);
await gotoPage(page, "settings"); await gotoPage(page, "settings");
const bar = page.getByTestId("draft-save-bar");
const status = page.getByTestId("draft-save-status");
const toggle = page.getByRole("switch", { name: TOGGLE }); const toggle = page.getByRole("switch", { name: TOGGLE });
const indicator = page.getByTestId("settings-save-indicator");
// Clean: the bar renders nothing. // Idle: nothing to save, so no indicator (and no draft Save bar).
await expect(bar).toHaveCount(0); await expect(indicator).toHaveCount(0);
// Edit → the bar appears. // Change it → the PUT fires on its own (no Save click) and the indicator confirms it.
await toggle.click(); const [saved] = await Promise.all([putPrefs(page), toggle.click()]);
await expect(bar).toBeVisible();
// Save → the PUT fires and the status flips to "saved".
const [saved] = await Promise.all([putPrefs(page), page.getByTestId("draft-save").click()]);
expect(saved.ok()).toBeTruthy(); expect(saved.ok()).toBeTruthy();
await expect(status).toHaveAttribute("data-state", "saved"); await expect(indicator).toHaveAttribute("data-state", "saved");
// Restore the baseline (toggle back and save) so the shared account stays clean. Assert the // Restore the baseline so the shared account stays clean.
// restore's own PUT (not the lingering "saved" from the first save, which shows for 2.5s). await Promise.all([putPrefs(page), toggle.click()]);
await toggle.click();
const [restored] = await Promise.all([putPrefs(page), page.getByTestId("draft-save").click()]);
expect(restored.ok()).toBeTruthy();
}); });
test("leaving with unsaved changes is guarded", async ({ page }) => { test("a changed setting no longer blocks leaving the page", async ({ page }) => {
await openApp(page); await openApp(page);
await gotoPage(page, "settings"); await gotoPage(page, "settings");
await page.getByRole("switch", { name: TOGGLE }).click(); const toggle = page.getByRole("switch", { name: TOGGLE });
await expect(page.getByTestId("draft-save-bar")).toBeVisible(); await Promise.all([putPrefs(page), toggle.click()]);
// Navigating away raises the confirm guard instead of leaving. // No unsaved-changes prompt — navigating away just lands on the feed.
await page.getByTestId("nav-feed").click(); await page.getByTestId("nav-feed").click();
await expect(page.getByText("You have unsaved settings")).toBeVisible(); await expect(page.getByTestId("nav-feed")).toHaveAttribute("aria-current", "page");
await expect(page.getByTestId("video-card").first()).toBeVisible();
// Cancel → stay on Settings, still dirty. // Restore the baseline (back on Settings).
await page.getByRole("button", { name: "Cancel" }).click(); await gotoPage(page, "settings");
await expect(page.getByTestId("draft-save-bar")).toBeVisible(); await Promise.all([putPrefs(page), page.getByRole("switch", { name: TOGGLE }).click()]);
// Discard → clean, so the account is left at baseline.
await page.getByTestId("draft-discard").click();
await expect(page.getByTestId("draft-save-bar")).toHaveCount(0);
}); });
}); });
+67 -106
View File
@@ -43,7 +43,6 @@ import {
writeAccount, writeAccount,
writeJSON, writeJSON,
} from "./lib/storage"; } from "./lib/storage";
import { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel"; import type { PrefsController } from "./components/SettingsPanel";
import type { ChannelStatusFilter, ChannelsView } from "./components/Channels"; import type { ChannelStatusFilter, ChannelsView } from "./components/Channels";
// Persistent shell — always mounted, so eagerly imported. // Persistent shell — always mounted, so eagerly imported.
@@ -157,13 +156,6 @@ function loadInitialFilters(): FeedFilters {
export default function App() { export default function App() {
const { t, i18n } = useTranslation(); 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<Page>("feed");
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme()); const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters); const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped) // Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
@@ -171,22 +163,26 @@ export default function App() {
const [initialHadUrlFilters] = useState(() => const [initialHadUrlFilters] = useState(() =>
hasFilterParams(new URLSearchParams(window.location.search)) hasFilterParams(new URLSearchParams(window.location.search))
); );
// The feed's view mode is picked from the feed's own toolbar, so — unlike the Settings prefs // The feed's view mode is picked from the feed's own toolbar; like every Settings pref it
// below — it persists the moment you choose it. It is NOT part of the EditablePrefs draft: // persists the moment you choose it.
// that would mark the Settings page dirty from a toolbar click and trip its leave-page guard.
const [view, setViewState] = useState<FeedView>(() => parseFeedView(readAccount(LS.feedView, null))); const [view, setViewState] = useState<FeedView>(() => parseFeedView(readAccount(LS.feedView, null)));
// Settings-page prefs draft (apply live, persist on Save — see EditablePrefs). // Settings-page prefs: applied live by the effects below, and auto-saved (debounced) on change
// — no draft, no Save button (see the auto-save effect).
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1"); const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
const [hints, setHints] = useState(() => hintsEnabled()); const [hints, setHints] = useState(() => hintsEnabled());
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings()); const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({ // The last server-known prefs. The same states are ALSO written when we adopt server prefs on
// login/account-switch, so the auto-save effect compares against this baseline to avoid echoing
// a server-adopted value straight back. A ref (drives no UI); updated on adopt + after each save.
const baselineRef = useRef<EditablePrefs>({
theme: loadLocalTheme(), theme: loadLocalTheme(),
performanceMode: getAccountRaw(PERF_KEY) === "1", performanceMode: getAccountRaw(PERF_KEY) === "1",
hints: hintsEnabled(), hints: hintsEnabled(),
notifications: getNotifSettings(), notifications: getNotifSettings(),
})); });
const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle"); const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
const saveMsgTimer = useRef<number | undefined>(undefined); const saveMsgTimer = useRef<number | undefined>(undefined);
const saveTimer = useRef<number | undefined>(undefined);
// Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards), // Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards),
// persisted per-account. One record so feed/plex/playlists share the same mechanism. // persisted per-account. One record so feed/plex/playlists share the same mechanism.
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => ({ const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => ({
@@ -323,31 +319,14 @@ export default function App() {
// While a channel page is open it overlays the content column, so a nav click must close it // While a channel page is open it overlays the content column, so a nav click must close it
// even when the underlying page is unchanged (e.g. "Feed" while a channel opened from the feed). // even when the underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
if (next === page && !channelView) return; if (next === page && !channelView) return;
const go = () => { setChannelView(null); // leave any open channel page when navigating via the rail
setChannelView(null); // leave any open channel page when navigating via the rail setPageState(next);
setPageState(next); setAccountRaw(PAGE_KEY, next);
setAccountRaw(PAGE_KEY, next); // Push an in-app history entry so the browser/mouse Back button steps through pages instead of
// Push an in-app history entry so the browser/mouse Back button steps through pages // leaving the app (e.g. back to the OAuth redirect). The URL stays clean — the page rides in
// instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean — // history.state, not the query string. A fresh { sfPage } (no carried-over _yt/_chan markers)
// the page rides in history.state, not the query string (filters never go in the URL). // so each page starts at its root.
// A fresh { sfPage } (no carried-over _sub/_ov/_chan markers) so each page starts at its root. window.history.pushState({ sfPage: next }, "");
window.history.pushState({ 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();
} }
// Expose the current setPage to decoupled callers (download toast "View", etc.). // Expose the current setPage to decoupled callers (download toast "View", etc.).
useEffect(() => setNavigator(setPage)); useEffect(() => setNavigator(setPage));
@@ -435,6 +414,51 @@ export default function App() {
useEffect(() => setHintsEnabled(hints), [hints]); useEffect(() => setHintsEnabled(hints), [hints]);
useEffect(() => configureNotifications(notif), [notif]); useEffect(() => configureNotifications(notif), [notif]);
// Auto-save the Settings prefs on change (debounced ~500ms so a slider drag coalesces into one
// PUT). Live-apply is handled by the effects above; this is the only persistence path — there is
// no draft or Save button. `baselineRef` suppresses the echo when the change came from adopting
// server prefs on login (`/me` merges, so a full payload is fine).
const editablePrefs = useMemo<EditablePrefs>(
() => ({ theme, performanceMode: perf, hints, notifications: notif }),
[theme, perf, hints, notif],
);
useEffect(() => {
window.clearTimeout(saveTimer.current);
if (JSON.stringify(editablePrefs) === JSON.stringify(baselineRef.current)) {
// Back at the last-saved value (initial load, server adopt, or an edit undone) — nothing to
// save; clear any "Saving…" left from a change that was reverted before it flushed.
setPrefsSaveState((s) => (s === "saving" ? "idle" : s));
return;
}
setPrefsSaveState("saving");
window.clearTimeout(saveMsgTimer.current);
const payload = editablePrefs;
saveTimer.current = window.setTimeout(() => {
api
.savePrefs(payload)
.then(() => {
baselineRef.current = payload;
setPrefsSaveState("saved");
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 2500);
})
.catch(() => {
// api.req() already surfaces the failure; flag the indicator so the user sees it didn't take.
setPrefsSaveState("error");
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000);
});
}, 500);
return () => window.clearTimeout(saveTimer.current);
}, [editablePrefs]);
// Clear pending pref-save timers on unmount so a debounced PUT or a fade timer never fires
// setState on a dead component.
useEffect(
() => () => {
window.clearTimeout(saveTimer.current);
window.clearTimeout(saveMsgTimer.current);
},
[],
);
// Filters live in localStorage, not the address bar. If we arrived via a "Share view" // 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 // link, its params have already hydrated the initial state — persist them and strip the
// query so the URL stays clean from here on. // query so the URL stays clean from here on.
@@ -464,23 +488,6 @@ export default function App() {
} }
function onPop(e: PopStateEvent) { function onPop(e: PopStateEvent) {
const p = (e.state?.sfPage as Page) ?? "feed"; 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);
setAccountRaw(PAGE_KEY, p);
});
return;
}
setPageState(p); setPageState(p);
setAccountRaw(PAGE_KEY, p); setAccountRaw(PAGE_KEY, p);
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or // The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
@@ -607,8 +614,8 @@ export default function App() {
useEffect(() => { useEffect(() => {
const prefs = meQuery.data?.preferences; const prefs = meQuery.data?.preferences;
if (!prefs) return; if (!prefs) return;
// Adopt the server prefs as both the saved baseline and the live draft. The runtime // Adopt the server prefs into the live states (the runtime effects above apply them for
// effects above apply the draft (theme/perf/hints/notifications) for display. // display) AND into the auto-save baseline, so adopting them doesn't echo a save back.
const adopted: EditablePrefs = { const adopted: EditablePrefs = {
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(), theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
performanceMode: performanceMode:
@@ -625,7 +632,7 @@ export default function App() {
setPerf(adopted.performanceMode); setPerf(adopted.performanceMode);
setHints(adopted.hints); setHints(adopted.hints);
setNotif(adopted.notifications); setNotif(adopted.notifications);
setSavedPrefs(adopted); baselineRef.current = adopted;
// Out-of-scope prefs stay instant (edited outside the Settings page). // Out-of-scope prefs stay instant (edited outside the Settings page).
const prefsRec = prefs as Record<string, unknown>; const prefsRec = prefs as Record<string, unknown>;
// parseFeedView also maps the pre-0.45 "grid" / "list" values forward. // parseFeedView also maps the pre-0.45 "grid" / "list" values forward.
@@ -687,7 +694,7 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.role, meQuery.data?.pending_invites]); }, [meQuery.data?.role, meQuery.data?.pending_invites]);
// Theme is part of the Settings draft: apply locally for preview, persist on Save. // Theme applies live (and caches to localStorage); the auto-save effect persists it to the server.
function setTheme(next: ThemePrefs) { function setTheme(next: ThemePrefs) {
setThemeState(next); setThemeState(next);
saveLocalTheme(next); saveLocalTheme(next);
@@ -698,39 +705,6 @@ export default function App() {
api.savePrefs({ language: code }).catch(() => {}); api.savePrefs({ language: code }).catch(() => {});
} }
const prefsDirty =
JSON.stringify({ theme, performanceMode: perf, hints, notifications: notif }) !==
JSON.stringify(savedPrefs);
const savePrefs = useCallback(() => {
const payload: EditablePrefs = { theme, 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, perf, hints, notif]);
const discardPrefs = useCallback(() => {
setThemeState(savedPrefs.theme);
saveLocalTheme(savedPrefs.theme);
setPerf(savedPrefs.performanceMode);
setHints(savedPrefs.hints);
setNotif(savedPrefs.notifications);
window.clearTimeout(saveMsgTimer.current);
setPrefsSaveState("idle");
}, [savedPrefs]);
const prefsCtl: PrefsController = { const prefsCtl: PrefsController = {
theme, theme,
setTheme, setTheme,
@@ -740,22 +714,9 @@ export default function App() {
setHints, setHints,
notif, notif,
setNotif, setNotif,
dirty: prefsDirty,
save: savePrefs,
discard: discardPrefs,
saveState: prefsSaveState, 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.
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard. // First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
if (setupQuery.isLoading) if (setupQuery.isLoading)
+31 -27
View File
@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Monitor, RefreshCw, Trash2, User } from "lucide-react"; import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api"; import { api, type Me } from "../lib/api";
import { LS, useAccountPersistedState } from "../lib/storage"; import { LS, useAccountPersistedState } from "../lib/storage";
@@ -9,12 +9,12 @@ import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications"; import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { Section, SettingRow, Switch } from "./ui/form"; import { Section, SettingRow, Switch } from "./ui/form";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar"; import { type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
// The Settings page edits server-persisted preferences as a draft: changes apply locally // The Settings page edits server-persisted preferences. Each change applies locally for instant
// for instant preview but reach the server only on an explicit Save (or revert on Discard). // preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
// App owns the draft + baseline (so the leave-the-page guard can see it); this controller is // outcome. App owns the state + the save; this controller is
// how the panel reads/writes it. See App.tsx. // how the panel reads/writes it. See App.tsx.
export type PrefsSaveState = SaveState; export type PrefsSaveState = SaveState;
export interface PrefsController { export interface PrefsController {
@@ -26,9 +26,6 @@ export interface PrefsController {
setHints: (v: boolean) => void; setHints: (v: boolean) => void;
notif: NotifSettings; notif: NotifSettings;
setNotif: (n: NotifSettings) => void; setNotif: (n: NotifSettings) => void;
dirty: boolean;
save: () => void;
discard: () => void;
saveState: PrefsSaveState; saveState: PrefsSaveState;
} }
@@ -94,32 +91,39 @@ export default function SettingsPanel({
</div> </div>
</div> </div>
{/* Save/Discard bar — spans the whole card (a change can come from any tab). Only the {/* Auto-save indicator — a change on any tab persists immediately; this just flashes the
Appearance/Notifications prefs are drafted; the Sync/Account tabs act immediately. */} outcome (the Account tab's actions have their own feedback). */}
<PrefsSaveBar prefs={prefs} /> <SaveIndicator state={prefs.saveState} />
</div> </div>
</div> </div>
); );
} }
function PrefsSaveBar({ prefs }: { prefs: PrefsController }) { // A small transient indicator of the auto-save outcome. Renders nothing while idle; it sits at the
// bottom of the settings card and fades after a success (the state resets to "idle" in App).
function SaveIndicator({ state }: { state: PrefsSaveState }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (state === "idle") return null;
return ( return (
<DraftSaveBar <div
variant="inline" data-testid="settings-save-indicator"
dirty={prefs.dirty} data-state={state}
state={prefs.saveState} className="flex items-center justify-end gap-1.5 border-t border-border/60 px-4 py-2.5 text-sm"
onSave={prefs.save} >
onDiscard={prefs.discard} {state === "saving" ? (
labels={{ <span className="inline-flex items-center gap-1.5 text-muted">
saved: t("settings.save.saved"), <Loader2 className="w-4 h-4 animate-spin" />
failed: t("settings.save.failed"), {t("settings.save.saving")}
unsaved: t("settings.save.unsaved"), </span>
discard: t("settings.save.discard"), ) : state === "saved" ? (
save: t("settings.save.save"), <span className="inline-flex items-center gap-1.5 text-emerald-500">
saving: t("settings.save.saving"), <Check className="w-4 h-4" />
}} {t("settings.save.saved")}
/> </span>
) : (
<span className="text-red-500">{t("settings.save.failed")}</span>
)}
</div>
); );
} }
+1 -9
View File
@@ -17,17 +17,9 @@
"importing": "Importing your Plex watch history…" "importing": "Importing your Plex watch history…"
}, },
"save": { "save": {
"unsaved": "Unsaved changes",
"saving": "Saving…", "saving": "Saving…",
"saved": "Settings saved", "saved": "Settings saved",
"failed": "Couldn't save", "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": { "appearance": {
"colorScheme": "Color scheme", "colorScheme": "Color scheme",
+1 -9
View File
@@ -17,17 +17,9 @@
"importing": "Plex-előzmény importálása…" "importing": "Plex-előzmény importálása…"
}, },
"save": { "save": {
"unsaved": "Nem mentett változások",
"saving": "Mentés…", "saving": "Mentés…",
"saved": "Beállítások elmentve", "saved": "Beállítások elmentve",
"failed": "Nem sikerült menteni", "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": { "appearance": {
"colorScheme": "Színséma", "colorScheme": "Színséma",