refactor: theme manager (#17976)

This commit is contained in:
Jason Rasmussen 2025-04-29 17:44:09 -04:00 committed by GitHub
parent 2c2dd01bf0
commit 038a82c4f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 197 additions and 85 deletions

View File

@ -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"

View File

@ -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) {

View File

@ -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,
}),

View File

@ -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}

View File

@ -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>

View File

@ -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];
}>();

View File

@ -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();

View File

@ -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: {

View File

@ -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));
}
},
});
}
}

View File

@ -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();

View File

@ -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(),
})),
});