feat: update theme application logic and color picker (#1181)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Upgraded the theme customization interface with a dropdown that lets
you choose from multiple themes (Light, Dark, Azure, Gray). Users can
now adjust options like text colors, background color, gradients, and
banner display more intuitively.
- Introduced a structured approach to theme variables, enhancing
compatibility and customization options.

- **Style**
- Enhanced the header’s visual presentation by introducing dynamic
background imagery and refined layout adjustments for a more polished
look.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-02-24 11:31:46 -05:00
committed by GitHub
parent e11d5e976d
commit a0306269c6
5 changed files with 288 additions and 235 deletions

View File

@@ -1,92 +1,77 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Input, Label, Switch } from '@unraid/ui'; import { Input, Label, Switch, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@unraid/ui';
import type { Theme } from '~/themes/types';
import type { Theme } from '~/store/theme'; import { useThemeStore } from '~/store/theme';
import { defaultColors } from '~/themes/default';
import { defaultColors, useThemeStore } from '~/store/theme';
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const { darkMode } = toRefs(themeStore);
const setDarkMode = ref<boolean>(false); // Form state
const setGradient = ref<boolean>(false); const form = reactive({
const setDescription = ref<boolean>(true); selectedTheme: 'white',
const setBanner = ref<boolean>(true); gradient: false,
description: true,
const toggleSwitch = (value: boolean) => { banner: true,
setDarkMode.value = value; textPrimary: '',
}; textSecondary: '',
const toggleGradient = (value: boolean) => { bgColor: ''
setGradient.value = value;
};
const toggleDescription = (value: boolean) => {
setDescription.value = value;
};
const toggleBanner = (value: boolean) => {
setBanner.value = value;
};
const textPrimary = ref<string>('');
const textSecondary = ref<string>('');
const bgColor = ref<string>('');
const textPrimaryToSet = computed(() => {
if (textPrimary.value) {
return textPrimary.value;
}
return darkMode.value ? defaultColors.dark.headerTextPrimary : defaultColors.light.headerTextPrimary;
}); });
const textSecondaryToSet = computed(() => { // Watch for changes and update theme
if (textSecondary.value) { watch([form], () => {
return textSecondary.value; // Enable gradient if banner is enabled
if (form.banner && !form.gradient) {
form.gradient = true;
} }
return darkMode.value
? defaultColors.dark.headerTextSecondary
: defaultColors.light.headerTextSecondary;
});
const bgColorToSet = computed(() => {
if (bgColor.value) {
return bgColor.value;
}
return darkMode.value
? defaultColors.dark.headerBackgroundColor
: defaultColors.light.headerBackgroundColor;
});
watch([setDarkMode, bgColorToSet, textSecondaryToSet, textPrimaryToSet], (newVal) => {
console.log(newVal);
const themeToSet: Theme = { const themeToSet: Theme = {
banner: setBanner.value, banner: form.banner,
bannerGradient: setGradient.value, bannerGradient: form.gradient,
descriptionShow: setDescription.value, descriptionShow: form.description,
textColor: textPrimaryToSet.value, textColor: form.textPrimary ?? defaultColors[form.selectedTheme]['--header-text-primary']!,
metaColor: textSecondaryToSet.value, metaColor: form.textSecondary ?? defaultColors[form.selectedTheme]['--header-text-secondary']!,
bgColor: bgColorToSet.value, bgColor: form.bgColor ?? defaultColors[form.selectedTheme]['--header-background-color']!,
name: setDarkMode.value ? 'black' : 'light', name: form.selectedTheme
}; };
themeStore.setTheme(themeToSet); themeStore.setTheme(themeToSet);
}); });
</script> </script>
<template> <template>
<div class="flex flex-col gap-2 border-solid border-2 p-2 border-r-2"> <div class="flex flex-col gap-2 border-solid border-2 p-2 border-r-2">
<h1 class="text-lg">Color Theme Customization</h1> <h1 class="text-lg">Color Theme Customization</h1>
<Label for="theme-select">Theme</Label>
<Select v-model="form.selectedTheme">
<SelectTrigger>
<SelectValue placeholder="Select a theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="white">Light</SelectItem>
<SelectItem value="black">Dark</SelectItem>
<SelectItem value="azure">Azure</SelectItem>
<SelectItem value="gray">Gray</SelectItem>
</SelectContent>
</Select>
<Label for="primary-text-color">Header Primary Text Color</Label> <Label for="primary-text-color">Header Primary Text Color</Label>
<Input id="primary-text-color" v-model="textPrimary" /> <Input id="primary-text-color" v-model="form.textPrimary" />
<Label for="primary-text-color">Header Secondary Text Color</Label>
<Input id="primary-text-color" v-model="textSecondary" /> <Label for="secondary-text-color">Header Secondary Text Color</Label>
<Label for="primary-text-color">Header Background Color</Label> <Input id="secondary-text-color" v-model="form.textSecondary" />
<Input id="primary-text-color" v-model="bgColor" />
<Label for="dark-mode">Dark Mode</Label> <Label for="background-color">Header Background Color</Label>
<Switch id="dark-mode" @update:checked="toggleSwitch" /> <Input id="background-color" v-model="form.bgColor" />
<Label for="gradient">Gradient</Label> <Label for="gradient">Gradient</Label>
<Switch id="gradient" @update:checked="toggleGradient" /> <Switch id="gradient" v-model:checked="form.gradient" />
<Label for="description">Description</Label> <Label for="description">Description</Label>
<Switch id="description" @update:checked="toggleDescription" /> <Switch id="description" v-model:checked="form.description" />
<Label for="banner">Banner</Label> <Label for="banner">Banner</Label>
<Switch id="banner" @update:checked="toggleBanner" /> <Switch id="banner" v-model:checked="form.banner" />
</div> </div>
</template> </template>

View File

@@ -7,10 +7,12 @@ import AES from 'crypto-js/aes';
import type { SendPayloads } from '~/store/callback'; import type { SendPayloads } from '~/store/callback';
import SsoButtonCe from '~/components/SsoButton.ce.vue'; import SsoButtonCe from '~/components/SsoButton.ce.vue';
import { useThemeStore } from '~/store/theme';
const serverStore = useDummyServerStore(); const serverStore = useDummyServerStore();
const { serverState } = storeToRefs(serverStore); const { serverState } = storeToRefs(serverStore);
const { registerEntry } = useCustomElements(); const { registerEntry } = useCustomElements();
const { theme } = storeToRefs(useThemeStore());
onBeforeMount(() => { onBeforeMount(() => {
registerEntry('UnraidComponents'); registerEntry('UnraidComponents');
}); });
@@ -75,6 +77,13 @@ onMounted(() => {
'forUpc' 'forUpc'
); );
}); });
const bannerImage = watch(theme, () => {
if (theme.value.banner) {
return `url(https://picsum.photos/1920/200?${Math.round(Math.random() * 100)})`;
}
return 'none';
});
</script> </script>
<template> <template>
@@ -86,7 +95,14 @@ onMounted(() => {
<ColorSwitcherCe /> <ColorSwitcherCe />
<h2 class="text-xl font-semibold font-mono">Vue Components</h2> <h2 class="text-xl font-semibold font-mono">Vue Components</h2>
<h3 class="text-lg font-semibold font-mono">UserProfileCe</h3> <h3 class="text-lg font-semibold font-mono">UserProfileCe</h3>
<header class="bg-header-background-color py-4 flex flex-row justify-between items-center"> <header
class="bg-header-background-color flex justify-between items-center"
:style="{
backgroundImage: bannerImage,
backgroundSize: 'cover',
backgroundPosition: 'center'
}"
>
<div class="inline-flex flex-col gap-4 items-start px-4"> <div class="inline-flex flex-col gap-4 items-start px-4">
<a href="https://unraid.net" target="_blank"> <a href="https://unraid.net" target="_blank">
<BrandLogo class="w-[100px] sm:w-[150px]" /> <BrandLogo class="w-[100px] sm:w-[150px]" />

View File

@@ -1,129 +1,33 @@
import { createPinia, defineStore, setActivePinia } from 'pinia'; import { createPinia, defineStore, setActivePinia } from 'pinia';
import { defaultColors } from '~/themes/default';
import hexToRgba from 'hex-to-rgba'; import hexToRgba from 'hex-to-rgba';
import type { Theme, ThemeVariables } from '~/themes/types';
/** /**
* @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components * @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components
* @see https://github.com/vuejs/pinia/discussions/1085 * @see https://github.com/vuejs/pinia/discussions/1085
*/ */
setActivePinia(createPinia()); setActivePinia(createPinia());
export interface Theme {
banner: boolean;
bannerGradient: boolean;
bgColor: string;
descriptionShow: boolean;
metaColor: string;
name: string;
textColor: string;
}
interface ThemeVariables {
[key: string]: string;
}
export const defaultColors: Record<string, ThemeVariables> = {
dark: {
'--background': '0 0% 3.9%',
'--foreground': '0 0% 98%',
'--muted': '0 0% 14.9%',
'--muted-foreground': '0 0% 63.9%',
'--popover': '0 0% 3.9%',
'--popover-foreground': '0 0% 98%',
'--card': '0 0% 14.9%',
'--card-foreground': '0 0% 98%',
'--border': '0 0% 20%',
'--input': '0 0% 14.9%',
'--primary': '24 100% 50%',
'--primary-foreground': '0 0% 98%',
'--secondary': '0 0% 14.9%',
'--secondary-foreground': '0 0% 77%',
'--accent': '0 0% 14.9%',
'--accent-foreground': '0 0% 98%',
'--destructive': '0 62.8% 30.6%',
'--destructive-foreground': '0 0% 98%',
'--ring': '0 0% 83.1%',
'--header-text-primary': '#1c1c1c',
'--header-text-secondary': '#999999',
'--header-background-color': '#f2f2f2',
'--gradient-start': 'rgba(0, 0, 0, 0)',
'--gradient-end': 'var(--header-background-color)',
},
light: {
'--background': '0 0% 100%',
'--foreground': '0 0% 3.9%',
'--muted': '0 0% 96.1%',
'--muted-foreground': '0 0% 45.1%',
'--popover': '0 0% 100%',
'--popover-foreground': '0 0% 3.9%',
'--card': '0 0% 100%',
'--card-foreground': '0 0% 3.9%',
'--border': '0 0% 89.8%',
'--input': '0 0% 89.8%',
'--primary': '24 100% 50%',
'--primary-foreground': '0 0% 98%',
'--secondary': '0 0% 96.1%',
'--secondary-foreground': '0 0% 45%',
'--accent': '0 0% 96.1%',
'--accent-foreground': '0 0% 9%',
'--destructive': '0 84.2% 60.2%',
'--destructive-foreground': '0 0% 98%',
'--ring': '0 0% 3.9%',
'--radius': '0.5rem',
'--header-text-primary': '#f2f2f2',
'--header-text-secondary': '#999999',
'--header-background-color': '#1c1b1b',
'--gradient-start': 'rgba(0, 0, 0, 0)',
'--gradient-end': 'var(--header-background-color)',
},
} as const;
/**
* Unraid default theme colors do not have consistent colors for the header.
* This is a workaround to set the correct colors for the header.
* DARK THEMES: black, gray
* DARK HEADER THEMES: white, gray
* LIGHT THEMES: white, gray
* LIGHT HEADER THEMES: black, gray
*/
export const defaultAzureGrayHeaderColors: ThemeVariables = {
// azure and gray header colors are the same but the background color is different
'--header-text-primary': '#39587f',
'--header-text-secondary': '#606e7f',
};
export const defaultHeaderColors: Record<string, ThemeVariables> = {
azure: {
...defaultAzureGrayHeaderColors,
'--header-background-color': '#1c1b1b',
},
black: {
'--header-text-primary': '#1c1c1c',
'--header-text-secondary': '#999999',
'--header-background-color': '#f2f2f2',
},
gray: {
...defaultAzureGrayHeaderColors,
'--header-background-color': '#f2f2f2',
},
white: {
'--header-text-primary': '#f2f2f2',
'--header-text-secondary': '#999999',
'--header-background-color': '#1c1b1b',
},
};
// used to swap the UPC text color when using the azure or gray theme // used to swap the UPC text color when using the azure or gray theme
export const DARK_THEMES = ['black', 'gray'] as const; export const DARK_THEMES = ['black', 'gray'] as const;
export const useThemeStore = defineStore('theme', () => { export const useThemeStore = defineStore('theme', () => {
// State // State
const theme = ref<Theme | undefined>(); const theme = ref<Theme>({
name: 'white',
const activeColorVariables = ref<ThemeVariables>({ banner: false,
...defaultColors.light, bannerGradient: false,
...defaultHeaderColors['white'], bgColor: '',
descriptionShow: false,
metaColor: '',
textColor: '',
}); });
const activeColorVariables = ref<ThemeVariables>(defaultColors.white);
// Getters // Getters
const darkMode = computed<boolean>( const darkMode = computed<boolean>(
() => DARK_THEMES.includes(theme.value?.name as (typeof DARK_THEMES)[number]) ?? false () => DARK_THEMES.includes(theme.value?.name as (typeof DARK_THEMES)[number]) ?? false
@@ -133,8 +37,8 @@ export const useThemeStore = defineStore('theme', () => {
if (!theme.value?.banner || !theme.value?.bannerGradient) { if (!theme.value?.banner || !theme.value?.bannerGradient) {
return undefined; return undefined;
} }
const start = theme.value?.bgColor ? 'var(--gradient-start)' : 'rgba(0, 0, 0, 0)'; const start = theme.value?.bgColor ? 'var(--header-gradient-start)' : 'rgba(0, 0, 0, 0)';
const end = theme.value?.bgColor ? 'var(--gradient-end)' : 'var(--header-background-color)'; const end = theme.value?.bgColor ? 'var(--header-gradient-end)' : 'var(--header-background-color)';
return `background-image: linear-gradient(90deg, ${start} 0, ${end} 30%);`; return `background-image: linear-gradient(90deg, ${start} 0, ${end} 30%);`;
}); });
// Actions // Actions
@@ -143,74 +47,68 @@ export const useThemeStore = defineStore('theme', () => {
}; };
const setCssVars = () => { const setCssVars = () => {
const customColorVariables = structuredClone(defaultColors); const selectedTheme = theme.value.name;
const body = document.body; const customTheme = { ...defaultColors[selectedTheme] };
const selectedMode = darkMode.value ? 'dark' : 'light';
// set the default header colors for the current theme
const themeName = theme.value?.name;
if (themeName && themeName in defaultHeaderColors) {
customColorVariables[selectedMode] = {
...customColorVariables[selectedMode],
...defaultHeaderColors[themeName],
};
}
// Set banner gradient if enabled // Set banner gradient if enabled
if (theme.value?.banner && theme.value?.bannerGradient) { if (theme.value.banner && theme.value.bannerGradient) {
const start = theme.value?.bgColor const start = theme.value.bgColor
? hexToRgba(theme.value.bgColor, 0) ? hexToRgba(theme.value.bgColor, 0)
: customColorVariables[selectedMode]['--gradient-start']; : customTheme['--header-gradient-start'];
const end = theme.value?.bgColor const end = theme.value.bgColor
? hexToRgba(theme.value.bgColor, 0.7) ? hexToRgba(theme.value.bgColor, 0.7)
: customColorVariables[selectedMode]['--gradient-end']; : customTheme['--header-gradient-end'];
body.style.setProperty('--banner-gradient', `linear-gradient(90deg, ${start} 0, ${end} 30%)`);
} else { // set the banner gradient
body.style.removeProperty('--banner-gradient'); customTheme['--banner-gradient'] = `linear-gradient(90deg, ${start} 0, ${end} 30%)`;
} }
// overwrite with hex colors set in webGUI @ /Settings/DisplaySettings // overwrite with hex colors set in webGUI @ /Settings/DisplaySettings
if (theme.value?.textColor) { if (theme.value.textColor) {
body.style.setProperty('--header-text-primary', theme.value.textColor); customTheme['--header-text-primary'] = theme.value.textColor;
} else {
body.style.setProperty(
'--header-text-primary',
customColorVariables[selectedMode]['--header-text-primary']
);
} }
if (theme.value?.metaColor) { if (theme.value.metaColor) {
body.style.setProperty('--header-text-secondary', theme.value.metaColor); customTheme['--header-text-secondary'] = theme.value.metaColor;
} else {
body.style.setProperty(
'--header-text-secondary',
customColorVariables[selectedMode]['--header-text-secondary']
);
}
if (theme.value?.bgColor) {
body.style.setProperty('--header-background-color', theme.value.bgColor);
body.style.setProperty('--gradient-start', hexToRgba(theme.value.bgColor, 0));
body.style.setProperty('--gradient-end', hexToRgba(theme.value.bgColor, 0.7));
} else {
body.style.setProperty(
'--header-background-color',
customColorVariables[selectedMode]['--header-background-color']
);
} }
// Apply all other CSS variables if (theme.value.bgColor) {
Object.entries(customColorVariables[selectedMode]).forEach(([key, value]) => { customTheme['--header-background-color'] = theme.value.bgColor;
if (!key.startsWith('--header-')) { customTheme['--header-gradient-start'] = hexToRgba(theme.value.bgColor, 0);
body.style.setProperty(key, value); customTheme['--header-gradient-end'] = hexToRgba(theme.value.bgColor, 0.7);
}
requestAnimationFrame(() => {
if (darkMode.value) {
document.body.classList.add('dark');
} else {
document.body.classList.remove('dark');
} }
document.body.style.cssText = createCssText(customTheme, document.body);
activeColorVariables.value = customTheme;
}); });
};
if (darkMode.value) { /**
document.body.classList.add('dark'); * Creates a string of CSS rules preserving existing rules that are not defined in the theme variables
} else { * @param themeVariables - The theme variables to apply
document.body.classList.remove('dark'); * @param body - The body element to apply the CSS to
} * @returns A string of CSS rules
*/
const createCssText = (themeVariables: ThemeVariables, body: HTMLElement) => {
const existingStyles = body.style.cssText
.split(';')
.filter((rule) => rule.trim())
.filter((rule) => {
// Keep rules that aren't in our theme variables
return !Object.keys(themeVariables).some((themeVar) => rule.startsWith(themeVar));
});
activeColorVariables.value = customColorVariables[selectedMode]; const themeStyles = Object.entries(themeVariables).reduce((acc, [key, value]) => {
if (value) acc.push(`${key}: ${value}`);
return acc;
}, [] as string[]);
return [...existingStyles, ...themeStyles].join(';');
}; };
watch(theme, () => { watch(theme, () => {

104
web/themes/default.ts Normal file
View File

@@ -0,0 +1,104 @@
import type { LegacyThemeVariables, ThemeVariables } from '~/themes/types';
/**
* Defines legacy colors that are kept for backwards compatibility
*
* Allows theme-engine to be updated without breaking existing themes
*/
export const legacyColors = {
'--color-alpha': 'var(--header-background-color)',
'--color-beta': 'var(--header-text-primary)',
'--color-gamma': 'var(--header-text-secondary)',
'--color-gamma-opaque': 'rgba(153, 153, 153, .5)',
'--color-customgradient-start': 'rgba(242, 242, 242, .0)',
'--color-customgradient-end': 'rgba(242, 242, 242, .85)',
'--shadow-beta': '0 25px 50px -12px rgba(242, 242, 242, .15)',
} as const satisfies LegacyThemeVariables;
export const defaultLight: ThemeVariables = {
'--background': '0 0% 3.9%',
'--foreground': '0 0% 98%',
'--muted': '0 0% 14.9%',
'--muted-foreground': '0 0% 63.9%',
'--popover': '0 0% 3.9%',
'--popover-foreground': '0 0% 98%',
'--card': '0 0% 14.9%',
'--card-foreground': '0 0% 98%',
'--border': '0 0% 20%',
'--input': '0 0% 14.9%',
'--primary': '24 100% 50%',
'--primary-foreground': '0 0% 98%',
'--secondary': '0 0% 14.9%',
'--secondary-foreground': '0 0% 77%',
'--accent': '0 0% 14.9%',
'--accent-foreground': '0 0% 98%',
'--destructive': '0 62.8% 30.6%',
'--destructive-foreground': '0 0% 98%',
'--ring': '0 0% 83.1%',
'--radius': '0.5rem',
'--header-text-primary': '#f2f2f2',
'--header-text-secondary': '#999999',
'--header-background-color': '#1c1b1b',
'--header-gradient-start': 'rgba(0, 0, 0, 0)',
'--header-gradient-end': 'var(--header-background-color)',
'--banner-gradient': null,
...legacyColors,
} as const satisfies ThemeVariables;
export const defaultDark: ThemeVariables = {
'--background': '0 0% 100%',
'--foreground': '0 0% 3.9%',
'--muted': '0 0% 96.1%',
'--muted-foreground': '0 0% 45.1%',
'--popover': '0 0% 100%',
'--popover-foreground': '0 0% 3.9%',
'--card': '0 0% 100%',
'--card-foreground': '0 0% 3.9%',
'--border': '0 0% 89.8%',
'--input': '0 0% 89.8%',
'--primary': '24 100% 50%',
'--primary-foreground': '0 0% 98%',
'--secondary': '0 0% 96.1%',
'--secondary-foreground': '0 0% 45%',
'--accent': '0 0% 96.1%',
'--accent-foreground': '0 0% 9%',
'--destructive': '0 84.2% 60.2%',
'--destructive-foreground': '0 0% 98%',
'--ring': '0 0% 3.9%',
'--radius': '0.5rem',
'--header-text-primary': '#1c1c1c',
'--header-text-secondary': '#999999',
'--header-background-color': '#f2f2f2',
'--header-gradient-start': 'rgba(0, 0, 0, 0)',
'--header-gradient-end': 'var(--header-background-color)',
'--banner-gradient': null,
...legacyColors,
} as const satisfies ThemeVariables;
/**
* Color Explanation:
* White (base light theme): has dark header background and light text
* Black (base dark theme): has light header background and dark text
* Gray (base dark theme): has dark header background and light text
* Azure (base light theme): has light header background and dark text
*/
export const defaultColors: Record<string, ThemeVariables> = {
white: {
...defaultLight,
},
black: {
...defaultDark,
},
gray: {
...defaultDark,
'--header-text-primary': '#39587f',
'--header-text-secondary': '#606e7f',
'--header-background-color': '#1c1b1b',
},
azure: {
...defaultDark,
'--header-text-primary': '#39587f',
'--header-text-secondary': '#606e7f',
'--header-background-color': '#f2f2f2',
},
} as const satisfies Record<string, ThemeVariables>;

50
web/themes/types.d.ts vendored Normal file
View File

@@ -0,0 +1,50 @@
export interface Theme {
banner: boolean;
bannerGradient: boolean;
bgColor: string;
descriptionShow: boolean;
metaColor: string;
name: string;
textColor: string;
}
type BaseThemeVariables = {
'--background': string;
'--foreground': string;
'--muted': string;
'--muted-foreground': string;
'--popover': string;
'--popover-foreground': string;
'--card': string;
'--card-foreground': string;
'--border': string;
'--input': string;
'--primary': string;
'--primary-foreground': string;
'--secondary': string;
'--secondary-foreground': string;
'--accent': string;
'--accent-foreground': string;
'--destructive': string;
'--destructive-foreground': string;
'--ring': string;
'--radius': string;
'--header-text-primary': string;
'--header-text-secondary': string;
'--header-background-color': string;
'--header-gradient-start': string;
'--header-gradient-end': string;
'--banner-gradient': string | null;
};
type LegacyThemeVariables = {
'--color-alpha': string;
'--color-beta': string;
'--color-gamma': string;
'--color-gamma-opaque': string;
'--color-customgradient-start': string;
'--color-customgradient-end': string;
'--shadow-beta': string;
};
export type ThemeVariables = BaseThemeVariables & LegacyThemeVariables;