add branding option
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m10s
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m10s
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user