mirror of
https://github.com/YunoHost/yunohost-portal.git
synced 2024-09-03 20:06:23 +02:00
feat: add user defined theme variable override
This commit is contained in:
parent
2d9099d25b
commit
d01074cef1
5 changed files with 215 additions and 0 deletions
|
@ -1,3 +1,5 @@
|
|||
import { useThemeOverrideState } from '@/composables/theming'
|
||||
|
||||
// AUTH
|
||||
|
||||
export const useIsLoggedIn = () => {
|
||||
|
@ -133,6 +135,11 @@ export const useSettings = async () => {
|
|||
|
||||
const colorMode = useColorMode()
|
||||
colorMode.preference = settings.value.portal_theme
|
||||
|
||||
const themeOverride = useThemeOverrideState()
|
||||
if (themeOverride.value) {
|
||||
useThemeOverride().init(themeOverride.value)
|
||||
}
|
||||
}
|
||||
|
||||
return settings
|
||||
|
|
93
composables/theming.ts
Normal file
93
composables/theming.ts
Normal 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,
|
||||
}
|
||||
}
|
|
@ -14,6 +14,31 @@
|
|||
"automatic": "Automatic ({name})",
|
||||
"back_to_apps": "Go back to app list",
|
||||
"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",
|
||||
"confirm_new_password": "Confirm new password",
|
||||
"current_password": "Current password",
|
||||
|
|
|
@ -26,6 +26,8 @@ const localesAsOptions = computed(() => {
|
|||
return options
|
||||
})
|
||||
|
||||
const { colors, sizes } = useThemeOverride()
|
||||
|
||||
const themesAsOptions = [
|
||||
'auto',
|
||||
'system',
|
||||
|
@ -131,7 +133,91 @@ const themesAsOptions = [
|
|||
</option>
|
||||
</select>
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-override input.input {
|
||||
min-height: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -16,3 +16,7 @@ export function exclude<T, K extends keyof T>(
|
|||
})
|
||||
return filtered
|
||||
}
|
||||
|
||||
export function keysOf<T extends Record<string, string>>(obj: T) {
|
||||
return Object.keys(obj) as Array<keyof T>
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue