add branding option
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m10s

This commit is contained in:
2026-02-24 19:43:59 +01:00
parent d8dcb6e628
commit cd98ee4cc7
18 changed files with 545 additions and 53 deletions

View File

@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './contexts/AuthContext';
import { useLanguage } from './contexts/LanguageContext';
import { useBranding } from './contexts/BrandingContext';
import Layout from './components/Layout';
import ProtectedRoute from './components/ProtectedRoute';
import Home from './pages/Home';
@@ -16,6 +17,7 @@ import GuestJoin from './pages/GuestJoin';
export default function App() {
const { user, loading } = useAuth();
const { setLanguage } = useLanguage();
const { appName } = useBranding();
// Sync language from server when user loads
useEffect(() => {
@@ -24,6 +26,11 @@ export default function App() {
}
}, [user?.language, setLanguage]);
// Update document title with branding
useEffect(() => {
document.title = `${appName} - BigBlueButton Frontend`;
}, [appName]);
if (loading) {
return (
<div className="min-h-screen bg-th-bg flex items-center justify-center">

View File

@@ -0,0 +1,35 @@
import { Video } from 'lucide-react';
import { useBranding } from '../contexts/BrandingContext';
const sizes = {
sm: { box: 'w-8 h-8', rounded: 'rounded-lg', icon: 16, text: 'text-lg' },
md: { box: 'w-9 h-9', rounded: 'rounded-lg', icon: 20, text: 'text-xl' },
lg: { box: 'w-10 h-10', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' },
};
export default function BrandLogo({ size = 'md', className = '' }) {
const { appName, hasLogo, logoUrl } = useBranding();
const s = sizes[size] || sizes.md;
if (hasLogo && logoUrl) {
return (
<div className={`flex items-center gap-2.5 ${className}`}>
<img
src={logoUrl}
alt={appName}
className={`${s.box} ${s.rounded} object-contain`}
/>
<span className={`${s.text} font-bold gradient-text`}>{appName}</span>
</div>
);
}
return (
<div className={`flex items-center gap-2.5 ${className}`}>
<div className={`${s.box} gradient-bg ${s.rounded} flex items-center justify-center`}>
<Video size={s.icon} className="text-white" />
</div>
<span className={`${s.text} font-bold gradient-text`}>{appName}</span>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, Settings, Shield, Video, X, Palette } from 'lucide-react';
import { LayoutDashboard, Settings, Shield, X, Palette } from 'lucide-react';
import BrandLogo from './BrandLogo';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import ThemeSelector from './ThemeSelector';
@@ -36,14 +37,7 @@ export default function Sidebar({ open, onClose }) {
<div className="flex flex-col h-full">
{/* Logo */}
<div className="flex items-center justify-between h-16 px-4 border-b border-th-border">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 gradient-bg rounded-lg flex items-center justify-center">
<Video size={18} className="text-white" />
</div>
<div>
<h1 className="text-lg font-bold gradient-text">Redlight</h1>
</div>
</div>
<BrandLogo size="sm" />
<button
onClick={onClose}
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"

View File

@@ -0,0 +1,37 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import api from '../services/api';
const BrandingContext = createContext();
export function BrandingProvider({ children }) {
const [branding, setBranding] = useState({
appName: 'Redlight',
hasLogo: false,
logoUrl: null,
});
const fetchBranding = useCallback(async () => {
try {
const res = await api.get('/branding');
setBranding(res.data);
} catch {
// keep defaults
}
}, []);
useEffect(() => {
fetchBranding();
}, [fetchBranding]);
return (
<BrandingContext.Provider value={{ ...branding, refreshBranding: fetchBranding }}>
{children}
</BrandingContext.Provider>
);
}
export function useBranding() {
const ctx = useContext(BrandingContext);
if (!ctx) throw new Error('useBranding must be used within BrandingProvider');
return ctx;
}

View File

@@ -277,6 +277,19 @@
"userDeleteFailed": "Fehler beim Löschen",
"passwordReset": "Passwort zurückgesetzt",
"passwordResetFailed": "Fehler beim Zurücksetzen",
"deleteUserConfirm": "Benutzer \"{name}\" wirklich löschen? Alle Räume werden ebenfalls gelöscht."
"deleteUserConfirm": "Benutzer \"{name}\" wirklich löschen? Alle Räume werden ebenfalls gelöscht.",
"brandingTitle": "Branding",
"brandingDescription": "Logo und App-Name anpassen, die in der Anwendung angezeigt werden.",
"logoLabel": "Logo",
"logoUpload": "Logo hochladen",
"logoChange": "Logo ändern",
"logoHint": "PNG, JPG, SVG oder WebP. Max. 2 MB.",
"logoUploaded": "Logo hochgeladen",
"logoUploadFailed": "Logo konnte nicht hochgeladen werden",
"logoRemoved": "Logo entfernt",
"logoRemoveFailed": "Logo konnte nicht entfernt werden",
"appNameLabel": "App-Name",
"appNameUpdated": "App-Name aktualisiert",
"appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden"
}
}

View File

@@ -277,6 +277,19 @@
"userDeleteFailed": "Error deleting user",
"passwordReset": "Password reset",
"passwordResetFailed": "Error resetting password",
"deleteUserConfirm": "Really delete user \"{name}\"? All rooms will also be deleted."
"deleteUserConfirm": "Really delete user \"{name}\"? All rooms will also be deleted.",
"brandingTitle": "Branding",
"brandingDescription": "Customize the logo and app name shown across the application.",
"logoLabel": "Logo",
"logoUpload": "Upload logo",
"logoChange": "Change logo",
"logoHint": "PNG, JPG, SVG or WebP. Max 2 MB.",
"logoUploaded": "Logo uploaded",
"logoUploadFailed": "Logo upload failed",
"logoRemoved": "Logo removed",
"logoRemoveFailed": "Could not remove logo",
"appNameLabel": "App name",
"appNameUpdated": "App name updated",
"appNameUpdateFailed": "Could not update app name"
}
}

View File

@@ -6,6 +6,7 @@ import App from './App';
import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { LanguageProvider } from './contexts/LanguageContext';
import { BrandingProvider } from './contexts/BrandingContext';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
@@ -13,21 +14,23 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<BrowserRouter>
<LanguageProvider>
<ThemeProvider>
<AuthProvider>
<App />
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: 'var(--card-bg)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
},
}}
/>
</AuthProvider>
</ThemeProvider>
<BrandingProvider>
<AuthProvider>
<App />
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: 'var(--card-bg)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
},
}}
/>
</AuthProvider>
</BrandingProvider>
</ThemeProvider>
</LanguageProvider>
</BrowserRouter>
</React.StrictMode>,

