feat: persist theme prefs in localStorage for reliable same-device persistence

- Add src/lib/local-theme-prefs.ts: read/write color theme, radius, sidebar
  config to localStorage key 'isletmem-theme-prefs'
- ThemeCustomizer: init state from localStorage (falls back to Appwrite prefs),
  save to localStorage on every color/radius/sidebar change in addition to Appwrite
- PrefsInitializer: merge localStorage (wins) with server Appwrite prefs on mount

Appwrite updatePrefs had a silent try/catch so failures were invisible;
dark/light was already in localStorage via ThemeProvider but color theme was not.
This commit is contained in:
kovakmedya
2026-05-08 14:55:29 +03:00
parent e2d09ab138
commit c307865a44
3 changed files with 74 additions and 16 deletions
+23 -4
View File
@@ -11,6 +11,7 @@ import { useSidebarConfig } from '@/contexts/sidebar-context'
import { tweakcnThemes } from '@/config/theme-data' import { tweakcnThemes } from '@/config/theme-data'
import { saveThemePrefsAction } from '@/lib/appwrite/theme-prefs-actions' import { saveThemePrefsAction } from '@/lib/appwrite/theme-prefs-actions'
import type { ThemePrefs } from '@/lib/appwrite/theme-prefs-actions' import type { ThemePrefs } from '@/lib/appwrite/theme-prefs-actions'
import { getLocalThemePrefs, saveLocalThemePrefs } from '@/lib/local-theme-prefs'
import { ThemeTab } from './theme-tab' import { ThemeTab } from './theme-tab'
import { LayoutTab } from './layout-tab' import { LayoutTab } from './layout-tab'
import { ImportModal } from './import-modal' import { ImportModal } from './import-modal'
@@ -29,9 +30,18 @@ export function ThemeCustomizer({ open, onOpenChange, initialPrefs }: ThemeCusto
const { config: sidebarConfig, updateConfig: updateSidebarConfig } = useSidebarConfig() const { config: sidebarConfig, updateConfig: updateSidebarConfig } = useSidebarConfig()
const [activeTab, setActiveTab] = React.useState("theme") const [activeTab, setActiveTab] = React.useState("theme")
const [selectedTheme, setSelectedTheme] = React.useState(initialPrefs.colorTheme ?? "default") const [selectedTheme, setSelectedTheme] = React.useState(() => {
const [selectedTweakcnTheme, setSelectedTweakcnTheme] = React.useState(initialPrefs.tweakcnTheme ?? "") const local = getLocalThemePrefs()
const [selectedRadius, setSelectedRadius] = React.useState(initialPrefs.radius ?? "0.5rem") return local.colorTheme ?? initialPrefs.colorTheme ?? "default"
})
const [selectedTweakcnTheme, setSelectedTweakcnTheme] = React.useState(() => {
const local = getLocalThemePrefs()
return local.tweakcnTheme ?? initialPrefs.tweakcnTheme ?? ""
})
const [selectedRadius, setSelectedRadius] = React.useState(() => {
const local = getLocalThemePrefs()
return local.radius ?? initialPrefs.radius ?? "0.5rem"
})
const [importModalOpen, setImportModalOpen] = React.useState(false) const [importModalOpen, setImportModalOpen] = React.useState(false)
const [importedTheme, setImportedTheme] = React.useState<ImportedTheme | null>(null) const [importedTheme, setImportedTheme] = React.useState<ImportedTheme | null>(null)
@@ -42,10 +52,15 @@ export function ThemeCustomizer({ open, onOpenChange, initialPrefs }: ThemeCusto
void saveThemePrefsAction({ theme }) void saveThemePrefsAction({ theme })
}, [theme]) }, [theme])
// Save sidebar config to Appwrite when it changes (skip first mount) // Save sidebar config to localStorage + Appwrite when it changes (skip first mount)
const sidebarMountRef = React.useRef(false) const sidebarMountRef = React.useRef(false)
React.useEffect(() => { React.useEffect(() => {
if (!sidebarMountRef.current) { sidebarMountRef.current = true; return } if (!sidebarMountRef.current) { sidebarMountRef.current = true; return }
saveLocalThemePrefs({
sidebarVariant: sidebarConfig.variant,
sidebarCollapsible: sidebarConfig.collapsible,
sidebarSide: sidebarConfig.side,
})
void saveThemePrefsAction({ void saveThemePrefsAction({
sidebarVariant: sidebarConfig.variant, sidebarVariant: sidebarConfig.variant,
sidebarCollapsible: sidebarConfig.collapsible, sidebarCollapsible: sidebarConfig.collapsible,
@@ -62,6 +77,7 @@ export function ThemeCustomizer({ open, onOpenChange, initialPrefs }: ThemeCusto
resetTheme() resetTheme()
applyRadius("0.5rem") applyRadius("0.5rem")
updateSidebarConfig({ variant: "inset", collapsible: "offcanvas", side: "left" }) updateSidebarConfig({ variant: "inset", collapsible: "offcanvas", side: "left" })
saveLocalThemePrefs({ colorTheme: "default", tweakcnTheme: "", radius: "0.5rem", sidebarVariant: "inset", sidebarCollapsible: "offcanvas", sidebarSide: "left" })
void saveThemePrefsAction({ colorTheme: "default", tweakcnTheme: "", radius: "0.5rem" }) void saveThemePrefsAction({ colorTheme: "default", tweakcnTheme: "", radius: "0.5rem" })
} }
@@ -158,17 +174,20 @@ export function ThemeCustomizer({ open, onOpenChange, initialPrefs }: ThemeCusto
setSelectedTheme={(value) => { setSelectedTheme={(value) => {
setSelectedTheme(value) setSelectedTheme(value)
setSelectedTweakcnTheme("") setSelectedTweakcnTheme("")
saveLocalThemePrefs({ colorTheme: value, tweakcnTheme: "" })
void saveThemePrefsAction({ colorTheme: value, tweakcnTheme: "" }) void saveThemePrefsAction({ colorTheme: value, tweakcnTheme: "" })
}} }}
selectedTweakcnTheme={selectedTweakcnTheme} selectedTweakcnTheme={selectedTweakcnTheme}
setSelectedTweakcnTheme={(value) => { setSelectedTweakcnTheme={(value) => {
setSelectedTweakcnTheme(value) setSelectedTweakcnTheme(value)
setSelectedTheme("") setSelectedTheme("")
saveLocalThemePrefs({ tweakcnTheme: value, colorTheme: "" })
void saveThemePrefsAction({ tweakcnTheme: value, colorTheme: "" }) void saveThemePrefsAction({ tweakcnTheme: value, colorTheme: "" })
}} }}
selectedRadius={selectedRadius} selectedRadius={selectedRadius}
setSelectedRadius={(value) => { setSelectedRadius={(value) => {
setSelectedRadius(value) setSelectedRadius(value)
saveLocalThemePrefs({ radius: value })
void saveThemePrefsAction({ radius: value }) void saveThemePrefsAction({ radius: value })
}} }}
setImportedTheme={setImportedTheme} setImportedTheme={setImportedTheme}
@@ -7,6 +7,7 @@ import { useTheme } from "@/hooks/use-theme";
import { useThemeManager } from "@/hooks/use-theme-manager"; import { useThemeManager } from "@/hooks/use-theme-manager";
import { tweakcnThemes } from "@/config/theme-data"; import { tweakcnThemes } from "@/config/theme-data";
import type { ThemePrefs } from "@/lib/appwrite/theme-prefs-actions"; import type { ThemePrefs } from "@/lib/appwrite/theme-prefs-actions";
import { getLocalThemePrefs } from "@/lib/local-theme-prefs";
export function PrefsInitializer({ prefs }: { prefs: ThemePrefs }) { export function PrefsInitializer({ prefs }: { prefs: ThemePrefs }) {
const { setTheme } = useTheme(); const { setTheme } = useTheme();
@@ -18,30 +19,40 @@ export function PrefsInitializer({ prefs }: { prefs: ThemePrefs }) {
if (applied.current) return; if (applied.current) return;
applied.current = true; applied.current = true;
if (prefs.theme) setTheme(prefs.theme); // localStorage wins (most recent change on this device); Appwrite prefs are fallback
const local = getLocalThemePrefs();
const effectiveTheme = prefs.theme;
const effectiveColorTheme = local.colorTheme ?? prefs.colorTheme;
const effectiveTweakcnTheme = local.tweakcnTheme ?? prefs.tweakcnTheme;
const effectiveRadius = local.radius ?? prefs.radius;
const effectiveSidebarVariant = (local.sidebarVariant as ThemePrefs["sidebarVariant"]) ?? prefs.sidebarVariant;
const effectiveSidebarCollapsible = (local.sidebarCollapsible as ThemePrefs["sidebarCollapsible"]) ?? prefs.sidebarCollapsible;
const effectiveSidebarSide = (local.sidebarSide as ThemePrefs["sidebarSide"]) ?? prefs.sidebarSide;
if (effectiveTheme) setTheme(effectiveTheme);
const isDark = const isDark =
prefs.theme === "dark" || effectiveTheme === "dark" ||
(prefs.theme !== "light" && (effectiveTheme !== "light" &&
typeof window !== "undefined" && typeof window !== "undefined" &&
window.matchMedia("(prefers-color-scheme: dark)").matches); window.matchMedia("(prefers-color-scheme: dark)").matches);
if (prefs.radius) applyRadius(prefs.radius); if (effectiveRadius) applyRadius(effectiveRadius);
if (prefs.colorTheme) { if (effectiveColorTheme) {
applyTheme(prefs.colorTheme, isDark); applyTheme(effectiveColorTheme, isDark);
} }
if (prefs.tweakcnTheme) { if (effectiveTweakcnTheme) {
const preset = tweakcnThemes.find((t) => t.value === prefs.tweakcnTheme)?.preset; const preset = tweakcnThemes.find((t) => t.value === effectiveTweakcnTheme)?.preset;
if (preset) applyTweakcnTheme(preset, isDark); if (preset) applyTweakcnTheme(preset, isDark);
} }
if (prefs.sidebarVariant || prefs.sidebarCollapsible || prefs.sidebarSide) { if (effectiveSidebarVariant || effectiveSidebarCollapsible || effectiveSidebarSide) {
updateConfig({ updateConfig({
...(prefs.sidebarVariant && { variant: prefs.sidebarVariant }), ...(effectiveSidebarVariant && { variant: effectiveSidebarVariant }),
...(prefs.sidebarCollapsible && { collapsible: prefs.sidebarCollapsible }), ...(effectiveSidebarCollapsible && { collapsible: effectiveSidebarCollapsible }),
...(prefs.sidebarSide && { side: prefs.sidebarSide }), ...(effectiveSidebarSide && { side: effectiveSidebarSide }),
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
+28
View File
@@ -0,0 +1,28 @@
const STORAGE_KEY = "isletmem-theme-prefs"
export interface LocalThemePrefs {
colorTheme?: string
tweakcnTheme?: string
radius?: string
sidebarVariant?: string
sidebarCollapsible?: string
sidebarSide?: string
}
export function getLocalThemePrefs(): LocalThemePrefs {
if (typeof window === "undefined") return {}
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as LocalThemePrefs) : {}
} catch {
return {}
}
}
export function saveLocalThemePrefs(update: Partial<LocalThemePrefs>): void {
if (typeof window === "undefined") return
try {
const existing = getLocalThemePrefs()
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...existing, ...update }))
} catch {}
}