Files
MindSift/frontend/src/lib/theme.ts
T
sandrodachandClaude Opus 5.5 bf0d68e7e1
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m21s
feat(themes): custom CSS themes per user (upload, paste, live)
- Theme manager in settings: upload a .css file or paste CSS, template
  download/copy/insert, edit and delete; custom themes show up in the
  gallery and the quick switchers
- Themes are injected live as <style id="mindsift-custom-theme">; the
  active one is cached so it applies on boot without flashing
- Backend: user_themes table and GET/POST /themes, DELETE /themes/{id}
  (per user, max 200 KB)
- Docs: theming, REST, database

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:12:27 +01:00

175 lines
5.7 KiB
TypeScript

import type { UserTheme } from './types';
// Built-in themes are compiled CSS files (src/themes/); custom themes are user CSS from the server,
// injected at runtime and addressed as "custom:<id>"
export type ThemeId = string;
export interface ThemeOption {
id: ThemeId;
name: string;
subtitle: string;
file: string;
previewBg: string;
previewPanel: string;
previewAccent: string;
previewText: string;
isRounded: boolean;
isCustom?: boolean;
}
export const THEME_STORAGE_KEY = 'mindsift_active_theme';
// CSS of the active custom theme, so it applies on boot before the API answers (no flash)
const CUSTOM_CSS_CACHE_KEY = 'mindsift_custom_theme_css';
const CUSTOM_STYLE_ID = 'mindsift-custom-theme';
export const CUSTOM_THEME_PREFIX = 'custom:';
export const AVAILABLE_THEMES: ThemeOption[] = [
{
id: 'dark',
name: 'MindSift Dark (Standard)',
subtitle: 'Monochrom, dunkel & eckig (0px)',
file: 'src/themes/theme-dark.css',
previewBg: '#121316',
previewPanel: '#1a1c20',
previewAccent: '#2563eb',
previewText: '#f4f4f5',
isRounded: false,
},
{
id: 'light',
name: 'MindSift Light (Tagmodus)',
subtitle: 'Helles Papier-Design & klare Kontraste',
file: 'src/themes/theme-light.css',
previewBg: '#f3f4f6',
previewPanel: '#ffffff',
previewAccent: '#2563eb',
previewText: '#111827',
isRounded: false,
},
{
id: 'high-contrast',
name: 'High Contrast (Scharf)',
subtitle: 'Tiefschwarz (#000) & weiße Konturen',
file: 'src/themes/theme-high-contrast.css',
previewBg: '#000000',
previewPanel: '#09090b',
previewAccent: '#38bdf8',
previewText: '#ffffff',
isRounded: false,
},
{
id: 'soft',
name: 'Soft Slate (Abgerundet)',
subtitle: 'Schiefer-Blau mit weichen Radien (10px)',
file: 'src/themes/theme-soft.css',
previewBg: '#0f172a',
previewPanel: '#1e293b',
previewAccent: '#3b82f6',
previewText: '#f8fafc',
isRounded: true,
},
];
export const isCustomThemeId = (id: ThemeId) => id.startsWith(CUSTOM_THEME_PREFIX);
export const customThemeId = (t: UserTheme): ThemeId => CUSTOM_THEME_PREFIX + t.id;
// Comments are ignored when reading the CSS (the template mentions [data-theme="mein-theme"] in one)
const stripComments = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, '');
const readCssVar = (css: string, name: string): string | undefined =>
stripComments(css).match(new RegExp(`--${name}\\s*:\\s*([^;]+);`))?.[1].trim();
// The CSS decides its own [data-theme="…"]; without one it targets plain :root
const themeAttribute = (css: string): string =>
stripComments(css).match(/data-theme\s*=\s*["']?([\w-]+)/)?.[1] || 'custom';
// Light background -> no Tailwind dark class
function isLightColor(color?: string): boolean {
const hex = color?.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
if (!hex) return false;
const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 140;
}
export function customThemeOption(t: UserTheme): ThemeOption {
const radius = readCssVar(t.css, 'radius-card');
return {
id: customThemeId(t),
name: t.name,
subtitle: 'Eigenes Theme',
file: 'Eigenes Theme (gespeichert auf dem Server)',
previewBg: readCssVar(t.css, 'bg-app') || '#121316',
previewPanel: readCssVar(t.css, 'bg-panel') || '#1a1c20',
previewAccent: readCssVar(t.css, 'accent-primary') || '#2563eb',
previewText: readCssVar(t.css, 'text-main') || '#f4f4f5',
isRounded: !!radius && !/^0(px)?$/.test(radius),
isCustom: true,
};
}
function injectCustomCss(css: string | null): void {
if (typeof document === 'undefined') return;
let el = document.getElementById(CUSTOM_STYLE_ID) as HTMLStyleElement | null;
if (css === null) {
el?.remove();
return;
}
if (!el) {
el = document.createElement('style');
el.id = CUSTOM_STYLE_ID;
}
el.textContent = css;
// Last in <head>, so it wins over the compiled themes
document.head.appendChild(el);
}
function applyToDocument(themeId: ThemeId, customCss: string | null): void {
if (typeof document === 'undefined') return;
injectCustomCss(customCss);
const root = document.documentElement;
const attr = customCss !== null ? themeAttribute(customCss) : themeId;
root.setAttribute('data-theme', attr);
const light = customCss !== null ? isLightColor(readCssVar(customCss, 'bg-app')) : themeId === 'light';
root.classList.toggle('dark', !light);
}
function store(key: string, value: string | null): void {
try {
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
} catch {
// ignore
}
}
function load(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
export function getActiveTheme(): ThemeId {
const saved = load(THEME_STORAGE_KEY);
if (saved && AVAILABLE_THEMES.some((t) => t.id === saved)) return saved;
if (saved && isCustomThemeId(saved) && load(CUSTOM_CSS_CACHE_KEY)) return saved;
return 'dark';
}
// customCss is required for custom themes and ignored for built-in ones
export function setActiveTheme(themeId: ThemeId, customCss?: string): void {
const css = isCustomThemeId(themeId) ? customCss ?? null : null;
const effectiveId = isCustomThemeId(themeId) && css === null ? 'dark' : themeId;
store(THEME_STORAGE_KEY, effectiveId);
store(CUSTOM_CSS_CACHE_KEY, css);
applyToDocument(effectiveId, css);
}
export function initThemeOnBoot(): ThemeId {
const active = getActiveTheme();
applyToDocument(active, isCustomThemeId(active) ? load(CUSTOM_CSS_CACHE_KEY) : null);
return active;
}