mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
refactor: theme manager (#17976)
This commit is contained in:
parent
2c2dd01bf0
commit
038a82c4f1
@ -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"
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
}),
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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];
|
||||
}>();
|
||||
|
78
web/src/lib/managers/theme-manager.svelte.ts
Normal file
78
web/src/lib/managers/theme-manager.svelte.ts
Normal 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();
|
@ -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: {
|
||||
|
81
web/src/lib/utils/persisted.ts
Normal file
81
web/src/lib/utils/persisted.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
@ -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(),
|
||||
})),
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user