mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: default theme setting not persisting across sessions (#98)
Three disconnected systems caused the theme to never apply from server settings: the DEFAULT_THEME env var was parsed but never seeded to the database, the settings store ignored defaultTheme from the API, and the settings dialog wrote to the DB without updating the active theme store. - Seed DEFAULT_THEME and DEFAULT_LOCALE env vars into the settings table on first startup (ensureDefaultSettings in index.ts) - Add applyServerDefault() to theme store that applies the server's default theme only when the user hasn't made an explicit choice - Extract defaultTheme from the settings API response and apply it on fresh sessions (no localStorage preference) - Apply theme immediately when admin saves settings - Allow "system" as a valid DEFAULT_THEME env var value
This commit is contained in:
@@ -53,6 +53,21 @@ function ensureInstanceId() {
|
||||
}
|
||||
|
||||
ensureInstanceId();
|
||||
|
||||
function ensureDefaultSettings() {
|
||||
const defaults: Record<string, string> = {
|
||||
defaultTheme: env.DEFAULT_THEME,
|
||||
defaultLocale: env.DEFAULT_LOCALE,
|
||||
};
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
|
||||
if (!existing) {
|
||||
db.insert(schema.settings).values({ key, value }).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ensureDefaultSettings();
|
||||
await initAnalytics();
|
||||
|
||||
// Mark any jobs left in processing/queued from a previous unclean shutdown
|
||||
|
||||
@@ -24,7 +24,7 @@ const envSchema = z.object({
|
||||
DB_PATH: z.string().default("./data/snapotter.db"),
|
||||
FILES_STORAGE_PATH: z.string().default("./data/files"),
|
||||
WORKSPACE_PATH: z.string().default("./tmp/workspace"),
|
||||
DEFAULT_THEME: z.enum(["light", "dark"]).default("light"),
|
||||
DEFAULT_THEME: z.enum(["light", "dark", "system"]).default("light"),
|
||||
DEFAULT_LOCALE: z.string().default("en"),
|
||||
APP_NAME: z.string().default("snapotter"),
|
||||
CORS_ORIGIN: z.string().default(""),
|
||||
|
||||
@@ -32,6 +32,7 @@ import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@
|
||||
import { cn, copyToClipboard } from "@/lib/utils";
|
||||
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { OtterLogo } from "../common/otter-logo";
|
||||
import { AiFeaturesSection } from "./ai-features-section";
|
||||
|
||||
@@ -367,6 +368,10 @@ function SystemSection() {
|
||||
setSaveMsg(null);
|
||||
try {
|
||||
await apiPut("/v1/settings", settings);
|
||||
if (settings.defaultTheme) {
|
||||
const theme = settings.defaultTheme as "light" | "dark" | "system";
|
||||
useThemeStore.getState().setTheme(theme);
|
||||
}
|
||||
setSaveMsg("Settings saved.");
|
||||
} catch {
|
||||
setSaveMsg("Failed to save settings.");
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { create } from "zustand";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { useThemeStore } from "./theme-store";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
interface SettingsState {
|
||||
disabledTools: string[];
|
||||
experimentalEnabled: boolean;
|
||||
defaultToolView: "sidebar" | "fullscreen";
|
||||
defaultTheme: Theme;
|
||||
loaded: boolean;
|
||||
fetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
const VALID_THEMES = new Set(["light", "dark", "system"]);
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
disabledTools: [],
|
||||
experimentalEnabled: false,
|
||||
defaultToolView: "sidebar",
|
||||
defaultTheme: "light",
|
||||
loaded: false,
|
||||
|
||||
fetch: async () => {
|
||||
@@ -22,12 +29,19 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
settings: Record<string, string>;
|
||||
}>("/v1/settings");
|
||||
|
||||
const defaultTheme = VALID_THEMES.has(data.settings.defaultTheme)
|
||||
? (data.settings.defaultTheme as Theme)
|
||||
: "light";
|
||||
|
||||
set({
|
||||
disabledTools: data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
|
||||
experimentalEnabled: data.settings.enableExperimentalTools === "true",
|
||||
defaultToolView: data.settings.defaultToolView === "fullscreen" ? "fullscreen" : "sidebar",
|
||||
defaultTheme,
|
||||
loaded: true,
|
||||
});
|
||||
|
||||
useThemeStore.getState().applyServerDefault(defaultTheme);
|
||||
} catch {
|
||||
set({ loaded: true });
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ import { persist } from "zustand/middleware";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
const USER_THEME_KEY = "snapotter-theme-user-set";
|
||||
|
||||
interface ThemeStore {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
resolvedTheme: "light" | "dark";
|
||||
applyServerDefault: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
@@ -14,14 +17,25 @@ function getSystemTheme(): "light" | "dark" {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme): "light" | "dark" {
|
||||
const resolved = theme === "system" ? getSystemTheme() : theme;
|
||||
document.documentElement.classList.toggle("dark", resolved === "dark");
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
theme: "light" as Theme,
|
||||
resolvedTheme: "light" as const,
|
||||
setTheme: (theme) => {
|
||||
const resolved = theme === "system" ? getSystemTheme() : theme;
|
||||
document.documentElement.classList.toggle("dark", resolved === "dark");
|
||||
const resolved = applyTheme(theme);
|
||||
localStorage.setItem(USER_THEME_KEY, "1");
|
||||
set({ theme, resolvedTheme: resolved });
|
||||
},
|
||||
applyServerDefault: (theme) => {
|
||||
if (localStorage.getItem(USER_THEME_KEY)) return;
|
||||
const resolved = applyTheme(theme);
|
||||
set({ theme, resolvedTheme: resolved });
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -858,8 +858,8 @@ import { useThemeStore } from "@/stores/theme-store";
|
||||
|
||||
describe("useThemeStore", () => {
|
||||
beforeEach(() => {
|
||||
// Reset to initial values
|
||||
useThemeStore.setState({ theme: "light", resolvedTheme: "light" });
|
||||
localStorage.removeItem("snapotter-theme-user-set");
|
||||
});
|
||||
|
||||
it("has correct initial state", () => {
|
||||
@@ -890,6 +890,31 @@ describe("useThemeStore", () => {
|
||||
// matchMedia mock returns matches: false, so system resolves to "light"
|
||||
expect(s.resolvedTheme).toBe("light");
|
||||
});
|
||||
|
||||
it("setTheme sets user-set flag in localStorage", () => {
|
||||
useThemeStore.getState().setTheme("dark");
|
||||
expect(localStorage.getItem("snapotter-theme-user-set")).toBe("1");
|
||||
});
|
||||
|
||||
it("applyServerDefault applies theme when no user-set flag", () => {
|
||||
useThemeStore.getState().applyServerDefault("dark");
|
||||
const s = useThemeStore.getState();
|
||||
expect(s.theme).toBe("dark");
|
||||
expect(s.resolvedTheme).toBe("dark");
|
||||
});
|
||||
|
||||
it("applyServerDefault skips when user-set flag exists", () => {
|
||||
useThemeStore.getState().setTheme("light");
|
||||
useThemeStore.getState().applyServerDefault("dark");
|
||||
const s = useThemeStore.getState();
|
||||
expect(s.theme).toBe("light");
|
||||
expect(s.resolvedTheme).toBe("light");
|
||||
});
|
||||
|
||||
it("applyServerDefault does not set user-set flag", () => {
|
||||
useThemeStore.getState().applyServerDefault("dark");
|
||||
expect(localStorage.getItem("snapotter-theme-user-set")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
@@ -1371,8 +1396,10 @@ describe("useSettingsStore", () => {
|
||||
disabledTools: [],
|
||||
experimentalEnabled: false,
|
||||
defaultToolView: "sidebar",
|
||||
defaultTheme: "light",
|
||||
loaded: false,
|
||||
});
|
||||
localStorage.removeItem("snapotter-theme-user-set");
|
||||
mockApiGet.mockReset();
|
||||
});
|
||||
|
||||
@@ -1381,6 +1408,7 @@ describe("useSettingsStore", () => {
|
||||
expect(s.disabledTools).toEqual([]);
|
||||
expect(s.experimentalEnabled).toBe(false);
|
||||
expect(s.defaultToolView).toBe("sidebar");
|
||||
expect(s.defaultTheme).toBe("light");
|
||||
expect(s.loaded).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1390,6 +1418,7 @@ describe("useSettingsStore", () => {
|
||||
disabledTools: JSON.stringify(["resize", "crop"]),
|
||||
enableExperimentalTools: "true",
|
||||
defaultToolView: "fullscreen",
|
||||
defaultTheme: "dark",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1399,9 +1428,44 @@ describe("useSettingsStore", () => {
|
||||
expect(s.disabledTools).toEqual(["resize", "crop"]);
|
||||
expect(s.experimentalEnabled).toBe(true);
|
||||
expect(s.defaultToolView).toBe("fullscreen");
|
||||
expect(s.defaultTheme).toBe("dark");
|
||||
expect(s.loaded).toBe(true);
|
||||
});
|
||||
|
||||
it("fetch applies server default theme when no user preference", async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
settings: { defaultTheme: "dark" },
|
||||
});
|
||||
|
||||
await useSettingsStore.getState().fetch();
|
||||
|
||||
const theme = useThemeStore.getState();
|
||||
expect(theme.theme).toBe("dark");
|
||||
expect(theme.resolvedTheme).toBe("dark");
|
||||
});
|
||||
|
||||
it("fetch does not override user theme preference", async () => {
|
||||
useThemeStore.getState().setTheme("light");
|
||||
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
settings: { defaultTheme: "dark" },
|
||||
});
|
||||
|
||||
await useSettingsStore.getState().fetch();
|
||||
|
||||
const theme = useThemeStore.getState();
|
||||
expect(theme.theme).toBe("light");
|
||||
});
|
||||
|
||||
it("fetch defaults defaultTheme to light for invalid values", async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
settings: { defaultTheme: "invalid" },
|
||||
});
|
||||
|
||||
await useSettingsStore.getState().fetch();
|
||||
expect(useSettingsStore.getState().defaultTheme).toBe("light");
|
||||
});
|
||||
|
||||
it("fetch skips when already loaded", async () => {
|
||||
useSettingsStore.setState({ loaded: true });
|
||||
await useSettingsStore.getState().fetch();
|
||||
@@ -1419,6 +1483,7 @@ describe("useSettingsStore", () => {
|
||||
expect(s.disabledTools).toEqual([]);
|
||||
expect(s.experimentalEnabled).toBe(false);
|
||||
expect(s.defaultToolView).toBe("sidebar");
|
||||
expect(s.defaultTheme).toBe("light");
|
||||
expect(s.loaded).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user