Phase 0 Complete: Tasks 0.7-0.20
- 0.7: UI-Design-Richtlinien (docs/ui-design-guidelines.md, 535 lines) - 0.8: Theme-Customization Backend (4 theme fields, migration 0023) - 0.9: Theme-Customization Frontend (SettingsTheme.tsx, themeStore.ts, live preview) - 0.10: RBAC-Audit (4 plugins secured, 53 routes with require_permission) - 0.11: LiteLLM-Cleanup (llm_client.py migrated from httpx to litellm) - 0.12: KI-Agent-Framework docs (plugin-development-guide.md, agent_capabilities field) - 0.13: Heartbeat configurable (ProactiveSettings, migration 0024, frontend UI) - 0.14: Unified Search Field-Level RBAC (resolve_permissions + filter_fields_by_permission) - 0.15: Undo/History-System (EntityHistory model, service, routes, migration 0025, HistoryViewer) - 0.16: Storage Backend (LocalStorage + S3Storage, DMS/attachments/mail updated) - 0.17: Import/Export unified Contact fields (firstname, surname, email_1, phone_1) - 0.18: .gitignore & Config-Cleanup (webui→frontend, python-jose removed, .env untracked) - 0.19: Mail-Salt Security-Fix (per-account random salt, migration 0026) - 0.20: AGPL replaced (PyMuPDF→pypdf, OnlyOffice→Collabora, LICENSE + THIRD_PARTY_LICENSES.md)
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Theme store — manages dynamic CSS variables for theme customization.
|
||||
* Applies primary_color, accent_color, font_family, border_radius
|
||||
* from system settings to CSS custom properties on :root.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface ThemeConfig {
|
||||
primaryColor: string;
|
||||
accentColor: string;
|
||||
fontFamily: string;
|
||||
borderRadius: string;
|
||||
darkMode: boolean;
|
||||
}
|
||||
|
||||
interface ThemeState extends ThemeConfig {
|
||||
setTheme: (config: Partial<ThemeConfig>) => void;
|
||||
toggleDarkMode: () => void;
|
||||
applyTheme: () => void;
|
||||
loadFromStorage: () => void;
|
||||
saveToStorage: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_THEME: ThemeConfig = {
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#d946ef',
|
||||
fontFamily: 'Inter',
|
||||
borderRadius: '0.5rem',
|
||||
darkMode: false,
|
||||
};
|
||||
|
||||
/** Convert hex color to RGB values for Tailwind CSS variable injection */
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Generate a Tailwind-style color scale (50-900) from a base hex color */
|
||||
function generateColorScale(hex: string): Record<string, string> {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return {};
|
||||
|
||||
// Mix with white for lighter shades, with black for darker
|
||||
const mix = (base: number, amount: number, target: number) =>
|
||||
Math.round(base + (target - base) * amount);
|
||||
|
||||
const scales: Record<number, [number, number]> = {
|
||||
50: [0.95, 0], // 95% white
|
||||
100: [0.90, 0],
|
||||
200: [0.80, 0],
|
||||
300: [0.60, 0],
|
||||
400: [0.30, 0],
|
||||
500: [0, 0], // base color
|
||||
600: [0, 0.10], // 10% black
|
||||
700: [0, 0.20],
|
||||
800: [0, 0.30],
|
||||
900: [0, 0.40],
|
||||
};
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const [scale, [whiteAmt, blackAmt]] of Object.entries(scales)) {
|
||||
const r = whiteAmt > 0 ? mix(rgb.r, whiteAmt, 255) : mix(rgb.r, blackAmt, 0);
|
||||
const g = whiteAmt > 0 ? mix(rgb.g, whiteAmt, 255) : mix(rgb.g, blackAmt, 0);
|
||||
const b = whiteAmt > 0 ? mix(rgb.b, whiteAmt, 255) : mix(rgb.b, blackAmt, 0);
|
||||
result[scale] = `rgb(${r} ${g} ${b})`;
|
||||
}
|
||||
result.DEFAULT = hex;
|
||||
return result;
|
||||
}
|
||||
|
||||
function applyCSSVariables(config: ThemeConfig) {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply primary color scale
|
||||
const primaryScale = generateColorScale(config.primaryColor);
|
||||
for (const [key, val] of Object.entries(primaryScale)) {
|
||||
root.style.setProperty(`--color-primary-${key.toLowerCase()}`, val);
|
||||
}
|
||||
|
||||
// Apply accent color scale
|
||||
const accentScale = generateColorScale(config.accentColor);
|
||||
for (const [key, val] of Object.entries(accentScale)) {
|
||||
root.style.setProperty(`--color-accent-${key.toLowerCase()}`, val);
|
||||
}
|
||||
|
||||
// Apply font family
|
||||
root.style.setProperty('--font-sans', `'${config.fontFamily}', system-ui, sans-serif`);
|
||||
|
||||
// Apply border radius
|
||||
root.style.setProperty('--radius-md', config.borderRadius);
|
||||
|
||||
// Apply dark mode
|
||||
if (config.darkMode) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'leocrm-theme';
|
||||
|
||||
export const useThemeStore = create<ThemeState>((set, get) => ({
|
||||
...DEFAULT_THEME,
|
||||
|
||||
setTheme: (config) => {
|
||||
set((state) => ({ ...state, ...config }));
|
||||
get().applyTheme();
|
||||
get().saveToStorage();
|
||||
},
|
||||
|
||||
toggleDarkMode: () => {
|
||||
set((state) => ({ ...state, darkMode: !state.darkMode }));
|
||||
get().applyTheme();
|
||||
get().saveToStorage();
|
||||
},
|
||||
|
||||
applyTheme: () => {
|
||||
const state = get();
|
||||
applyCSSVariables({
|
||||
primaryColor: state.primaryColor,
|
||||
accentColor: state.accentColor,
|
||||
fontFamily: state.fontFamily,
|
||||
borderRadius: state.borderRadius,
|
||||
darkMode: state.darkMode,
|
||||
});
|
||||
},
|
||||
|
||||
loadFromStorage: () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const config = JSON.parse(stored) as Partial<ThemeConfig>;
|
||||
set((state) => ({ ...state, ...config }));
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
get().applyTheme();
|
||||
},
|
||||
|
||||
saveToStorage: () => {
|
||||
const state = get();
|
||||
const { setTheme, toggleDarkMode, applyTheme, loadFromStorage, saveToStorage, ...config } = state;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user