View File

@@ -1,17 +1,20 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
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,
} from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useBranding } from '../contexts/BrandingContext';
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 navigate = useNavigate();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
@@ -23,6 +26,12 @@ export default function Admin() {
const [creatingUser, setCreatingUser] = useState(false);
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'user' });
// Branding state
const [editAppName, setEditAppName] = useState('');
const [savingName, setSavingName] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const logoInputRef = useRef(null);
useEffect(() => {
if (user?.role !== 'admin') {
navigate('/dashboard');
@@ -31,6 +40,10 @@ export default function Admin() {
fetchUsers();
}, [user]);
useEffect(() => {
setEditAppName(appName || 'Redlight');
}, [appName]);
const fetchUsers = async () => {
try {
const res = await api.get('/admin/users');
@@ -77,6 +90,51 @@ export default function Admin() {
}
};
// ── Branding handlers ──────────────────────────────────────────────────
const handleLogoUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingLogo(true);
try {
const formData = new FormData();
formData.append('logo', file);
await api.post('/branding/logo', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
toast.success(t('admin.logoUploaded'));
refreshBranding();
} catch (err) {
toast.error(err.response?.data?.error || t('admin.logoUploadFailed'));
} finally {
setUploadingLogo(false);
if (logoInputRef.current) logoInputRef.current.value = '';
}
};
const handleLogoRemove = async () => {
try {
await api.delete('/branding/logo');
toast.success(t('admin.logoRemoved'));
refreshBranding();
} catch {
toast.error(t('admin.logoRemoveFailed'));
}
};
const handleAppNameSave = async () => {
if (!editAppName.trim()) return;
setSavingName(true);
try {
await api.put('/branding/name', { appName: editAppName.trim() });
toast.success(t('admin.appNameUpdated'));
refreshBranding();
} catch {
toast.error(t('admin.appNameUpdateFailed'));
} finally {
setSavingName(false);
}
};
const handleCreateUser = async (e) => {
e.preventDefault();
setCreatingUser(true);
@@ -126,6 +184,90 @@ export default function Admin() {
</div>
</div>
{/* Branding */}
<div className="card p-6 mb-8">
<div className="flex items-center gap-2 mb-4">
<Image size={20} className="text-th-accent" />
<h2 className="text-lg font-semibold text-th-text">{t('admin.brandingTitle')}</h2>
</div>
<p className="text-sm text-th-text-s mb-5">{t('admin.brandingDescription')}</p>
<div className="grid gap-6 sm:grid-cols-2">
{/* Logo upload */}
<div>
<label className="block text-sm font-medium text-th-text mb-2">{t('admin.logoLabel')}</label>
<div className="flex items-center gap-4">
{hasLogo && logoUrl ? (
<div className="relative group">
<img
src={`${logoUrl}?t=${Date.now()}`}
alt="Logo"
className="w-14 h-14 rounded-xl object-contain border border-th-border bg-th-bg p-1"
/>
<button
onClick={handleLogoRemove}
className="absolute -top-2 -right-2 w-5 h-5 bg-th-error text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<XIcon size={12} />
</button>
</div>
) : (
<div className="w-14 h-14 rounded-xl border-2 border-dashed border-th-border flex items-center justify-center text-th-text-s">
<Image size={24} />
</div>
)}
<div>
<input
ref={logoInputRef}
type="file"
accept="image/*"
onChange={handleLogoUpload}
className="hidden"
/>
<button
onClick={() => logoInputRef.current?.click()}
disabled={uploadingLogo}
className="btn-secondary text-sm"
>
{uploadingLogo ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Upload size={14} />
)}
{hasLogo ? t('admin.logoChange') : t('admin.logoUpload')}
</button>
<p className="text-xs text-th-text-s mt-1">{t('admin.logoHint')}</p>
</div>
</div>
</div>
{/* App name */}
<div>
<label className="block text-sm font-medium text-th-text mb-2">{t('admin.appNameLabel')}</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Type size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={editAppName}
onChange={e => setEditAppName(e.target.value)}
className="input-field pl-9 text-sm"
placeholder="Redlight"
maxLength={30}
/>
</div>
<button
onClick={handleAppNameSave}
disabled={savingName || editAppName.trim() === appName}
className="btn-primary text-sm px-4"
>
{savingName ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button>
</div>
</div>
</div>
</div>
{/* Search */}
<div className="card p-4 mb-6">
<div className="relative">

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import api from '../services/api';
import toast from 'react-hot-toast';
import { useLanguage } from '../contexts/LanguageContext';
@@ -132,11 +133,8 @@ export default function GuestJoin() {
<div className="relative w-full max-w-md">
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
{/* Logo */}
<div className="flex items-center justify-center gap-2.5 mb-6">
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
<Video size={22} className="text-white" />
</div>
<span className="text-2xl font-bold gradient-text">Redlight</span>
<div className="flex justify-center mb-6">
<BrandLogo size="lg" />
</div>
{/* Room info */}

View File

@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom';
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import { useLanguage } from '../contexts/LanguageContext';
export default function Home() {
@@ -48,12 +49,7 @@ export default function Home() {
{/* Navbar */}
<nav className="relative z-10 flex items-center justify-between px-6 md:px-12 py-4">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 gradient-bg rounded-lg flex items-center justify-center">
<Video size={20} className="text-white" />
</div>
<span className="text-xl font-bold gradient-text">Redlight</span>
</div>
<BrandLogo size="md" />
<div className="flex items-center gap-3">
<Link to="/login" className="btn-ghost text-sm">
{t('auth.login')}

View File

@@ -2,7 +2,8 @@ import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { Video, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
import { Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import toast from 'react-hot-toast';
export default function Login() {
@@ -42,11 +43,8 @@ export default function Login() {
<div className="relative w-full max-w-md">
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
{/* Logo */}
<div className="flex items-center justify-center gap-2.5 mb-8">
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
<Video size={22} className="text-white" />
</div>
<span className="text-2xl font-bold gradient-text">Redlight</span>
<div className="flex justify-center mb-8">
<BrandLogo size="lg" />
</div>
<div className="mb-8">

View File

@@ -2,7 +2,8 @@ import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { Video, Mail, Lock, User, ArrowRight, Loader2 } from 'lucide-react';
import { Mail, Lock, User, ArrowRight, Loader2 } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import toast from 'react-hot-toast';
export default function Register() {
@@ -55,11 +56,8 @@ export default function Register() {
<div className="relative w-full max-w-md">
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
{/* Logo */}
<div className="flex items-center justify-center gap-2.5 mb-8">
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
<Video size={22} className="text-white" />
</div>
<span className="text-2xl font-bold gradient-text">Redlight</span>
<div className="flex justify-center mb-8">
<BrandLogo size="lg" />
</div>
<div className="mb-8">