Add default theme management to branding settings and admin interface
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m14s

This commit is contained in:
2026-02-27 15:54:41 +01:00
parent d7d7991ff0
commit 2762df3e57
6 changed files with 102 additions and 8 deletions

View File

@@ -76,12 +76,14 @@ function findLogoFile() {
router.get('/', async (req, res) => {
try {
const appName = await getSetting('app_name');
const defaultTheme = await getSetting('default_theme');
const logoFile = findLogoFile();
res.json({
appName: appName || 'Redlight',
hasLogo: !!logoFile,
logoUrl: logoFile ? '/api/branding/logo' : null,
defaultTheme: defaultTheme || null,
});
} catch (err) {
console.error('Get branding error:', err);
@@ -156,4 +158,19 @@ router.put('/name', authenticateToken, requireAdmin, async (req, res) => {
}
});
// PUT /api/branding/default-theme - Set default theme for unauthenticated pages (admin only)
router.put('/default-theme', authenticateToken, requireAdmin, async (req, res) => {
try {
const { defaultTheme } = req.body;
if (!defaultTheme || !defaultTheme.trim()) {
return res.status(400).json({ error: 'defaultTheme is required' });
}
await setSetting('default_theme', defaultTheme.trim());
res.json({ defaultTheme: defaultTheme.trim() });
} catch (err) {
console.error('Update default theme error:', err);
res.status(500).json({ error: 'Could not update default theme' });
}
});
export default router;

View File

@@ -1,23 +1,29 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import api from '../services/api';
import { useTheme } from './ThemeContext';
const BrandingContext = createContext();
export function BrandingProvider({ children }) {
const { applyBrandingDefault } = useTheme();
const [branding, setBranding] = useState({
appName: 'Redlight',
hasLogo: false,
logoUrl: null,
defaultTheme: null,
});
const fetchBranding = useCallback(async () => {
try {
const res = await api.get('/branding');
setBranding(res.data);
if (res.data.defaultTheme) {
applyBrandingDefault(res.data.defaultTheme);
}
} catch {
// keep defaults
}
}, []);
}, [applyBrandingDefault]);
useEffect(() => {
fetchBranding();

View File

@@ -4,20 +4,33 @@ const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setThemeState] = useState(() => {
return localStorage.getItem('theme') || 'dark';
// Personal preference > last known branding default > hardcoded fallback
return localStorage.getItem('theme') || localStorage.getItem('branding-default-theme') || 'dark';
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
// Called by user intentionally (Settings page etc.) — saves as personal preference
const setTheme = useCallback((newTheme) => {
localStorage.setItem('theme', newTheme);
setThemeState(newTheme);
}, []);
// Called by BrandingContext after loading the admin-configured default theme.
// Only takes effect when the user has no personal preference stored.
const applyBrandingDefault = useCallback((newDefault) => {
if (!newDefault) return;
localStorage.setItem('branding-default-theme', newDefault);
if (!localStorage.getItem('theme')) {
setThemeState(newDefault);
document.documentElement.setAttribute('data-theme', newDefault);
}
}, []);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<ThemeContext.Provider value={{ theme, setTheme, applyBrandingDefault }}>
{children}
</ThemeContext.Provider>
);

View File

@@ -306,7 +306,11 @@
"logoRemoveFailed": "Logo konnte nicht entfernt werden",
"appNameLabel": "App-Name",
"appNameUpdated": "App-Name aktualisiert",
"appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden"
"appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden",
"defaultThemeLabel": "Standard-Theme",
"defaultThemeDesc": "Wird für nicht angemeldete Seiten (Gast-Join, Login, Startseite) verwendet, wenn keine persönliche Einstellung gesetzt ist.",
"defaultThemeSaved": "Standard-Theme gespeichert",
"defaultThemeUpdateFailed": "Standard-Theme konnte nicht aktualisiert werden"
},
"federation": {
"inbox": "Einladungen",

View File

@@ -306,7 +306,11 @@
"logoRemoveFailed": "Could not remove logo",
"appNameLabel": "App name",
"appNameUpdated": "App name updated",
"appNameUpdateFailed": "Could not update app name"
"appNameUpdateFailed": "Could not update app name",
"defaultThemeLabel": "Default Theme",
"defaultThemeDesc": "Applied to unauthenticated pages (guest join, login, home) when no personal preference is set.",
"defaultThemeSaved": "Default theme saved",
"defaultThemeUpdateFailed": "Could not update default theme"
},
"federation": {
"inbox": "Invitations",

View File

@@ -3,18 +3,19 @@ import { useNavigate } from 'react-router-dom';
import {
Users, Shield, Search, Trash2, ChevronDown, Loader2,
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
Upload, X as XIcon, Image, Type,
Upload, X as XIcon, Image, Type, Palette,
} from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useBranding } from '../contexts/BrandingContext';
import { themes } from '../themes';
import api from '../services/api';
import toast from 'react-hot-toast';
export default function Admin() {
const { user } = useAuth();
const { t, language } = useLanguage();
const { appName, hasLogo, logoUrl, refreshBranding } = useBranding();
const { appName, hasLogo, logoUrl, defaultTheme, refreshBranding } = useBranding();
const navigate = useNavigate();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
@@ -31,6 +32,8 @@ export default function Admin() {
const [savingName, setSavingName] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const logoInputRef = useRef(null);
const [editDefaultTheme, setEditDefaultTheme] = useState('');
const [savingDefaultTheme, setSavingDefaultTheme] = useState(false);
useEffect(() => {
if (user?.role !== 'admin') {
@@ -44,6 +47,10 @@ export default function Admin() {
setEditAppName(appName || 'Redlight');
}, [appName]);
useEffect(() => {
setEditDefaultTheme(defaultTheme || 'dark');
}, [defaultTheme]);
const fetchUsers = async () => {
try {
const res = await api.get('/admin/users');
@@ -135,6 +142,20 @@ export default function Admin() {
}
};
const handleDefaultThemeSave = async () => {
if (!editDefaultTheme) return;
setSavingDefaultTheme(true);
try {
await api.put('/branding/default-theme', { defaultTheme: editDefaultTheme });
toast.success(t('admin.defaultThemeSaved'));
refreshBranding();
} catch {
toast.error(t('admin.defaultThemeUpdateFailed'));
} finally {
setSavingDefaultTheme(false);
}
};
const handleCreateUser = async (e) => {
e.preventDefault();
setCreatingUser(true);
@@ -266,6 +287,35 @@ export default function Admin() {
</div>
</div>
</div>
{/* Default theme */}
<div className="mt-6 pt-6 border-t border-th-border">
<div className="flex items-center gap-2 mb-1">
<Palette size={16} className="text-th-accent" />
<label className="block text-sm font-medium text-th-text">{t('admin.defaultThemeLabel')}</label>
</div>
<p className="text-xs text-th-text-s mb-3">{t('admin.defaultThemeDesc')}</p>
<div className="flex items-center gap-2">
<select
value={editDefaultTheme}
onChange={e => setEditDefaultTheme(e.target.value)}
className="input-field text-sm flex-1"
>
{themes.map(th => (
<option key={th.id} value={th.id}>
{th.name} ({th.type === 'light' ? t('themes.light') : t('themes.dark')})
</option>
))}
</select>
<button
onClick={handleDefaultThemeSave}
disabled={savingDefaultTheme || editDefaultTheme === (defaultTheme || 'dark')}
className="btn-primary text-sm px-4 flex-shrink-0"
>
{savingDefaultTheme ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button>
</div>
</div>
</div>
{/* Search */}