diff --git a/server/routes/branding.js b/server/routes/branding.js index 47a51a3..c09a334 100644 --- a/server/routes/branding.js +++ b/server/routes/branding.js @@ -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; diff --git a/src/contexts/BrandingContext.jsx b/src/contexts/BrandingContext.jsx index cc9f8d6..ec62b65 100644 --- a/src/contexts/BrandingContext.jsx +++ b/src/contexts/BrandingContext.jsx @@ -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(); diff --git a/src/contexts/ThemeContext.jsx b/src/contexts/ThemeContext.jsx index 835e1e4..7a12f96 100644 --- a/src/contexts/ThemeContext.jsx +++ b/src/contexts/ThemeContext.jsx @@ -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 ( - + {children} ); diff --git a/src/i18n/de.json b/src/i18n/de.json index 9c317b1..72cc07b 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -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", diff --git a/src/i18n/en.json b/src/i18n/en.json index 1c9b4f6..553ca29 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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", diff --git a/src/pages/Admin.jsx b/src/pages/Admin.jsx index cf6de24..4e60804 100644 --- a/src/pages/Admin.jsx +++ b/src/pages/Admin.jsx @@ -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() { + + {/* Default theme */} +
+
+ + +
+

{t('admin.defaultThemeDesc')}

+
+ + +
+
{/* Search */}