feat: theming
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -4,17 +4,68 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import { onMounted } from 'vue';
|
||||
import { onMounted, watch } from 'vue';
|
||||
import { useTheme } from 'vuetify';
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply saved theme on mount
|
||||
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,15 +0,0 @@
|
||||
export type ConfigProps = {
|
||||
Sidebar_drawer: boolean;
|
||||
mini_sidebar: boolean;
|
||||
actTheme: string;
|
||||
fontTheme: string;
|
||||
};
|
||||
|
||||
const config: ConfigProps = {
|
||||
Sidebar_drawer: true,
|
||||
mini_sidebar: false,
|
||||
actTheme: 'light',
|
||||
fontTheme: 'Public sans'
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import config from '@KTXC/config';
|
||||
import { useUserStore } from './userStore';
|
||||
|
||||
export type MenuMode = 'apps' | 'user-settings' | 'admin-settings';
|
||||
@@ -9,15 +8,15 @@ export const useLayoutStore = defineStore('layout', () => {
|
||||
// Loading state
|
||||
const isLoading = ref(false);
|
||||
|
||||
// Sidebar state - initialize from settings or config
|
||||
// Sidebar state - initialize from settings or defaults
|
||||
const userStore = useUserStore();
|
||||
const sidebarDrawer = ref(userStore.getSetting('sidebar_drawer') ?? config.Sidebar_drawer);
|
||||
const miniSidebar = ref(userStore.getSetting('mini_sidebar') ?? config.mini_sidebar);
|
||||
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 config
|
||||
const theme = ref(userStore.getSetting('theme') ?? config.actTheme);
|
||||
const font = ref(userStore.getSetting('font') ?? config.fontTheme);
|
||||
// Theme state - initialize from settings or defaults
|
||||
const theme = ref(userStore.getSetting('theme') ?? 'light');
|
||||
const font = ref(userStore.getSetting('font') ?? 'Public sans');
|
||||
|
||||
// Watch and sync sidebar state to settings
|
||||
watch(sidebarDrawer, (value) => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper';
|
||||
|
||||
export interface TenantState {
|
||||
id: string | null;
|
||||
@@ -6,22 +8,117 @@ export interface TenantState {
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export const useTenantStore = defineStore('tenantStore', {
|
||||
state: () => ({
|
||||
tenant: null as TenantState | null,
|
||||
}),
|
||||
actions: {
|
||||
init(tenant: Partial<TenantState> | null) {
|
||||
this.tenant = tenant
|
||||
? {
|
||||
id: tenant.id ?? null,
|
||||
domain: tenant.domain ?? null,
|
||||
label: tenant.label ?? null,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
reset() {
|
||||
this.tenant = null;
|
||||
},
|
||||
},
|
||||
export interface TenantData extends TenantState {
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Flush pending settings writes before the page unloads
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
useTenantStore().flushSettings();
|
||||
});
|
||||
}
|
||||
|
||||
export const useTenantStore = defineStore('tenantStore', () => {
|
||||
// =========================================================================
|
||||
// State
|
||||
// =========================================================================
|
||||
|
||||
const tenant = ref<TenantState | null>(null);
|
||||
const settings = ref<Record<string, unknown>>({});
|
||||
|
||||
// Pending batch for debounced writes
|
||||
let _pendingSettings: Record<string, unknown> = {};
|
||||
let _debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// =========================================================================
|
||||
// Getters
|
||||
// =========================================================================
|
||||
|
||||
function getSetting(key: string): unknown {
|
||||
return settings.value[key] ?? null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Settings actions
|
||||
// =========================================================================
|
||||
|
||||
function setSetting(key: string, value: unknown): void {
|
||||
settings.value[key] = value;
|
||||
|
||||
// Batch writes with a 500 ms debounce — same pattern as userService
|
||||
_pendingSettings[key] = value;
|
||||
|
||||
if (_debounceTimer !== null) {
|
||||
clearTimeout(_debounceTimer);
|
||||
}
|
||||
|
||||
_debounceTimer = setTimeout(() => {
|
||||
_flush();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function _flush(): Promise<void> {
|
||||
if (Object.keys(_pendingSettings).length === 0) return;
|
||||
|
||||
const payload = { ..._pendingSettings };
|
||||
_pendingSettings = {};
|
||||
_debounceTimer = null;
|
||||
|
||||
try {
|
||||
await fetchWrapper.patch('/tenant/settings', { data: payload });
|
||||
} catch (error) {
|
||||
console.error('Failed to save tenant settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Force-flush any pending settings writes (called on beforeunload). */
|
||||
async function flushSettings(): Promise<void> {
|
||||
if (_debounceTimer !== null) {
|
||||
clearTimeout(_debounceTimer);
|
||||
_debounceTimer = null;
|
||||
}
|
||||
await _flush();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Initialise from /init endpoint
|
||||
// =========================================================================
|
||||
|
||||
function init(tenantData: Partial<TenantData> | null): void {
|
||||
tenant.value = tenantData
|
||||
? {
|
||||
id: tenantData.id ?? null,
|
||||
domain: tenantData.domain ?? null,
|
||||
label: tenantData.label ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
settings.value = (tenantData?.settings as Record<string, unknown>) ?? {};
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
tenant.value = null;
|
||||
settings.value = {};
|
||||
_pendingSettings = {};
|
||||
if (_debounceTimer !== null) {
|
||||
clearTimeout(_debounceTimer);
|
||||
_debounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
tenant,
|
||||
settings,
|
||||
|
||||
// Getters
|
||||
getSetting,
|
||||
|
||||
// Actions
|
||||
setSetting,
|
||||
flushSettings,
|
||||
init,
|
||||
reset,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user