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:
@@ -1,62 +1,51 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { openApp, gotoPage } from "./helpers";
|
||||
|
||||
// Locks the Settings draft/dirty-save machine: an edit raises the save bar, Save persists via
|
||||
// PUT /me/preferences, and leaving with unsaved changes is guarded. The dirty-guard is one of
|
||||
// the behaviors the god-component refactor must preserve exactly.
|
||||
test.describe("settings dirty-save", () => {
|
||||
// Locks the Settings AUTO-SAVE contract (the draft + Save button + leave-guard were removed): an
|
||||
// Appearance change persists on its own via PUT /me/preferences and a transient indicator confirms
|
||||
// it; leaving Settings after a change is no longer guarded.
|
||||
test.describe("settings auto-save", () => {
|
||||
const TOGGLE = "Performance mode"; // an Appearance switch (locale pinned to en)
|
||||
|
||||
async function putPrefs(page: import("@playwright/test").Page) {
|
||||
return page.waitForResponse(
|
||||
// The save is debounced (~500ms), so wait for the PUT rather than assuming it's synchronous.
|
||||
const putPrefs = (page: Page) =>
|
||||
page.waitForResponse(
|
||||
(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 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 indicator = page.getByTestId("settings-save-indicator");
|
||||
|
||||
// Clean: the bar renders nothing.
|
||||
await expect(bar).toHaveCount(0);
|
||||
// Idle: nothing to save, so no indicator (and no draft Save bar).
|
||||
await expect(indicator).toHaveCount(0);
|
||||
|
||||
// Edit → the bar appears.
|
||||
await 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()]);
|
||||
// Change it → the PUT fires on its own (no Save click) and the indicator confirms it.
|
||||
const [saved] = await Promise.all([putPrefs(page), toggle.click()]);
|
||||
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's own PUT (not the lingering "saved" from the first save, which shows for 2.5s).
|
||||
await toggle.click();
|
||||
const [restored] = await Promise.all([putPrefs(page), page.getByTestId("draft-save").click()]);
|
||||
expect(restored.ok()).toBeTruthy();
|
||||
// Restore the baseline so the shared account stays clean.
|
||||
await Promise.all([putPrefs(page), toggle.click()]);
|
||||
});
|
||||
|
||||
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 gotoPage(page, "settings");
|
||||
|
||||
await page.getByRole("switch", { name: TOGGLE }).click();
|
||||
await expect(page.getByTestId("draft-save-bar")).toBeVisible();
|
||||
const toggle = page.getByRole("switch", { name: TOGGLE });
|
||||
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 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.
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(page.getByTestId("draft-save-bar")).toBeVisible();
|
||||
|
||||
// Discard → clean, so the account is left at baseline.
|
||||
await page.getByTestId("draft-discard").click();
|
||||
await expect(page.getByTestId("draft-save-bar")).toHaveCount(0);
|
||||
// Restore the baseline (back on Settings).
|
||||
await gotoPage(page, "settings");
|
||||
await Promise.all([putPrefs(page), page.getByRole("switch", { name: TOGGLE }).click()]);
|
||||
});
|
||||
});
|
||||
|
||||
+67
-106
@@ -43,7 +43,6 @@ import {
|
||||
writeAccount,
|
||||
writeJSON,
|
||||
} from "./lib/storage";
|
||||
import { useConfirm } from "./components/ConfirmProvider";
|
||||
import type { PrefsController } from "./components/SettingsPanel";
|
||||
import type { ChannelStatusFilter, ChannelsView } from "./components/Channels";
|
||||
// Persistent shell — always mounted, so eagerly imported.
|
||||
@@ -157,13 +156,6 @@ 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<Page>("feed");
|
||||
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
|
||||
@@ -171,22 +163,26 @@ export default function App() {
|
||||
const [initialHadUrlFilters] = useState(() =>
|
||||
hasFilterParams(new URLSearchParams(window.location.search))
|
||||
);
|
||||
// The feed's view mode is picked from the feed's own toolbar, so — unlike the Settings prefs
|
||||
// below — it persists the moment you choose it. It is NOT part of the EditablePrefs draft:
|
||||
// that would mark the Settings page dirty from a toolbar click and trip its leave-page guard.
|
||||
// The feed's view mode is picked from the feed's own toolbar; like every Settings pref it
|
||||
// persists the moment you choose it.
|
||||
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 [hints, setHints] = useState(() => hintsEnabled());
|
||||
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(),
|
||||
performanceMode: getAccountRaw(PERF_KEY) === "1",
|
||||
hints: hintsEnabled(),
|
||||
notifications: getNotifSettings(),
|
||||
}));
|
||||
});
|
||||
const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
|
||||
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),
|
||||
// persisted per-account. One record so feed/plex/playlists share the same mechanism.
|
||||
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
|
||||
// even when the underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||
if (next === page && !channelView) return;
|
||||
const go = () => {
|
||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||
setPageState(next);
|
||||
setAccountRaw(PAGE_KEY, next);
|
||||
// Push an in-app history entry so the browser/mouse Back button steps through pages
|
||||
// instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean —
|
||||
// the page rides in history.state, not the query string (filters never go in the URL).
|
||||
// A fresh { sfPage } (no carried-over _sub/_ov/_chan markers) so each page starts at its root.
|
||||
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();
|
||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||
setPageState(next);
|
||||
setAccountRaw(PAGE_KEY, next);
|
||||
// Push an in-app history entry so the browser/mouse Back button steps through pages instead of
|
||||
// leaving the app (e.g. back to the OAuth redirect). The URL stays clean — the page rides in
|
||||
// history.state, not the query string. A fresh { sfPage } (no carried-over _yt/_chan markers)
|
||||
// so each page starts at its root.
|
||||
window.history.pushState({ sfPage: next }, "");
|
||||
}
|
||||
// Expose the current setPage to decoupled callers (download toast "View", etc.).
|
||||
useEffect(() => setNavigator(setPage));
|
||||
@@ -435,6 +414,51 @@ export default function App() {
|
||||
useEffect(() => setHintsEnabled(hints), [hints]);
|
||||
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"
|
||||
// link, its params have already hydrated the initial state — persist them and strip the
|
||||
// query so the URL stays clean from here on.
|
||||
@@ -464,23 +488,6 @@ 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);
|
||||
setAccountRaw(PAGE_KEY, p);
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPageState(p);
|
||||
setAccountRaw(PAGE_KEY, p);
|
||||
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
|
||||
@@ -607,8 +614,8 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
const prefs = meQuery.data?.preferences;
|
||||
if (!prefs) return;
|
||||
// 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.
|
||||
// Adopt the server prefs into the live states (the runtime effects above apply them for
|
||||
// display) AND into the auto-save baseline, so adopting them doesn't echo a save back.
|
||||
const adopted: EditablePrefs = {
|
||||
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
|
||||
performanceMode:
|
||||
@@ -625,7 +632,7 @@ export default function App() {
|
||||
setPerf(adopted.performanceMode);
|
||||
setHints(adopted.hints);
|
||||
setNotif(adopted.notifications);
|
||||
setSavedPrefs(adopted);
|
||||
baselineRef.current = adopted;
|
||||
// Out-of-scope prefs stay instant (edited outside the Settings page).
|
||||
const prefsRec = prefs as Record<string, unknown>;
|
||||
// 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
|
||||
}, [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) {
|
||||
setThemeState(next);
|
||||
saveLocalTheme(next);
|
||||
@@ -698,39 +705,6 @@ export default function App() {
|
||||
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 = {
|
||||
theme,
|
||||
setTheme,
|
||||
@@ -740,22 +714,9 @@ export default function App() {
|
||||
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.
|
||||
|
||||
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
|
||||
if (setupQuery.isLoading)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { api, type Me } from "../lib/api";
|
||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||
@@ -9,12 +9,12 @@ import Avatar from "./Avatar";
|
||||
import { notify, type NotifSettings } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { Section, SettingRow, Switch } from "./ui/form";
|
||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
||||
import { type SaveState } from "./ui/DraftSaveBar";
|
||||
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
|
||||
// The Settings page edits server-persisted preferences. Each change applies locally for instant
|
||||
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
|
||||
// outcome. App owns the state + the save; this controller is
|
||||
// how the panel reads/writes it. See App.tsx.
|
||||
export type PrefsSaveState = SaveState;
|
||||
export interface PrefsController {
|
||||
@@ -26,9 +26,6 @@ export interface PrefsController {
|
||||
setHints: (v: boolean) => void;
|
||||
notif: NotifSettings;
|
||||
setNotif: (n: NotifSettings) => void;
|
||||
dirty: boolean;
|
||||
save: () => void;
|
||||
discard: () => void;
|
||||
saveState: PrefsSaveState;
|
||||
}
|
||||
|
||||
@@ -94,32 +91,39 @@ export default function SettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<PrefsSaveBar prefs={prefs} />
|
||||
{/* Auto-save indicator — a change on any tab persists immediately; this just flashes the
|
||||
outcome (the Account tab's actions have their own feedback). */}
|
||||
<SaveIndicator state={prefs.saveState} />
|
||||
</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();
|
||||
if (state === "idle") return null;
|
||||
return (
|
||||
<DraftSaveBar
|
||||
variant="inline"
|
||||
dirty={prefs.dirty}
|
||||
state={prefs.saveState}
|
||||
onSave={prefs.save}
|
||||
onDiscard={prefs.discard}
|
||||
labels={{
|
||||
saved: t("settings.save.saved"),
|
||||
failed: t("settings.save.failed"),
|
||||
unsaved: t("settings.save.unsaved"),
|
||||
discard: t("settings.save.discard"),
|
||||
save: t("settings.save.save"),
|
||||
saving: t("settings.save.saving"),
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
data-testid="settings-save-indicator"
|
||||
data-state={state}
|
||||
className="flex items-center justify-end gap-1.5 border-t border-border/60 px-4 py-2.5 text-sm"
|
||||
>
|
||||
{state === "saving" ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-muted">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t("settings.save.saving")}
|
||||
</span>
|
||||
) : state === "saved" ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-emerald-500">
|
||||
<Check className="w-4 h-4" />
|
||||
{t("settings.save.saved")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-red-500">{t("settings.save.failed")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,17 +17,9 @@
|
||||
"importing": "Importing your Plex watch history…"
|
||||
},
|
||||
"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"
|
||||
"failed": "Couldn't save"
|
||||
},
|
||||
"appearance": {
|
||||
"colorScheme": "Color scheme",
|
||||
|
||||
@@ -17,17 +17,9 @@
|
||||
"importing": "Plex-előzmény importálása…"
|
||||
},
|
||||
"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"
|
||||
"failed": "Nem sikerült menteni"
|
||||
},
|
||||
"appearance": {
|
||||
"colorScheme": "Színséma",
|
||||
|
||||
Reference in New Issue
Block a user