mirror of
https://github.com/unraid/api.git
synced 2026-01-03 15:09:48 -06:00
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Style** * Enhanced header banner styling: centered, non-repeating cover images with layered gradient overlays and adjusted user-profile banner positioning for improved layout. * **Bug Fixes** * Banner display logic updated so "image" is treated like "yes" for showing banner images. * **Tests** * Added unit tests covering banner/theme display behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
311 lines
9.6 KiB
TypeScript
311 lines
9.6 KiB
TypeScript
import { computed, ref, watch } from 'vue';
|
|
import { defineStore } from 'pinia';
|
|
import { useQuery } from '@vue/apollo-composable';
|
|
|
|
import { defaultColors } from '~/themes/default';
|
|
import hexToRgba from 'hex-to-rgba';
|
|
|
|
import type { GetThemeQuery } from '~/composables/gql/graphql';
|
|
import type { Theme, ThemeVariables } from '~/themes/types';
|
|
|
|
import { graphql } from '~/composables/gql/gql';
|
|
|
|
// Themes that should apply the .dark class (dark UI themes)
|
|
export const DARK_UI_THEMES = ['gray', 'black'] as const;
|
|
|
|
export const GET_THEME_QUERY = graphql(`
|
|
query getTheme {
|
|
publicTheme {
|
|
name
|
|
showBannerImage
|
|
showBannerGradient
|
|
headerBackgroundColor
|
|
showHeaderDescription
|
|
headerPrimaryTextColor
|
|
headerSecondaryTextColor
|
|
}
|
|
}
|
|
`);
|
|
|
|
export const THEME_STORAGE_KEY = 'unraid.theme.publicTheme';
|
|
|
|
const DEFAULT_THEME: Theme = {
|
|
name: 'white',
|
|
banner: false,
|
|
bannerGradient: false,
|
|
bgColor: '',
|
|
descriptionShow: false,
|
|
metaColor: '',
|
|
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',
|
|
'--custom-header-background-color',
|
|
'--custom-header-gradient-start',
|
|
'--custom-header-gradient-end',
|
|
'--banner-gradient',
|
|
] as const;
|
|
|
|
type DynamicVarKey = (typeof DYNAMIC_VAR_KEYS)[number];
|
|
|
|
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 { result, onResult, onError } = useQuery<GetThemeQuery>(GET_THEME_QUERY, null, {
|
|
fetchPolicy: 'cache-and-network',
|
|
nextFetchPolicy: 'cache-first',
|
|
});
|
|
|
|
const mapPublicTheme = (publicTheme?: GetThemeQuery['publicTheme'] | null): Theme | null => {
|
|
if (!publicTheme) {
|
|
return null;
|
|
}
|
|
|
|
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,
|
|
});
|
|
};
|
|
|
|
const applyThemeFromQuery = (publicTheme?: GetThemeQuery['publicTheme'] | null) => {
|
|
const sanitized = mapPublicTheme(publicTheme);
|
|
if (!sanitized) {
|
|
return;
|
|
}
|
|
|
|
setTheme(sanitized, { source: 'server' });
|
|
};
|
|
|
|
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>, options: { source?: ThemeSource } = {}) => {
|
|
if (data) {
|
|
const { source = 'local' } = options;
|
|
|
|
if (source === 'server') {
|
|
hasServerTheme.value = true;
|
|
} else if (hasServerTheme.value && !devOverride.value) {
|
|
return;
|
|
}
|
|
|
|
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) {
|
|
themeClasses.push('dark');
|
|
}
|
|
|
|
// Apply theme-specific class for Tailwind v4 theme variants
|
|
themeClasses.push(`theme-${selectedTheme}`);
|
|
|
|
// 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%)`;
|
|
customClasses.push('has-banner-gradient');
|
|
}
|
|
|
|
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-') &&
|
|
c !== 'has-banner-gradient'
|
|
)
|
|
.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');
|
|
}
|
|
});
|
|
|
|
// 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;
|
|
});
|
|
};
|
|
|
|
watch(
|
|
theme,
|
|
() => {
|
|
setCssVars();
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
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);
|
|
},
|
|
},
|
|
}
|
|
);
|