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
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { setActiveTheme, initThemeOnBoot, customThemeOption, getActiveTheme } from '../theme';
|
||||
|
||||
const lightCss = `:root[data-theme="papier"] { --bg-app: #fafafa; --accent-primary: #15803d; --radius-card: 8px; }`;
|
||||
|
||||
describe('custom themes', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.getElementById('mindsift-custom-theme')?.remove();
|
||||
});
|
||||
|
||||
it('injects the CSS live and uses its data-theme', () => {
|
||||
setActiveTheme('custom:thm_1', lightCss);
|
||||
const style = document.getElementById('mindsift-custom-theme');
|
||||
expect(style?.textContent).toBe(lightCss);
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('papier');
|
||||
// light background -> no Tailwind dark class
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores data-theme mentioned in comments (like in the template)', () => {
|
||||
setActiveTheme('custom:thm_2', `/* benenne [data-theme="mein-theme"] um */\n:root[data-theme="wald"] { --bg-app: #0b1a10; }`);
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('wald');
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
it('restores the active custom theme on boot from the cache', () => {
|
||||
setActiveTheme('custom:thm_1', lightCss);
|
||||
document.getElementById('mindsift-custom-theme')?.remove();
|
||||
expect(initThemeOnBoot()).toBe('custom:thm_1');
|
||||
expect(document.getElementById('mindsift-custom-theme')?.textContent).toBe(lightCss);
|
||||
});
|
||||
|
||||
it('removes the custom CSS when switching back to a built-in theme', () => {
|
||||
setActiveTheme('custom:thm_1', lightCss);
|
||||
setActiveTheme('dark');
|
||||
expect(document.getElementById('mindsift-custom-theme')).toBeNull();
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
expect(getActiveTheme()).toBe('dark');
|
||||
});
|
||||
|
||||
it('falls back to dark when a custom theme has no CSS', () => {
|
||||
setActiveTheme('custom:gone');
|
||||
expect(getActiveTheme()).toBe('dark');
|
||||
});
|
||||
|
||||
it('builds gallery previews from the CSS variables', () => {
|
||||
const opt = customThemeOption({ id: 'thm_1', name: 'Papier', css: lightCss, createdAt: '', updatedAt: '' });
|
||||
expect(opt.id).toBe('custom:thm_1');
|
||||
expect(opt.previewBg).toBe('#fafafa');
|
||||
expect(opt.previewAccent).toBe('#15803d');
|
||||
expect(opt.isRounded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SpaceShare,
|
||||
SystemSettings,
|
||||
SharingMode,
|
||||
UserTheme,
|
||||
Team,
|
||||
TaskList,
|
||||
TaskListShare,
|
||||
@@ -1009,6 +1010,23 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
// --- CUSTOM THEMES ---
|
||||
async getThemes(): Promise<UserTheme[]> {
|
||||
return ensureArray(await request<UserTheme[]>('/api/v1/themes'));
|
||||
},
|
||||
|
||||
// Creates a theme, or updates it when `id` is given
|
||||
async saveTheme(theme: { id?: string; name: string; css: string }): Promise<UserTheme> {
|
||||
return request<UserTheme>('/api/v1/themes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(theme),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteTheme(id: string): Promise<void> {
|
||||
await request(`/api/v1/themes/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- API KEYS, WEBHOOKS & EXPORT ---
|
||||
async getAPIKeys(): Promise<APIKey[]> {
|
||||
const res = await request<APIKey[]>('/api/v1/keys');
|
||||
|
||||
+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;
|
||||
}
|
||||
@@ -390,6 +390,15 @@ export interface SharedList {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Custom CSS theme of the current user (Benutzereinstellungen > Darstellung)
|
||||
export interface UserTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
css: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface APIKey {
|
||||
id: string;
|
||||
userId: string;
|
||||
|
||||
Reference in new issue
Block a user