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
@@ -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 & 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 & 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="mein-theme"] { --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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user