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

@@ -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>
)}