feat(themes): custom CSS themes per user (upload, paste, live)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m21s

- 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>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 22:12:27 +01:00
1 parent 84395f3322
commit bf0d68e7e1
15 files changed
+653 -51

No files matched your search

+99 -28
View File
@@ -1,4 +1,8 @@
export type ThemeId = 'dark' | 'light' | 'high-contrast' | 'soft';
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;
@@ -10,9 +14,14 @@ export interface ThemeOption {
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[] = [
{
@@ -61,43 +70,105 @@ export const AVAILABLE_THEMES: ThemeOption[] = [
},
];
export function getActiveTheme(): ThemeId {
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 {
const saved = localStorage.getItem(THEME_STORAGE_KEY) as ThemeId | null;
if (saved && AVAILABLE_THEMES.some((t) => t.id === saved)) {
return saved;
}
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';
}
export function setActiveTheme(themeId: ThemeId): void {
try {
localStorage.setItem(THEME_STORAGE_KEY, themeId);
} catch {
// ignore
}
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', themeId);
if (themeId !== 'light') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('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();
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', active);
if (active !== 'light') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
applyToDocument(active, isCustomThemeId(active) ? load(CUSTOM_CSS_CACHE_KEY) : null);
return active;
}