mirror of
https://github.com/unraid/api.git
synced 2025-12-31 21:49:57 -06:00
feat: use persisted theme css to fix flashes on header (#1784)
## Summary - install the pinia-plugin-persistedstate integration directly inside the theme store and hydrate cached themes before applying CSS variables - fall back to the active/global Pinia instance while ensuring persisted state is only wired once per store instance - update the theme store tests to reset the shared Pinia state between runs and rely on the plugin-backed persistence ## Testing - pnpm --filter web test __test__/store/theme.test.ts ------ [Codex Task](https://chatgpt.com/codex/tasks/task_e_69156c5e8de48323841f7dbfdadec51d) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Theme preferences now persist across sessions and are restored on return. * **Behavior Change** * Theme switching may now update the URL/address bar to reflect the selected theme. * **Chores** * Added a persistence integration to enable storing/restoring theme data. * **Tests** * Updated/added tests covering hydration from storage and persistence of server-provided themes. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@@ -1154,6 +1154,9 @@ importers:
|
||||
pinia:
|
||||
specifier: 3.0.3
|
||||
version: 3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2))
|
||||
pinia-plugin-persistedstate:
|
||||
specifier: 4.7.1
|
||||
version: 4.7.1(@nuxt/kit@4.0.3(magicast@0.3.5))(pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)))
|
||||
postcss-import:
|
||||
specifier: 16.1.1
|
||||
version: 16.1.1(postcss@8.5.6)
|
||||
@@ -10116,6 +10119,20 @@ packages:
|
||||
resolution: {integrity: sha512-KocF8ve28eFjjuBKKGvzOBGzG8ew2OqOOSxTTZhirkzH7h3BI1vyzqlR0qbfcDBve1Yzo3FVlWUAtCRrbVN8Fw==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
pinia-plugin-persistedstate@4.7.1:
|
||||
resolution: {integrity: sha512-WHOqh2esDlR3eAaknPbqXrkkj0D24h8shrDPqysgCFR6ghqP/fpFfJmMPJp0gETHsvrh9YNNg6dQfo2OEtDnIQ==}
|
||||
peerDependencies:
|
||||
'@nuxt/kit': '>=3.0.0'
|
||||
'@pinia/nuxt': '>=0.10.0'
|
||||
pinia: '>=3.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@nuxt/kit':
|
||||
optional: true
|
||||
'@pinia/nuxt':
|
||||
optional: true
|
||||
pinia:
|
||||
optional: true
|
||||
|
||||
pinia@3.0.3:
|
||||
resolution: {integrity: sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==}
|
||||
peerDependencies:
|
||||
@@ -22744,6 +22761,13 @@ snapshots:
|
||||
|
||||
pify@6.1.0: {}
|
||||
|
||||
pinia-plugin-persistedstate@4.7.1(@nuxt/kit@4.0.3(magicast@0.3.5))(pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2))):
|
||||
dependencies:
|
||||
defu: 6.1.4
|
||||
optionalDependencies:
|
||||
'@nuxt/kit': 4.0.3(magicast@0.3.5)
|
||||
pinia: 3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2))
|
||||
|
||||
pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 7.7.2
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
* Theme store test coverage
|
||||
*/
|
||||
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createApp, nextTick, ref } from 'vue';
|
||||
import { setActivePinia } from 'pinia';
|
||||
|
||||
import { defaultColors } from '~/themes/default';
|
||||
import hexToRgba from 'hex-to-rgba';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
import type { Theme } from '~/themes/types';
|
||||
|
||||
import { globalPinia } from '~/store/globalPinia';
|
||||
import { THEME_STORAGE_KEY, useThemeStore } from '~/store/theme';
|
||||
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
useQuery: () => ({
|
||||
@@ -25,15 +28,23 @@ vi.mock('hex-to-rgba', () => ({
|
||||
}));
|
||||
|
||||
describe('Theme Store', () => {
|
||||
let store: ReturnType<typeof useThemeStore>;
|
||||
const originalAddClassFn = document.body.classList.add;
|
||||
const originalRemoveClassFn = document.body.classList.remove;
|
||||
const originalStyleCssText = document.body.style.cssText;
|
||||
const originalDocumentElementSetProperty = document.documentElement.style.setProperty;
|
||||
const originalDocumentElementAddClass = document.documentElement.classList.add;
|
||||
const originalDocumentElementRemoveClass = document.documentElement.classList.remove;
|
||||
|
||||
let store: ReturnType<typeof useThemeStore> | undefined;
|
||||
let app: ReturnType<typeof createApp> | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
store = useThemeStore();
|
||||
app = createApp({ render: () => null });
|
||||
app.use(globalPinia);
|
||||
setActivePinia(globalPinia);
|
||||
store = undefined;
|
||||
window.localStorage.clear();
|
||||
delete (globalPinia.state.value as Record<string, unknown>).theme;
|
||||
|
||||
document.body.classList.add = vi.fn();
|
||||
document.body.classList.remove = vi.fn();
|
||||
@@ -51,16 +62,34 @@ describe('Theme Store', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original methods
|
||||
store?.$dispose();
|
||||
store = undefined;
|
||||
app?.unmount();
|
||||
app = undefined;
|
||||
|
||||
document.body.classList.add = originalAddClassFn;
|
||||
document.body.classList.remove = originalRemoveClassFn;
|
||||
document.body.style.cssText = originalStyleCssText;
|
||||
document.documentElement.style.setProperty = originalDocumentElementSetProperty;
|
||||
document.documentElement.classList.add = originalDocumentElementAddClass;
|
||||
document.documentElement.classList.remove = originalDocumentElementRemoveClass;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createStore = () => {
|
||||
if (!store) {
|
||||
store = useThemeStore();
|
||||
}
|
||||
|
||||
return store;
|
||||
};
|
||||
|
||||
describe('State and Initialization', () => {
|
||||
it('should initialize with default theme', () => {
|
||||
const store = createStore();
|
||||
|
||||
expect(typeof store.$persist).toBe('function');
|
||||
|
||||
expect(store.theme).toEqual({
|
||||
name: 'white',
|
||||
banner: false,
|
||||
@@ -74,6 +103,8 @@ describe('Theme Store', () => {
|
||||
});
|
||||
|
||||
it('should compute darkMode correctly', () => {
|
||||
const store = createStore();
|
||||
|
||||
expect(store.darkMode).toBe(false);
|
||||
|
||||
store.setTheme({ ...store.theme, name: 'black' });
|
||||
@@ -87,6 +118,8 @@ describe('Theme Store', () => {
|
||||
});
|
||||
|
||||
it('should compute bannerGradient correctly', () => {
|
||||
const store = createStore();
|
||||
|
||||
expect(store.bannerGradient).toBeUndefined();
|
||||
|
||||
store.setTheme({
|
||||
@@ -112,6 +145,8 @@ describe('Theme Store', () => {
|
||||
|
||||
describe('Actions', () => {
|
||||
it('should set theme correctly', () => {
|
||||
const store = createStore();
|
||||
|
||||
const newTheme = {
|
||||
name: 'black',
|
||||
banner: true,
|
||||
@@ -127,6 +162,8 @@ describe('Theme Store', () => {
|
||||
});
|
||||
|
||||
it('should update body classes for dark mode', async () => {
|
||||
const store = createStore();
|
||||
|
||||
store.setTheme({ ...store.theme, name: 'black' });
|
||||
|
||||
await nextTick();
|
||||
@@ -141,6 +178,8 @@ describe('Theme Store', () => {
|
||||
});
|
||||
|
||||
it('should update activeColorVariables when theme changes', async () => {
|
||||
const store = createStore();
|
||||
|
||||
store.setTheme({
|
||||
...store.theme,
|
||||
name: 'white',
|
||||
@@ -170,6 +209,7 @@ describe('Theme Store', () => {
|
||||
});
|
||||
|
||||
it('should handle banner gradient correctly', async () => {
|
||||
const store = createStore();
|
||||
const mockHexToRgba = vi.mocked(hexToRgba);
|
||||
|
||||
mockHexToRgba.mockClear();
|
||||
@@ -200,5 +240,44 @@ describe('Theme Store', () => {
|
||||
'linear-gradient(90deg, rgba(mock-#112233-0) 0, rgba(mock-#112233-0.7) 90%)'
|
||||
);
|
||||
});
|
||||
|
||||
it('should hydrate theme from cache when available', () => {
|
||||
const cachedTheme = {
|
||||
name: 'black',
|
||||
banner: true,
|
||||
bannerGradient: false,
|
||||
bgColor: '#222222',
|
||||
descriptionShow: true,
|
||||
metaColor: '#aaaaaa',
|
||||
textColor: '#ffffff',
|
||||
} satisfies Theme;
|
||||
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({ theme: cachedTheme }));
|
||||
|
||||
const store = createStore();
|
||||
|
||||
expect(store.theme).toEqual(cachedTheme);
|
||||
});
|
||||
|
||||
it('should persist server theme responses to cache', async () => {
|
||||
const store = createStore();
|
||||
|
||||
const serverTheme = {
|
||||
name: 'gray',
|
||||
banner: false,
|
||||
bannerGradient: false,
|
||||
bgColor: '#111111',
|
||||
descriptionShow: false,
|
||||
metaColor: '#999999',
|
||||
textColor: '#eeeeee',
|
||||
} satisfies Theme;
|
||||
|
||||
store.setTheme(serverTheme, { source: 'server' });
|
||||
await nextTick();
|
||||
|
||||
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toEqual(
|
||||
JSON.stringify({ theme: serverTheme })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"marked": "16.2.1",
|
||||
"marked-base-url": "1.1.7",
|
||||
"pinia": "3.0.3",
|
||||
"pinia-plugin-persistedstate": "4.7.1",
|
||||
"postcss-import": "16.1.1",
|
||||
"semver": "7.7.2",
|
||||
"tailwind-merge": "2.6.0",
|
||||
|
||||
@@ -51,7 +51,7 @@ const updateTheme = (themeName: string, skipUrlUpdate = false) => {
|
||||
// ignore
|
||||
}
|
||||
|
||||
themeStore.setTheme({ name: themeName }, true);
|
||||
themeStore.setTheme({ name: themeName });
|
||||
themeStore.setCssVars();
|
||||
|
||||
const linkId = 'dev-theme-css-link';
|
||||
@@ -100,7 +100,7 @@ onMounted(() => {
|
||||
if (!existingLink || !existingLink.href) {
|
||||
updateTheme(initialTheme, true);
|
||||
} else {
|
||||
themeStore.setTheme({ name: initialTheme }, true);
|
||||
themeStore.setTheme({ name: initialTheme });
|
||||
themeStore.setCssVars();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
|
||||
|
||||
// Create a single shared Pinia instance for all web components
|
||||
export const globalPinia = createPinia();
|
||||
globalPinia.use(piniaPluginPersistedstate);
|
||||
|
||||
// IMPORTANT: Set it as the active pinia instance immediately
|
||||
// This ensures stores work even when called during component setup
|
||||
|
||||
@@ -27,6 +27,8 @@ export const GET_THEME_QUERY = graphql(`
|
||||
}
|
||||
`);
|
||||
|
||||
export const THEME_STORAGE_KEY = 'unraid.theme.publicTheme';
|
||||
|
||||
const DEFAULT_THEME: Theme = {
|
||||
name: 'white',
|
||||
banner: false,
|
||||
@@ -37,6 +39,26 @@ const DEFAULT_THEME: Theme = {
|
||||
textColor: '',
|
||||
};
|
||||
|
||||
type ThemeSource = 'local' | 'server';
|
||||
|
||||
const sanitizeTheme = (data: Partial<Theme> | null | undefined): Theme | null => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: typeof data.name === 'string' ? data.name : DEFAULT_THEME.name,
|
||||
banner: typeof data.banner === 'boolean' ? data.banner : DEFAULT_THEME.banner,
|
||||
bannerGradient:
|
||||
typeof data.bannerGradient === 'boolean' ? data.bannerGradient : DEFAULT_THEME.bannerGradient,
|
||||
bgColor: typeof data.bgColor === 'string' ? data.bgColor : DEFAULT_THEME.bgColor,
|
||||
descriptionShow:
|
||||
typeof data.descriptionShow === 'boolean' ? data.descriptionShow : DEFAULT_THEME.descriptionShow,
|
||||
metaColor: typeof data.metaColor === 'string' ? data.metaColor : DEFAULT_THEME.metaColor,
|
||||
textColor: typeof data.textColor === 'string' ? data.textColor : DEFAULT_THEME.textColor,
|
||||
};
|
||||
};
|
||||
|
||||
const DYNAMIC_VAR_KEYS = [
|
||||
'--custom-header-text-primary',
|
||||
'--custom-header-text-secondary',
|
||||
@@ -48,201 +70,234 @@ const DYNAMIC_VAR_KEYS = [
|
||||
|
||||
type DynamicVarKey = (typeof DYNAMIC_VAR_KEYS)[number];
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
// State
|
||||
const theme = ref<Theme>({ ...DEFAULT_THEME });
|
||||
export const useThemeStore = defineStore(
|
||||
'theme',
|
||||
() => {
|
||||
// State
|
||||
const theme = ref<Theme>({ ...DEFAULT_THEME });
|
||||
|
||||
const activeColorVariables = ref<ThemeVariables>(defaultColors.white);
|
||||
const hasServerTheme = ref(false);
|
||||
const devOverride = ref(false);
|
||||
const activeColorVariables = ref<ThemeVariables>(defaultColors.white);
|
||||
const hasServerTheme = ref(false);
|
||||
const devOverride = ref(false);
|
||||
|
||||
const { result, onResult, onError } = useQuery<GetThemeQuery>(GET_THEME_QUERY, null, {
|
||||
fetchPolicy: 'cache-and-network',
|
||||
nextFetchPolicy: 'cache-first',
|
||||
});
|
||||
const { result, onResult, onError } = useQuery<GetThemeQuery>(GET_THEME_QUERY, null, {
|
||||
fetchPolicy: 'cache-and-network',
|
||||
nextFetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
const applyThemeFromQuery = (publicTheme?: GetThemeQuery['publicTheme'] | null) => {
|
||||
if (!publicTheme) {
|
||||
return;
|
||||
}
|
||||
const mapPublicTheme = (publicTheme?: GetThemeQuery['publicTheme'] | null): Theme | null => {
|
||||
if (!publicTheme) {
|
||||
return null;
|
||||
}
|
||||
|
||||
hasServerTheme.value = true;
|
||||
theme.value = {
|
||||
name: publicTheme.name?.toLowerCase() ?? DEFAULT_THEME.name,
|
||||
banner: publicTheme.showBannerImage ?? DEFAULT_THEME.banner,
|
||||
bannerGradient: publicTheme.showBannerGradient ?? DEFAULT_THEME.bannerGradient,
|
||||
bgColor: publicTheme.headerBackgroundColor ?? DEFAULT_THEME.bgColor,
|
||||
descriptionShow: publicTheme.showHeaderDescription ?? DEFAULT_THEME.descriptionShow,
|
||||
metaColor: publicTheme.headerSecondaryTextColor ?? DEFAULT_THEME.metaColor,
|
||||
textColor: publicTheme.headerPrimaryTextColor ?? DEFAULT_THEME.textColor,
|
||||
return sanitizeTheme({
|
||||
name: publicTheme.name?.toLowerCase(),
|
||||
banner: publicTheme.showBannerImage,
|
||||
bannerGradient: publicTheme.showBannerGradient,
|
||||
bgColor: publicTheme.headerBackgroundColor ?? undefined,
|
||||
descriptionShow: publicTheme.showHeaderDescription,
|
||||
metaColor: publicTheme.headerSecondaryTextColor ?? undefined,
|
||||
textColor: publicTheme.headerPrimaryTextColor ?? undefined,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
onResult(({ data }) => {
|
||||
if (data?.publicTheme) {
|
||||
applyThemeFromQuery(data.publicTheme);
|
||||
}
|
||||
});
|
||||
|
||||
if (result.value?.publicTheme) {
|
||||
applyThemeFromQuery(result.value.publicTheme);
|
||||
}
|
||||
|
||||
onError((err) => {
|
||||
console.warn('Failed to load theme from server, keeping existing theme:', err);
|
||||
});
|
||||
|
||||
// Getters
|
||||
// Apply dark mode for gray and black themes
|
||||
const darkMode = computed<boolean>(() =>
|
||||
DARK_UI_THEMES.includes(theme.value?.name as (typeof DARK_UI_THEMES)[number])
|
||||
);
|
||||
|
||||
const bannerGradient = computed(() => {
|
||||
if (!theme.value?.banner || !theme.value?.bannerGradient) {
|
||||
return undefined;
|
||||
}
|
||||
const start = theme.value?.bgColor ? 'var(--header-gradient-start)' : 'rgba(0, 0, 0, 0)';
|
||||
const end = theme.value?.bgColor ? 'var(--header-gradient-end)' : 'var(--header-background-color)';
|
||||
return `background-image: linear-gradient(90deg, ${start} 0, ${end} 90%);`;
|
||||
});
|
||||
|
||||
// Actions
|
||||
const setTheme = (data?: Partial<Theme>, force = false) => {
|
||||
if (data) {
|
||||
if (hasServerTheme.value && !force && !devOverride.value) {
|
||||
const applyThemeFromQuery = (publicTheme?: GetThemeQuery['publicTheme'] | null) => {
|
||||
const sanitized = mapPublicTheme(publicTheme);
|
||||
if (!sanitized) {
|
||||
return;
|
||||
}
|
||||
|
||||
theme.value = {
|
||||
...theme.value,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
};
|
||||
setTheme(sanitized, { source: 'server' });
|
||||
};
|
||||
|
||||
const setDevOverride = (enabled: boolean) => {
|
||||
devOverride.value = enabled;
|
||||
};
|
||||
onResult(({ data }) => {
|
||||
if (data?.publicTheme) {
|
||||
applyThemeFromQuery(data.publicTheme);
|
||||
}
|
||||
});
|
||||
|
||||
const setCssVars = () => {
|
||||
const selectedTheme = theme.value.name;
|
||||
|
||||
// Prepare Tailwind v4 theme classes
|
||||
const themeClasses: string[] = [];
|
||||
const customClasses: string[] = [];
|
||||
|
||||
// Apply dark/light mode using Tailwind v4 theme switching
|
||||
if (darkMode.value) {
|
||||
themeClasses.push('dark');
|
||||
if (result.value?.publicTheme) {
|
||||
applyThemeFromQuery(result.value.publicTheme);
|
||||
}
|
||||
|
||||
// Apply theme-specific class for Tailwind v4 theme variants
|
||||
themeClasses.push(`theme-${selectedTheme}`);
|
||||
onError((err) => {
|
||||
console.warn('Failed to load theme from server, keeping existing theme:', err);
|
||||
});
|
||||
|
||||
// Only set CSS variables for dynamic/user-configured values from GraphQL
|
||||
// Static theme values are handled by Tailwind v4 theme classes in @tailwind-shared
|
||||
const dynamicVars: Partial<Record<DynamicVarKey, string>> = {};
|
||||
// Getters
|
||||
// Apply dark mode for gray and black themes
|
||||
const darkMode = computed<boolean>(() =>
|
||||
DARK_UI_THEMES.includes(theme.value?.name as (typeof DARK_UI_THEMES)[number])
|
||||
);
|
||||
|
||||
// User-configured colors from webGUI @ /Settings/DisplaySettings
|
||||
if (theme.value.textColor) {
|
||||
dynamicVars['--custom-header-text-primary'] = theme.value.textColor;
|
||||
customClasses.push('has-custom-header-text');
|
||||
}
|
||||
if (theme.value.metaColor) {
|
||||
dynamicVars['--custom-header-text-secondary'] = theme.value.metaColor;
|
||||
customClasses.push('has-custom-header-meta');
|
||||
}
|
||||
const bannerGradient = computed(() => {
|
||||
if (!theme.value?.banner || !theme.value?.bannerGradient) {
|
||||
return undefined;
|
||||
}
|
||||
const start = theme.value?.bgColor ? 'var(--header-gradient-start)' : 'rgba(0, 0, 0, 0)';
|
||||
const end = theme.value?.bgColor ? 'var(--header-gradient-end)' : 'var(--header-background-color)';
|
||||
return `background-image: linear-gradient(90deg, ${start} 0, ${end} 90%);`;
|
||||
});
|
||||
|
||||
if (theme.value.bgColor) {
|
||||
dynamicVars['--custom-header-background-color'] = theme.value.bgColor;
|
||||
dynamicVars['--custom-header-gradient-start'] = hexToRgba(theme.value.bgColor, 0);
|
||||
dynamicVars['--custom-header-gradient-end'] = hexToRgba(theme.value.bgColor, 0.7);
|
||||
customClasses.push('has-custom-header-bg');
|
||||
}
|
||||
// Actions
|
||||
const setTheme = (data?: Partial<Theme>, options: { source?: ThemeSource } = {}) => {
|
||||
if (data) {
|
||||
const { source = 'local' } = options;
|
||||
|
||||
// Set banner gradient if needed
|
||||
if (theme.value.banner && theme.value.bannerGradient) {
|
||||
const start = theme.value.bgColor
|
||||
? hexToRgba(theme.value.bgColor, 0)
|
||||
: 'var(--header-gradient-start)';
|
||||
const end = theme.value.bgColor
|
||||
? hexToRgba(theme.value.bgColor, 0.7)
|
||||
: 'var(--header-gradient-end)';
|
||||
|
||||
dynamicVars['--banner-gradient'] = `linear-gradient(90deg, ${start} 0, ${end} 90%)`;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const scopedTargets: HTMLElement[] = [
|
||||
document.documentElement,
|
||||
...Array.from(document.querySelectorAll<HTMLElement>('.unapi')),
|
||||
];
|
||||
|
||||
const cleanClassList = (classList: string) =>
|
||||
classList
|
||||
.split(' ')
|
||||
.filter((c) => !c.startsWith('theme-') && c !== 'dark' && !c.startsWith('has-custom-'))
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
// Apply theme and custom classes to html element and all .unapi roots
|
||||
scopedTargets.forEach((target) => {
|
||||
target.className = cleanClassList(target.className);
|
||||
[...themeClasses, ...customClasses].forEach((cls) => target.classList.add(cls));
|
||||
|
||||
if (darkMode.value) {
|
||||
target.classList.add('dark');
|
||||
} else {
|
||||
target.classList.remove('dark');
|
||||
if (source === 'server') {
|
||||
hasServerTheme.value = true;
|
||||
} else if (hasServerTheme.value && !devOverride.value) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Maintain dark mode flag on body for legacy components
|
||||
const sanitized = sanitizeTheme({
|
||||
...theme.value,
|
||||
...data,
|
||||
});
|
||||
|
||||
if (sanitized) {
|
||||
theme.value = sanitized;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setDevOverride = (enabled: boolean) => {
|
||||
devOverride.value = enabled;
|
||||
};
|
||||
|
||||
const setCssVars = () => {
|
||||
const selectedTheme = theme.value.name;
|
||||
|
||||
// Prepare Tailwind v4 theme classes
|
||||
const themeClasses: string[] = [];
|
||||
const customClasses: string[] = [];
|
||||
|
||||
// Apply dark/light mode using Tailwind v4 theme switching
|
||||
if (darkMode.value) {
|
||||
document.body.classList.add('dark');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
themeClasses.push('dark');
|
||||
}
|
||||
|
||||
// Only apply dynamic CSS variables for custom user values
|
||||
// All theme defaults are handled by classes in @tailwind-shared/theme-variants.css
|
||||
const activeDynamicKeys = Object.keys(dynamicVars) as DynamicVarKey[];
|
||||
// Apply theme-specific class for Tailwind v4 theme variants
|
||||
themeClasses.push(`theme-${selectedTheme}`);
|
||||
|
||||
scopedTargets.forEach((target) => {
|
||||
activeDynamicKeys.forEach((key) => {
|
||||
const value = dynamicVars[key];
|
||||
if (value !== undefined) {
|
||||
target.style.setProperty(key, value);
|
||||
// Only set CSS variables for dynamic/user-configured values from GraphQL
|
||||
// Static theme values are handled by Tailwind v4 theme classes in @tailwind-shared
|
||||
const dynamicVars: Partial<Record<DynamicVarKey, string>> = {};
|
||||
|
||||
// User-configured colors from webGUI @ /Settings/DisplaySettings
|
||||
if (theme.value.textColor) {
|
||||
dynamicVars['--custom-header-text-primary'] = theme.value.textColor;
|
||||
customClasses.push('has-custom-header-text');
|
||||
}
|
||||
if (theme.value.metaColor) {
|
||||
dynamicVars['--custom-header-text-secondary'] = theme.value.metaColor;
|
||||
customClasses.push('has-custom-header-meta');
|
||||
}
|
||||
|
||||
if (theme.value.bgColor) {
|
||||
dynamicVars['--custom-header-background-color'] = theme.value.bgColor;
|
||||
dynamicVars['--custom-header-gradient-start'] = hexToRgba(theme.value.bgColor, 0);
|
||||
dynamicVars['--custom-header-gradient-end'] = hexToRgba(theme.value.bgColor, 0.7);
|
||||
customClasses.push('has-custom-header-bg');
|
||||
}
|
||||
|
||||
// Set banner gradient if needed
|
||||
if (theme.value.banner && theme.value.bannerGradient) {
|
||||
const start = theme.value.bgColor
|
||||
? hexToRgba(theme.value.bgColor, 0)
|
||||
: 'var(--header-gradient-start)';
|
||||
const end = theme.value.bgColor
|
||||
? hexToRgba(theme.value.bgColor, 0.7)
|
||||
: 'var(--header-gradient-end)';
|
||||
|
||||
dynamicVars['--banner-gradient'] = `linear-gradient(90deg, ${start} 0, ${end} 90%)`;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const scopedTargets: HTMLElement[] = [
|
||||
document.documentElement,
|
||||
...Array.from(document.querySelectorAll<HTMLElement>('.unapi')),
|
||||
];
|
||||
|
||||
const cleanClassList = (classList: string) =>
|
||||
classList
|
||||
.split(' ')
|
||||
.filter((c) => !c.startsWith('theme-') && c !== 'dark' && !c.startsWith('has-custom-'))
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
// Apply theme and custom classes to html element and all .unapi roots
|
||||
scopedTargets.forEach((target) => {
|
||||
target.className = cleanClassList(target.className);
|
||||
[...themeClasses, ...customClasses].forEach((cls) => target.classList.add(cls));
|
||||
|
||||
if (darkMode.value) {
|
||||
target.classList.add('dark');
|
||||
} else {
|
||||
target.classList.remove('dark');
|
||||
}
|
||||
});
|
||||
|
||||
DYNAMIC_VAR_KEYS.forEach((key) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(dynamicVars, key)) {
|
||||
target.style.removeProperty(key);
|
||||
}
|
||||
// Maintain dark mode flag on body for legacy components
|
||||
if (darkMode.value) {
|
||||
document.body.classList.add('dark');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Only apply dynamic CSS variables for custom user values
|
||||
// All theme defaults are handled by classes in @tailwind-shared/theme-variants.css
|
||||
const activeDynamicKeys = Object.keys(dynamicVars) as DynamicVarKey[];
|
||||
|
||||
scopedTargets.forEach((target) => {
|
||||
activeDynamicKeys.forEach((key) => {
|
||||
const value = dynamicVars[key];
|
||||
if (value !== undefined) {
|
||||
target.style.setProperty(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
DYNAMIC_VAR_KEYS.forEach((key) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(dynamicVars, key)) {
|
||||
target.style.removeProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Store active variables for reference (from defaultColors for compatibility)
|
||||
const customTheme = { ...defaultColors[selectedTheme] };
|
||||
activeColorVariables.value = customTheme;
|
||||
});
|
||||
};
|
||||
|
||||
// Store active variables for reference (from defaultColors for compatibility)
|
||||
const customTheme = { ...defaultColors[selectedTheme] };
|
||||
activeColorVariables.value = customTheme;
|
||||
});
|
||||
};
|
||||
watch(
|
||||
theme,
|
||||
() => {
|
||||
setCssVars();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(theme, () => {
|
||||
setCssVars();
|
||||
});
|
||||
|
||||
return {
|
||||
// state
|
||||
activeColorVariables,
|
||||
bannerGradient,
|
||||
darkMode,
|
||||
theme,
|
||||
// actions
|
||||
setTheme,
|
||||
setCssVars,
|
||||
setDevOverride,
|
||||
};
|
||||
});
|
||||
return {
|
||||
// state
|
||||
activeColorVariables,
|
||||
bannerGradient,
|
||||
darkMode,
|
||||
theme,
|
||||
// actions
|
||||
setTheme,
|
||||
setCssVars,
|
||||
setDevOverride,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: THEME_STORAGE_KEY,
|
||||
pick: ['theme'],
|
||||
afterHydrate: (ctx) => {
|
||||
const store = ctx.store as ReturnType<typeof useThemeStore>;
|
||||
store.setTheme(store.theme);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user