feat(themes): custom CSS themes per user (upload, paste, live)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m21s
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:
1 parent
84395f3322
commit
bf0d68e7e1
15 files changed
+653
-51
No files matched your search
+99
-28
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user