Initial commit
This commit is contained in:
56
frontend/src/pages/AdminPanel.tsx
Normal file
56
frontend/src/pages/AdminPanel.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import GeneralTab from '../components/admin/GeneralTab';
|
||||
import UsersTab from '../components/admin/UsersTab';
|
||||
import '../styles/AdminPanel.css';
|
||||
|
||||
type TabId = 'general' | 'users';
|
||||
|
||||
export default function AdminPanel() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
const [activeTab, setActiveTab] = useState<TabId>('general');
|
||||
|
||||
if (!currentUser?.is_superuser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="main-content admin-panel-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="material-symbols-outlined">admin_panel_settings</span>
|
||||
<span className="page-title-text">{t.admin.panel}</span>
|
||||
</div>
|
||||
<div className="page-tabs-divider"></div>
|
||||
<button
|
||||
className={`page-tab-btn ${activeTab === 'general' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('general')}
|
||||
>
|
||||
<span className="material-symbols-outlined">tune</span>
|
||||
<span>{t.admin.generalTab}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`page-tab-btn ${activeTab === 'users' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('users')}
|
||||
>
|
||||
<span className="material-symbols-outlined">group</span>
|
||||
<span>{t.admin.usersTab}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-tab-content">
|
||||
{activeTab === 'general' && <GeneralTab />}
|
||||
{activeTab === 'users' && <UsersTab />}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
72
frontend/src/pages/Dashboard.tsx
Normal file
72
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import '../styles/Dashboard.css';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
|
||||
return (
|
||||
<main className="main-content">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="material-symbols-outlined">dashboard</span>
|
||||
<span className="page-title-text">{t.dashboard.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<h3>{t.dashboard.profile}</h3>
|
||||
<p>
|
||||
<strong>{t.auth.username}:</strong> <span className="selectable">{user?.username}</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t.auth.email}:</strong> <span className="selectable">{user?.email}</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t.dashboard.userId}:</strong> <span className="selectable">{user?.id}</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t.dashboard.status}:</strong>{' '}
|
||||
<span className={user?.is_active ? 'status-active' : 'status-inactive'}>
|
||||
{user?.is_active ? t.dashboard.active : t.dashboard.inactive}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<h3>{t.features.feature1}</h3>
|
||||
<p>{t.features.comingSoon}</p>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<h3>{t.features.feature2}</h3>
|
||||
<p>{t.features.comingSoon}</p>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<h3>{t.features.feature3}</h3>
|
||||
<p>{t.features.comingSoon}</p>
|
||||
</div>
|
||||
|
||||
{user?.is_superuser && (
|
||||
<div className="stat-card admin-card">
|
||||
<h3>{t.admin.panel}</h3>
|
||||
<p>{t.admin.userManagement} - {t.features.comingSoon}</p>
|
||||
<p>{t.admin.systemSettings} - {t.features.comingSoon}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
35
frontend/src/pages/Feature1.tsx
Normal file
35
frontend/src/pages/Feature1.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import '../styles/AdminPanel.css';
|
||||
|
||||
export default function Feature1() {
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
|
||||
return (
|
||||
<main className="main-content">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="material-symbols-outlined">playlist_play</span>
|
||||
<span className="page-title-text">{t.feature1.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
<div className="tab-content-placeholder">
|
||||
<div className="placeholder-icon">
|
||||
<span className="material-symbols-outlined">playlist_play</span>
|
||||
</div>
|
||||
|
||||
<h3>{t.feature1.title}</h3>
|
||||
<p>{t.feature1.comingSoon}</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
161
frontend/src/pages/Login.tsx
Normal file
161
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useSiteConfig } from '../contexts/SiteConfigContext';
|
||||
import '../styles/Login.css';
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [isRegister, setIsRegister] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
||||
const { login, register } = useAuth();
|
||||
const { t, language, setLanguage } = useTranslation();
|
||||
const { theme, toggleTheme, showDarkModeLogin, showLanguageLogin, showDarkModeToggle, showLanguageToggle } = useTheme();
|
||||
const { config } = useSiteConfig();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Check if registration is enabled
|
||||
useEffect(() => {
|
||||
const checkRegistrationStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/registration-status');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setRegistrationEnabled(data.registration_enabled !== false);
|
||||
} else {
|
||||
// Default to enabled if we can't fetch the setting
|
||||
setRegistrationEnabled(true);
|
||||
}
|
||||
} catch (error) {
|
||||
// Default to enabled if we can't fetch the setting
|
||||
setRegistrationEnabled(true);
|
||||
}
|
||||
};
|
||||
|
||||
checkRegistrationStatus();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (isRegister) {
|
||||
await register(username, email, password);
|
||||
} else {
|
||||
await login(username, password);
|
||||
}
|
||||
navigate('/dashboard');
|
||||
} catch (err: any) {
|
||||
const detail = err.response?.data?.detail;
|
||||
if (Array.isArray(detail)) {
|
||||
// Handle Pydantic validation errors
|
||||
const messages = detail.map((e: any) => {
|
||||
const field = e.loc[e.loc.length - 1];
|
||||
return `${field}: ${e.msg}`;
|
||||
}).join('\n');
|
||||
setError(messages);
|
||||
} else if (typeof detail === 'string') {
|
||||
// Handle standard HTTP exceptions
|
||||
setError(detail);
|
||||
} else {
|
||||
// Fallback
|
||||
setError(t.auth.authenticationFailed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLanguage = () => {
|
||||
setLanguage(language === 'it' ? 'en' : 'it');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<h1>{config.name}</h1>
|
||||
<p className="login-tagline">{config.tagline}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">{t.auth.username}</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isRegister && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">{t.auth.email}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">{t.auth.password}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<button type="submit" className="btn-primary">
|
||||
{isRegister ? t.auth.register : t.auth.login}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-footer">
|
||||
{registrationEnabled && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRegister(!isRegister);
|
||||
setError('');
|
||||
}}
|
||||
className="btn-link"
|
||||
>
|
||||
{isRegister ? t.auth.alreadyHaveAccount : t.auth.dontHaveAccount}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="footer-actions">
|
||||
{showDarkModeToggle && showDarkModeLogin && (
|
||||
<button onClick={toggleTheme} className="btn-footer-action" title={theme === 'dark' ? 'Dark mode' : 'Light mode'}>
|
||||
<span className="material-symbols-outlined">{theme === 'dark' ? 'dark_mode' : 'light_mode'}</span>
|
||||
</button>
|
||||
)}
|
||||
{showLanguageToggle && showLanguageLogin && (
|
||||
<button onClick={toggleLanguage} className="btn-footer-action" title="Change language">
|
||||
<span className="material-symbols-outlined">language</span>
|
||||
<span className="lang-text">{language.toUpperCase()}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
frontend/src/pages/Settings.tsx
Normal file
53
frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import '../styles/SettingsPage.css';
|
||||
|
||||
export default function Settings() {
|
||||
const { t, language, setLanguage } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
|
||||
return (
|
||||
<main className="main-content settings-page-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="material-symbols-outlined">settings</span>
|
||||
<span className="page-title-text">{t.settings.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-content settings-tab-content">
|
||||
<div className="settings-section-modern">
|
||||
|
||||
<div className="settings-grid">
|
||||
<div className="setting-item-modern">
|
||||
<div className="setting-info-modern">
|
||||
<div className="setting-icon-modern">
|
||||
<span className="material-symbols-outlined">language</span>
|
||||
</div>
|
||||
<div className="setting-text">
|
||||
<h4>{t.settings.language}</h4>
|
||||
<p>{t.settings.languageDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-control">
|
||||
<select
|
||||
className="select-modern"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value as 'en' | 'it')}
|
||||
>
|
||||
<option value="en">{t.settings.english}</option>
|
||||
<option value="it">{t.settings.italian}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
404
frontend/src/pages/Users.tsx
Normal file
404
frontend/src/pages/Users.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import { usersAPI } from '../api/client';
|
||||
import type { User, UserCreate, UserUpdatePayload } from '../types';
|
||||
import '../styles/Users.css';
|
||||
|
||||
type UserFormData = {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
};
|
||||
|
||||
const emptyForm: UserFormData = {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
};
|
||||
|
||||
export default function Users() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [formData, setFormData] = useState<UserFormData>({ ...emptyForm });
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await usersAPI.list();
|
||||
setUsers(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || t.usersPage.errorLoading);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadUsers();
|
||||
}, [t.usersPage.errorLoading]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const term = searchTerm.toLowerCase().trim();
|
||||
if (!term) return users;
|
||||
return users.filter(
|
||||
(u) =>
|
||||
u.username.toLowerCase().includes(term) ||
|
||||
u.email.toLowerCase().includes(term)
|
||||
);
|
||||
}, [users, searchTerm]);
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingUser(null);
|
||||
setFormData({ ...emptyForm });
|
||||
setModalOpen(true);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const openEditModal = (user: User) => {
|
||||
setEditingUser(user);
|
||||
setFormData({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: '',
|
||||
is_active: user.is_active,
|
||||
is_superuser: user.is_superuser,
|
||||
});
|
||||
setModalOpen(true);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setError('');
|
||||
setFormData({ ...emptyForm });
|
||||
setEditingUser(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (editingUser) {
|
||||
if (formData.password && formData.password.trim().length > 0 && formData.password.trim().length < 8) {
|
||||
throw new Error('password-too-short');
|
||||
}
|
||||
|
||||
const payload: UserUpdatePayload = {
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
is_active: formData.is_active,
|
||||
is_superuser: formData.is_superuser,
|
||||
};
|
||||
|
||||
if (formData.password.trim()) {
|
||||
payload.password = formData.password;
|
||||
}
|
||||
|
||||
const updated = await usersAPI.update(editingUser.id, payload);
|
||||
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)));
|
||||
} else {
|
||||
if (!formData.password.trim()) {
|
||||
throw new Error('password-required');
|
||||
}
|
||||
|
||||
if (formData.password.trim().length < 8) {
|
||||
throw new Error('password-too-short');
|
||||
}
|
||||
|
||||
const payload: UserCreate = {
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
is_active: formData.is_active,
|
||||
is_superuser: formData.is_superuser,
|
||||
};
|
||||
|
||||
const created = await usersAPI.create(payload);
|
||||
setUsers((prev) => [created, ...prev]);
|
||||
}
|
||||
|
||||
closeModal();
|
||||
} catch (err: any) {
|
||||
if (err?.message === 'password-required') {
|
||||
setError(t.usersPage.passwordRequired);
|
||||
} else if (err?.message === 'password-too-short') {
|
||||
setError(t.usersPage.passwordTooShort);
|
||||
} else {
|
||||
setError(err?.response?.data?.detail || t.usersPage.saveError);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (target: User) => {
|
||||
if (currentUser?.id === target.id) {
|
||||
setError(t.usersPage.selfDeleteWarning);
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(t.usersPage.confirmDelete);
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await usersAPI.delete(target.id);
|
||||
setUsers((prev) => prev.filter((u) => u.id !== target.id));
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || t.usersPage.saveError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentUser?.is_superuser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<Sidebar />
|
||||
<main className="main-content users-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="material-symbols-outlined">group</span>
|
||||
<span className="page-title-text">{t.admin.userManagement}</span>
|
||||
</div>
|
||||
<button className="btn-primary add-user-btn" onClick={openCreateModal}>
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
<span>{t.usersPage.addUser}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-content users-page">
|
||||
<div className="users-toolbar">
|
||||
<div className="input-group">
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t.usersPage.searchPlaceholder}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="users-badges">
|
||||
<span className="badge badge-success">
|
||||
{t.usersPage.active}: {users.filter((u) => u.is_active).length}
|
||||
</span>
|
||||
<span className="badge badge-neutral">
|
||||
{t.usersPage.inactive}: {users.filter((u) => !u.is_active).length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="users-alert">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">{t.common.loading}</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="users-empty">{t.usersPage.noUsers}</div>
|
||||
) : (
|
||||
<div className="users-card">
|
||||
<table className="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t.usersPage.name}</th>
|
||||
<th>{t.usersPage.status}</th>
|
||||
<th>{t.usersPage.role}</th>
|
||||
<th>{t.usersPage.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<div className="user-avatar">
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="user-meta">
|
||||
<span className="user-name">{u.username}</span>
|
||||
<span className="user-email">{u.email}</span>
|
||||
<span className="user-id">
|
||||
{t.dashboard.userId}: {u.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`badge ${u.is_active ? 'badge-success' : 'badge-muted'}`}
|
||||
>
|
||||
{u.is_active ? t.usersPage.active : t.usersPage.inactive}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`badge ${u.is_superuser ? 'badge-accent' : 'badge-neutral'}`}
|
||||
>
|
||||
{u.is_superuser ? t.usersPage.superuser : t.usersPage.regular}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="users-actions">
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => openEditModal(u)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
{t.usersPage.edit}
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost danger"
|
||||
onClick={() => handleDelete(u)}
|
||||
disabled={isSaving || currentUser?.id === u.id}
|
||||
title={
|
||||
currentUser?.id === u.id
|
||||
? t.usersPage.selfDeleteWarning
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
{t.usersPage.delete}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="users-modal-backdrop" onClick={closeModal}>
|
||||
<div className="users-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>
|
||||
{editingUser ? t.usersPage.editUser : t.usersPage.addUser}
|
||||
</h2>
|
||||
<button className="btn-icon btn-close" onClick={closeModal} aria-label={t.common.close}>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="users-alert in-modal">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="users-form">
|
||||
<div className="form-row">
|
||||
<label htmlFor="username">{t.usersPage.name}</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, username: e.target.value }))
|
||||
}
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="email">{t.usersPage.email}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, email: e.target.value }))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label htmlFor="password">
|
||||
{t.usersPage.password}{' '}
|
||||
{editingUser ? (
|
||||
<span className="helper-text">{t.usersPage.passwordHintEdit}</span>
|
||||
) : (
|
||||
<span className="helper-text">{t.usersPage.passwordHintCreate}</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, password: e.target.value }))
|
||||
}
|
||||
minLength={formData.password ? 8 : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-grid">
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, is_active: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
<span>{t.usersPage.isActive}</span>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_superuser}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
is_superuser: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span>{t.usersPage.isSuperuser}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-ghost" onClick={closeModal}>
|
||||
{t.common.cancel}
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={isSaving}>
|
||||
{isSaving ? t.common.loading : t.usersPage.save}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
frontend/src/pages/admin/Features.tsx
Normal file
217
frontend/src/pages/admin/Features.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useTranslation } from '../../contexts/LanguageContext';
|
||||
import { useSidebar } from '../../contexts/SidebarContext';
|
||||
import { useModules } from '../../contexts/ModulesContext';
|
||||
import type { ModuleId } from '../../contexts/ModulesContext';
|
||||
import Feature1Tab from '../../components/admin/Feature1Tab';
|
||||
import '../../styles/AdminPanel.css';
|
||||
|
||||
type TabId = 'feature1' | 'feature2' | 'feature3';
|
||||
|
||||
export default function Features() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
const { moduleStates, setModuleEnabled, saveModulesToBackend, hasInitialized } = useModules();
|
||||
const [activeTab, setActiveTab] = useState<TabId>('feature1');
|
||||
const hasUserMadeChanges = useRef(false);
|
||||
const saveRef = useRef(saveModulesToBackend);
|
||||
const [tooltip, setTooltip] = useState<{ text: string; left: number; visible: boolean }>({
|
||||
text: '',
|
||||
left: 0,
|
||||
visible: false,
|
||||
});
|
||||
|
||||
const handleTabMouseEnter = (text: string, e: React.MouseEvent) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setTooltip({
|
||||
text,
|
||||
left: rect.left + rect.width / 2,
|
||||
visible: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTabMouseLeave = () => {
|
||||
setTooltip((prev) => ({ ...prev, visible: false }));
|
||||
};
|
||||
|
||||
// Keep saveRef updated with latest function
|
||||
useEffect(() => {
|
||||
saveRef.current = saveModulesToBackend;
|
||||
}, [saveModulesToBackend]);
|
||||
|
||||
if (!currentUser?.is_superuser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleModuleToggle = async (moduleId: ModuleId, type: 'admin' | 'user', enabled: boolean) => {
|
||||
hasUserMadeChanges.current = true;
|
||||
setModuleEnabled(moduleId, type, enabled);
|
||||
};
|
||||
|
||||
// Save changes when moduleStates change, but debounce to avoid too many requests
|
||||
// Only save if: 1) Backend data has been loaded, and 2) User has made changes
|
||||
useEffect(() => {
|
||||
if (!hasInitialized || !hasUserMadeChanges.current) {
|
||||
return;
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
saveRef.current().catch(console.error);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [moduleStates, hasInitialized]);
|
||||
|
||||
// Save on unmount if there are pending changes (empty deps = only on unmount)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hasUserMadeChanges.current) {
|
||||
saveRef.current().catch(console.error);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getModuleDescription = (moduleId: string): string => {
|
||||
const key = `${moduleId}Desc` as keyof typeof t.admin;
|
||||
return t.admin[key] || t.admin.moduleDefaultDesc;
|
||||
};
|
||||
|
||||
const renderModuleToggle = (moduleId: ModuleId) => {
|
||||
const state = moduleStates[moduleId];
|
||||
const adminEnabled = state?.admin ?? true;
|
||||
const userEnabled = state?.user ?? true;
|
||||
|
||||
return (
|
||||
<div className="feature-header">
|
||||
<div className="feature-header-info">
|
||||
<p>{getModuleDescription(moduleId)}</p>
|
||||
</div>
|
||||
<div className="feature-header-actions">
|
||||
<div className={`feature-status-badge ${adminEnabled ? 'active' : ''}`}>
|
||||
{adminEnabled ? t.admin.active : t.admin.inactive}
|
||||
</div>
|
||||
<div className="toggle-group">
|
||||
<span className="toggle-label">{t.admin.adminRole}</span>
|
||||
<label className="toggle-modern">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={adminEnabled}
|
||||
onChange={(e) => handleModuleToggle(moduleId, 'admin', e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="toggle-group">
|
||||
<span className="toggle-label">{t.admin.userRole}</span>
|
||||
<label className={`toggle-modern ${!adminEnabled ? 'disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={userEnabled}
|
||||
disabled={!adminEnabled}
|
||||
onChange={(e) => handleModuleToggle(moduleId, 'user', e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'feature1':
|
||||
return (
|
||||
<>
|
||||
{renderModuleToggle('feature1')}
|
||||
<Feature1Tab />
|
||||
</>
|
||||
);
|
||||
case 'feature2':
|
||||
return (
|
||||
<>
|
||||
{renderModuleToggle('feature2')}
|
||||
<div className="tab-content-placeholder">
|
||||
<div className="placeholder-icon">
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
</div>
|
||||
<h3>{t.features.feature2}</h3>
|
||||
<p>{t.features.comingSoon}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case 'feature3':
|
||||
return (
|
||||
<>
|
||||
{renderModuleToggle('feature3')}
|
||||
<div className="tab-content-placeholder">
|
||||
<div className="placeholder-icon">
|
||||
<span className="material-symbols-outlined">cast</span>
|
||||
</div>
|
||||
<h3>{t.features.feature3}</h3>
|
||||
<p>{t.features.comingSoon}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="main-content admin-panel-root">
|
||||
{/* Tab Tooltip */}
|
||||
{tooltip.visible && (
|
||||
<div
|
||||
className="admin-tab-tooltip"
|
||||
style={{ left: tooltip.left }}
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
)}
|
||||
<div className="admin-tabs-container">
|
||||
<div className="admin-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="admin-title-section">
|
||||
<span className="material-symbols-outlined">extension</span>
|
||||
<span className="admin-title-text">{t.featuresPage.title}</span>
|
||||
</div>
|
||||
<div className="admin-tabs-divider"></div>
|
||||
<button
|
||||
className={`admin-tab-btn ${activeTab === 'feature1' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('feature1')}
|
||||
onMouseEnter={(e) => handleTabMouseEnter(t.sidebar.feature1, e)}
|
||||
onMouseLeave={handleTabMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">playlist_play</span>
|
||||
<span>{t.sidebar.feature1}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab-btn ${activeTab === 'feature2' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('feature2')}
|
||||
onMouseEnter={(e) => handleTabMouseEnter(t.sidebar.feature2, e)}
|
||||
onMouseLeave={handleTabMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
<span>{t.sidebar.feature2}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab-btn ${activeTab === 'feature3' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('feature3')}
|
||||
onMouseEnter={(e) => handleTabMouseEnter(t.sidebar.feature3, e)}
|
||||
onMouseLeave={handleTabMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">cast</span>
|
||||
<span>{t.sidebar.feature3}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-tab-content">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
136
frontend/src/pages/admin/Settings.tsx
Normal file
136
frontend/src/pages/admin/Settings.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from '../../contexts/LanguageContext';
|
||||
import { useSidebar } from '../../contexts/SidebarContext';
|
||||
import Sidebar from '../../components/Sidebar';
|
||||
import '../../styles/Settings.css';
|
||||
|
||||
interface Settings {
|
||||
registration_enabled?: boolean;
|
||||
show_logo?: boolean;
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
const [settings, setSettings] = useState<Settings>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch('/api/v1/settings', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSettings(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSetting = async (key: string, value: any) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`/api/v1/settings/${key}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ value }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const updatedSetting = await response.json();
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
[key]: updatedSetting.value
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update setting:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegistrationToggle = (checked: boolean) => {
|
||||
updateSetting('registration_enabled', checked);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">{t.common.loading}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<Sidebar />
|
||||
<main className="main-content settings-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="material-symbols-outlined">settings</span>
|
||||
<span className="page-title-text">{t.settings.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
<div className="settings-card">
|
||||
<div className="settings-section">
|
||||
<h2>{t.settings.authentication}</h2>
|
||||
|
||||
<div className="setting-item">
|
||||
<div className="setting-info">
|
||||
<h3>{t.settings.allowRegistration}</h3>
|
||||
<p>{t.settings.allowRegistrationDesc}</p>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.registration_enabled !== false}
|
||||
onChange={(e) => handleRegistrationToggle(e.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<div className="setting-info">
|
||||
<h3>{t.settings.showLogo}</h3>
|
||||
<p>{t.settings.showLogoDesc}</p>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.show_logo === true}
|
||||
onChange={(e) => updateSetting('show_logo', e.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
frontend/src/pages/admin/Sources.tsx
Normal file
40
frontend/src/pages/admin/Sources.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useTranslation } from '../../contexts/LanguageContext';
|
||||
import { useSidebar } from '../../contexts/SidebarContext';
|
||||
import '../../styles/AdminPanel.css';
|
||||
|
||||
export default function Sources() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
|
||||
if (!currentUser?.is_superuser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="main-content admin-panel-root">
|
||||
<div className="admin-tabs-container">
|
||||
<div className="admin-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="admin-title-section">
|
||||
<span className="material-symbols-outlined">database</span>
|
||||
<span className="admin-title-text">{t.sourcesPage.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-tab-content">
|
||||
<div className="tab-content-placeholder">
|
||||
<div className="placeholder-icon">
|
||||
<span className="material-symbols-outlined">database</span>
|
||||
</div>
|
||||
<h3>{t.sourcesPage.title}</h3>
|
||||
<p>{t.sourcesPage.comingSoon}</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
808
frontend/src/pages/admin/ThemeSettings.tsx
Normal file
808
frontend/src/pages/admin/ThemeSettings.tsx
Normal file
@@ -0,0 +1,808 @@
|
||||
import { useState } from 'react';
|
||||
import { useTheme, COLOR_PALETTES } from '../../contexts/ThemeContext';
|
||||
import type { AccentColor, BorderRadius, SidebarStyle, Density, FontFamily, ColorPalette } from '../../contexts/ThemeContext';
|
||||
import { useTranslation } from '../../contexts/LanguageContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useSidebar } from '../../contexts/SidebarContext';
|
||||
import type { SidebarMode } from '../../contexts/SidebarContext';
|
||||
import { ChromePicker, HuePicker } from 'react-color';
|
||||
import '../../styles/ThemeSettings.css';
|
||||
|
||||
type ThemeTab = 'colors' | 'appearance' | 'preview' | 'advanced';
|
||||
|
||||
type ColorPickerState = {
|
||||
isOpen: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
property: string;
|
||||
value: string;
|
||||
} | null;
|
||||
|
||||
export default function ThemeSettings() {
|
||||
const [activeTab, setActiveTab] = useState<ThemeTab>('colors');
|
||||
const {
|
||||
accentColor,
|
||||
borderRadius,
|
||||
sidebarStyle,
|
||||
density,
|
||||
fontFamily,
|
||||
colorPalette,
|
||||
setAccentColor,
|
||||
setBorderRadius,
|
||||
setSidebarStyle,
|
||||
setDensity,
|
||||
setFontFamily,
|
||||
setColorPalette,
|
||||
saveThemeToBackend
|
||||
} = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { toggleMobileMenu, sidebarMode, setSidebarMode } = useSidebar();
|
||||
const isAdmin = user?.is_superuser || false;
|
||||
const [tooltip, setTooltip] = useState<{ text: string; left: number; visible: boolean }>({
|
||||
text: '',
|
||||
left: 0,
|
||||
visible: false,
|
||||
});
|
||||
|
||||
const handleTabMouseEnter = (text: string, e: React.MouseEvent) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setTooltip({
|
||||
text,
|
||||
left: rect.left + rect.width / 2,
|
||||
visible: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTabMouseLeave = () => {
|
||||
setTooltip((prev) => ({ ...prev, visible: false }));
|
||||
};
|
||||
|
||||
// Handlers that save to backend after setting
|
||||
const handleAccentColorChange = async (color: AccentColor) => {
|
||||
setAccentColor(color);
|
||||
saveThemeToBackend({ accentColor: color }).catch(console.error);
|
||||
};
|
||||
|
||||
const handleBorderRadiusChange = async (radius: BorderRadius) => {
|
||||
setBorderRadius(radius);
|
||||
saveThemeToBackend({ borderRadius: radius }).catch(console.error);
|
||||
};
|
||||
|
||||
const handleSidebarStyleChange = async (style: SidebarStyle) => {
|
||||
setSidebarStyle(style);
|
||||
saveThemeToBackend({ sidebarStyle: style }).catch(console.error);
|
||||
};
|
||||
|
||||
const handleDensityChange = async (d: Density) => {
|
||||
setDensity(d);
|
||||
saveThemeToBackend({ density: d }).catch(console.error);
|
||||
};
|
||||
|
||||
const handleFontFamilyChange = async (font: FontFamily) => {
|
||||
setFontFamily(font);
|
||||
saveThemeToBackend({ fontFamily: font }).catch(console.error);
|
||||
};
|
||||
|
||||
const handleColorPaletteChange = async (palette: ColorPalette) => {
|
||||
setColorPalette(palette);
|
||||
saveThemeToBackend({ colorPalette: palette }).catch(console.error);
|
||||
};
|
||||
|
||||
// Advanced color states
|
||||
const [customColors, setCustomColors] = useState({
|
||||
light: {
|
||||
bgMain: '#ffffff',
|
||||
bgCard: '#f9fafb',
|
||||
bgElevated: '#ffffff',
|
||||
textPrimary: '#111827',
|
||||
textSecondary: '#6b7280',
|
||||
border: '#e5e7eb',
|
||||
sidebarBg: '#1f2937',
|
||||
sidebarText: '#f9fafb'
|
||||
},
|
||||
dark: {
|
||||
bgMain: '#0f172a',
|
||||
bgCard: '#1e293b',
|
||||
bgElevated: '#334155',
|
||||
textPrimary: '#f1f5f9',
|
||||
textSecondary: '#94a3b8',
|
||||
border: '#334155',
|
||||
sidebarBg: '#0c1222',
|
||||
sidebarText: '#f9fafb'
|
||||
}
|
||||
});
|
||||
|
||||
// Color picker popup state
|
||||
const [colorPickerState, setColorPickerState] = useState<ColorPickerState>(null);
|
||||
|
||||
const colors: { id: AccentColor; label: string; value: string; description: string }[] = [
|
||||
{ id: 'auto', label: t.theme.colors.auto, value: '#374151', description: t.theme.colors.autoDesc },
|
||||
{ id: 'blue', label: t.theme.colors.blue, value: '#3b82f6', description: t.theme.colors.blueDesc },
|
||||
{ id: 'purple', label: t.theme.colors.purple, value: '#8b5cf6', description: t.theme.colors.purpleDesc },
|
||||
{ id: 'green', label: t.theme.colors.green, value: '#10b981', description: t.theme.colors.greenDesc },
|
||||
{ id: 'orange', label: t.theme.colors.orange, value: '#f97316', description: t.theme.colors.orangeDesc },
|
||||
{ id: 'pink', label: t.theme.colors.pink, value: '#ec4899', description: t.theme.colors.pinkDesc },
|
||||
{ id: 'red', label: t.theme.colors.red, value: '#ef4444', description: t.theme.colors.redDesc },
|
||||
{ id: 'teal', label: t.theme.colors.teal, value: '#14b8a6', description: t.theme.colors.tealDesc },
|
||||
{ id: 'amber', label: t.theme.colors.amber, value: '#f59e0b', description: t.theme.colors.amberDesc },
|
||||
{ id: 'indigo', label: t.theme.colors.indigo, value: '#6366f1', description: t.theme.colors.indigoDesc },
|
||||
{ id: 'cyan', label: t.theme.colors.cyan, value: '#06b6d4', description: t.theme.colors.cyanDesc },
|
||||
{ id: 'rose', label: t.theme.colors.rose, value: '#f43f5e', description: t.theme.colors.roseDesc },
|
||||
];
|
||||
|
||||
const radii: { id: BorderRadius; label: string; description: string; preview: string }[] = [
|
||||
{ id: 'large', label: t.theme.radius.large, description: t.theme.radius.largeDesc, preview: '16px' },
|
||||
{ id: 'medium', label: t.theme.radius.medium, description: t.theme.radius.mediumDesc, preview: '8px' },
|
||||
{ id: 'small', label: t.theme.radius.small, description: t.theme.radius.smallDesc, preview: '4px' },
|
||||
];
|
||||
|
||||
const sidebarStyles: { id: SidebarStyle; label: string; description: string }[] = [
|
||||
{ id: 'default', label: t.theme.sidebarOptions.default, description: t.theme.sidebarOptions.defaultDesc },
|
||||
{ id: 'dark', label: t.theme.sidebarOptions.dark, description: t.theme.sidebarOptions.darkDesc },
|
||||
{ id: 'light', label: t.theme.sidebarOptions.light, description: t.theme.sidebarOptions.lightDesc },
|
||||
];
|
||||
|
||||
const sidebarModes: { id: SidebarMode; label: string; description: string }[] = [
|
||||
{ id: 'toggle', label: t.admin.sidebarModeToggle, description: t.admin.sidebarModeToggleDesc },
|
||||
{ id: 'dynamic', label: t.admin.sidebarModeDynamic, description: t.admin.sidebarModeDynamicDesc },
|
||||
{ id: 'collapsed', label: t.admin.sidebarModeCollapsed, description: t.admin.sidebarModeCollapsedDesc },
|
||||
];
|
||||
|
||||
const densities: { id: Density; label: string; description: string }[] = [
|
||||
{ id: 'compact', label: t.theme.densityOptions.compact, description: t.theme.densityOptions.compactDesc },
|
||||
{ id: 'comfortable', label: t.theme.densityOptions.comfortable, description: t.theme.densityOptions.comfortableDesc },
|
||||
{ id: 'spacious', label: t.theme.densityOptions.spacious, description: t.theme.densityOptions.spaciousDesc },
|
||||
];
|
||||
|
||||
const fonts: { id: FontFamily; label: string; description: string; fontStyle: string }[] = [
|
||||
{ id: 'sans', label: t.theme.fontOptions.system, description: t.theme.fontOptions.systemDesc, fontStyle: 'system-ui' },
|
||||
{ id: 'inter', label: t.theme.fontOptions.inter, description: t.theme.fontOptions.interDesc, fontStyle: 'Inter' },
|
||||
{ id: 'roboto', label: t.theme.fontOptions.roboto, description: t.theme.fontOptions.robotoDesc, fontStyle: 'Roboto' },
|
||||
];
|
||||
|
||||
const palettes: { id: ColorPalette; label: string; description: string }[] = [
|
||||
{ id: 'monochrome', label: t.theme.palettes.monochrome, description: t.theme.palettes.monochromeDesc },
|
||||
{ id: 'default', label: t.theme.palettes.default, description: t.theme.palettes.defaultDesc },
|
||||
{ id: 'monochromeBlue', label: t.theme.palettes.monochromeBlue, description: t.theme.palettes.monochromeBlueDesc },
|
||||
{ id: 'sepia', label: t.theme.palettes.sepia, description: t.theme.palettes.sepiaDesc },
|
||||
{ id: 'nord', label: t.theme.palettes.nord, description: t.theme.palettes.nordDesc },
|
||||
{ id: 'dracula', label: t.theme.palettes.dracula, description: t.theme.palettes.draculaDesc },
|
||||
{ id: 'solarized', label: t.theme.palettes.solarized, description: t.theme.palettes.solarizedDesc },
|
||||
{ id: 'github', label: t.theme.palettes.github, description: t.theme.palettes.githubDesc },
|
||||
{ id: 'ocean', label: t.theme.palettes.ocean, description: t.theme.palettes.oceanDesc },
|
||||
{ id: 'forest', label: t.theme.palettes.forest, description: t.theme.palettes.forestDesc },
|
||||
{ id: 'midnight', label: t.theme.palettes.midnight, description: t.theme.palettes.midnightDesc },
|
||||
{ id: 'sunset', label: t.theme.palettes.sunset, description: t.theme.palettes.sunsetDesc },
|
||||
];
|
||||
|
||||
// Helper component for color control
|
||||
const ColorControl = ({ theme, property, label, value }: {
|
||||
theme: 'light' | 'dark';
|
||||
property: string;
|
||||
label: string;
|
||||
value: string
|
||||
}) => {
|
||||
return (
|
||||
<div className="color-control-item">
|
||||
<label className="color-control-label">
|
||||
<span>{label}</span>
|
||||
<span className="color-value-display selectable">{value}</span>
|
||||
</label>
|
||||
<div className="color-control-actions">
|
||||
<div className="color-input-group">
|
||||
<button
|
||||
className="btn-color-picker"
|
||||
style={{ backgroundColor: value }}
|
||||
title={t.theme.pickColor}
|
||||
onClick={() => {
|
||||
setColorPickerState({
|
||||
isOpen: true,
|
||||
theme,
|
||||
property,
|
||||
value
|
||||
});
|
||||
}}
|
||||
>
|
||||
{/* Color preview via background color */}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={value.toUpperCase()}
|
||||
onChange={(e) => handleHexInput(theme, property, e.target.value)}
|
||||
className="color-hex-input"
|
||||
placeholder="#FFFFFF"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Color picker handlers
|
||||
const handleColorChange = (theme: 'light' | 'dark', property: string, value: string) => {
|
||||
setCustomColors(prev => ({
|
||||
...prev,
|
||||
[theme]: {
|
||||
...prev[theme],
|
||||
[property]: value
|
||||
}
|
||||
}));
|
||||
// Apply to CSS variables immediately
|
||||
const root = document.documentElement;
|
||||
const varName = `--color-${property.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
||||
root.style.setProperty(varName, value);
|
||||
};
|
||||
|
||||
const handleHexInput = (theme: 'light' | 'dark', property: string, value: string) => {
|
||||
// Validate hex color format
|
||||
const hexRegex = /^#?[0-9A-Fa-f]{6}$/;
|
||||
const formattedValue = value.startsWith('#') ? value : `#${value}`;
|
||||
|
||||
if (hexRegex.test(formattedValue)) {
|
||||
handleColorChange(theme, property, formattedValue);
|
||||
}
|
||||
};
|
||||
|
||||
const resetToDefaults = () => {
|
||||
setCustomColors({
|
||||
light: {
|
||||
bgMain: '#ffffff',
|
||||
bgCard: '#f9fafb',
|
||||
bgElevated: '#ffffff',
|
||||
textPrimary: '#111827',
|
||||
textSecondary: '#6b7280',
|
||||
border: '#e5e7eb',
|
||||
sidebarBg: '#1f2937',
|
||||
sidebarText: '#f9fafb'
|
||||
},
|
||||
dark: {
|
||||
bgMain: '#0f172a',
|
||||
bgCard: '#1e293b',
|
||||
bgElevated: '#334155',
|
||||
textPrimary: '#f1f5f9',
|
||||
textSecondary: '#94a3b8',
|
||||
border: '#334155',
|
||||
sidebarBg: '#0c1222',
|
||||
sidebarText: '#f9fafb'
|
||||
}
|
||||
});
|
||||
// Reset CSS variables
|
||||
const root = document.documentElement;
|
||||
root.style.removeProperty('--color-bg-main');
|
||||
root.style.removeProperty('--color-bg-card');
|
||||
root.style.removeProperty('--color-bg-elevated');
|
||||
root.style.removeProperty('--color-text-primary');
|
||||
root.style.removeProperty('--color-text-secondary');
|
||||
root.style.removeProperty('--color-border');
|
||||
root.style.removeProperty('--color-sidebar-bg');
|
||||
root.style.removeProperty('--color-sidebar-text');
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="main-content theme-settings-root">
|
||||
{/* Tab Tooltip */}
|
||||
{tooltip.visible && (
|
||||
<div
|
||||
className="admin-tab-tooltip"
|
||||
style={{ left: tooltip.left }}
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
)}
|
||||
{/* Modern Tab Navigation */}
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="material-symbols-outlined">brush</span>
|
||||
<span className="page-title-text">{t.theme.title}</span>
|
||||
</div>
|
||||
<div className="admin-tabs-divider"></div>
|
||||
<button
|
||||
className={`admin-tab-btn ${activeTab === 'colors' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('colors')}
|
||||
onMouseEnter={(e) => handleTabMouseEnter(t.theme.colorsTab, e)}
|
||||
onMouseLeave={handleTabMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">color_lens</span>
|
||||
<span>{t.theme.colorsTab}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab-btn ${activeTab === 'appearance' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('appearance')}
|
||||
onMouseEnter={(e) => handleTabMouseEnter(t.theme.appearanceTab, e)}
|
||||
onMouseLeave={handleTabMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">tune</span>
|
||||
<span>{t.theme.appearanceTab}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab-btn ${activeTab === 'preview' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('preview')}
|
||||
onMouseEnter={(e) => handleTabMouseEnter(t.theme.previewTab, e)}
|
||||
onMouseLeave={handleTabMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">visibility</span>
|
||||
<span>{t.theme.previewTab}</span>
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
className={`admin-tab-btn ${activeTab === 'advanced' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('advanced')}
|
||||
onMouseEnter={(e) => handleTabMouseEnter(t.theme.advancedTab, e)}
|
||||
onMouseLeave={handleTabMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">code</span>
|
||||
<span>{t.theme.advancedTab}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-tab-content">
|
||||
{activeTab === 'colors' && (
|
||||
<div className="theme-tab-content">
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.theme.accentColor}</h3>
|
||||
</div>
|
||||
<div className="color-grid-enhanced">
|
||||
{colors.map((color) => (
|
||||
<div
|
||||
key={color.id}
|
||||
className={`color-card ${accentColor === color.id ? 'active' : ''}`}
|
||||
onClick={() => handleAccentColorChange(color.id)}
|
||||
>
|
||||
<div className="color-swatch-large" style={{ backgroundColor: color.value }}>
|
||||
{accentColor === color.id && (
|
||||
<span className="material-symbols-outlined">check</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="color-info">
|
||||
<span className="color-name">{color.label}</span>
|
||||
<span className="color-description">{color.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.theme.colorPalette}</h3>
|
||||
</div>
|
||||
<div className="palette-grid">
|
||||
{palettes.map((palette) => {
|
||||
const paletteColors = COLOR_PALETTES[palette.id];
|
||||
return (
|
||||
<div
|
||||
key={palette.id}
|
||||
className={`palette-card ${colorPalette === palette.id ? 'active' : ''}`}
|
||||
onClick={() => handleColorPaletteChange(palette.id)}
|
||||
>
|
||||
<div className="palette-preview">
|
||||
<div className="palette-swatch-row">
|
||||
<div className="palette-swatch" style={{ backgroundColor: paletteColors.light.bgMain }} title="Light BG" />
|
||||
<div className="palette-swatch" style={{ backgroundColor: paletteColors.light.bgCard }} title="Light Card" />
|
||||
<div className="palette-swatch" style={{ backgroundColor: paletteColors.light.textPrimary }} title="Light Text" />
|
||||
<div className="palette-swatch" style={{ backgroundColor: paletteColors.light.sidebarBg }} title="Light Sidebar" />
|
||||
</div>
|
||||
<div className="palette-swatch-row">
|
||||
<div className="palette-swatch" style={{ backgroundColor: paletteColors.dark.bgMain }} title="Dark BG" />
|
||||
<div className="palette-swatch" style={{ backgroundColor: paletteColors.dark.bgCard }} title="Dark Card" />
|
||||
<div className="palette-swatch" style={{ backgroundColor: paletteColors.dark.textPrimary }} title="Dark Text" />
|
||||
<div className="palette-swatch" style={{ backgroundColor: paletteColors.dark.sidebarBg }} title="Dark Sidebar" />
|
||||
</div>
|
||||
{colorPalette === palette.id && (
|
||||
<div className="palette-check">
|
||||
<span className="material-symbols-outlined">check</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="palette-info">
|
||||
<span className="palette-name">{palette.label}</span>
|
||||
<span className="palette-description">{palette.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'appearance' && (
|
||||
<div className="theme-tab-content">
|
||||
<div className="appearance-grid">
|
||||
{/* Border Radius Section */}
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.theme.borderRadius}</h3>
|
||||
</div>
|
||||
<div className="option-cards">
|
||||
{radii.map((radius) => (
|
||||
<div
|
||||
key={radius.id}
|
||||
className={`option-card ${borderRadius === radius.id ? 'active' : ''}`}
|
||||
onClick={() => handleBorderRadiusChange(radius.id)}
|
||||
>
|
||||
<div className="option-preview">
|
||||
<div
|
||||
className="radius-preview-box"
|
||||
style={{ borderRadius: radius.preview }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="option-info">
|
||||
<span className="option-name">{radius.label}</span>
|
||||
<span className="option-description">{radius.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Style Section */}
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.theme.sidebarStyle}</h3>
|
||||
</div>
|
||||
<div className="option-cards">
|
||||
{sidebarStyles.map((style) => (
|
||||
<div
|
||||
key={style.id}
|
||||
className={`option-card ${sidebarStyle === style.id ? 'active' : ''}`}
|
||||
onClick={() => handleSidebarStyleChange(style.id)}
|
||||
>
|
||||
<div className="option-preview">
|
||||
<div className={`sidebar-preview sidebar-preview-${style.id}`}>
|
||||
<div className="sidebar-part"></div>
|
||||
<div className="content-part"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="option-info">
|
||||
<span className="option-name">{style.label}</span>
|
||||
<span className="option-description">{style.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Mode Section */}
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.admin.sidebarMode}</h3>
|
||||
</div>
|
||||
<div className="option-cards">
|
||||
{sidebarModes.map((mode) => (
|
||||
<div
|
||||
key={mode.id}
|
||||
className={`option-card ${sidebarMode === mode.id ? 'active' : ''}`}
|
||||
onClick={() => setSidebarMode(mode.id)}
|
||||
>
|
||||
<div className="option-preview">
|
||||
<div className={`sidebar-mode-preview sidebar-mode-${mode.id}`}>
|
||||
<div className="sidebar-line"></div>
|
||||
<div className="sidebar-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="option-info">
|
||||
<span className="option-name">{mode.label}</span>
|
||||
<span className="option-description">{mode.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Density Section */}
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.theme.density}</h3>
|
||||
</div>
|
||||
<div className="option-cards">
|
||||
{densities.map((d) => (
|
||||
<div
|
||||
key={d.id}
|
||||
className={`option-card ${density === d.id ? 'active' : ''}`}
|
||||
onClick={() => handleDensityChange(d.id)}
|
||||
>
|
||||
<div className="option-preview">
|
||||
<div className={`density-preview density-preview-${d.id}`}>
|
||||
<div className="density-line"></div>
|
||||
<div className="density-line"></div>
|
||||
<div className="density-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="option-info">
|
||||
<span className="option-name">{d.label}</span>
|
||||
<span className="option-description">{d.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font Family Section */}
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.theme.fontFamily}</h3>
|
||||
</div>
|
||||
<div className="option-cards">
|
||||
{fonts.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className={`option-card ${fontFamily === f.id ? 'active' : ''}`}
|
||||
onClick={() => handleFontFamilyChange(f.id)}
|
||||
>
|
||||
<div className="option-preview">
|
||||
<div className="font-preview" style={{ fontFamily: f.fontStyle }}>
|
||||
Aa
|
||||
</div>
|
||||
</div>
|
||||
<div className="option-info">
|
||||
<span className="option-name">{f.label}</span>
|
||||
<span className="option-description">{f.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'preview' && (
|
||||
<div className="theme-tab-content">
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.theme.preview}</h3>
|
||||
</div>
|
||||
<div className="preview-container">
|
||||
<div className="preview-card">
|
||||
<div className="preview-header">
|
||||
<h3>{t.theme.previewCard}</h3>
|
||||
<span className="badge badge-accent">{t.theme.badge}</span>
|
||||
</div>
|
||||
<p>{t.theme.previewDescription}</p>
|
||||
<div className="preview-actions">
|
||||
<button className="btn-primary">{t.theme.primaryButton}</button>
|
||||
<button className="btn-ghost">{t.theme.ghostButton}</button>
|
||||
</div>
|
||||
<div className="preview-inputs">
|
||||
<input type="text" placeholder={t.theme.inputPlaceholder} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="preview-card">
|
||||
<div className="preview-header">
|
||||
<h3>{t.theme.sampleCard}</h3>
|
||||
<span className="badge badge-success">{t.dashboard.active}</span>
|
||||
</div>
|
||||
<p>{t.theme.sampleCardDesc}</p>
|
||||
<div className="preview-stats">
|
||||
<div className="stat-item">
|
||||
<span className="stat-value">142</span>
|
||||
<span className="stat-label">{t.theme.totalItems}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-value">89%</span>
|
||||
<span className="stat-label">{t.theme.successRate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'advanced' && isAdmin && (
|
||||
<div className="theme-tab-content">
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.theme.advancedColors}</h3>
|
||||
<button className="btn-ghost" onClick={resetToDefaults} style={{ marginTop: '1rem' }}>
|
||||
<span className="material-symbols-outlined">refresh</span>
|
||||
{t.theme.resetColors}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="advanced-colors-grid" style={{ marginTop: '2rem' }}>
|
||||
{/* Light Theme Colors */}
|
||||
<div className="color-theme-section">
|
||||
<h3 className="color-theme-title">{t.theme.lightThemeColors}</h3>
|
||||
<div className="color-controls-list">
|
||||
<ColorControl
|
||||
theme="light"
|
||||
property="bgMain"
|
||||
label={t.theme.background}
|
||||
value={customColors.light.bgMain}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="light"
|
||||
property="bgCard"
|
||||
label={t.theme.backgroundCard}
|
||||
value={customColors.light.bgCard}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="light"
|
||||
property="bgElevated"
|
||||
label={t.theme.backgroundElevated}
|
||||
value={customColors.light.bgElevated}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="light"
|
||||
property="textPrimary"
|
||||
label={t.theme.textPrimary}
|
||||
value={customColors.light.textPrimary}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="light"
|
||||
property="textSecondary"
|
||||
label={t.theme.textSecondary}
|
||||
value={customColors.light.textSecondary}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="light"
|
||||
property="border"
|
||||
label={t.theme.border}
|
||||
value={customColors.light.border}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="light"
|
||||
property="sidebarBg"
|
||||
label={t.theme.sidebarBackground}
|
||||
value={customColors.light.sidebarBg}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="light"
|
||||
property="sidebarText"
|
||||
label={t.theme.sidebarText}
|
||||
value={customColors.light.sidebarText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dark Theme Colors */}
|
||||
<div className="color-theme-section">
|
||||
<h3 className="color-theme-title">{t.theme.darkThemeColors}</h3>
|
||||
<div className="color-controls-list">
|
||||
<ColorControl
|
||||
theme="dark"
|
||||
property="bgMain"
|
||||
label={t.theme.background}
|
||||
value={customColors.dark.bgMain}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="dark"
|
||||
property="bgCard"
|
||||
label={t.theme.backgroundCard}
|
||||
value={customColors.dark.bgCard}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="dark"
|
||||
property="bgElevated"
|
||||
label={t.theme.backgroundElevated}
|
||||
value={customColors.dark.bgElevated}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="dark"
|
||||
property="textPrimary"
|
||||
label={t.theme.textPrimary}
|
||||
value={customColors.dark.textPrimary}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="dark"
|
||||
property="textSecondary"
|
||||
label={t.theme.textSecondary}
|
||||
value={customColors.dark.textSecondary}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="dark"
|
||||
property="border"
|
||||
label={t.theme.border}
|
||||
value={customColors.dark.border}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="dark"
|
||||
property="sidebarBg"
|
||||
label={t.theme.sidebarBackground}
|
||||
value={customColors.dark.sidebarBg}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="dark"
|
||||
property="sidebarText"
|
||||
label={t.theme.sidebarText}
|
||||
value={customColors.dark.sidebarText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color Picker Popup */}
|
||||
{colorPickerState && (
|
||||
<div
|
||||
className="color-picker-overlay"
|
||||
onClick={() => setColorPickerState(null)}
|
||||
>
|
||||
<div
|
||||
className="color-picker-popup"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="color-picker-header">
|
||||
<h3>{t.theme.pickColor}</h3>
|
||||
<button
|
||||
className="btn-close-picker"
|
||||
onClick={() => setColorPickerState(null)}
|
||||
>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="color-picker-content">
|
||||
<div className="color-picker-preview-section">
|
||||
<div
|
||||
className="color-preview-box"
|
||||
style={{ backgroundColor: colorPickerState.value }}
|
||||
/>
|
||||
<div className="color-preview-info">
|
||||
<span className="color-preview-hex">{colorPickerState.value.toUpperCase()}</span>
|
||||
<span className="color-preview-label">{t.theme.currentColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chrome-picker-wrapper">
|
||||
<ChromePicker
|
||||
color={colorPickerState.value}
|
||||
onChange={(color) => {
|
||||
handleColorChange(
|
||||
colorPickerState.theme,
|
||||
colorPickerState.property,
|
||||
color.hex
|
||||
);
|
||||
setColorPickerState({
|
||||
...colorPickerState,
|
||||
value: color.hex
|
||||
});
|
||||
}}
|
||||
disableAlpha={true}
|
||||
/>
|
||||
<div className="hue-picker-wrapper">
|
||||
<HuePicker
|
||||
color={colorPickerState.value}
|
||||
onChange={(color) => {
|
||||
handleColorChange(
|
||||
colorPickerState.theme,
|
||||
colorPickerState.property,
|
||||
color.hex
|
||||
);
|
||||
setColorPickerState({
|
||||
...colorPickerState,
|
||||
value: color.hex
|
||||
});
|
||||
}}
|
||||
width="100%"
|
||||
height="16px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="color-picker-actions">
|
||||
<button
|
||||
className="btn-primary btn-full-width"
|
||||
onClick={() => setColorPickerState(null)}
|
||||
>
|
||||
{t.theme.apply}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user