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

+2
View File
@@ -30,6 +30,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| GET | `/stats` | Zähler fürs Dashboard |
| GET | `/search?q=` | Suche in eigenen Gedanken und Notizen |
| GET | `/export` | Datenexport als JSON |
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
| DELETE | `/themes/{id}` | Eigenes Theme löschen |
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
## Gedanken, Triage, Aufgaben
+1
View File
@@ -12,6 +12,7 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation) |
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
| `revoked_tokens` | Sperrliste ausgeloggter JWTs | `token_hash`, `expires_at` |
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
+18 -1
View File
@@ -104,7 +104,24 @@ Das standardmäßige Marken-Icon von MindSift (`frontend/public/icon.svg`, PWA-I
## 🛠️ 5. Ein eigenes Theme erstellen
Um ein neues Theme (z.B. „Nordic Blue" oder „Cyberpunk") hinzuzufügen:
### Ohne Rebuild: Theme-Manager in den Kontoeinstellungen
Unter **Kontoeinstellungen → Darstellung & Themes** kann jeder Benutzer eigene Themes anlegen:
- **Vorlage** herunterladen, kopieren oder direkt ins Eingabefeld einfügen (`theme-template.css`).
- CSS als **`.css`-Datei hochladen** oder **einfügen**, Namen vergeben, „Speichern & aktivieren“.
- Eigene Themes erscheinen als Kacheln in der Galerie und im Profil-Dropdown, mit **Bearbeiten** und **Löschen**.
Technik:
- Gespeichert pro Benutzer in der Tabelle `user_themes` (API `GET/POST /themes`, `DELETE /themes/{id}`, max. 200 KB), also im Daten-Volume und auf allen Geräten verfügbar.
- Aktiv gesetzt wird ein eigenes Theme als `custom:<id>`. [`theme.ts`](../frontend/src/lib/theme.ts) setzt das CSS live als `<style id="mindsift-custom-theme">` ans Ende von `<head>` (gewinnt damit gegen die eingebauten Themes) und übernimmt den Wert aus dem Selektor `[data-theme="…"]` des CSS für `<html data-theme>`. Ohne solchen Selektor gilt das CSS für `:root`. Kommentare werden dabei ignoriert.
- Ob die Tailwind-Klasse `dark` gesetzt wird, entscheidet die Helligkeit von `--bg-app`.
- Das CSS des aktiven Themes liegt zusätzlich im `localStorage`, damit es beim Start sofort greift; nach dem Login wird es mit dem Server abgeglichen. Wird es gelöscht, fällt die Ansicht auf „Dark“ zurück.
### Eingebautes Theme (mit Rebuild)
Um ein neues Theme (z.B. „Nordic Blue" oder „Cyberpunk") fest in MindSift einzubauen:
1. Kopiere `frontend/src/themes/theme-template.css` nach `frontend/src/themes/theme-meintheme.css`.
2. Ändere den Selektor `:root[data-theme="template"]` in `:root[data-theme="meintheme"]`.
3. Passe Farben und Rundungen nach Belieben an.
+34 -6
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Layers,
CheckSquare,
@@ -24,7 +24,7 @@ import {
Mic,
MicOff,
} from 'lucide-react';
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User } from './lib/types';
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types';
import {
api,
clearToken,
@@ -36,7 +36,9 @@ import {
} from './lib/api';
import {
AVAILABLE_THEMES,
customThemeOption,
initThemeOnBoot,
isCustomThemeId,
setActiveTheme as applyTheme,
ThemeId,
} from './lib/theme';
@@ -115,6 +117,8 @@ export const App: React.FC = () => {
instanceName: 'MindSift',
});
const [user, setUser] = useState<User | null>(null);
const [customThemes, setCustomThemes] = useState<UserTheme[]>([]);
const allThemes = useMemo(() => [...AVAILABLE_THEMES, ...customThemes.map(customThemeOption)], [customThemes]);
const [stats, setStats] = useState<DashboardStats>({
inboxCount: 0,
tasksOpenCount: 0,
@@ -416,12 +420,33 @@ export const App: React.FC = () => {
setShowProfileMenu(false);
};
const activateTheme = (themeId: ThemeId, themes: UserTheme[]) => {
const css = themes.find((t) => 'custom:' + t.id === themeId)?.css;
applyTheme(themeId, css);
setActiveThemeState(isCustomThemeId(themeId) && css === undefined ? 'dark' : themeId);
};
const handleChangeTheme = (themeId: ThemeId) => {
triggerHaptic(10);
applyTheme(themeId);
setActiveThemeState(themeId);
activateTheme(themeId, customThemes);
};
// Custom themes live on the server; after login refresh them and the active one (it may have
// been edited or deleted on another device)
const handleCustomThemesChange = (themes: UserTheme[], activate?: ThemeId) => {
setCustomThemes(themes);
const next = activate ?? activeTheme;
if (activate || isCustomThemeId(next)) activateTheme(next, themes);
};
useEffect(() => {
if (!user?.id) return;
api
.getThemes()
.then((themes) => handleCustomThemesChange(themes))
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id]);
const refreshAll = useCallback(async () => {
try {
const [meRes, deckRes, listsRes] = await Promise.all([
@@ -770,7 +795,7 @@ export const App: React.FC = () => {
<span>Design-Theme</span>
</span>
<div className="grid grid-cols-2 gap-1">
{AVAILABLE_THEMES.map((t) => (
{allThemes.map((t) => (
<button
key={t.id}
type="button"
@@ -1009,7 +1034,10 @@ export const App: React.FC = () => {
<UserSettingsView
currentUser={user}
activeTheme={activeTheme}
themes={allThemes}
customThemes={customThemes}
onThemeChange={handleChangeTheme}
onCustomThemesChange={handleCustomThemesChange}
onUserUpdated={(u) => {
setUser(u);
refreshAll();
@@ -1320,7 +1348,7 @@ export const App: React.FC = () => {
<span>Design-Theme</span>
</span>
<div className="grid grid-cols-2 gap-2">
{AVAILABLE_THEMES.map((t) => (
{allThemes.map((t) => (
<button
key={t.id}
type="button"
@@ -17,16 +17,22 @@ import {
Palette,
Upload,
Camera,
Pencil,
Copy,
} from 'lucide-react';
import { APIKey, User } from '../../lib/types';
import { APIKey, User, UserTheme } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme';
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
import themeTemplateCss from '../../themes/theme-template.css?raw';
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
interface UserSettingsViewProps {
currentUser: User;
activeTheme: ThemeId;
themes: ThemeOption[]; // built-in + custom
customThemes: UserTheme[];
onThemeChange: (themeId: ThemeId) => void;
onCustomThemesChange: (themes: UserTheme[], activate?: ThemeId) => void;
onUserUpdated: (u: User) => void;
onBack: () => void;
}
@@ -36,12 +42,98 @@ type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks';
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
currentUser,
activeTheme,
themes,
customThemes,
onThemeChange,
onCustomThemesChange,
onUserUpdated,
onBack,
}) => {
const [section, setSection] = useState<UserSettingsSection>('profile');
// --- Custom Theme Editor State ---
const [themeEditId, setThemeEditId] = useState<string | null>(null); // null = new theme
const [themeName, setThemeName] = useState('');
const [themeCss, setThemeCss] = useState('');
const [themeMsg, setThemeMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingTheme, setSavingTheme] = useState(false);
const [deletingThemeId, setDeletingThemeId] = useState<string | null>(null);
const themeFileRef = useRef<HTMLInputElement>(null);
const resetThemeEditor = () => {
setThemeEditId(null);
setThemeName('');
setThemeCss('');
};
const handleThemeFile = async (file?: File) => {
if (!file) return;
const text = await file.text();
setThemeCss(text);
if (!themeName.trim()) setThemeName(file.name.replace(/\.css$/i, '').replace(/^theme-/, ''));
setThemeMsg(null);
};
const handleDownloadTemplate = () => {
const url = URL.createObjectURL(new Blob([themeTemplateCss], { type: 'text/css' }));
const a = document.createElement('a');
a.href = url;
a.download = 'theme-template.css';
a.click();
URL.revokeObjectURL(url);
};
const handleCopyTemplate = async () => {
try {
await navigator.clipboard.writeText(themeTemplateCss);
setThemeMsg({ type: 'ok', text: 'Vorlage in die Zwischenablage kopiert.' });
} catch {
setThemeMsg({ type: 'err', text: 'Kopieren nicht möglich – nutze „Vorlage einfügen“ oder den Download.' });
}
};
// Saving activates the theme right away (injected live, no reload)
const handleSaveTheme = async (e: React.FormEvent) => {
e.preventDefault();
setSavingTheme(true);
setThemeMsg(null);
try {
const saved = await api.saveTheme({ id: themeEditId || undefined, name: themeName.trim(), css: themeCss });
const next = themeEditId
? customThemes.map((t) => (t.id === saved.id ? saved : t))
: [...customThemes, saved];
onCustomThemesChange(next, customThemeId(saved));
setThemeMsg({ type: 'ok', text: `Theme „${saved.name}“ gespeichert und aktiviert.` });
resetThemeEditor();
} catch (err: any) {
setThemeMsg({ type: 'err', text: err.message || 'Theme konnte nicht gespeichert werden' });
} finally {
setSavingTheme(false);
}
};
const handleEditTheme = (t: UserTheme) => {
setThemeEditId(t.id);
setThemeName(t.name);
setThemeCss(t.css);
setThemeMsg(null);
};
const handleDeleteTheme = async (t: UserTheme) => {
try {
await api.deleteTheme(t.id);
setDeletingThemeId(null);
if (themeEditId === t.id) resetThemeEditor();
// Deleting the active theme falls back to the default one
onCustomThemesChange(
customThemes.filter((x) => x.id !== t.id),
activeTheme === customThemeId(t) ? 'dark' : undefined
);
} catch (err: any) {
setThemeMsg({ type: 'err', text: err.message || 'Theme konnte nicht gelöscht werden' });
}
};
// --- Profile State ---
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
const [email, setEmail] = useState(currentUser.email || '');
@@ -591,14 +683,15 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{AVAILABLE_THEMES.map((t) => {
{themes.map((t) => {
const isSelected = activeTheme === t.id;
const custom = customThemes.find((c) => customThemeId(c) === t.id);
return (
<div key={t.id} className="flex flex-col">
<button
key={t.id}
type="button"
onClick={() => onThemeChange(t.id)}
className={`p-4 text-left border transition-all flex flex-col justify-between gap-3 ${
className={`flex-1 p-4 text-left border transition-all flex flex-col justify-between gap-3 ${
isSelected
? 'bg-canvas border-accent shadow-sm'
: 'bg-canvas border-border hover:border-strong'
@@ -654,23 +747,130 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<code className="text-[10px] font-mono text-faint">{t.file}</code>
</div>
</button>
{custom && (
<div className="flex items-center justify-end gap-1.5 px-2 py-1.5 border border-t-0 border-border bg-canvas">
{deletingThemeId === custom.id ? (
<>
<span className="flex-1 text-[11px] text-muted">Theme wirklich löschen?</span>
<button onClick={() => handleDeleteTheme(custom)} className="btn-primary h-7 px-2.5 text-[11px]">
Löschen
</button>
<button onClick={() => setDeletingThemeId(null)} className="btn-action h-7 px-2 text-[11px]">
Abbrechen
</button>
</>
) : (
<>
<button onClick={() => handleEditTheme(custom)} className="btn-icon" title="Theme bearbeiten">
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setDeletingThemeId(custom.id)}
className="btn-icon btn-icon-danger"
title="Theme löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
)}
</div>
);
})}
</div>
<div className="p-3.5 bg-canvas border border-border space-y-1.5">
<p className="font-semibold text-main">Eigenes Theme anpassen oder hinzufügen:</p>
{/* Custom theme: upload or paste CSS, based on the template */}
<form onSubmit={handleSaveTheme} className="p-4 bg-canvas border border-border space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-semibold text-main">
{themeEditId ? 'Eigenes Theme bearbeiten' : 'Eigenes Theme hinzufügen'}
</p>
<div className="flex flex-wrap gap-1.5">
<button type="button" onClick={handleDownloadTemplate} className="btn-action h-7 px-2 text-[11px] flex items-center gap-1">
<Download className="w-3 h-3" />
<span>Vorlage herunterladen</span>
</button>
<button type="button" onClick={handleCopyTemplate} className="btn-action h-7 px-2 text-[11px] flex items-center gap-1">
<Copy className="w-3 h-3" />
<span>Vorlage kopieren</span>
</button>
<button
type="button"
onClick={() => setThemeCss(themeTemplateCss)}
className="btn-action h-7 px-2 text-[11px]"
>
Vorlage einfügen
</button>
</div>
</div>
<p className="text-muted text-[11px] leading-relaxed">
Alle Theme-Dateien liegen unter{' '}
<code className="font-mono text-accent">frontend/src/themes/</code> (inklusive der
Kopiervorlage{' '}
<code className="font-mono text-accent">theme-template.css</code>). Dort sind alle
Eigenschaften in 6 Blöcken gruppiert: <strong>1. Akzentfarbe</strong>,{' '}
<strong>2. Oberflächen</strong>, <strong>3. Textfarben</strong>,{' '}
<strong>4. Rahmen &amp; Schatten</strong>, <strong>5. Statusfarben</strong> und{' '}
<strong>6. Rundungen</strong>.
Ein Theme ist CSS mit 6 Blöcken: <strong>1. Akzentfarbe</strong>, <strong>2. Oberflächen</strong>,{' '}
<strong>3. Textfarben</strong>, <strong>4. Rahmen &amp; Schatten</strong>, <strong>5. Statusfarben</strong> und{' '}
<strong>6. Rundungen</strong>. Am einfachsten startest du mit der Vorlage und änderst die Werte. Gespeicherte
Themes liegen auf dem Server und stehen dir auf allen Geräten zur Verfügung.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={themeName}
onChange={(e) => setThemeName(e.target.value)}
placeholder="Name, z. B. Wald"
maxLength={60}
className="flex-1 min-w-0 h-8 px-2.5 input-field"
/>
<button
type="button"
onClick={() => themeFileRef.current?.click()}
className="btn-action h-8 px-3 text-xs flex items-center justify-center gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
<span>CSS-Datei hochladen</span>
</button>
<input
ref={themeFileRef}
type="file"
accept=".css,text/css"
className="hidden"
onChange={(e) => {
handleThemeFile(e.target.files?.[0]);
e.target.value = '';
}}
/>
</div>
<textarea
value={themeCss}
onChange={(e) => setThemeCss(e.target.value)}
placeholder=":root[data-theme=&quot;mein-theme&quot;] { --accent-primary: #15803d; … }"
rows={10}
spellCheck={false}
className="w-full px-2.5 py-2 input-field font-mono text-[11px] leading-relaxed resize-y"
/>
{themeMsg && (
<p className={`text-[11px] ${themeMsg.type === 'ok' ? 'text-status-ok' : 'text-status-err'}`}>
{themeMsg.text}
</p>
)}
<div className="flex justify-end gap-2">
{themeEditId && (
<button type="button" onClick={resetThemeEditor} className="btn-action h-8 px-3 text-xs">
Abbrechen
</button>
)}
<button
type="submit"
disabled={savingTheme || !themeName.trim() || !themeCss.trim()}
className="btn-primary h-8 px-4 text-xs font-semibold disabled:opacity-50"
>
{savingTheme ? 'Speichere…' : 'Speichern & aktivieren'}
</button>
</div>
</form>
</div>
)}
+55
View File
@@ -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
View File
@@ -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
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;
}
+9
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+46
View File
@@ -110,6 +110,9 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/stats", s.handleStats)
r.Get("/api/v1/search", s.handleSearch)
r.Get("/api/v1/export", s.handleExport)
r.Get("/api/v1/themes", s.handleGetThemes)
r.Post("/api/v1/themes", s.handleSaveTheme)
r.Delete("/api/v1/themes/{id}", s.handleDeleteTheme)
// Instant Capture & Thoughts
r.Post("/api/v1/inbox", s.handleCaptureInbox)
@@ -2049,6 +2052,49 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
})
}
// --- CUSTOM THEMES ---
func (s *Server) handleGetThemes(w http.ResponseWriter, r *http.Request) {
themes, err := s.store.GetUserThemes(auth.GetUserID(r.Context()))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, themes)
}
// handleSaveTheme creates a theme, or updates it when an id of the caller's theme is given.
func (s *Server) handleSaveTheme(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
Name string `json:"name"`
CSS string `json:"css"`
}
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxThemeCSSBytes+16*1024)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage oder Theme zu groß")
return
}
theme, err := s.store.SaveUserTheme(auth.GetUserID(r.Context()), strings.TrimSpace(req.ID), req.Name, req.CSS)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
status := http.StatusCreated
if req.ID != "" {
status = http.StatusOK
}
writeJSON(w, status, theme)
}
func (s *Server) handleDeleteTheme(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteUserTheme(auth.GetUserID(r.Context()), chi.URLParam(r, "id")); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
exportData, err := s.store.ExportUserData(userID)
+9
View File
@@ -449,6 +449,15 @@ type SharedList struct {
CreatedAt string `json:"createdAt"`
}
// UserTheme is a custom CSS theme of one user.
type UserTheme struct {
ID string `json:"id"`
Name string `json:"name"`
CSS string `json:"css"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type APIKey struct {
ID string `json:"id"`
UserID string `json:"userId"`
+12
View File
@@ -368,6 +368,18 @@ func (s *Store) migrate() error {
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at);
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_user ON revoked_tokens(user_id);
-- Custom CSS themes uploaded or pasted by a user (stored in the volume, survive updates)
CREATE TABLE IF NOT EXISTS user_themes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
css_content TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_themes_user ON user_themes(user_id);
`
if _, err := s.db.Exec(schema); err != nil {
return err
+83
View File
@@ -0,0 +1,83 @@
package storage
import (
"errors"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
// MaxThemeCSSBytes limits a custom theme; the shipped themes are around 3 KB.
const MaxThemeCSSBytes = 200 * 1024
// GetUserThemes returns the user's own CSS themes, oldest first.
func (s *Store) GetUserThemes(userID string) ([]domain.UserTheme, error) {
rows, err := s.db.Query(
`SELECT id, name, css_content, created_at, updated_at FROM user_themes WHERE user_id = ? ORDER BY created_at ASC`,
userID,
)
if err != nil {
return []domain.UserTheme{}, err
}
defer rows.Close()
themes := make([]domain.UserTheme, 0)
for rows.Next() {
var t domain.UserTheme
if rows.Scan(&t.ID, &t.Name, &t.CSS, &t.CreatedAt, &t.UpdatedAt) == nil {
themes = append(themes, t)
}
}
return themes, nil
}
// SaveUserTheme creates a theme (empty themeID) or updates one of the user's themes.
func (s *Store) SaveUserTheme(userID, themeID, name, css string) (*domain.UserTheme, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("Name des Themes ist erforderlich")
}
if len([]rune(name)) > 60 {
return nil, errors.New("Name des Themes ist zu lang (max. 60 Zeichen)")
}
if strings.TrimSpace(css) == "" {
return nil, errors.New("Das Theme enthält kein CSS")
}
if len(css) > MaxThemeCSSBytes {
return nil, errors.New("Das Theme ist zu groß (max. 200 KB)")
}
now := domain.NowISO()
if themeID == "" {
t := &domain.UserTheme{ID: domain.NewID("thm"), Name: name, CSS: css, CreatedAt: now, UpdatedAt: now}
if _, err := s.db.Exec(
`INSERT INTO user_themes (id, user_id, name, css_content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
t.ID, userID, t.Name, t.CSS, t.CreatedAt, t.UpdatedAt,
); err != nil {
return nil, err
}
return t, nil
}
res, err := s.db.Exec(
`UPDATE user_themes SET name = ?, css_content = ?, updated_at = ? WHERE id = ? AND user_id = ?`,
name, css, now, themeID, userID,
)
if err != nil {
return nil, err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil, errors.New("Theme nicht gefunden")
}
t := &domain.UserTheme{ID: themeID, Name: name, CSS: css, UpdatedAt: now}
_ = s.db.QueryRow(`SELECT created_at FROM user_themes WHERE id = ?`, themeID).Scan(&t.CreatedAt)
return t, nil
}
func (s *Store) DeleteUserTheme(userID, themeID string) error {
res, err := s.db.Exec(`DELETE FROM user_themes WHERE id = ? AND user_id = ?`, themeID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return errors.New("Theme nicht gefunden")
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package storage
import (
"path/filepath"
"strings"
"testing"
)
func TestUserThemesArePrivateAndLimited(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "themes.db"))
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
css := `:root[data-theme="wald"] { --accent-primary: #15803d; }`
th, err := store.SaveUserTheme(anna.ID, "", "Wald", css)
if err != nil {
t.Fatalf("SaveUserTheme failed: %v", err)
}
if _, err := store.SaveUserTheme(anna.ID, th.ID, "Wald 2", css); err != nil {
t.Fatalf("update failed: %v", err)
}
if themes, _ := store.GetUserThemes(anna.ID); len(themes) != 1 || themes[0].Name != "Wald 2" {
t.Fatalf("expected one updated theme, got %+v", themes)
}
// Other users neither see, change nor delete it
if themes, _ := store.GetUserThemes(ben.ID); len(themes) != 0 {
t.Errorf("Ben must not see Anna's themes")
}
if _, err := store.SaveUserTheme(ben.ID, th.ID, "Gekapert", css); err == nil {
t.Errorf("Ben must not update Anna's theme")
}
if err := store.DeleteUserTheme(ben.ID, th.ID); err == nil {
t.Errorf("Ben must not delete Anna's theme")
}
if _, err := store.SaveUserTheme(anna.ID, "", "Riesig", strings.Repeat("a", MaxThemeCSSBytes+1)); err == nil {
t.Errorf("oversized theme must be rejected")
}
if _, err := store.SaveUserTheme(anna.ID, "", " ", css); err == nil {
t.Errorf("theme without name must be rejected")
}
if err := store.DeleteUserTheme(anna.ID, th.ID); err != nil {
t.Fatalf("delete failed: %v", err)
}
}