feat: add user defined theme variable override

This commit is contained in:
axolotle 2024-02-14 14:09:07 +01:00
parent 2d9099d25b
commit d01074cef1
5 changed files with 215 additions and 0 deletions

View file

@ -1,3 +1,5 @@
import { useThemeOverrideState } from '@/composables/theming'
// AUTH // AUTH
export const useIsLoggedIn = () => { export const useIsLoggedIn = () => {
@ -133,6 +135,11 @@ export const useSettings = async () => {
const colorMode = useColorMode() const colorMode = useColorMode()
colorMode.preference = settings.value.portal_theme colorMode.preference = settings.value.portal_theme
const themeOverride = useThemeOverrideState()
if (themeOverride.value) {
useThemeOverride().init(themeOverride.value)
}
} }
return settings return settings

93
composables/theming.ts Normal file
View file

@ -0,0 +1,93 @@
import { reactive } from 'vue'
import C from 'colorjs.io'
import { keysOf } from '@/utils/common'
enum Color {
primary = 'p',
secondary = 's',
accent = 'a',
neutral = 'n',
'base-100' = 'b1',
'base-200' = 'b2',
'base-300' = 'b3',
'base-content' = 'bc',
info = 'in',
success = 'su',
warning = 'wa',
error = 'er',
}
enum Size {
'card-radius' = 'rounded-box',
'btn-radius' = 'rounded-btn',
'btn-border' = 'border-btn',
// Other possible vars, not ~used atm
// "rounded-badge",
// "animation-btn",
// "animation-input",
// "btn-focus-scale",
// "tab-border",
// "tab-radius",
}
type Colors = Record<keyof typeof Color, string>
type Sizes = Record<keyof typeof Size, string>
type Theme = Colors & Sizes
export const useThemeOverrideState = () =>
useState<Theme | null>('customTheme', () => {
const theme = localStorage.getItem('customTheme')
return theme ? JSON.parse(theme) : null
})
export const useThemeOverride = () => {
const themeOverride = useThemeOverrideState()
const colorNames = keysOf(Color)
const sizeNames = keysOf(Size)
const colors = reactive(
Object.fromEntries(
colorNames.map((key) => [key, themeOverride.value?.[key] ?? '']),
) as Colors,
)
const sizes = reactive(
Object.fromEntries(
sizeNames.map((key) => [key, themeOverride.value?.[key] ?? '']),
) as Sizes,
)
const toCss = (theme: Theme) => {
return keysOf(theme)
.reduce<string[]>((cssVars, key) => {
if (theme[key] === '') return cssVars
if (key in Color) {
const oklch = new C(theme[key])
.to('oklch')
.coords.map((n) => (isNaN(n) ? 0 : n))
.join(' ')
cssVars.push(`--${Color[key as keyof Colors]}: ${oklch};`)
} else {
cssVars.push(`--${Size[key as keyof Sizes]}: ${theme[key]}rem;`)
}
return cssVars
}, [])
.join('')
}
const update = (theme: Theme | null) => {
localStorage.setItem('customTheme', theme ? JSON.stringify(theme) : '')
document
.querySelector('html')!
.setAttribute('style', theme ? toCss(theme) : '')
}
watch([sizes, colors], () => {
update({ ...colors, ...sizes })
})
return {
init: update,
colors,
sizes,
}
}

View file

@ -14,6 +14,31 @@
"automatic": "Automatic ({name})", "automatic": "Automatic ({name})",
"back_to_apps": "Go back to app list", "back_to_apps": "Go back to app list",
"cancel": "Cancel", "cancel": "Cancel",
"theming": {
"color_picker": "Pick a color for {colorName}",
"color_hex": "Enter a color hex value for {colorName}",
"colors": {
"primary": "Primary",
"secondary": "Secondary",
"accent": "Accentuated",
"neutral": "Neutral",
"base-100": "Site background",
"base-200": "Disabled state",
"base-300": "Card borders and headers",
"base-content": "Text",
"info": "Information",
"success": "Success/Valid",
"warning": "Warning",
"error": "Error/Invalid"
},
"override": "Theme override",
"size": "Define a size in rem for {sizeName}",
"sizes": {
"card-radius": "Card radius",
"btn-radius": "Button radius",
"btn-border": "Button border size"
}
},
"change_password": "Change password", "change_password": "Change password",
"confirm_new_password": "Confirm new password", "confirm_new_password": "Confirm new password",
"current_password": "Current password", "current_password": "Current password",

View file

@ -26,6 +26,8 @@ const localesAsOptions = computed(() => {
return options return options
}) })
const { colors, sizes } = useThemeOverride()
const themesAsOptions = [ const themesAsOptions = [
'auto', 'auto',
'system', 'system',
@ -131,7 +133,91 @@ const themesAsOptions = [
</option> </option>
</select> </select>
</div> </div>
<fieldset class="theme-override mt-8">
<legend class="text-xl mb-6">{{ $t('theming.override') }}</legend>
<div
v-for="(_, colorName) in colors"
:key="colorName"
class="flex flex-wrap mb-2"
>
<FormField
:name="`color-picker-${colorName}`"
:label="
$t('theming.color_picker', {
colorName: $t(`theming.colors.${colorName}`),
})
"
sr-hide-label
>
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -->
<input
:id="`color-picker-${colorName}`"
v-model="colors[colorName]"
type="color"
class="inline-block w-8 h-8 -mr-8 cursor-pointer"
/>
<div
:class="'bg-' + colorName"
class="inline-block w-8 h-8 pointer-events-none border border-base-300"
/>
</FormField>
<FormField
:name="`color-hex-${colorName}`"
:label="
$t('theming.color_hex', {
colorName: $t(`theming.colors.${colorName}`),
})
"
sr-hide-label
>
<input
:id="`color-hex-${colorName}`"
v-model="colors[colorName]"
size="7"
class="input input-bordered px-2 font-mono ml-3"
/>
</FormField>
<span class="ml-3" aria-hidden>
{{ $t(`theming.colors.${colorName}`) }}
</span>
</div>
<div v-for="(_, sizeName) in sizes" :key="sizeName" class="flex mb-2">
<FormField
:name="`size-${sizeName}`"
:label="
$t('theming.size', {
sizeName: $t(`theming.sizes.${sizeName}`),
})
"
sr-hide-label
>
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -->
<input
:id="`size-${sizeName}`"
v-model="sizes[sizeName]"
type="number"
size="6"
class="input input-bordered inline-block pe-1"
/>
<span class="ml-3" aria-hidden>
{{ $t(`theming.sizes.${sizeName}`) }}
</span>
</FormField>
</div>
</fieldset>
</form> </form>
</section> </section>
</div> </div>
</template> </template>
<style scoped>
.theme-override input.input {
min-height: 2rem;
height: 2rem;
}
</style>

View file

@ -16,3 +16,7 @@ export function exclude<T, K extends keyof T>(
}) })
return filtered return filtered
} }
export function keysOf<T extends Record<string, string>>(obj: T) {
return Object.keys(obj) as Array<keyof T>
}