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) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const appName = await getSetting('app_name');
|
const appName = await getSetting('app_name');
|
||||||
|
const defaultTheme = await getSetting('default_theme');
|
||||||
const logoFile = findLogoFile();
|
const logoFile = findLogoFile();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
appName: appName || 'Redlight',
|
appName: appName || 'Redlight',
|
||||||
hasLogo: !!logoFile,
|
hasLogo: !!logoFile,
|
||||||
logoUrl: logoFile ? '/api/branding/logo' : null,
|
logoUrl: logoFile ? '/api/branding/logo' : null,
|
||||||
|
defaultTheme: defaultTheme || null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Get branding error:', 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;
|
export default router;
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
import { useTheme } from './ThemeContext';
|
||||||
|
|
||||||
const BrandingContext = createContext();
|
const BrandingContext = createContext();
|
||||||
|
|
||||||
export function BrandingProvider({ children }) {
|
export function BrandingProvider({ children }) {
|
||||||
|
const { applyBrandingDefault } = useTheme();
|
||||||
const [branding, setBranding] = useState({
|
const [branding, setBranding] = useState({
|
||||||
appName: 'Redlight',
|
appName: 'Redlight',
|
||||||
hasLogo: false,
|
hasLogo: false,
|
||||||
logoUrl: null,
|
logoUrl: null,
|
||||||
|
defaultTheme: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchBranding = useCallback(async () => {
|
const fetchBranding = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/branding');
|
const res = await api.get('/branding');
|
||||||
setBranding(res.data);
|
setBranding(res.data);
|
||||||
|
if (res.data.defaultTheme) {
|
||||||
|
applyBrandingDefault(res.data.defaultTheme);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// keep defaults
|
// keep defaults
|
||||||
}
|
}
|
||||||
}, []);
|
}, [applyBrandingDefault]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBranding();
|
fetchBranding();
|
||||||
|
|||||||
@@ -4,20 +4,33 @@ const ThemeContext = createContext(null);
|
|||||||
|
|
||||||
export function ThemeProvider({ children }) {
|
export function ThemeProvider({ children }) {
|
||||||
const [theme, setThemeState] = useState(() => {
|
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(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
localStorage.setItem('theme', theme);
|
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
|
// Called by user intentionally (Settings page etc.) — saves as personal preference
|
||||||
const setTheme = useCallback((newTheme) => {
|
const setTheme = useCallback((newTheme) => {
|
||||||
|
localStorage.setItem('theme', newTheme);
|
||||||
setThemeState(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 (
|
return (
|
||||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
<ThemeContext.Provider value={{ theme, setTheme, applyBrandingDefault }}>
|
||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -306,7 +306,11 @@
|
|||||||
"logoRemoveFailed": "Logo konnte nicht entfernt werden",
|
"logoRemoveFailed": "Logo konnte nicht entfernt werden",
|
||||||
"appNameLabel": "App-Name",
|
"appNameLabel": "App-Name",
|
||||||
"appNameUpdated": "App-Name aktualisiert",
|
"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": {
|
"federation": {
|
||||||
"inbox": "Einladungen",
|
"inbox": "Einladungen",
|
||||||
|
|||||||
@@ -306,7 +306,11 @@
|
|||||||
"logoRemoveFailed": "Could not remove logo",
|
"logoRemoveFailed": "Could not remove logo",
|
||||||
"appNameLabel": "App name",
|
"appNameLabel": "App name",
|
||||||
"appNameUpdated": "App name updated",
|
"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": {
|
"federation": {
|
||||||
"inbox": "Invitations",
|
"inbox": "Invitations",
|
||||||
|
|||||||
@@ -3,18 +3,19 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
||||||
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
||||||
Upload, X as XIcon, Image, Type,
|
Upload, X as XIcon, Image, Type, Palette,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { useBranding } from '../contexts/BrandingContext';
|
import { useBranding } from '../contexts/BrandingContext';
|
||||||
|
import { themes } from '../themes';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { t, language } = useLanguage();
|
const { t, language } = useLanguage();
|
||||||
const { appName, hasLogo, logoUrl, refreshBranding } = useBranding();
|
const { appName, hasLogo, logoUrl, defaultTheme, refreshBranding } = useBranding();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -31,6 +32,8 @@ export default function Admin() {
|
|||||||
const [savingName, setSavingName] = useState(false);
|
const [savingName, setSavingName] = useState(false);
|
||||||
const [uploadingLogo, setUploadingLogo] = useState(false);
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||||
const logoInputRef = useRef(null);
|
const logoInputRef = useRef(null);
|
||||||
|
const [editDefaultTheme, setEditDefaultTheme] = useState('');
|
||||||
|
const [savingDefaultTheme, setSavingDefaultTheme] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.role !== 'admin') {
|
if (user?.role !== 'admin') {
|
||||||
@@ -44,6 +47,10 @@ export default function Admin() {
|
|||||||
setEditAppName(appName || 'Redlight');
|
setEditAppName(appName || 'Redlight');
|
||||||
}, [appName]);
|
}, [appName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditDefaultTheme(defaultTheme || 'dark');
|
||||||
|
}, [defaultTheme]);
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/admin/users');
|
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) => {
|
const handleCreateUser = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setCreatingUser(true);
|
setCreatingUser(true);
|
||||||
@@ -266,6 +287,35 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
|
|||||||
Reference in New Issue
Block a user