diff --git a/web/src/lib/components/onboarding-page/onboarding-theme.svelte b/web/src/lib/components/onboarding-page/onboarding-theme.svelte index 4229cf9f67..9d3345dc2d 100644 --- a/web/src/lib/components/onboarding-page/onboarding-theme.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-theme.svelte @@ -1,12 +1,12 @@ <script lang="ts"> - import { mdiArrowRight, mdiThemeLightDark } from '@mdi/js'; + import { moonPath, moonViewBox, sunPath, sunViewBox } from '$lib/assets/svg-paths'; import Button from '$lib/components/elements/buttons/button.svelte'; import Icon from '$lib/components/elements/icon.svelte'; - import OnboardingCard from './onboarding-card.svelte'; - import { colorTheme } from '$lib/stores/preferences.store'; - import { moonPath, moonViewBox, sunPath, sunViewBox } from '$lib/assets/svg-paths'; import { Theme } from '$lib/constants'; + import { themeManager } from '$lib/managers/theme-manager.svelte'; + import { mdiArrowRight, mdiThemeLightDark } from '@mdi/js'; import { t } from 'svelte-i18n'; + import OnboardingCard from './onboarding-card.svelte'; interface Props { onDone: () => void; @@ -24,7 +24,7 @@ <button type="button" class="w-1/2 aspect-square bg-immich-bg rounded-3xl transition-all shadow-sm hover:shadow-xl border-[3px] border-immich-dark-primary/80 border-immich-primary dark:border dark:border-transparent" - onclick={() => ($colorTheme.value = Theme.LIGHT)} + onclick={() => themeManager.setTheme(Theme.LIGHT)} > <div class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-primary" @@ -36,7 +36,7 @@ <button type="button" class="w-1/2 aspect-square bg-immich-dark-bg rounded-3xl dark:border-[3px] dark:border-immich-dark-primary/80 dark:border-immich-dark-primary border border-transparent" - onclick={() => ($colorTheme.value = Theme.DARK)} + onclick={() => themeManager.setTheme(Theme.DARK)} > <div class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-dark-primary" diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index e21a73ab43..80e7f56148 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -9,15 +9,15 @@ <script lang="ts"> import Icon from '$lib/components/elements/icon.svelte'; import { Theme } from '$lib/constants'; - import { colorTheme, mapSettings } from '$lib/stores/preferences.store'; + import { themeManager } from '$lib/managers/theme-manager.svelte'; + import { mapSettings } from '$lib/stores/preferences.store'; import { serverConfig } from '$lib/stores/server-config.store'; import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; import { type MapMarkerResponseDto } from '@immich/sdk'; import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js?url'; import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js'; import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson'; - import { type GeoJSONSource, GlobeControl, type LngLatLike } from 'maplibre-gl'; - import maplibregl from 'maplibre-gl'; + import maplibregl, { GlobeControl, type GeoJSONSource, type LngLatLike } from 'maplibre-gl'; import { t } from 'svelte-i18n'; import { AttributionControl, @@ -68,7 +68,7 @@ let map: maplibregl.Map | undefined = $state(); let marker: maplibregl.Marker | null = null; - const theme = $derived($mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT); + const theme = $derived($mapSettings.allowDarkMode ? themeManager.value : Theme.LIGHT); const styleUrl = $derived(theme === Theme.DARK ? $serverConfig.mapDarkStyleUrl : $serverConfig.mapLightStyleUrl); export function addClipMapMarker(lng: number, lat: number) { diff --git a/web/src/lib/components/shared-components/qrcode.svelte b/web/src/lib/components/shared-components/qrcode.svelte index fa0a237577..3940975fba 100644 --- a/web/src/lib/components/shared-components/qrcode.svelte +++ b/web/src/lib/components/shared-components/qrcode.svelte @@ -1,7 +1,7 @@ <script lang="ts"> - import QRCode from 'qrcode'; - import { colorTheme } from '$lib/stores/preferences.store'; import { Theme } from '$lib/constants'; + import { themeManager } from '$lib/managers/theme-manager.svelte'; + import QRCode from 'qrcode'; import { t } from 'svelte-i18n'; type Props = { @@ -14,7 +14,7 @@ let promise = $derived( QRCode.toDataURL(value, { - color: { dark: $colorTheme.value === Theme.DARK ? '#ffffffff' : '#000000ff', light: '#00000000' }, + color: { dark: themeManager.value === Theme.DARK ? '#ffffffff' : '#000000ff', light: '#00000000' }, margin: 0, width, }), diff --git a/web/src/lib/components/shared-components/theme-button.svelte b/web/src/lib/components/shared-components/theme-button.svelte index 446668256f..2ed59e6cf7 100644 --- a/web/src/lib/components/shared-components/theme-button.svelte +++ b/web/src/lib/components/shared-components/theme-button.svelte @@ -1,13 +1,11 @@ <script lang="ts"> import { moonPath, moonViewBox, sunPath, sunViewBox } from '$lib/assets/svg-paths'; import CircleIconButton, { type Padding } from '$lib/components/elements/buttons/circle-icon-button.svelte'; - import { Theme } from '$lib/constants'; - import { colorTheme, handleToggleTheme } from '$lib/stores/preferences.store'; + import { themeManager } from '$lib/managers/theme-manager.svelte'; import { t } from 'svelte-i18n'; - let icon = $derived($colorTheme.value === Theme.LIGHT ? moonPath : sunPath); - let viewBox = $derived($colorTheme.value === Theme.LIGHT ? moonViewBox : sunViewBox); - let isDark = $derived($colorTheme.value === Theme.DARK); + let icon = $derived(themeManager.isDark ? sunPath : moonPath); + let viewBox = $derived(themeManager.isDark ? sunViewBox : moonViewBox); interface Props { padding?: Padding; @@ -16,14 +14,14 @@ let { padding = '3' }: Props = $props(); </script> -{#if !$colorTheme.system} +{#if !themeManager.theme.system} <CircleIconButton title={$t('toggle_theme')} {icon} {viewBox} role="switch" - aria-checked={isDark ? 'true' : 'false'} - onclick={handleToggleTheme} + aria-checked={themeManager.isDark ? 'true' : 'false'} + onclick={() => themeManager.toggleTheme()} {padding} /> {/if} diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte index 5b4a19c34f..f1d8e14787 100644 --- a/web/src/lib/components/user-settings-page/app-settings.svelte +++ b/web/src/lib/components/user-settings-page/app-settings.svelte @@ -1,11 +1,12 @@ <script lang="ts"> + import { invalidateAll } from '$app/navigation'; import type { ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; import SettingCombobox from '$lib/components/shared-components/settings/setting-combobox.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { defaultLang, fallbackLocale, langs, locales } from '$lib/constants'; + import { themeManager } from '$lib/managers/theme-manager.svelte'; import { alwaysLoadOriginalFile, - colorTheme, lang, locale, loopVideo, @@ -17,7 +18,6 @@ import { onMount } from 'svelte'; import { locale as i18nLocale, t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - import { invalidateAll } from '$app/navigation'; let time = $state(new Date()); @@ -40,10 +40,6 @@ })); }; - const handleToggleColorTheme = () => { - $colorTheme.system = !$colorTheme.system; - }; - const handleToggleLocaleBrowser = () => { $locale = $locale ? undefined : fallbackLocale.code; }; @@ -101,8 +97,8 @@ <SettingSwitch title={$t('theme_selection')} subtitle={$t('theme_selection_description')} - bind:checked={$colorTheme.system} - onToggle={handleToggleColorTheme} + checked={themeManager.theme.system} + onToggle={(isChecked) => themeManager.setSystem(isChecked)} /> </div> diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 1102e482ec..f8e39411cf 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -1,3 +1,4 @@ +import type { ThemeSetting } from '$lib/managers/theme-manager.svelte'; import type { LoginResponseDto } from '@immich/sdk'; type Listener<EventMap extends Record<string, unknown[]>, K extends keyof EventMap> = (...params: EventMap[K]) => void; @@ -56,4 +57,5 @@ export const eventManager = new EventManager<{ 'auth.login': [LoginResponseDto]; 'auth.logout': []; 'language.change': [{ name: string; code: string; rtl?: boolean }]; + 'theme.change': [ThemeSetting]; }>(); diff --git a/web/src/lib/managers/theme-manager.svelte.ts b/web/src/lib/managers/theme-manager.svelte.ts new file mode 100644 index 0000000000..a20e5f9a98 --- /dev/null +++ b/web/src/lib/managers/theme-manager.svelte.ts @@ -0,0 +1,78 @@ +import { browser } from '$app/environment'; +import { Theme } from '$lib/constants'; +import { eventManager } from '$lib/managers/event-manager.svelte'; +import { PersistedLocalStorage } from '$lib/utils/persisted'; + +export interface ThemeSetting { + value: Theme; + system: boolean; +} + +const getDefaultTheme = () => { + if (!browser) { + return Theme.DARK; + } + + return globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT; +}; + +class ThemeManager { + #theme = new PersistedLocalStorage<ThemeSetting>( + 'color-theme', + { value: getDefaultTheme(), system: false }, + { + valid: (value): value is ThemeSetting => { + return Object.values(Theme).includes((value as ThemeSetting)?.value); + }, + }, + ); + + get theme() { + return this.#theme.current; + } + + value = $derived(this.theme.value); + + isDark = $derived(this.value === Theme.DARK); + + constructor() { + eventManager.on('app.init', () => this.#onAppInit()); + } + + setSystem(system: boolean) { + this.#update(system ? 'system' : getDefaultTheme()); + } + + setTheme(theme: Theme) { + this.#update(theme); + } + + toggleTheme() { + this.#update(this.value === Theme.DARK ? Theme.LIGHT : Theme.DARK); + } + + #onAppInit() { + globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (this.theme.system) { + this.#update('system'); + } + }); + } + + #update(value: Theme | 'system') { + const theme: ThemeSetting = + value === 'system' ? { system: true, value: getDefaultTheme() } : { system: false, value }; + + if (theme.value === Theme.LIGHT) { + document.documentElement.classList.remove('dark'); + } else { + document.documentElement.classList.add('dark'); + } + + this.#theme.current = theme; + + eventManager.emit('theme.change', theme); + } +} + +export const themeManager = new ThemeManager(); diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index e268e8817d..e7f38eb6d0 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -2,39 +2,12 @@ import { browser } from '$app/environment'; import { Theme, defaultLang } from '$lib/constants'; import { getPreferredLocale } from '$lib/utils/i18n'; import { persisted } from 'svelte-persisted-store'; -import { get } from 'svelte/store'; export interface ThemeSetting { value: Theme; system: boolean; } -export const handleToggleTheme = () => { - const theme = get(colorTheme); - theme.value = theme.value === Theme.DARK ? Theme.LIGHT : Theme.DARK; - colorTheme.set(theme); -}; - -const initTheme = (): ThemeSetting => { - if (browser && globalThis.matchMedia && !globalThis.matchMedia('(prefers-color-scheme: dark)').matches) { - return { value: Theme.LIGHT, system: false }; - } - return { value: Theme.DARK, system: false }; -}; - -const initialTheme = initTheme(); - -// The 'color-theme' key is also used by app.html to prevent FOUC on page load. -export const colorTheme = persisted<ThemeSetting>('color-theme', initialTheme, { - serializer: { - parse: (text: string): ThemeSetting => { - const parsedText: ThemeSetting = JSON.parse(text); - return Object.values(Theme).includes(parsedText.value) ? parsedText : initTheme(); - }, - stringify: (object) => JSON.stringify(object), - }, -}); - // Locale to use for formatting dates, numbers, etc. export const locale = persisted<string | undefined>('locale', undefined, { serializer: { diff --git a/web/src/lib/utils/persisted.ts b/web/src/lib/utils/persisted.ts new file mode 100644 index 0000000000..73eb4de5db --- /dev/null +++ b/web/src/lib/utils/persisted.ts @@ -0,0 +1,81 @@ +import { browser } from '$app/environment'; +import { createSubscriber } from 'svelte/reactivity'; + +type PersistedBaseOptions<T> = { + read: (key: string) => T | undefined; + write: (key: string, value: T) => void; +}; + +class PersistedBase<T> { + #value: T; + #subscribe: () => void; + #update = () => {}; + + #write: (value: T) => void; + + get current() { + this.#subscribe(); + return this.#value as T; + } + + set current(value: T) { + this.#write(value); + this.#update(); + this.#value = value; + } + + constructor(key: string, defaultValue: T, options: PersistedBaseOptions<T>) { + const value = options.read(key); + + this.#value = value === undefined ? defaultValue : value; + this.#write = (value: T) => options.write(key, value); + + this.#subscribe = createSubscriber((update) => { + this.#update = update; + + return () => { + this.#update = () => {}; + }; + }); + } +} + +type PersistedLocalStorageOptions<T> = { + serializer?: { + stringify(value: T): string; + parse(text: string): T; + }; + valid?: (value: T | unknown) => value is T; +}; + +export class PersistedLocalStorage<T> extends PersistedBase<T> { + constructor(key: string, defaultValue: T, options: PersistedLocalStorageOptions<T> = {}) { + const valid = options.valid || (() => true); + const serializer = options.serializer || JSON; + + super(key, defaultValue, { + read: (key: string) => { + if (!browser) { + return; + } + + const item = localStorage.getItem(key) ?? undefined; + if (item === undefined) { + return; + } + + const parsed = serializer.parse(item); + if (!valid(parsed)) { + return; + } + + return parsed; + }, + write: (key: string, value: T) => { + if (browser) { + localStorage.setItem(key, serializer.stringify(value)); + } + }, + }); + } +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 39740e16fe..844037a13f 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -10,16 +10,14 @@ import NotificationList from '$lib/components/shared-components/notification/notification-list.svelte'; import UploadPanel from '$lib/components/shared-components/upload-panel.svelte'; import VersionAnnouncementBox from '$lib/components/shared-components/version-announcement-box.svelte'; - import { Theme } from '$lib/constants'; import { eventManager } from '$lib/managers/event-manager.svelte'; - import { colorTheme, handleToggleTheme, type ThemeSetting } from '$lib/stores/preferences.store'; import { serverConfig } from '$lib/stores/server-config.store'; import { user } from '$lib/stores/user.store'; import { closeWebsocketConnection, openWebsocketConnection } from '$lib/stores/websocket'; import { copyToClipboard } from '$lib/utils'; import { isAssetViewerRoute } from '$lib/utils/navigation'; import { setTranslations } from '@immich/ui'; - import { onDestroy, onMount, type Snippet } from 'svelte'; + import { onMount, type Snippet } from 'svelte'; import { t } from 'svelte-i18n'; import { run } from 'svelte/legacy'; import '../app.css'; @@ -40,24 +38,6 @@ let showNavigationLoadingBar = $state(false); - const changeTheme = (theme: ThemeSetting) => { - if (theme.system) { - theme.value = globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT; - } - - if (theme.value === Theme.LIGHT) { - document.documentElement.classList.remove('dark'); - } else { - document.documentElement.classList.add('dark'); - } - }; - - const handleChangeTheme = () => { - if ($colorTheme.system) { - handleToggleTheme(); - } - }; - const getMyImmichLink = () => { return new URL(page.url.pathname + page.url.search, 'https://my.immich.app'); }; @@ -66,11 +46,6 @@ const element = document.querySelector('#stencil'); element?.remove(); // if the browser theme changes, changes the Immich theme too - globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', handleChangeTheme); - }); - - onDestroy(() => { - document.removeEventListener('change', handleChangeTheme); }); eventManager.emit('app.init'); @@ -85,9 +60,6 @@ afterNavigate(() => { showNavigationLoadingBar = false; }); - run(() => { - changeTheme($colorTheme); - }); run(() => { if ($user) { openWebsocketConnection(); diff --git a/web/src/test-data/setup.ts b/web/src/test-data/setup.ts index 6709bd5b80..f2cfac3c36 100644 --- a/web/src/test-data/setup.ts +++ b/web/src/test-data/setup.ts @@ -4,3 +4,15 @@ import { init } from 'svelte-i18n'; beforeAll(async () => { await init({ fallbackLocale: 'dev' }); }); + +Object.defineProperty(globalThis, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +});