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>
import { Input, Label, Switch } from '@unraid/ui';
import type { Theme } from '~/store/theme';
import { defaultColors, useThemeStore } from '~/store/theme';
import { Input, Label, Switch, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@unraid/ui';
import type { Theme } from '~/themes/types';
import { useThemeStore } from '~/store/theme';
import { defaultColors } from '~/themes/default';
const themeStore = useThemeStore();
const { darkMode } = toRefs(themeStore);
const setDarkMode = ref<boolean>(false);
const setGradient = ref<boolean>(false);
const setDescription = ref<boolean>(true);
const setBanner = ref<boolean>(true);
const toggleSwitch = (value: boolean) => {
setDarkMode.value = value;
};
const toggleGradient = (value: boolean) => {
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;
// Form state
const form = reactive({
selectedTheme: 'white',
gradient: false,
description: true,
banner: true,
textPrimary: '',
textSecondary: '',
bgColor: ''
});
const textSecondaryToSet = computed(() => {
if (textSecondary.value) {
return textSecondary.value;
// Watch for changes and update theme
watch([form], () => {
// 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 = {
banner: setBanner.value,
bannerGradient: setGradient.value,
descriptionShow: setDescription.value,
textColor: textPrimaryToSet.value,
metaColor: textSecondaryToSet.value,
bgColor: bgColorToSet.value,
name: setDarkMode.value ? 'black' : 'light',
banner: form.banner,
bannerGradient: form.gradient,
descriptionShow: form.description,
textColor: form.textPrimary ?? defaultColors[form.selectedTheme]['--header-text-primary']!,
metaColor: form.textSecondary ?? defaultColors[form.selectedTheme]['--header-text-secondary']!,
bgColor: form.bgColor ?? defaultColors[form.selectedTheme]['--header-background-color']!,
name: form.selectedTheme
};
themeStore.setTheme(themeToSet);
});
</script>
<template>
<div class="flex flex-col gap-2 border-solid border-2 p-2 border-r-2">
<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>
<Input id="primary-text-color" v-model="textPrimary" />
<Label for="primary-text-color">Header Secondary Text Color</Label>
<Input id="primary-text-color" v-model="textSecondary" />
<Label for="primary-text-color">Header Background Color</Label>
<Input id="primary-text-color" v-model="bgColor" />
<Label for="dark-mode">Dark Mode</Label>
<Switch id="dark-mode" @update:checked="toggleSwitch" />
<Input id="primary-text-color" v-model="form.textPrimary" />
<Label for="secondary-text-color">Header Secondary Text Color</Label>
<Input id="secondary-text-color" v-model="form.textSecondary" />
<Label for="background-color">Header Background Color</Label>
<Input id="background-color" v-model="form.bgColor" />
<Label for="gradient">Gradient</Label>
<Switch id="gradient" @update:checked="toggleGradient" />
<Switch id="gradient" v-model:checked="form.gradient" />
<Label for="description">Description</Label>
<Switch id="description" @update:checked="toggleDescription" />
<Switch id="description" v-model:checked="form.description" />
<Label for="banner">Banner</Label>
<Switch id="banner" @update:checked="toggleBanner" />
<Switch id="banner" v-model:checked="form.banner" />
</div>
</template>

View File

@@ -7,10 +7,12 @@ import AES from 'crypto-js/aes';
import type { SendPayloads } from '~/store/callback';
import SsoButtonCe from '~/components/SsoButton.ce.vue';
import { useThemeStore } from '~/store/theme';
const serverStore = useDummyServerStore();
const { serverState } = storeToRefs(serverStore);
const { registerEntry } = useCustomElements();
const { theme } = storeToRefs(useThemeStore());
onBeforeMount(() => {
registerEntry('UnraidComponents');
});
@@ -75,6 +77,13 @@ onMounted(() => {
'forUpc'
);
});
const bannerImage = watch(theme, () => {
if (theme.value.banner) {
return `url(https://picsum.photos/1920/200?${Math.round(Math.random() * 100)})`;
}
return 'none';
});
</script>
<template>
@@ -86,7 +95,14 @@ onMounted(() => {
<ColorSwitcherCe />
<h2 class="text-xl font-semibold font-mono">Vue Components</h2>
<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">
<a href="https://unraid.net" target="_blank">
<BrandLogo class="w-[100px] sm:w-[150px]" />

View File

@@ -1,129 +1,33 @@
import { createPinia, defineStore, setActivePinia } from 'pinia';
import { defaultColors } from '~/themes/default';
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://github.com/vuejs/pinia/discussions/1085
*/
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
export const DARK_THEMES = ['black', 'gray'] as const;
export const useThemeStore = defineStore('theme', () => {
// State
const theme = ref<Theme | undefined>();
const activeColorVariables = ref<ThemeVariables>({
...defaultColors.light,
...defaultHeaderColors['white'],
const theme = ref<Theme>({
name: 'white',
banner: false,
bannerGradient: false,
bgColor: '',
descriptionShow: false,
metaColor: '',
textColor: '',
});
const activeColorVariables = ref<ThemeVariables>(defaultColors.white);
// Getters
const darkMode = computed<boolean>(
() => 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) {
return undefined;
}
const start = theme.value?.bgColor ? 'var(--gradient-start)' : 'rgba(0, 0, 0, 0)';
const end = theme.value?.bgColor ? 'var(--gradient-end)' : 'var(--header-background-color)';
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} 30%);`;
});
// Actions
@@ -143,74 +47,68 @@ export const useThemeStore = defineStore('theme', () => {
};
const setCssVars = () => {
const customColorVariables = structuredClone(defaultColors);
const body = document.body;
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],
};
}
const selectedTheme = theme.value.name;
const customTheme = { ...defaultColors[selectedTheme] };
// Set banner gradient if enabled
if (theme.value?.banner && theme.value?.bannerGradient) {
const start = theme.value?.bgColor
if (theme.value.banner && theme.value.bannerGradient) {
const start = theme.value.bgColor
? hexToRgba(theme.value.bgColor, 0)
: customColorVariables[selectedMode]['--gradient-start'];
const end = theme.value?.bgColor
: customTheme['--header-gradient-start'];
const end = theme.value.bgColor
? hexToRgba(theme.value.bgColor, 0.7)
: customColorVariables[selectedMode]['--gradient-end'];
body.style.setProperty('--banner-gradient', `linear-gradient(90deg, ${start} 0, ${end} 30%)`);
} else {
body.style.removeProperty('--banner-gradient');
: customTheme['--header-gradient-end'];
// set the banner gradient
customTheme['--banner-gradient'] = `linear-gradient(90deg, ${start} 0, ${end} 30%)`;
}
// overwrite with hex colors set in webGUI @ /Settings/DisplaySettings
if (theme.value?.textColor) {
body.style.setProperty('--header-text-primary', theme.value.textColor);
} else {
body.style.setProperty(
'--header-text-primary',
customColorVariables[selectedMode]['--header-text-primary']
);
if (theme.value.textColor) {
customTheme['--header-text-primary'] = theme.value.textColor;
}
if (theme.value?.metaColor) {
body.style.setProperty('--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']
);
if (theme.value.metaColor) {
customTheme['--header-text-secondary'] = theme.value.metaColor;
}
// Apply all other CSS variables
Object.entries(customColorVariables[selectedMode]).forEach(([key, value]) => {
if (!key.startsWith('--header-')) {
body.style.setProperty(key, value);
if (theme.value.bgColor) {
customTheme['--header-background-color'] = theme.value.bgColor;
customTheme['--header-gradient-start'] = hexToRgba(theme.value.bgColor, 0);
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');
} else {
document.body.classList.remove('dark');
}
/**
* Creates a string of CSS rules preserving existing rules that are not defined in the theme variables
* @param themeVariables - The theme variables to apply
* @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, () => {

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;