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
@@ -30,6 +30,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| GET | `/stats` | Zähler fürs Dashboard |
|
| GET | `/stats` | Zähler fürs Dashboard |
|
||||||
| GET | `/search?q=` | Suche in eigenen Gedanken und Notizen |
|
| GET | `/search?q=` | Suche in eigenen Gedanken und Notizen |
|
||||||
| GET | `/export` | Datenexport als JSON |
|
| 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) |
|
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
|
||||||
|
|
||||||
## Gedanken, Triage, Aufgaben
|
## Gedanken, Triage, Aufgaben
|
||||||
|
|||||||
@@ -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) |
|
| `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` |
|
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
|
||||||
| `revoked_tokens` | Sperrliste ausgeloggter JWTs | `token_hash`, `expires_at` |
|
| `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` |
|
| `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_*` |
|
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -104,7 +104,24 @@ Das standardmäßige Marken-Icon von MindSift (`frontend/public/icon.svg`, PWA-I
|
|||||||
|
|
||||||
## 🛠️ 5. Ein eigenes Theme erstellen
|
## 🛠️ 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`.
|
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"]`.
|
2. Ändere den Selektor `:root[data-theme="template"]` in `:root[data-theme="meintheme"]`.
|
||||||
3. Passe Farben und Rundungen nach Belieben an.
|
3. Passe Farben und Rundungen nach Belieben an.
|
||||||
|
|||||||
+34
-6
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Layers,
|
Layers,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
Mic,
|
Mic,
|
||||||
MicOff,
|
MicOff,
|
||||||
} from 'lucide-react';
|
} 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 {
|
import {
|
||||||
api,
|
api,
|
||||||
clearToken,
|
clearToken,
|
||||||
@@ -36,7 +36,9 @@ import {
|
|||||||
} from './lib/api';
|
} from './lib/api';
|
||||||
import {
|
import {
|
||||||
AVAILABLE_THEMES,
|
AVAILABLE_THEMES,
|
||||||
|
customThemeOption,
|
||||||
initThemeOnBoot,
|
initThemeOnBoot,
|
||||||
|
isCustomThemeId,
|
||||||
setActiveTheme as applyTheme,
|
setActiveTheme as applyTheme,
|
||||||
ThemeId,
|
ThemeId,
|
||||||
} from './lib/theme';
|
} from './lib/theme';
|
||||||
@@ -115,6 +117,8 @@ export const App: React.FC = () => {
|
|||||||
instanceName: 'MindSift',
|
instanceName: 'MindSift',
|
||||||
});
|
});
|
||||||
const [user, setUser] = useState<User | null>(null);
|
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>({
|
const [stats, setStats] = useState<DashboardStats>({
|
||||||
inboxCount: 0,
|
inboxCount: 0,
|
||||||
tasksOpenCount: 0,
|
tasksOpenCount: 0,
|
||||||
@@ -416,12 +420,33 @@ export const App: React.FC = () => {
|
|||||||
setShowProfileMenu(false);
|
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) => {
|
const handleChangeTheme = (themeId: ThemeId) => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
applyTheme(themeId);
|
activateTheme(themeId, customThemes);
|
||||||
setActiveThemeState(themeId);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 () => {
|
const refreshAll = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [meRes, deckRes, listsRes] = await Promise.all([
|
const [meRes, deckRes, listsRes] = await Promise.all([
|
||||||
@@ -770,7 +795,7 @@ export const App: React.FC = () => {
|
|||||||
<span>Design-Theme</span>
|
<span>Design-Theme</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="grid grid-cols-2 gap-1">
|
<div className="grid grid-cols-2 gap-1">
|
||||||
{AVAILABLE_THEMES.map((t) => (
|
{allThemes.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1009,7 +1034,10 @@ export const App: React.FC = () => {
|
|||||||
<UserSettingsView
|
<UserSettingsView
|
||||||
currentUser={user}
|
currentUser={user}
|
||||||
activeTheme={activeTheme}
|
activeTheme={activeTheme}
|
||||||
|
themes={allThemes}
|
||||||
|
customThemes={customThemes}
|
||||||
onThemeChange={handleChangeTheme}
|
onThemeChange={handleChangeTheme}
|
||||||
|
onCustomThemesChange={handleCustomThemesChange}
|
||||||
onUserUpdated={(u) => {
|
onUserUpdated={(u) => {
|
||||||
setUser(u);
|
setUser(u);
|
||||||
refreshAll();
|
refreshAll();
|
||||||
@@ -1320,7 +1348,7 @@ export const App: React.FC = () => {
|
|||||||
<span>Design-Theme</span>
|
<span>Design-Theme</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{AVAILABLE_THEMES.map((t) => (
|
{allThemes.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -17,16 +17,22 @@ import {
|
|||||||
Palette,
|
Palette,
|
||||||
Upload,
|
Upload,
|
||||||
Camera,
|
Camera,
|
||||||
|
Pencil,
|
||||||
|
Copy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { APIKey, User } from '../../lib/types';
|
import { APIKey, User, UserTheme } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
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';
|
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
||||||
|
|
||||||
interface UserSettingsViewProps {
|
interface UserSettingsViewProps {
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
activeTheme: ThemeId;
|
activeTheme: ThemeId;
|
||||||
|
themes: ThemeOption[]; // built-in + custom
|
||||||
|
customThemes: UserTheme[];
|
||||||
onThemeChange: (themeId: ThemeId) => void;
|
onThemeChange: (themeId: ThemeId) => void;
|
||||||
|
onCustomThemesChange: (themes: UserTheme[], activate?: ThemeId) => void;
|
||||||
onUserUpdated: (u: User) => void;
|
onUserUpdated: (u: User) => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
@@ -36,12 +42,98 @@ type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks';
|
|||||||
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||||
currentUser,
|
currentUser,
|
||||||
activeTheme,
|
activeTheme,
|
||||||
|
themes,
|
||||||
|
customThemes,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
|
onCustomThemesChange,
|
||||||
onUserUpdated,
|
onUserUpdated,
|
||||||
onBack,
|
onBack,
|
||||||
}) => {
|
}) => {
|
||||||
const [section, setSection] = useState<UserSettingsSection>('profile');
|
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 ---
|
// --- Profile State ---
|
||||||
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
|
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
|
||||||
const [email, setEmail] = useState(currentUser.email || '');
|
const [email, setEmail] = useState(currentUser.email || '');
|
||||||
@@ -591,14 +683,15 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<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 isSelected = activeTheme === t.id;
|
||||||
|
const custom = customThemes.find((c) => customThemeId(c) === t.id);
|
||||||
return (
|
return (
|
||||||
|
<div key={t.id} className="flex flex-col">
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onThemeChange(t.id)}
|
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
|
isSelected
|
||||||
? 'bg-canvas border-accent shadow-sm'
|
? 'bg-canvas border-accent shadow-sm'
|
||||||
: 'bg-canvas border-border hover:border-strong'
|
: '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>
|
<code className="text-[10px] font-mono text-faint">{t.file}</code>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="p-3.5 bg-canvas border border-border space-y-1.5">
|
{/* Custom theme: upload or paste CSS, based on the template */}
|
||||||
<p className="font-semibold text-main">Eigenes Theme anpassen oder hinzufügen:</p>
|
<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">
|
<p className="text-muted text-[11px] leading-relaxed">
|
||||||
Alle Theme-Dateien liegen unter{' '}
|
Ein Theme ist CSS mit 6 Blöcken: <strong>1. Akzentfarbe</strong>, <strong>2. Oberflächen</strong>,{' '}
|
||||||
<code className="font-mono text-accent">frontend/src/themes/</code> (inklusive der
|
<strong>3. Textfarben</strong>, <strong>4. Rahmen & Schatten</strong>, <strong>5. Statusfarben</strong> und{' '}
|
||||||
Kopiervorlage{' '}
|
<strong>6. Rundungen</strong>. Am einfachsten startest du mit der Vorlage und änderst die Werte. Gespeicherte
|
||||||
<code className="font-mono text-accent">theme-template.css</code>). Dort sind alle
|
Themes liegen auf dem Server und stehen dir auf allen Geräten zur Verfügung.
|
||||||
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>.
|
|
||||||
</p>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
SpaceShare,
|
||||||
SystemSettings,
|
SystemSettings,
|
||||||
SharingMode,
|
SharingMode,
|
||||||
|
UserTheme,
|
||||||
Team,
|
Team,
|
||||||
TaskList,
|
TaskList,
|
||||||
TaskListShare,
|
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 ---
|
// --- API KEYS, WEBHOOKS & EXPORT ---
|
||||||
async getAPIKeys(): Promise<APIKey[]> {
|
async getAPIKeys(): Promise<APIKey[]> {
|
||||||
const res = await request<APIKey[]>('/api/v1/keys');
|
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 {
|
export interface ThemeOption {
|
||||||
id: ThemeId;
|
id: ThemeId;
|
||||||
@@ -10,9 +14,14 @@ export interface ThemeOption {
|
|||||||
previewAccent: string;
|
previewAccent: string;
|
||||||
previewText: string;
|
previewText: string;
|
||||||
isRounded: boolean;
|
isRounded: boolean;
|
||||||
|
isCustom?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const THEME_STORAGE_KEY = 'mindsift_active_theme';
|
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[] = [
|
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 {
|
try {
|
||||||
const saved = localStorage.getItem(THEME_STORAGE_KEY) as ThemeId | null;
|
if (value === null) localStorage.removeItem(key);
|
||||||
if (saved && AVAILABLE_THEMES.some((t) => t.id === saved)) {
|
else localStorage.setItem(key, value);
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// 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';
|
return 'dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setActiveTheme(themeId: ThemeId): void {
|
// customCss is required for custom themes and ignored for built-in ones
|
||||||
try {
|
export function setActiveTheme(themeId: ThemeId, customCss?: string): void {
|
||||||
localStorage.setItem(THEME_STORAGE_KEY, themeId);
|
const css = isCustomThemeId(themeId) ? customCss ?? null : null;
|
||||||
} catch {
|
const effectiveId = isCustomThemeId(themeId) && css === null ? 'dark' : themeId;
|
||||||
// ignore
|
store(THEME_STORAGE_KEY, effectiveId);
|
||||||
}
|
store(CUSTOM_CSS_CACHE_KEY, css);
|
||||||
if (typeof document !== 'undefined') {
|
applyToDocument(effectiveId, css);
|
||||||
document.documentElement.setAttribute('data-theme', themeId);
|
|
||||||
if (themeId !== 'light') {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initThemeOnBoot(): ThemeId {
|
export function initThemeOnBoot(): ThemeId {
|
||||||
const active = getActiveTheme();
|
const active = getActiveTheme();
|
||||||
if (typeof document !== 'undefined') {
|
applyToDocument(active, isCustomThemeId(active) ? load(CUSTOM_CSS_CACHE_KEY) : null);
|
||||||
document.documentElement.setAttribute('data-theme', active);
|
|
||||||
if (active !== 'light') {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return active;
|
return active;
|
||||||
}
|
}
|
||||||
@@ -390,6 +390,15 @@ export interface SharedList {
|
|||||||
createdAt: string;
|
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 {
|
export interface APIKey {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -110,6 +110,9 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Get("/api/v1/stats", s.handleStats)
|
r.Get("/api/v1/stats", s.handleStats)
|
||||||
r.Get("/api/v1/search", s.handleSearch)
|
r.Get("/api/v1/search", s.handleSearch)
|
||||||
r.Get("/api/v1/export", s.handleExport)
|
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
|
// Instant Capture & Thoughts
|
||||||
r.Post("/api/v1/inbox", s.handleCaptureInbox)
|
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) {
|
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
exportData, err := s.store.ExportUserData(userID)
|
exportData, err := s.store.ExportUserData(userID)
|
||||||
|
|||||||
@@ -449,6 +449,15 @@ type SharedList struct {
|
|||||||
CreatedAt string `json:"createdAt"`
|
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 {
|
type APIKey struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
|
|||||||
@@ -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_expires ON revoked_tokens(expires_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_user ON revoked_tokens(user_id);
|
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 {
|
if _, err := s.db.Exec(schema); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user