diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 98ab097f..2f7a6c5d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -53,6 +53,21 @@ function ensureInstanceId() { } ensureInstanceId(); + +function ensureDefaultSettings() { + const defaults: Record = { + 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 diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 6f58678a..e3d009d5 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -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(""), diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index dc758244..a70b7fe9 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -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."); diff --git a/apps/web/src/stores/settings-store.ts b/apps/web/src/stores/settings-store.ts index 0fbbc5af..1a1abcd8 100644 --- a/apps/web/src/stores/settings-store.ts +++ b/apps/web/src/stores/settings-store.ts @@ -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; } +const VALID_THEMES = new Set(["light", "dark", "system"]); + export const useSettingsStore = create((set, get) => ({ disabledTools: [], experimentalEnabled: false, defaultToolView: "sidebar", + defaultTheme: "light", loaded: false, fetch: async () => { @@ -22,12 +29,19 @@ export const useSettingsStore = create((set, get) => ({ settings: Record; }>("/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 }); } diff --git a/apps/web/src/stores/theme-store.ts b/apps/web/src/stores/theme-store.ts index 6ae7bbaf..c56d3e35 100644 --- a/apps/web/src/stores/theme-store.ts +++ b/apps/web/src/stores/theme-store.ts @@ -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()( 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 }); }, }), diff --git a/tests/unit/web/zustand-stores.test.ts b/tests/unit/web/zustand-stores.test.ts index 6ec357ee..a2695508 100644 --- a/tests/unit/web/zustand-stores.test.ts +++ b/tests/unit/web/zustand-stores.test.ts @@ -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); });