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

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