Add default theme management to branding settings and admin interface
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m14s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m14s
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user