fix: theming
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
+2
-1
@@ -9,6 +9,7 @@
|
||||
"darkMode": "Dark Mode",
|
||||
"lightMode": "Light Mode",
|
||||
"logout": "Logout",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"systemMode": "System Mode"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"userMenu": {
|
||||
"darkMode": "Dark Mode",
|
||||
"lightMode": "Light Mode",
|
||||
"systemMode": "System Mode"
|
||||
}
|
||||
}
|
||||
@@ -49,9 +49,9 @@ class TenantSettingsController extends ControllerAbstract
|
||||
* @example request body:
|
||||
* {
|
||||
* "data": {
|
||||
* "default_mode": "dark",
|
||||
* "primary_color": "#6366F1",
|
||||
* "lock_user_colors": true
|
||||
* "theme_default_mode": "dark",
|
||||
* "theme_palette": {"light": {"colors": {"primary": "#0284C7"}}},
|
||||
* "theme_lock": true
|
||||
* }
|
||||
* }
|
||||
*
|
||||
|
||||
@@ -5,69 +5,5 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import { onMounted, watch } from 'vue';
|
||||
import { useTheme } from 'vuetify';
|
||||
import SharedSnackbar from '@KTXC/components/shared/SharedSnackbar.vue';
|
||||
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
||||
import { useUserStore } from '@KTXC/stores/userStore';
|
||||
import { useTenantStore } from '@KTXC/stores/tenantStore';
|
||||
|
||||
const theme = useTheme();
|
||||
const layoutStore = useLayoutStore();
|
||||
const userStore = useUserStore();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
// Maps user/tenant setting keys → Vuetify color token names
|
||||
const COLOR_SETTINGS: Array<{ token: string; key: string }> = [
|
||||
{ token: 'primary', key: 'primary_color' },
|
||||
{ token: 'secondary', key: 'secondary_color' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Apply brand color overrides from stored preferences to all Vuetify theme
|
||||
* variants (light & dark). Tenant colors take priority when lock is active.
|
||||
*/
|
||||
function applyThemeColors(): void {
|
||||
const locked = tenantStore.getSetting('lock_user_colors') as boolean | null;
|
||||
|
||||
for (const { token, key } of COLOR_SETTINGS) {
|
||||
const value = locked
|
||||
? ((tenantStore.getSetting(key) as string | null) ?? (userStore.getSetting(key) as string | null))
|
||||
: ((userStore.getSetting(key) as string | null) ?? (tenantStore.getSetting(key) as string | null));
|
||||
|
||||
if (value) {
|
||||
for (const variant of Object.keys(theme.themes.value)) {
|
||||
theme.themes.value[variant].colors[token] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply font preference via CSS custom property and body style. */
|
||||
function applyFont(): void {
|
||||
const font =
|
||||
((userStore.getSetting('font') as string | null) ??
|
||||
(tenantStore.getSetting('font') as string | null));
|
||||
|
||||
if (font && font !== 'Public Sans') {
|
||||
document.documentElement.style.setProperty('--themer-font', font);
|
||||
document.body.style.fontFamily = `"${font}", sans-serif`;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Apply saved theme mode
|
||||
if (layoutStore.theme) {
|
||||
theme.global.name.value = layoutStore.theme;
|
||||
}
|
||||
|
||||
applyThemeColors();
|
||||
applyFont();
|
||||
});
|
||||
|
||||
// Re-apply whenever tenant settings change (e.g. admin saves new brand colors)
|
||||
watch(() => tenantStore.settings, () => {
|
||||
applyThemeColors();
|
||||
applyFont();
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useTheme } from 'vuetify';
|
||||
import { useUserStore } from '@KTXC/stores/userStore';
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
||||
@@ -10,7 +9,6 @@ import defaultAvatar from '@KTXC/assets/images/users/avatar-1.png';
|
||||
|
||||
const { t } = useL10n('core');
|
||||
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const integrationStore = useIntegrationStore();
|
||||
@@ -27,12 +25,22 @@ const userName = computed(() => {
|
||||
});
|
||||
const userEmail = computed(() => userStore.getProfileField('email') || '');
|
||||
|
||||
// Theme toggle
|
||||
const isDarkMode = computed(() => theme.global.name.value === 'dark');
|
||||
const toggleTheme = () => {
|
||||
const newTheme = theme.global.name.value === 'light' ? 'dark' : 'light';
|
||||
theme.global.name.value = newTheme;
|
||||
layoutStore.setTheme(newTheme);
|
||||
// Theme mode cycle: light -> dark -> system, driven by the stored preference
|
||||
// (not the resolved theme name, which never reads 'system')
|
||||
const THEME_MODES = ['light', 'dark', 'system'] as const;
|
||||
type ThemeMode = (typeof THEME_MODES)[number];
|
||||
|
||||
const MODE_PRESENTATION: Record<ThemeMode, { icon: string; l10n: string; label: string }> = {
|
||||
light: { icon: 'mdi-weather-sunny', l10n: 'userMenu.lightMode', label: 'Light Mode' },
|
||||
dark: { icon: 'mdi-weather-night', l10n: 'userMenu.darkMode', label: 'Dark Mode' },
|
||||
system: { icon: 'mdi-theme-light-dark', l10n: 'userMenu.systemMode', label: 'System Mode' },
|
||||
};
|
||||
|
||||
const themeMode = computed(() => (layoutStore.theme ?? 'light') as ThemeMode);
|
||||
const nextThemeMode = computed(() => THEME_MODES[(THEME_MODES.indexOf(themeMode.value) + 1) % THEME_MODES.length]);
|
||||
const cycleTheme = () => {
|
||||
// App.vue watches layoutStore.theme and applies it via theme.change()
|
||||
layoutStore.setTheme(nextThemeMode.value);
|
||||
};
|
||||
|
||||
// Navigate to settings
|
||||
@@ -82,12 +90,12 @@ const goToSettings = () => {
|
||||
|
||||
<v-divider v-if="profileMenuItems.length" class="my-2" />
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<v-list-item @click="toggleTheme" color="primary" rounded="0">
|
||||
<!-- Theme Mode Cycle (light -> dark -> system) -->
|
||||
<v-list-item @click="cycleTheme" color="primary" rounded="0">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>{{ isDarkMode ? 'mdi-weather-sunny' : 'mdi-weather-night' }}</v-icon>
|
||||
<v-icon>{{ MODE_PRESENTATION[nextThemeMode].icon }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-h6">{{ isDarkMode ? t('userMenu.lightMode', 'Light Mode') : t('userMenu.darkMode', 'Dark Mode') }}</v-list-item-title>
|
||||
<v-list-item-title class="text-h6">{{ t(MODE_PRESENTATION[nextThemeMode].l10n, MODE_PRESENTATION[nextThemeMode].label) }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Go to Settings -->
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useModuleStore } from '@KTXC/stores/moduleStore'
|
||||
import { useTenantStore } from '@KTXC/stores/tenantStore'
|
||||
import { useUserStore } from '@KTXC/stores/userStore'
|
||||
import { useL10nStore } from '@KTXC/stores/l10nStore'
|
||||
import { useThemeStore } from '@KTXC/stores/themeStore'
|
||||
import { i18n } from '@KTXC/l10n/runtime'
|
||||
import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'
|
||||
import { FetchError } from '@KTXC/utils/helpers/fetch-wrapper-core'
|
||||
@@ -41,12 +42,14 @@ globalWindow.Pinia = PiniaLib as unknown
|
||||
const tenantStore = useTenantStore();
|
||||
const userStore = useUserStore();
|
||||
const l10nStore = useL10nStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
try {
|
||||
const payload = await fetchWrapper.get('/init');
|
||||
moduleStore.init(payload?.modules ?? {});
|
||||
tenantStore.init(payload?.tenant ?? null);
|
||||
userStore.init(payload?.user ?? {});
|
||||
themeStore.boot();
|
||||
|
||||
// Resolve locale and load catalogs before modules boot
|
||||
await l10nStore.init(payload?.l10n ?? null);
|
||||
|
||||
@@ -13,6 +13,8 @@ export { useUserStore } from '../stores/userStore'
|
||||
export { useIntegrationStore } from '../stores/integrationStore'
|
||||
export { useLayoutStore } from '../stores/layoutStore'
|
||||
export { useL10nStore } from '../stores/l10nStore'
|
||||
export { useThemeStore, DEFAULT_FONT, encodePaletteSet } from '../stores/themeStore'
|
||||
export type { ThemePalette, ThemePaletteSet, ThemeVariant } from '../stores/themeStore'
|
||||
|
||||
// Composables
|
||||
export { useL10n, t } from '../composables/useL10n'
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useUserStore } from './userStore';
|
||||
import { useTenantStore } from './tenantStore';
|
||||
|
||||
export type MenuMode = 'apps' | 'user-settings' | 'admin-settings';
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
export const useLayoutStore = defineStore('layout', () => {
|
||||
// Loading state
|
||||
@@ -10,13 +12,17 @@ export const useLayoutStore = defineStore('layout', () => {
|
||||
|
||||
// Sidebar state - initialize from settings or defaults
|
||||
const userStore = useUserStore();
|
||||
const tenantStore = useTenantStore();
|
||||
const sidebarDrawer = ref(userStore.getSetting('sidebar_drawer') ?? true);
|
||||
const miniSidebar = ref(userStore.getSetting('mini_sidebar') ?? false);
|
||||
const menuMode = ref<MenuMode>('apps');
|
||||
|
||||
// Theme state - initialize from settings or defaults
|
||||
const theme = ref(userStore.getSetting('theme') ?? 'light');
|
||||
const font = ref(userStore.getSetting('font') ?? 'Public sans');
|
||||
// Theme mode - user choice, falling back to the tenant default
|
||||
const theme = ref<ThemeMode>(
|
||||
(userStore.getSetting('theme') as ThemeMode | null) ??
|
||||
(tenantStore.getSetting('theme_default_mode') as ThemeMode | null) ??
|
||||
'light'
|
||||
);
|
||||
|
||||
// Watch and sync sidebar state to settings
|
||||
watch(sidebarDrawer, (value) => {
|
||||
@@ -31,10 +37,6 @@ export const useLayoutStore = defineStore('layout', () => {
|
||||
userStore.setSetting('theme', value);
|
||||
});
|
||||
|
||||
watch(font, (value) => {
|
||||
userStore.setSetting('font', value);
|
||||
});
|
||||
|
||||
// Actions
|
||||
function toggleSidebarDrawer() {
|
||||
sidebarDrawer.value = !sidebarDrawer.value;
|
||||
@@ -44,14 +46,10 @@ export const useLayoutStore = defineStore('layout', () => {
|
||||
miniSidebar.value = value;
|
||||
}
|
||||
|
||||
function setTheme(value: string) {
|
||||
function setTheme(value: ThemeMode) {
|
||||
theme.value = value;
|
||||
}
|
||||
|
||||
function setFont(value: string) {
|
||||
font.value = value;
|
||||
}
|
||||
|
||||
function setMenuMode(value: MenuMode) {
|
||||
menuMode.value = value;
|
||||
}
|
||||
@@ -71,14 +69,12 @@ export const useLayoutStore = defineStore('layout', () => {
|
||||
miniSidebar,
|
||||
menuMode,
|
||||
theme,
|
||||
font,
|
||||
|
||||
// Actions
|
||||
toggleSidebarDrawer,
|
||||
setMiniSidebar,
|
||||
setMenuMode,
|
||||
toggleMenuMode,
|
||||
setTheme,
|
||||
setFont
|
||||
setTheme
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { computed, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import vuetify from '@KTXC/plugins/vuetify/index';
|
||||
import { useUserStore } from './userStore';
|
||||
import { useTenantStore } from './tenantStore';
|
||||
import { useLayoutStore } from './layoutStore';
|
||||
|
||||
export const DEFAULT_FONT = 'Public Sans';
|
||||
|
||||
export type ThemeVariant = 'light' | 'dark';
|
||||
|
||||
/** Sparse palette overrides for one theme variant */
|
||||
export interface ThemePalette {
|
||||
colors: Record<string, string>;
|
||||
variables?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
/** The shape persisted in the `theme_palette` user/tenant setting */
|
||||
export type ThemePaletteSet = Partial<Record<ThemeVariant, ThemePalette>>;
|
||||
|
||||
interface VariantSnapshot {
|
||||
colors: Record<string, string>;
|
||||
variables: Record<string, string | number>;
|
||||
}
|
||||
|
||||
const VARIANTS: ThemeVariant[] = ['light', 'dark'];
|
||||
|
||||
export function encodePaletteSet(paletteSet: ThemePaletteSet | null): string | null {
|
||||
return paletteSet ? JSON.stringify(paletteSet) : null;
|
||||
}
|
||||
|
||||
function parsePaletteSet(value: unknown): ThemePaletteSet | null {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value) as ThemePaletteSet;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* System theming engine.
|
||||
*
|
||||
* Applies the appearance settings (mode, palette, font) persisted in the
|
||||
* user/tenant settings to the live Vuetify instance. Booted from private.ts
|
||||
* before app.mount(), so the first paint is already themed. Settings are
|
||||
* plain data — UI for editing them is provided by the themer module, which
|
||||
* only reads/writes settings and never touches Vuetify directly.
|
||||
*
|
||||
* Setting keys consumed:
|
||||
* - user: `theme` (light|dark|system), `theme_palette`, `theme_font`
|
||||
* - tenant: `theme_default_mode`, `theme_palette`, `theme_font`,
|
||||
* `theme_lock` (forces the tenant palette/font for all users)
|
||||
*/
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const userStore = useUserStore();
|
||||
const tenantStore = useTenantStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
|
||||
const theme = vuetify.theme;
|
||||
|
||||
// Frozen copies of the built-in palettes, captured before the first
|
||||
// apply(). Stored palettes are sparse and always merge over these, so no
|
||||
// token (grey scale, scrollbar, variables, ...) can vanish.
|
||||
const defaults: Record<ThemeVariant, VariantSnapshot> = {
|
||||
light: snapshotVariant('light'),
|
||||
dark: snapshotVariant('dark'),
|
||||
};
|
||||
|
||||
function snapshotVariant(variant: ThemeVariant): VariantSnapshot {
|
||||
const definition = theme.themes.value[variant];
|
||||
return Object.freeze({
|
||||
colors: Object.freeze({ ...(definition?.colors ?? {}) }) as Record<string, string>,
|
||||
variables: Object.freeze({ ...(definition?.variables ?? {}) }) as Record<string, string | number>,
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Effective settings (tenant lock > user choice > tenant default > built-in)
|
||||
// ===========================================================================
|
||||
|
||||
const isLocked = computed(() => tenantStore.getSetting('theme_lock') === true);
|
||||
|
||||
const effectivePalette = computed<ThemePaletteSet>(() => {
|
||||
const tenantPalette = parsePaletteSet(tenantStore.getSetting('theme_palette'));
|
||||
if (isLocked.value) return tenantPalette ?? {};
|
||||
const userPalette = parsePaletteSet(userStore.getSetting('theme_palette'));
|
||||
return userPalette ?? tenantPalette ?? {};
|
||||
});
|
||||
|
||||
const effectiveFont = computed<string>(() => {
|
||||
const tenantFont = tenantStore.getSetting('theme_font') as string | null;
|
||||
if (isLocked.value) return tenantFont ?? DEFAULT_FONT;
|
||||
const userFont = userStore.getSetting('theme_font') as string | null;
|
||||
return userFont ?? tenantFont ?? DEFAULT_FONT;
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Application
|
||||
// ===========================================================================
|
||||
|
||||
function mergeVariant(variant: ThemeVariant, palette: ThemePalette | undefined): VariantSnapshot {
|
||||
const overrides = palette?.colors ?? {};
|
||||
const colors: Record<string, string> = { ...defaults[variant].colors, ...overrides };
|
||||
// Any token overridden without an explicit foreground pair loses the
|
||||
// inherited `on-*` value, so Vuetify regenerates it with luma-based
|
||||
// contrast (arbitrary user colors stay readable).
|
||||
for (const token of Object.keys(overrides)) {
|
||||
if (!token.startsWith('on-') && !(`on-${token}` in overrides)) {
|
||||
delete colors[`on-${token}`];
|
||||
}
|
||||
}
|
||||
const variables = { ...defaults[variant].variables, ...(palette?.variables ?? {}) };
|
||||
return { colors, variables };
|
||||
}
|
||||
|
||||
function applyPalette(): void {
|
||||
const paletteSet = effectivePalette.value;
|
||||
for (const variant of VARIANTS) {
|
||||
const target = theme.themes.value[variant];
|
||||
if (!target) continue;
|
||||
const merged = mergeVariant(variant, paletteSet[variant]);
|
||||
// Wholesale assignment on the themes ref is reactive in Vuetify 4.0.x:
|
||||
// computedThemes/styles regenerate the theme stylesheet.
|
||||
target.colors = merged.colors as typeof target.colors;
|
||||
target.variables = merged.variables;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFont(font: string): void {
|
||||
if (font && font !== DEFAULT_FONT) {
|
||||
document.documentElement.style.setProperty('--themer-font', font);
|
||||
document.body.style.fontFamily = `"${font}", sans-serif`;
|
||||
} else {
|
||||
document.documentElement.style.removeProperty('--themer-font');
|
||||
document.body.style.fontFamily = '';
|
||||
}
|
||||
}
|
||||
|
||||
function applyMode(): void {
|
||||
// 'system' resolves via Vuetify's built-in prefers-color-scheme tracking
|
||||
theme.change(layoutStore.theme);
|
||||
}
|
||||
|
||||
let booted = false;
|
||||
|
||||
/** Called from private.ts after the settings stores init, before app.mount(). */
|
||||
function boot(): void {
|
||||
if (booted) return;
|
||||
booted = true;
|
||||
watch(effectivePalette, applyPalette);
|
||||
watch(effectiveFont, applyFont);
|
||||
watch(() => layoutStore.theme, applyMode);
|
||||
applyPalette();
|
||||
applyFont(effectiveFont.value);
|
||||
applyMode();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Preview themes (used by editing UIs; never persisted)
|
||||
// ===========================================================================
|
||||
|
||||
/** Register/update a throwaway named theme, merged over the built-in defaults. */
|
||||
function setPreviewTheme(name: string, variant: ThemeVariant, palette: ThemePalette): void {
|
||||
const merged = mergeVariant(variant, palette);
|
||||
theme.themes.value[name] = {
|
||||
dark: variant === 'dark',
|
||||
colors: merged.colors,
|
||||
variables: merged.variables,
|
||||
} as (typeof theme.themes.value)[string];
|
||||
}
|
||||
|
||||
function removePreviewTheme(name: string): void {
|
||||
delete theme.themes.value[name];
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
defaults,
|
||||
isLocked,
|
||||
effectivePalette,
|
||||
effectiveFont,
|
||||
|
||||
// Lifecycle
|
||||
boot,
|
||||
|
||||
// Preview support
|
||||
setPreviewTheme,
|
||||
removePreviewTheme,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user