Initial commit
This commit is contained in:
19
frontend/src/App.css
Normal file
19
frontend/src/App.css
Normal file
@@ -0,0 +1,19 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
font-size: 1.5rem;
|
||||
color: #667eea;
|
||||
}
|
||||
93
frontend/src/App.tsx
Normal file
93
frontend/src/App.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import type { ReactElement } from 'react';
|
||||
import { SiteConfigProvider } from './contexts/SiteConfigContext';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { SidebarProvider } from './contexts/SidebarContext';
|
||||
import { ViewModeProvider } from './contexts/ViewModeContext';
|
||||
import { ModulesProvider } from './contexts/ModulesContext';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Feature1 from './pages/Feature1';
|
||||
import AdminPanel from './pages/AdminPanel';
|
||||
import Sources from './pages/admin/Sources';
|
||||
import Features from './pages/admin/Features';
|
||||
import Settings from './pages/Settings';
|
||||
import ThemeSettings from './pages/admin/ThemeSettings';
|
||||
import './App.css';
|
||||
|
||||
function PrivateRoute({ children }: { children: ReactElement }) {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="loading">Loading...</div>;
|
||||
}
|
||||
|
||||
return user ? children : <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
function AdminRoute({ children }: { children: ReactElement }) {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="loading">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
return user.is_superuser ? children : <Navigate to="/dashboard" />;
|
||||
}
|
||||
|
||||
import MainLayout from './components/MainLayout';
|
||||
|
||||
// ...
|
||||
|
||||
function AppRoutes() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} />
|
||||
|
||||
<Route element={<PrivateRoute><MainLayout /></PrivateRoute>}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/feature1" element={<Feature1 />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
|
||||
<Route path="/admin" element={<AdminRoute><AdminPanel /></AdminRoute>} />
|
||||
<Route path="/admin/sources" element={<AdminRoute><Sources /></AdminRoute>} />
|
||||
<Route path="/admin/features" element={<AdminRoute><Features /></AdminRoute>} />
|
||||
<Route path="/admin/theme" element={<AdminRoute><ThemeSettings /></AdminRoute>} />
|
||||
</Route>
|
||||
|
||||
<Route path="/" element={<Navigate to={user ? '/dashboard' : '/login'} />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<SiteConfigProvider>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<AuthProvider>
|
||||
<ModulesProvider>
|
||||
<ViewModeProvider>
|
||||
<SidebarProvider>
|
||||
<AppRoutes />
|
||||
</SidebarProvider>
|
||||
</ViewModeProvider>
|
||||
</ModulesProvider>
|
||||
</AuthProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</SiteConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
95
frontend/src/api/client.ts
Normal file
95
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
Token,
|
||||
User,
|
||||
UserCreate,
|
||||
UserUpdatePayload,
|
||||
} from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Auth endpoints
|
||||
export const authAPI = {
|
||||
login: async (data: LoginRequest): Promise<Token> => {
|
||||
const response = await api.post<Token>('/auth/login', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
register: async (data: RegisterRequest): Promise<User> => {
|
||||
const response = await api.post<User>('/auth/register', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getCurrentUser: async (): Promise<User> => {
|
||||
const response = await api.get<User>('/auth/me');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Settings endpoints
|
||||
export const settingsAPI = {
|
||||
getTheme: async (): Promise<Record<string, string>> => {
|
||||
const response = await api.get<Record<string, string>>('/settings/theme');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTheme: async (data: Record<string, string>): Promise<Record<string, string>> => {
|
||||
const response = await api.put<Record<string, string>>('/settings/theme', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getModules: async (): Promise<Record<string, boolean | string>> => {
|
||||
const response = await api.get<Record<string, boolean | string>>('/settings/modules');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateModules: async (data: Record<string, boolean>): Promise<Record<string, boolean>> => {
|
||||
const response = await api.put<Record<string, boolean>>('/settings/modules', data);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Users endpoints
|
||||
export const usersAPI = {
|
||||
list: async (): Promise<User[]> => {
|
||||
const response = await api.get<User[]>('/users');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<User> => {
|
||||
const response = await api.get<User>(`/users/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: UserCreate): Promise<User> => {
|
||||
const response = await api.post<User>('/users', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UserUpdatePayload): Promise<User> => {
|
||||
const response = await api.put<User>(`/users/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/users/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
11
frontend/src/components/MainLayout.tsx
Normal file
11
frontend/src/components/MainLayout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
export default function MainLayout() {
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<Sidebar />
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/MobileHeader.tsx
Normal file
19
frontend/src/components/MobileHeader.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useSiteConfig } from '../contexts/SiteConfigContext';
|
||||
import '../styles/MobileHeader.css';
|
||||
|
||||
export default function MobileHeader() {
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
const { config } = useSiteConfig();
|
||||
|
||||
return (
|
||||
<header className="mobile-header">
|
||||
<button className="btn-hamburger" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<h1 className="mobile-title">{config.name}</h1>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
279
frontend/src/components/Sidebar.tsx
Normal file
279
frontend/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import { useViewMode } from '../contexts/ViewModeContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useModules } from '../contexts/ModulesContext';
|
||||
import { useSiteConfig } from '../contexts/SiteConfigContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { appModules } from '../modules';
|
||||
import UserMenu from './UserMenu';
|
||||
import '../styles/Sidebar.css';
|
||||
|
||||
export default function Sidebar() {
|
||||
const { t, language, setLanguage } = useTranslation();
|
||||
const { config } = useSiteConfig();
|
||||
const { sidebarStyle, theme, toggleTheme, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle } = useTheme();
|
||||
const {
|
||||
isCollapsed,
|
||||
isMobileOpen,
|
||||
sidebarMode,
|
||||
toggleCollapse,
|
||||
closeMobileMenu,
|
||||
isHovered,
|
||||
setIsHovered,
|
||||
showLogo: showLogoContext
|
||||
} = useSidebar();
|
||||
const { viewMode, toggleViewMode, isUserModeEnabled } = useViewMode();
|
||||
const { user } = useAuth();
|
||||
const { isModuleEnabled, isModuleEnabledForUser, hasInitialized: modulesInitialized } = useModules();
|
||||
|
||||
// When admin is in "user mode", show only user-permitted modules
|
||||
// Otherwise, show all globally enabled modules (admin view)
|
||||
const shouldUseUserPermissions = viewMode === 'user' || !user?.is_superuser;
|
||||
|
||||
// Don't show modules until initialization is complete to prevent flash
|
||||
const mainModules = !modulesInitialized ? [] : (appModules
|
||||
.find((cat) => cat.id === 'main')
|
||||
?.modules.filter((m) => {
|
||||
if (!m.enabled) return false;
|
||||
if (shouldUseUserPermissions) {
|
||||
return isModuleEnabledForUser(m.id, user?.permissions, user?.is_superuser || false);
|
||||
}
|
||||
return isModuleEnabled(m.id);
|
||||
}) || []);
|
||||
|
||||
const handleCollapseClick = () => {
|
||||
if (isMobileOpen) {
|
||||
closeMobileMenu();
|
||||
} else {
|
||||
toggleCollapse();
|
||||
}
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleNavClick = (e: React.MouseEvent, path: string) => {
|
||||
// Close mobile menu when clicking navigation items
|
||||
if (isMobileOpen) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMobileMenu();
|
||||
setTimeout(() => {
|
||||
navigate(path);
|
||||
}, 400);
|
||||
}
|
||||
};
|
||||
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
if (sidebarMode === 'dynamic' && isCollapsed && !isMobileOpen) {
|
||||
setIsHovered(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setIsHovered(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// derived state for expansion in dynamic mode
|
||||
const isDynamicExpanded = sidebarMode === 'dynamic' && isCollapsed && (isHovered || isUserMenuOpen);
|
||||
|
||||
const handleSidebarClick = (e: React.MouseEvent) => {
|
||||
// Only toggle if in toggle mode and not on mobile
|
||||
if (sidebarMode === 'toggle' && !isMobileOpen) {
|
||||
// Check if the click target is an interactive element or inside one
|
||||
const target = e.target as HTMLElement;
|
||||
const isInteractive = target.closest('a, button, [role="button"], input, select, textarea');
|
||||
|
||||
if (!isInteractive) {
|
||||
toggleCollapse();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Logo logic - use white logo on dark backgrounds, black logo on light backgrounds
|
||||
// sidebarStyle 'default' and 'dark' both use dark sidebar background
|
||||
// Only 'light' sidebarStyle uses a light background
|
||||
const isLightSidebar = sidebarStyle === 'light';
|
||||
const logoSrc = isLightSidebar ? '/logo_black.svg' : '/logo_white.svg';
|
||||
// Show toggle button ONLY on mobile
|
||||
const showToggle = isMobileOpen;
|
||||
// Show logo only if enabled in config AND toggle button is not present
|
||||
const showLogo = showLogoContext && !showToggle;
|
||||
|
||||
const [tooltip, setTooltip] = useState<{ text: string; top: number; visible: boolean }>({
|
||||
text: '',
|
||||
top: 0,
|
||||
visible: false,
|
||||
});
|
||||
|
||||
const handleItemMouseEnter = (text: string, e: React.MouseEvent) => {
|
||||
if (isCollapsed && !isMobileOpen && sidebarMode !== 'dynamic') {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setTooltip({
|
||||
text,
|
||||
top: rect.top + rect.height / 2,
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemMouseLeave = () => {
|
||||
setTooltip((prev) => ({ ...prev, visible: false }));
|
||||
};
|
||||
|
||||
const updateTooltipText = (text: string) => {
|
||||
setTooltip((prev) => prev.visible ? { ...prev, text } : prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
<div className={`sidebar-overlay ${isMobileOpen ? 'visible' : ''}`} onClick={closeMobileMenu} />
|
||||
|
||||
{/* Sidebar Tooltip */}
|
||||
{tooltip.visible && (
|
||||
<div
|
||||
className="sidebar-tooltip"
|
||||
style={{
|
||||
top: tooltip.top,
|
||||
left: 'calc(80px + 1.5rem)', // Sidebar width + gap
|
||||
}}
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`sidebar ${isCollapsed && !isMobileOpen ? 'collapsed' : ''} ${isMobileOpen ? 'open' : ''} ${sidebarMode === 'dynamic' ? 'dynamic' : ''} ${isDynamicExpanded ? 'expanded-force' : ''} ${sidebarMode === 'toggle' ? 'clickable' : ''}`}
|
||||
data-collapsed={isCollapsed}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleSidebarClick}
|
||||
>
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-header-content">
|
||||
{showLogo ? (
|
||||
<>
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt={config.name}
|
||||
className="sidebar-logo"
|
||||
onMouseEnter={(e) => handleItemMouseEnter(config.name, e)}
|
||||
onMouseLeave={handleItemMouseLeave}
|
||||
/>
|
||||
{(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
|
||||
<div className="sidebar-title">
|
||||
<h2>{config.name}</h2>
|
||||
<p className="sidebar-tagline">{config.tagline}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
|
||||
<div className="sidebar-title">
|
||||
<h2>{config.name}</h2>
|
||||
<p className="sidebar-tagline">{config.tagline}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{showToggle && (
|
||||
<button onClick={handleCollapseClick} className="btn-collapse" title={isMobileOpen ? 'Close' : (isCollapsed ? 'Expand' : 'Collapse')}>
|
||||
<span className="material-symbols-outlined">
|
||||
{isMobileOpen ? 'close' : (isCollapsed ? 'chevron_right' : 'chevron_left')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<div className="nav-section">
|
||||
{mainModules.map((module) => (
|
||||
<NavLink
|
||||
key={module.id}
|
||||
to={module.path}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={(e) => handleNavClick(e, module.path)}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(t.sidebar[module.id as keyof typeof t.sidebar], e)}
|
||||
onMouseLeave={handleItemMouseLeave}
|
||||
>
|
||||
<span className="nav-icon material-symbols-outlined">{module.icon}</span>
|
||||
<span className="nav-label">{t.sidebar[module.id as keyof typeof t.sidebar]}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
{user?.is_superuser && isUserModeEnabled && (
|
||||
<button
|
||||
className={`view-mode-toggle ${viewMode === 'user' ? 'user-mode' : 'admin-mode'}`}
|
||||
onClick={() => { toggleViewMode(); updateTooltipText(viewMode === 'admin' ? t.admin.userView : t.admin.adminView); }}
|
||||
title={viewMode === 'admin' ? t.admin.adminView : t.admin.userView}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(viewMode === 'admin' ? t.admin.adminView : t.admin.userView, e)}
|
||||
onMouseLeave={handleItemMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">
|
||||
{viewMode === 'admin' ? 'admin_panel_settings' : 'person'}
|
||||
</span>
|
||||
{(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
|
||||
<span className="view-mode-label">
|
||||
{viewMode === 'admin' ? t.admin.adminView : t.admin.userView}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showDarkModeToggle && darkModeLocation === 'sidebar' && (
|
||||
<button
|
||||
className="view-mode-toggle"
|
||||
onClick={() => { toggleTheme(); updateTooltipText(theme === 'dark' ? t.theme.lightMode : t.theme.darkMode); }}
|
||||
title={theme === 'dark' ? t.theme.darkMode : t.theme.lightMode}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(theme === 'dark' ? t.theme.darkMode : t.theme.lightMode, e)}
|
||||
onMouseLeave={handleItemMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">
|
||||
{theme === 'dark' ? 'dark_mode' : 'light_mode'}
|
||||
</span>
|
||||
{(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
|
||||
<span className="view-mode-label">
|
||||
{theme === 'dark' ? t.theme.darkMode : t.theme.lightMode}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showLanguageToggle && languageLocation === 'sidebar' && (
|
||||
<button
|
||||
className="view-mode-toggle"
|
||||
onClick={() => { setLanguage(language === 'it' ? 'en' : 'it'); updateTooltipText(language === 'it' ? t.settings.english : t.settings.italian); }}
|
||||
title={language === 'it' ? t.settings.italian : t.settings.english}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(language === 'it' ? t.settings.italian : t.settings.english, e)}
|
||||
onMouseLeave={handleItemMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">language</span>
|
||||
{(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
|
||||
<span className="view-mode-label">
|
||||
{language === 'it' ? t.settings.italian : t.settings.english}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<UserMenu onOpenChange={setIsUserMenuOpen} />
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
159
frontend/src/components/UserMenu.tsx
Normal file
159
frontend/src/components/UserMenu.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import { useViewMode } from '../contexts/ViewModeContext';
|
||||
|
||||
export default function UserMenu({ onOpenChange }: { onOpenChange?: (isOpen: boolean) => void }) {
|
||||
const { user, logout } = useAuth();
|
||||
const { t, language, setLanguage } = useTranslation();
|
||||
const { theme, toggleTheme, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle } = useTheme();
|
||||
const { isCollapsed, isMobileOpen, closeMobileMenu, sidebarMode } = useSidebar();
|
||||
const { viewMode } = useViewMode();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
onOpenChange?.(isOpen);
|
||||
}, [isOpen, onOpenChange]);
|
||||
|
||||
const toggleMenu = () => setIsOpen(!isOpen);
|
||||
|
||||
const toggleLanguage = () => {
|
||||
setLanguage(language === 'it' ? 'en' : 'it');
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleNavClick = (e: React.MouseEvent, path: string) => {
|
||||
setIsOpen(false);
|
||||
// Close mobile sidebar when clicking navigation items
|
||||
if (isMobileOpen) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMobileMenu();
|
||||
setTimeout(() => {
|
||||
navigate(path);
|
||||
}, 400);
|
||||
}
|
||||
};
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className="user-menu-container" ref={menuRef}>
|
||||
{isOpen && (
|
||||
<div className="user-menu-dropdown">
|
||||
|
||||
|
||||
{user?.is_superuser && viewMode === 'admin' && (
|
||||
<>
|
||||
<nav className="user-menu-nav">
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className="user-menu-item"
|
||||
onClick={(e) => handleNavClick(e, '/admin')}
|
||||
>
|
||||
<span className="material-symbols-outlined">admin_panel_settings</span>
|
||||
<span>{t.admin.panel}</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/sources"
|
||||
className="user-menu-item"
|
||||
onClick={(e) => handleNavClick(e, '/admin/sources')}
|
||||
>
|
||||
<span className="material-symbols-outlined">database</span>
|
||||
<span>{t.sourcesPage.title}</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/features"
|
||||
className="user-menu-item"
|
||||
onClick={(e) => handleNavClick(e, '/admin/features')}
|
||||
>
|
||||
<span className="material-symbols-outlined">extension</span>
|
||||
<span>{t.featuresPage.title}</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/theme"
|
||||
className="user-menu-item"
|
||||
onClick={(e) => handleNavClick(e, '/admin/theme')}
|
||||
>
|
||||
<span className="material-symbols-outlined">brush</span>
|
||||
<span>{t.theme.title}</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="user-menu-divider" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="user-menu-actions">
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className="user-menu-item"
|
||||
onClick={(e) => handleNavClick(e, '/settings')}
|
||||
>
|
||||
<span className="material-symbols-outlined">settings</span>
|
||||
<span>{t.sidebar.settings}</span>
|
||||
</NavLink>
|
||||
{showDarkModeToggle && darkModeLocation === 'user_menu' && (
|
||||
<button onClick={toggleTheme} className="user-menu-item">
|
||||
<span className="material-symbols-outlined">
|
||||
{theme === 'dark' ? 'dark_mode' : 'light_mode'}
|
||||
</span>
|
||||
<span>{theme === 'dark' ? 'Dark Mode' : 'Light Mode'}</span>
|
||||
</button>
|
||||
)}
|
||||
{showLanguageToggle && languageLocation === 'user_menu' && (
|
||||
<button onClick={toggleLanguage} className="user-menu-item">
|
||||
<span className="material-symbols-outlined">language</span>
|
||||
<span>{language === 'it' ? 'Italiano' : 'English'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="user-menu-divider" />
|
||||
|
||||
<button onClick={logout} className="user-menu-item danger">
|
||||
<span className="material-symbols-outlined">logout</span>
|
||||
<span>{t.auth.logout}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`user-menu-trigger ${isOpen ? 'active' : ''}`}
|
||||
onClick={toggleMenu}
|
||||
title={t.dashboard.profile}
|
||||
>
|
||||
<div className="user-info-compact">
|
||||
<span className="user-initial">{user?.username?.charAt(0).toUpperCase()}</span>
|
||||
{(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
|
||||
<>
|
||||
<span className="user-name">{user?.username}</span>
|
||||
<span className="material-symbols-outlined chevron">
|
||||
expand_more
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/admin/Feature1Tab.tsx
Normal file
15
frontend/src/components/admin/Feature1Tab.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useTranslation } from '../../contexts/LanguageContext';
|
||||
|
||||
export default function Feature1Tab() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="tab-content-placeholder">
|
||||
<div className="placeholder-icon">
|
||||
<span className="material-symbols-outlined">playlist_play</span>
|
||||
</div>
|
||||
<h3>{t.feature1.management}</h3>
|
||||
<p>{t.feature1.comingSoon}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
358
frontend/src/components/admin/GeneralTab.tsx
Normal file
358
frontend/src/components/admin/GeneralTab.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../../contexts/LanguageContext';
|
||||
import { useViewMode } from '../../contexts/ViewModeContext';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
import { useSidebar } from '../../contexts/SidebarContext';
|
||||
|
||||
export default function GeneralTab() {
|
||||
const { t } = useTranslation();
|
||||
const { isUserModeEnabled, setUserModeEnabled } = useViewMode();
|
||||
const { showLogo, setShowLogo } = useSidebar();
|
||||
const {
|
||||
darkModeLocation,
|
||||
setDarkModeLocation,
|
||||
languageLocation,
|
||||
setLanguageLocation,
|
||||
showDarkModeToggle,
|
||||
setShowDarkModeToggle,
|
||||
showLanguageToggle,
|
||||
setShowLanguageToggle,
|
||||
showDarkModeLogin,
|
||||
setShowDarkModeLogin,
|
||||
showLanguageLogin,
|
||||
setShowLanguageLogin,
|
||||
saveThemeToBackend
|
||||
} = useTheme();
|
||||
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
||||
const [savingRegistration, setSavingRegistration] = useState(false);
|
||||
const [savingShowLogo, setSavingShowLogo] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/settings', {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Helper to check for true values (bool, string 'true'/'True', 1)
|
||||
const isTrue = (val: any) => {
|
||||
if (val === true) return true;
|
||||
if (typeof val === 'string' && val.toLowerCase() === 'true') return true;
|
||||
if (val === 1) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Default to true if undefined/null, otherwise check value
|
||||
const regEnabled = data.registration_enabled;
|
||||
setRegistrationEnabled(
|
||||
regEnabled === undefined || regEnabled === null ? true : isTrue(regEnabled)
|
||||
);
|
||||
|
||||
// Update sidebar context
|
||||
if (data.show_logo !== undefined) {
|
||||
setShowLogo(isTrue(data.show_logo));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, [setShowLogo]);
|
||||
|
||||
const handleRegistrationToggle = async (checked: boolean) => {
|
||||
setRegistrationEnabled(checked); // Optimistic update
|
||||
setSavingRegistration(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/settings/registration_enabled', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ value: checked }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setRegistrationEnabled(data.value);
|
||||
} else {
|
||||
// Revert on failure
|
||||
setRegistrationEnabled(!checked);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update registration setting:', error);
|
||||
setRegistrationEnabled(!checked);
|
||||
} finally {
|
||||
setSavingRegistration(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowLogoToggle = async (checked: boolean) => {
|
||||
setShowLogo(checked); // Optimistic update
|
||||
setSavingShowLogo(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/settings/show_logo', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ value: checked }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setShowLogo(data.value);
|
||||
} else {
|
||||
// Revert on failure
|
||||
setShowLogo(!checked);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update show logo setting:', error);
|
||||
setShowLogo(!checked);
|
||||
} finally {
|
||||
setSavingShowLogo(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="general-tab-content">
|
||||
{/* Interface Section */}
|
||||
<div className="general-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.admin.viewMode}</h3>
|
||||
</div>
|
||||
<div className="modules-grid">
|
||||
<div className="setting-card-compact">
|
||||
<div className="setting-info">
|
||||
<div className="setting-icon">
|
||||
<span className="material-symbols-outlined">visibility</span>
|
||||
</div>
|
||||
<div className="setting-text">
|
||||
<h4>{t.admin.userModeToggle}</h4>
|
||||
<p>{t.admin.userModeToggleDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-modern">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isUserModeEnabled}
|
||||
onChange={(e) => setUserModeEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-card-compact">
|
||||
<div className="setting-info">
|
||||
<div className="setting-icon">
|
||||
<span className="material-symbols-outlined">person_add</span>
|
||||
</div>
|
||||
<div className="setting-text">
|
||||
<h4>{t.settings.allowRegistration}</h4>
|
||||
<p>{t.settings.allowRegistrationDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-modern">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={registrationEnabled}
|
||||
onChange={(e) => handleRegistrationToggle(e.target.checked)}
|
||||
disabled={savingRegistration}
|
||||
/>
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-card-compact">
|
||||
<div className="setting-info">
|
||||
<div className="setting-icon">
|
||||
<span className="material-symbols-outlined">branding_watermark</span>
|
||||
</div>
|
||||
<div className="setting-text">
|
||||
<h4>{t.settings.showLogo}</h4>
|
||||
<p>{t.settings.showLogoDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-modern">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLogo}
|
||||
onChange={(e) => handleShowLogoToggle(e.target.checked)}
|
||||
disabled={savingShowLogo}
|
||||
/>
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dark Mode Settings Group */}
|
||||
<div className="general-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.admin.darkModeSettings}</h3>
|
||||
</div>
|
||||
<div className="modules-grid">
|
||||
<div className="setting-card-compact">
|
||||
<div className="setting-info">
|
||||
<div className="setting-icon">
|
||||
<span className="material-symbols-outlined">dark_mode</span>
|
||||
</div>
|
||||
<div className="setting-text">
|
||||
<h4>{t.admin.enableDarkModeToggle}</h4>
|
||||
<p>{t.admin.enableDarkModeToggleDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-modern">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDarkModeToggle}
|
||||
onChange={(e) => {
|
||||
setShowDarkModeToggle(e.target.checked);
|
||||
saveThemeToBackend({ showDarkModeToggle: e.target.checked });
|
||||
}}
|
||||
/>
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{showDarkModeToggle && (
|
||||
<>
|
||||
<div className="setting-card-compact">
|
||||
<div className="setting-info">
|
||||
<div className="setting-icon">
|
||||
<span className="material-symbols-outlined">login</span>
|
||||
</div>
|
||||
<div className="setting-text">
|
||||
<h4>{t.admin.showOnLoginScreen}</h4>
|
||||
<p>{t.admin.showOnLoginScreenDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-modern">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDarkModeLogin}
|
||||
onChange={(e) => {
|
||||
setShowDarkModeLogin(e.target.checked);
|
||||
saveThemeToBackend({ showDarkModeLogin: e.target.checked });
|
||||
}}
|
||||
/>
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-card-compact">
|
||||
<div className="setting-info">
|
||||
<div className="setting-icon">
|
||||
<span className="material-symbols-outlined">splitscreen</span>
|
||||
</div>
|
||||
<div className="setting-text">
|
||||
<h4>{t.admin.controlLocation}</h4>
|
||||
<p>{t.admin.controlLocationDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-modern">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={darkModeLocation === 'sidebar'}
|
||||
onChange={(e) => {
|
||||
const newLocation = e.target.checked ? 'sidebar' : 'user_menu';
|
||||
setDarkModeLocation(newLocation);
|
||||
saveThemeToBackend({ darkModeLocation: newLocation });
|
||||
}}
|
||||
/>
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language Settings Group */}
|
||||
<div className="general-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.admin.languageSettings}</h3>
|
||||
</div>
|
||||
<div className="modules-grid">
|
||||
<div className="setting-card-compact">
|
||||
<div className="setting-info">
|
||||
<div className="setting-icon">
|
||||
<span className="material-symbols-outlined">language</span>
|
||||
</div>
|
||||
<div className="setting-text">
|
||||
<h4>{t.admin.enableLanguageSelector}</h4>
|
||||
<p>{t.admin.enableLanguageSelectorDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-modern">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLanguageToggle}
|
||||
onChange={(e) => {
|
||||
setShowLanguageToggle(e.target.checked);
|
||||
saveThemeToBackend({ showLanguageToggle: e.target.checked });
|
||||
}}
|
||||
/>
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{showLanguageToggle && (
|
||||
<>
|
||||
<div className="setting-card-compact">
|
||||
<div className="setting-info">
|
||||
<div className="setting-icon">
|
||||
<span className="material-symbols-outlined">login</span>
|
||||
</div>
|
||||
<div className="setting-text">
|
||||
<h4>{t.admin.showOnLoginScreen}</h4>
|
||||
<p>{t.admin.showLanguageOnLoginDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-modern">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLanguageLogin}
|
||||
onChange={(e) => {
|
||||
setShowLanguageLogin(e.target.checked);
|
||||
saveThemeToBackend({ showLanguageLogin: e.target.checked });
|
||||
}}
|
||||
/>
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-card-compact">
|
||||
<div className="setting-info">
|
||||
<div className="setting-icon">
|
||||
<span className="material-symbols-outlined">splitscreen</span>
|
||||
</div>
|
||||
<div className="setting-text">
|
||||
<h4>{t.admin.controlLocation}</h4>
|
||||
<p>{t.admin.controlLocationDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-modern">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={languageLocation === 'sidebar'}
|
||||
onChange={(e) => {
|
||||
const newLocation = e.target.checked ? 'sidebar' : 'user_menu';
|
||||
setLanguageLocation(newLocation);
|
||||
saveThemeToBackend({ languageLocation: newLocation });
|
||||
}}
|
||||
/>
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
frontend/src/components/admin/SettingsTab.tsx
Normal file
98
frontend/src/components/admin/SettingsTab.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from '../../contexts/LanguageContext';
|
||||
|
||||
interface Settings {
|
||||
registration_enabled?: boolean;
|
||||
}
|
||||
|
||||
export default function SettingsTab() {
|
||||
const { t } = useTranslation();
|
||||
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="settings-tab-content">
|
||||
<div className="settings-section-modern">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.settings.authentication}</h3>
|
||||
</div>
|
||||
|
||||
<div className="setting-item-modern">
|
||||
<div className="setting-info-modern">
|
||||
<h4>{t.settings.allowRegistration}</h4>
|
||||
<p>{t.settings.allowRegistrationDesc}</p>
|
||||
</div>
|
||||
<label className="toggle-modern">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.registration_enabled !== false}
|
||||
onChange={(e) => handleRegistrationToggle(e.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
819
frontend/src/components/admin/UsersTab.tsx
Normal file
819
frontend/src/components/admin/UsersTab.tsx
Normal file
@@ -0,0 +1,819 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useTranslation } from '../../contexts/LanguageContext';
|
||||
import { useModules, TOGGLEABLE_MODULES } from '../../contexts/ModulesContext';
|
||||
import { usersAPI } from '../../api/client';
|
||||
import type { User, UserCreate, UserUpdatePayload, UserPermissions } from '../../types';
|
||||
|
||||
type UserFormData = {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
hasCustomPermissions: boolean;
|
||||
permissions: UserPermissions;
|
||||
};
|
||||
|
||||
type SortColumn = 'username' | 'created_at' | 'last_login' | 'is_active' | 'is_superuser';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
// Get default permissions (all modules enabled)
|
||||
const getDefaultPermissions = (): UserPermissions => {
|
||||
const perms: UserPermissions = {};
|
||||
TOGGLEABLE_MODULES.forEach(m => {
|
||||
perms[m.id] = true;
|
||||
});
|
||||
return perms;
|
||||
};
|
||||
|
||||
const emptyForm: UserFormData = {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
hasCustomPermissions: false,
|
||||
permissions: getDefaultPermissions(),
|
||||
};
|
||||
|
||||
// Helper function to format dates
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('it-IT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export default function UsersTab() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const { moduleStates } = useModules();
|
||||
|
||||
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('');
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('username');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [showActive, setShowActive] = useState(true);
|
||||
const [showInactive, setShowInactive] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const usersData = await usersAPI.list();
|
||||
setUsers(usersData);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || t.usersPage.errorLoading);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadUsers();
|
||||
}, [t.usersPage.errorLoading]);
|
||||
|
||||
const handleSort = (column: SortColumn) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortUsers = (userList: User[]): User[] => {
|
||||
return [...userList].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortColumn) {
|
||||
case 'username':
|
||||
comparison = a.username.localeCompare(b.username);
|
||||
break;
|
||||
case 'created_at':
|
||||
comparison = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
break;
|
||||
case 'last_login':
|
||||
const aLogin = a.last_login ? new Date(a.last_login).getTime() : 0;
|
||||
const bLogin = b.last_login ? new Date(b.last_login).getTime() : 0;
|
||||
comparison = aLogin - bLogin;
|
||||
break;
|
||||
case 'is_active':
|
||||
comparison = (a.is_active === b.is_active) ? 0 : a.is_active ? -1 : 1;
|
||||
break;
|
||||
case 'is_superuser':
|
||||
comparison = (a.is_superuser === b.is_superuser) ? 0 : a.is_superuser ? -1 : 1;
|
||||
break;
|
||||
}
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
};
|
||||
|
||||
const { superusers, regularUsers } = useMemo(() => {
|
||||
// First filter by search term
|
||||
const term = searchTerm.toLowerCase().trim();
|
||||
let filtered = users;
|
||||
if (term) {
|
||||
filtered = users.filter(
|
||||
(u) =>
|
||||
u.username.toLowerCase().includes(term) ||
|
||||
u.email.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
// Then filter by status badges
|
||||
filtered = filtered.filter((u) => {
|
||||
if (u.is_active && !showActive) return false;
|
||||
if (!u.is_active && !showInactive) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Separate and sort
|
||||
const supers = sortUsers(filtered.filter((u) => u.is_superuser));
|
||||
const regulars = sortUsers(filtered.filter((u) => !u.is_superuser));
|
||||
|
||||
return { superusers: supers, regularUsers: regulars };
|
||||
}, [users, searchTerm, showActive, showInactive, sortColumn, sortDirection]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
return [...superusers, ...regularUsers];
|
||||
}, [superusers, regularUsers]);
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingUser(null);
|
||||
setFormData({ ...emptyForm });
|
||||
setModalOpen(true);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const openEditModal = (user: User) => {
|
||||
setEditingUser(user);
|
||||
// Check if user has custom permissions (non-empty object)
|
||||
const hasCustom = user.permissions && Object.keys(user.permissions).length > 0;
|
||||
// Merge user's existing permissions with defaults (in case new modules were added)
|
||||
const userPerms = { ...getDefaultPermissions(), ...(user.permissions || {}) };
|
||||
setFormData({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: '',
|
||||
is_active: user.is_active,
|
||||
is_superuser: user.is_superuser,
|
||||
hasCustomPermissions: hasCustom,
|
||||
permissions: userPerms,
|
||||
});
|
||||
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,
|
||||
// Only send permissions if custom permissions are enabled, otherwise send empty object (inherit global)
|
||||
permissions: formData.hasCustomPermissions ? formData.permissions : {},
|
||||
};
|
||||
|
||||
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,
|
||||
// Only send permissions if custom permissions are enabled
|
||||
permissions: formData.hasCustomPermissions ? formData.permissions : undefined,
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="users-root">
|
||||
<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="toolbar-right">
|
||||
<span
|
||||
className="badge badge-success"
|
||||
style={{ cursor: 'pointer', opacity: showActive ? 1 : 0.4 }}
|
||||
onClick={() => setShowActive(!showActive)}
|
||||
>
|
||||
{users.filter((u) => u.is_active).length} {t.usersPage.active}
|
||||
</span>
|
||||
<span
|
||||
className="badge badge-neutral"
|
||||
style={{ cursor: 'pointer', opacity: showInactive ? 1 : 0.4 }}
|
||||
onClick={() => setShowInactive(!showInactive)}
|
||||
>
|
||||
{users.filter((u) => !u.is_active).length} {t.usersPage.inactive}
|
||||
</span>
|
||||
<button className="btn-primary btn-sm" onClick={openCreateModal}>
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
{t.usersPage.addUser}
|
||||
</button>
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
{/* Superusers Table */}
|
||||
{superusers.length > 0 && (
|
||||
<div className="users-card">
|
||||
<div className="users-table-wrapper">
|
||||
<table className="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sortable" onClick={() => handleSort('username')}>
|
||||
{t.usersPage.name}
|
||||
{sortColumn === 'username' && (
|
||||
<span className="material-symbols-outlined sort-icon">
|
||||
{sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th className="sortable" onClick={() => handleSort('created_at')}>
|
||||
{t.usersPage.createdAt}
|
||||
{sortColumn === 'created_at' && (
|
||||
<span className="material-symbols-outlined sort-icon">
|
||||
{sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th className="sortable" onClick={() => handleSort('last_login')}>
|
||||
{t.usersPage.lastLogin}
|
||||
{sortColumn === 'last_login' && (
|
||||
<span className="material-symbols-outlined sort-icon">
|
||||
{sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th className="sortable" onClick={() => handleSort('is_active')}>
|
||||
{t.usersPage.status}
|
||||
{sortColumn === 'is_active' && (
|
||||
<span className="material-symbols-outlined sort-icon">
|
||||
{sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th>
|
||||
{t.usersPage.role}
|
||||
</th>
|
||||
<th>{t.usersPage.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{superusers.map((u) => (
|
||||
<tr key={u.id} className="superuser-row">
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<div className="user-avatar superuser">
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="user-meta">
|
||||
<span className="user-name selectable">{u.username}</span>
|
||||
<span className="user-email selectable">{u.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="user-date selectable">{formatDate(u.created_at)}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="user-date selectable">
|
||||
{u.last_login ? formatDate(u.last_login) : '-'}
|
||||
</span>
|
||||
</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 badge-accent">
|
||||
{t.usersPage.superuser}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="users-actions-icons">
|
||||
<button
|
||||
className="btn-icon-action btn-edit"
|
||||
onClick={() => openEditModal(u)}
|
||||
disabled={isSaving}
|
||||
title={t.usersPage.edit}
|
||||
>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon-action btn-delete"
|
||||
onClick={() => handleDelete(u)}
|
||||
disabled={isSaving || currentUser?.id === u.id}
|
||||
title={
|
||||
currentUser?.id === u.id
|
||||
? t.usersPage.selfDeleteWarning
|
||||
: t.usersPage.delete
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Regular Users Table */}
|
||||
{regularUsers.length > 0 && (
|
||||
<div className="users-card" style={{ marginTop: superusers.length > 0 ? '1rem' : 0 }}>
|
||||
<div className="users-table-wrapper">
|
||||
<table className="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sortable" onClick={() => handleSort('username')}>
|
||||
{t.usersPage.name}
|
||||
{sortColumn === 'username' && (
|
||||
<span className="material-symbols-outlined sort-icon">
|
||||
{sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th className="sortable" onClick={() => handleSort('created_at')}>
|
||||
{t.usersPage.createdAt}
|
||||
{sortColumn === 'created_at' && (
|
||||
<span className="material-symbols-outlined sort-icon">
|
||||
{sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th className="sortable" onClick={() => handleSort('last_login')}>
|
||||
{t.usersPage.lastLogin}
|
||||
{sortColumn === 'last_login' && (
|
||||
<span className="material-symbols-outlined sort-icon">
|
||||
{sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th className="sortable" onClick={() => handleSort('is_active')}>
|
||||
{t.usersPage.status}
|
||||
{sortColumn === 'is_active' && (
|
||||
<span className="material-symbols-outlined sort-icon">
|
||||
{sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th>
|
||||
{t.usersPage.role}
|
||||
</th>
|
||||
<th>{t.usersPage.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{regularUsers.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 selectable">{u.username}</span>
|
||||
<span className="user-email selectable">{u.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="user-date selectable">{formatDate(u.created_at)}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="user-date selectable">
|
||||
{u.last_login ? formatDate(u.last_login) : '-'}
|
||||
</span>
|
||||
</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 badge-neutral">
|
||||
{t.usersPage.regular}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="users-actions-icons">
|
||||
<button
|
||||
className="btn-icon-action btn-edit"
|
||||
onClick={() => openEditModal(u)}
|
||||
disabled={isSaving}
|
||||
title={t.usersPage.edit}
|
||||
>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon-action btn-delete"
|
||||
onClick={() => handleDelete(u)}
|
||||
disabled={isSaving || currentUser?.id === u.id}
|
||||
title={
|
||||
currentUser?.id === u.id
|
||||
? t.usersPage.selfDeleteWarning
|
||||
: t.usersPage.delete
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<div className="mobile-users-list">
|
||||
{superusers.map((u) => (
|
||||
<div key={u.id} className="mobile-user-card superuser-card">
|
||||
<div className="mobile-user-header">
|
||||
<div className="user-avatar superuser">
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="mobile-user-info">
|
||||
<div className="mobile-user-name selectable">{u.username}</div>
|
||||
<div className="mobile-user-email selectable">{u.email}</div>
|
||||
</div>
|
||||
<div className="mobile-user-actions">
|
||||
<button
|
||||
className="btn-icon-action btn-edit"
|
||||
onClick={() => openEditModal(u)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon-action btn-delete"
|
||||
onClick={() => handleDelete(u)}
|
||||
disabled={isSaving || currentUser?.id === u.id}
|
||||
>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mobile-user-footer">
|
||||
<div className="mobile-badges-row">
|
||||
<span className={`badge ${u.is_active ? 'badge-success' : 'badge-muted'}`}>
|
||||
{u.is_active ? t.usersPage.active : t.usersPage.inactive}
|
||||
</span>
|
||||
<span className="badge badge-accent">
|
||||
{t.usersPage.superuser}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mobile-dates">
|
||||
<span className="mobile-date-item" title={t.usersPage.createdAt}>
|
||||
<span className="material-symbols-outlined">calendar_add_on</span>
|
||||
<span className="selectable">{formatDate(u.created_at)}</span>
|
||||
</span>
|
||||
<span className="mobile-date-item" title={t.usersPage.lastLogin}>
|
||||
<span className="material-symbols-outlined">login</span>
|
||||
<span className="selectable">{u.last_login ? formatDate(u.last_login) : '-'}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Mobile Divider */}
|
||||
{superusers.length > 0 && regularUsers.length > 0 && (
|
||||
<div className="mobile-users-divider" />
|
||||
)}
|
||||
|
||||
{/* Regular Users */}
|
||||
{regularUsers.map((u) => (
|
||||
<div key={u.id} className="mobile-user-card">
|
||||
<div className="mobile-user-header">
|
||||
<div className="user-avatar">
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="mobile-user-info">
|
||||
<div className="mobile-user-name selectable">{u.username}</div>
|
||||
<div className="mobile-user-email selectable">{u.email}</div>
|
||||
</div>
|
||||
<div className="mobile-user-actions">
|
||||
<button
|
||||
className="btn-icon-action btn-edit"
|
||||
onClick={() => openEditModal(u)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon-action btn-delete"
|
||||
onClick={() => handleDelete(u)}
|
||||
disabled={isSaving || currentUser?.id === u.id}
|
||||
>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mobile-user-footer">
|
||||
<div className="mobile-badges-row">
|
||||
<span className={`badge ${u.is_active ? 'badge-success' : 'badge-muted'}`}>
|
||||
{u.is_active ? t.usersPage.active : t.usersPage.inactive}
|
||||
</span>
|
||||
<span className="badge badge-neutral">
|
||||
{t.usersPage.regular}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mobile-dates">
|
||||
<span className="mobile-date-item" title={t.usersPage.createdAt}>
|
||||
<span className="material-symbols-outlined">calendar_add_on</span>
|
||||
<span className="selectable">{formatDate(u.created_at)}</span>
|
||||
</span>
|
||||
<span className="mobile-date-item" title={t.usersPage.lastLogin}>
|
||||
<span className="material-symbols-outlined">login</span>
|
||||
<span className="selectable">{u.last_login ? formatDate(u.last_login) : '-'}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isModalOpen && createPortal(
|
||||
<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-close-modal" 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>
|
||||
|
||||
{/* Status Section */}
|
||||
<div className="form-section">
|
||||
<label className="form-section-title">{t.usersPage.status}</label>
|
||||
<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>
|
||||
|
||||
{/* Permissions Section - only show for non-superuser */}
|
||||
{!formData.is_superuser && (
|
||||
<div className="form-section">
|
||||
<div className="form-section-header">
|
||||
<label className="form-section-title">{t.usersPage.permissions}</label>
|
||||
<label className="toggle-inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hasCustomPermissions}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
hasCustomPermissions: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="toggle-slider-sm"></span>
|
||||
<span className="toggle-label">{t.usersPage.customPermissions}</span>
|
||||
</label>
|
||||
</div>
|
||||
{formData.hasCustomPermissions ? (
|
||||
<div className="permissions-grid">
|
||||
{TOGGLEABLE_MODULES.map((module) => {
|
||||
const isGloballyDisabled = !moduleStates[module.id];
|
||||
const isChecked = formData.permissions[module.id] ?? true;
|
||||
const moduleName = t.sidebar[module.id as keyof typeof t.sidebar] || module.id;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={module.id}
|
||||
className={`checkbox-row ${isGloballyDisabled ? 'disabled' : ''}`}
|
||||
title={isGloballyDisabled ? t.usersPage.moduleDisabledGlobally : ''}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isGloballyDisabled ? false : isChecked}
|
||||
disabled={isGloballyDisabled}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
permissions: {
|
||||
...prev.permissions,
|
||||
[module.id]: e.target.checked,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="material-symbols-outlined">{module.icon}</span>
|
||||
<span>{moduleName}</span>
|
||||
{isGloballyDisabled && (
|
||||
<span className="badge badge-muted badge-sm">{t.usersPage.disabled}</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="permissions-default-hint">
|
||||
{t.usersPage.usingDefaultPermissions}
|
||||
</div>
|
||||
)}
|
||||
</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>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
frontend/src/contexts/AuthContext.tsx
Normal file
80
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { authAPI } from '../api/client';
|
||||
import type { AuthContextType, User } from '../types';
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
if (token) {
|
||||
try {
|
||||
const userData = await authAPI.getCurrentUser();
|
||||
setUser(userData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load user:', error);
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadUser();
|
||||
}, [token]);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authAPI.login({ username, password });
|
||||
localStorage.setItem('token', response.access_token);
|
||||
setToken(response.access_token);
|
||||
|
||||
const userData = await authAPI.getCurrentUser();
|
||||
setUser(userData);
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (username: string, email: string, password: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await authAPI.register({ username, email, password });
|
||||
await login(username, password);
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, login, register, logout, isLoading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
56
frontend/src/contexts/LanguageContext.tsx
Normal file
56
frontend/src/contexts/LanguageContext.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import it from '../locales/it.json';
|
||||
import en from '../locales/en.json';
|
||||
|
||||
type Language = 'it' | 'en';
|
||||
type Translations = typeof it;
|
||||
|
||||
interface LanguageContextType {
|
||||
language: Language;
|
||||
setLanguage: (lang: Language) => void;
|
||||
t: Translations;
|
||||
}
|
||||
|
||||
const translations: Record<Language, Translations> = {
|
||||
it,
|
||||
en,
|
||||
};
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [language, setLanguageState] = useState<Language>(() => {
|
||||
const saved = localStorage.getItem('language');
|
||||
if (saved) return saved as Language;
|
||||
|
||||
// Try to detect from browser
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
return browserLang === 'it' ? 'it' : 'en';
|
||||
});
|
||||
|
||||
const setLanguage = (lang: Language) => {
|
||||
setLanguageState(lang);
|
||||
localStorage.setItem('language', lang);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language;
|
||||
}, [language]);
|
||||
|
||||
const value = {
|
||||
language,
|
||||
setLanguage,
|
||||
t: translations[language],
|
||||
};
|
||||
|
||||
return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
const context = useContext(LanguageContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTranslation must be used within a LanguageProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
203
frontend/src/contexts/ModulesContext.tsx
Normal file
203
frontend/src/contexts/ModulesContext.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { settingsAPI } from '../api/client';
|
||||
import type { UserPermissions } from '../types';
|
||||
|
||||
// User-facing modules that can be toggled
|
||||
export const TOGGLEABLE_MODULES = [
|
||||
{ id: 'feature1', icon: 'playlist_play', defaultEnabled: true },
|
||||
{ id: 'feature2', icon: 'download', defaultEnabled: true },
|
||||
{ id: 'feature3', icon: 'cast', defaultEnabled: true },
|
||||
] as const;
|
||||
|
||||
export type ModuleId = typeof TOGGLEABLE_MODULES[number]['id'];
|
||||
|
||||
export interface ModuleState {
|
||||
admin: boolean;
|
||||
user: boolean;
|
||||
}
|
||||
|
||||
interface ModulesContextType {
|
||||
moduleStates: Record<ModuleId, ModuleState>;
|
||||
isModuleEnabled: (moduleId: string) => boolean;
|
||||
isModuleEnabledForUser: (moduleId: string, userPermissions: UserPermissions | undefined, isSuperuser: boolean) => boolean;
|
||||
setModuleEnabled: (moduleId: ModuleId, type: 'admin' | 'user', enabled: boolean) => void;
|
||||
saveModulesToBackend: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
hasInitialized: boolean;
|
||||
}
|
||||
|
||||
const ModulesContext = createContext<ModulesContextType | undefined>(undefined);
|
||||
|
||||
// Default states
|
||||
const getDefaultStates = (): Record<ModuleId, ModuleState> => {
|
||||
const states: Record<string, ModuleState> = {};
|
||||
TOGGLEABLE_MODULES.forEach(m => {
|
||||
states[m.id] = { admin: m.defaultEnabled, user: m.defaultEnabled };
|
||||
});
|
||||
return states as Record<ModuleId, ModuleState>;
|
||||
};
|
||||
|
||||
export function ModulesProvider({ children }: { children: ReactNode }) {
|
||||
const [moduleStates, setModuleStates] = useState<Record<ModuleId, ModuleState>>(getDefaultStates);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
|
||||
// Load module settings from backend
|
||||
const loadModulesFromBackend = useCallback(async () => {
|
||||
try {
|
||||
const settings = await settingsAPI.getModules();
|
||||
const newStates = { ...getDefaultStates() };
|
||||
|
||||
TOGGLEABLE_MODULES.forEach(m => {
|
||||
const adminKey = `module_${m.id}_admin_enabled`;
|
||||
const userKey = `module_${m.id}_user_enabled`;
|
||||
|
||||
// Helper to safely parse boolean
|
||||
const parseBool = (val: any, defaultVal: boolean): boolean => {
|
||||
if (val === undefined || val === null) return defaultVal;
|
||||
if (val === true || val === 'true' || val === 1 || val === '1') return true;
|
||||
if (val === false || val === 'false' || val === 0 || val === '0') return false;
|
||||
return defaultVal;
|
||||
};
|
||||
|
||||
// Check for new keys
|
||||
// If key exists in settings, use it (parsed). If not, use defaultEnabled.
|
||||
// Crucially, if settings[key] is present, we MUST use it, even if it parses to false.
|
||||
if (settings[adminKey] !== undefined) {
|
||||
newStates[m.id].admin = parseBool(settings[adminKey], m.defaultEnabled);
|
||||
} else {
|
||||
newStates[m.id].admin = m.defaultEnabled;
|
||||
}
|
||||
|
||||
if (settings[userKey] !== undefined) {
|
||||
newStates[m.id].user = parseBool(settings[userKey], m.defaultEnabled);
|
||||
} else {
|
||||
newStates[m.id].user = m.defaultEnabled;
|
||||
}
|
||||
|
||||
// Fallback for backward compatibility (if old key exists)
|
||||
const oldKey = `module_${m.id}_enabled`;
|
||||
if (settings[oldKey] !== undefined && settings[adminKey] === undefined) {
|
||||
const val = parseBool(settings[oldKey], m.defaultEnabled);
|
||||
newStates[m.id].admin = val;
|
||||
newStates[m.id].user = val;
|
||||
}
|
||||
});
|
||||
|
||||
setModuleStates(newStates);
|
||||
setHasInitialized(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to load modules from backend:', error);
|
||||
setHasInitialized(true); // Even on error, mark as initialized to prevent saving defaults
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save module settings to backend
|
||||
const saveModulesToBackend = useCallback(async () => {
|
||||
try {
|
||||
const data: Record<string, boolean> = {};
|
||||
TOGGLEABLE_MODULES.forEach(m => {
|
||||
data[`module_${m.id}_admin_enabled`] = moduleStates[m.id].admin;
|
||||
data[`module_${m.id}_user_enabled`] = moduleStates[m.id].user;
|
||||
});
|
||||
await settingsAPI.updateModules(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to save modules to backend:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [moduleStates]);
|
||||
|
||||
// Load modules on mount when token exists
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
loadModulesFromBackend();
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
setHasInitialized(true); // No token, mark as initialized
|
||||
}
|
||||
}, [loadModulesFromBackend]);
|
||||
|
||||
const isModuleEnabled = useCallback((moduleId: string): boolean => {
|
||||
// Dashboard is always enabled
|
||||
if (moduleId === 'dashboard') return true;
|
||||
// Admin modules are always enabled for admins
|
||||
if (moduleId === 'users' || moduleId === 'settings') return true;
|
||||
|
||||
const state = moduleStates[moduleId as ModuleId];
|
||||
return state ? state.admin : true;
|
||||
}, [moduleStates]);
|
||||
|
||||
// Check if module is enabled for a specific user (considering global state + user permissions)
|
||||
const isModuleEnabledForUser = useCallback((
|
||||
moduleId: string,
|
||||
userPermissions: UserPermissions | undefined,
|
||||
isSuperuser: boolean
|
||||
): boolean => {
|
||||
// Dashboard is always enabled
|
||||
if (moduleId === 'dashboard') return true;
|
||||
// Admin modules are always enabled for admins
|
||||
if (moduleId === 'users' || moduleId === 'settings') return true;
|
||||
|
||||
const state = moduleStates[moduleId as ModuleId];
|
||||
if (!state) return true;
|
||||
|
||||
// 1. If disabled for admin, it's disabled for everyone
|
||||
if (!state.admin) return false;
|
||||
|
||||
// 2. If superuser, they have access (since admin is enabled)
|
||||
if (isSuperuser) return true;
|
||||
|
||||
// 3. If disabled for users globally, regular users can't access
|
||||
if (!state.user) return false;
|
||||
|
||||
// 4. Check user-specific permissions
|
||||
if (userPermissions && userPermissions[moduleId] !== undefined) {
|
||||
return userPermissions[moduleId];
|
||||
}
|
||||
|
||||
// Default: enabled
|
||||
return true;
|
||||
}, [moduleStates]);
|
||||
|
||||
const setModuleEnabled = useCallback((moduleId: ModuleId, type: 'admin' | 'user', enabled: boolean) => {
|
||||
setModuleStates(prev => {
|
||||
const newState = { ...prev };
|
||||
newState[moduleId] = { ...newState[moduleId], [type]: enabled };
|
||||
|
||||
// If admin is disabled, user must be disabled too
|
||||
if (type === 'admin' && !enabled) {
|
||||
newState[moduleId].user = false;
|
||||
}
|
||||
|
||||
return newState;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ModulesContext.Provider
|
||||
value={{
|
||||
moduleStates,
|
||||
isModuleEnabled,
|
||||
isModuleEnabledForUser,
|
||||
setModuleEnabled,
|
||||
saveModulesToBackend,
|
||||
isLoading,
|
||||
hasInitialized,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ModulesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useModules() {
|
||||
const context = useContext(ModulesContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useModules must be used within a ModulesProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
127
frontend/src/contexts/SidebarContext.tsx
Normal file
127
frontend/src/contexts/SidebarContext.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type SidebarMode = 'collapsed' | 'expanded' | 'toggle' | 'dynamic';
|
||||
|
||||
interface SidebarContextType {
|
||||
isCollapsed: boolean;
|
||||
isMobileOpen: boolean;
|
||||
sidebarMode: SidebarMode;
|
||||
canToggle: boolean;
|
||||
toggleCollapse: () => void;
|
||||
toggleMobileMenu: () => void;
|
||||
closeMobileMenu: () => void;
|
||||
setSidebarMode: (mode: SidebarMode) => Promise<void>;
|
||||
isHovered: boolean;
|
||||
setIsHovered: (isHovered: boolean) => void;
|
||||
showLogo: boolean;
|
||||
setShowLogo: (show: boolean) => void;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
||||
|
||||
export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||
const [userCollapsed, setUserCollapsed] = useState(true);
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||
const [sidebarMode, setSidebarModeState] = useState<SidebarMode>('toggle');
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [showLogo, setShowLogo] = useState(false);
|
||||
|
||||
// Load sidebar mode from backend
|
||||
useEffect(() => {
|
||||
const loadSidebarMode = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
const response = await fetch('/api/v1/settings', {
|
||||
headers: { 'Authorization': `Bearer ${token} ` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.sidebar_mode && ['collapsed', 'expanded', 'toggle', 'dynamic'].includes(data.sidebar_mode)) {
|
||||
setSidebarModeState(data.sidebar_mode as SidebarMode);
|
||||
}
|
||||
if (data.show_logo !== undefined) {
|
||||
setShowLogo(data.show_logo === true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load sidebar mode:', error);
|
||||
}
|
||||
};
|
||||
loadSidebarMode();
|
||||
}, []);
|
||||
|
||||
// Compute isCollapsed based on mode
|
||||
const isCollapsed = sidebarMode === 'collapsed' ? true :
|
||||
sidebarMode === 'dynamic' ? true :
|
||||
sidebarMode === 'expanded' ? false :
|
||||
userCollapsed;
|
||||
|
||||
// Can only toggle if mode is 'toggle'
|
||||
const canToggle = sidebarMode === 'toggle';
|
||||
|
||||
const toggleCollapse = () => {
|
||||
if (canToggle) {
|
||||
setUserCollapsed((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setIsMobileOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const closeMobileMenu = () => {
|
||||
setIsMobileOpen(false);
|
||||
};
|
||||
|
||||
const setSidebarMode = useCallback(async (mode: SidebarMode) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch('/api/v1/settings/sidebar_mode', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token} `,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ value: mode }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSidebarModeState(mode);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save sidebar mode:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider
|
||||
value={{
|
||||
isCollapsed,
|
||||
isMobileOpen,
|
||||
sidebarMode,
|
||||
canToggle,
|
||||
toggleCollapse,
|
||||
toggleMobileMenu,
|
||||
closeMobileMenu,
|
||||
setSidebarMode,
|
||||
isHovered,
|
||||
setIsHovered: (value: boolean) => setIsHovered(value),
|
||||
showLogo,
|
||||
setShowLogo,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebar() {
|
||||
const context = useContext(SidebarContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
41
frontend/src/contexts/SiteConfigContext.tsx
Normal file
41
frontend/src/contexts/SiteConfigContext.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import siteConfig, { type SiteConfig, isFeatureEnabled } from '../config/site.config';
|
||||
|
||||
interface SiteConfigContextValue {
|
||||
config: SiteConfig;
|
||||
isFeatureEnabled: (feature: keyof SiteConfig['features']) => boolean;
|
||||
}
|
||||
|
||||
const SiteConfigContext = createContext<SiteConfigContextValue | null>(null);
|
||||
|
||||
export function SiteConfigProvider({ children }: { children: ReactNode }) {
|
||||
const value: SiteConfigContextValue = {
|
||||
config: siteConfig,
|
||||
isFeatureEnabled,
|
||||
};
|
||||
|
||||
return (
|
||||
<SiteConfigContext.Provider value={value}>
|
||||
{children}
|
||||
</SiteConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSiteConfig(): SiteConfigContextValue {
|
||||
const context = useContext(SiteConfigContext);
|
||||
if (!context) {
|
||||
throw new Error('useSiteConfig must be used within a SiteConfigProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Shortcut hooks
|
||||
export function useSiteName(): string {
|
||||
const { config } = useSiteConfig();
|
||||
return config.name;
|
||||
}
|
||||
|
||||
export function useSiteFeature(feature: keyof SiteConfig['features']): boolean {
|
||||
const { isFeatureEnabled } = useSiteConfig();
|
||||
return isFeatureEnabled(feature);
|
||||
}
|
||||
768
frontend/src/contexts/ThemeContext.tsx
Normal file
768
frontend/src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,768 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { settingsAPI } from '../api/client';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
export type AccentColor = 'blue' | 'purple' | 'green' | 'orange' | 'pink' | 'red' | 'teal' | 'amber' | 'indigo' | 'cyan' | 'rose' | 'auto';
|
||||
export type BorderRadius = 'small' | 'medium' | 'large';
|
||||
export type SidebarStyle = 'default' | 'dark' | 'light';
|
||||
export type Density = 'compact' | 'comfortable' | 'spacious';
|
||||
export type FontFamily = 'sans' | 'inter' | 'roboto';
|
||||
export type ColorPalette = 'default' | 'monochrome' | 'monochromeBlue' | 'sepia' | 'nord' | 'dracula' | 'solarized' | 'github' | 'ocean' | 'forest' | 'midnight' | 'sunset';
|
||||
|
||||
export type ControlLocation = 'sidebar' | 'user_menu';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
accentColor: AccentColor;
|
||||
borderRadius: BorderRadius;
|
||||
sidebarStyle: SidebarStyle;
|
||||
density: Density;
|
||||
fontFamily: FontFamily;
|
||||
colorPalette: ColorPalette;
|
||||
customColors: Partial<PaletteColors>;
|
||||
darkModeLocation: ControlLocation;
|
||||
languageLocation: ControlLocation;
|
||||
showDarkModeToggle: boolean;
|
||||
showLanguageToggle: boolean;
|
||||
showDarkModeLogin: boolean;
|
||||
showLanguageLogin: boolean;
|
||||
toggleTheme: () => void;
|
||||
setAccentColor: (color: AccentColor) => void;
|
||||
setBorderRadius: (radius: BorderRadius) => void;
|
||||
setSidebarStyle: (style: SidebarStyle) => void;
|
||||
setDensity: (density: Density) => void;
|
||||
setFontFamily: (font: FontFamily) => void;
|
||||
setColorPalette: (palette: ColorPalette) => void;
|
||||
setCustomColors: (colors: Partial<PaletteColors>) => void;
|
||||
setDarkModeLocation: (location: ControlLocation) => void;
|
||||
setLanguageLocation: (location: ControlLocation) => void;
|
||||
setShowDarkModeToggle: (show: boolean) => void;
|
||||
setShowLanguageToggle: (show: boolean) => void;
|
||||
setShowDarkModeLogin: (show: boolean) => void;
|
||||
setShowLanguageLogin: (show: boolean) => void;
|
||||
loadThemeFromBackend: () => Promise<void>;
|
||||
saveThemeToBackend: (overrides?: Partial<{
|
||||
accentColor: AccentColor;
|
||||
borderRadius: BorderRadius;
|
||||
sidebarStyle: SidebarStyle;
|
||||
density: Density;
|
||||
fontFamily: FontFamily;
|
||||
colorPalette: ColorPalette;
|
||||
customColors: Partial<PaletteColors>;
|
||||
darkModeLocation: ControlLocation;
|
||||
languageLocation: ControlLocation;
|
||||
showDarkModeToggle: boolean;
|
||||
showLanguageToggle: boolean;
|
||||
showDarkModeLogin: boolean;
|
||||
showLanguageLogin: boolean;
|
||||
}>) => Promise<void>;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
const ACCENT_COLORS: Record<AccentColor, { main: string; hover: string; darkMain?: string; darkHover?: string }> = {
|
||||
blue: { main: '#3b82f6', hover: '#2563eb' },
|
||||
purple: { main: '#8b5cf6', hover: '#7c3aed' },
|
||||
green: { main: '#10b981', hover: '#059669' },
|
||||
orange: { main: '#f97316', hover: '#ea580c' },
|
||||
pink: { main: '#ec4899', hover: '#db2777' },
|
||||
red: { main: '#ef4444', hover: '#dc2626' },
|
||||
teal: { main: '#14b8a6', hover: '#0d9488' },
|
||||
amber: { main: '#f59e0b', hover: '#d97706' },
|
||||
indigo: { main: '#6366f1', hover: '#4f46e5' },
|
||||
cyan: { main: '#06b6d4', hover: '#0891b2' },
|
||||
rose: { main: '#f43f5e', hover: '#e11d48' },
|
||||
auto: { main: '#374151', hover: '#1f2937', darkMain: '#838b99', darkHover: '#b5bcc8' },
|
||||
};
|
||||
|
||||
const BORDER_RADII: Record<BorderRadius, { sm: string; md: string; lg: string }> = {
|
||||
small: { sm: '2px', md: '4px', lg: '6px' },
|
||||
medium: { sm: '4px', md: '6px', lg: '8px' },
|
||||
large: { sm: '8px', md: '12px', lg: '16px' },
|
||||
};
|
||||
|
||||
const DENSITIES: Record<Density, {
|
||||
button: string;
|
||||
input: string;
|
||||
nav: string;
|
||||
scale: number;
|
||||
cardPadding: string;
|
||||
cardPaddingSm: string;
|
||||
cardGap: string;
|
||||
sectionGap: string;
|
||||
elementGap: string;
|
||||
pagePaddingX: string;
|
||||
pagePaddingY: string;
|
||||
}> = {
|
||||
compact: {
|
||||
button: '32px',
|
||||
input: '36px',
|
||||
nav: '36px',
|
||||
scale: 0.85,
|
||||
cardPadding: '0.75rem',
|
||||
cardPaddingSm: '0.5rem',
|
||||
cardGap: '0.5rem',
|
||||
sectionGap: '1rem',
|
||||
elementGap: '0.375rem',
|
||||
pagePaddingX: '1.5rem',
|
||||
pagePaddingY: '1.5rem',
|
||||
},
|
||||
comfortable: {
|
||||
button: '36px',
|
||||
input: '40px',
|
||||
nav: '40px',
|
||||
scale: 1,
|
||||
cardPadding: '1rem',
|
||||
cardPaddingSm: '0.75rem',
|
||||
cardGap: '0.75rem',
|
||||
sectionGap: '1.5rem',
|
||||
elementGap: '0.5rem',
|
||||
pagePaddingX: '2rem',
|
||||
pagePaddingY: '2rem',
|
||||
},
|
||||
spacious: {
|
||||
button: '44px',
|
||||
input: '48px',
|
||||
nav: '48px',
|
||||
scale: 1.15,
|
||||
cardPadding: '1.5rem',
|
||||
cardPaddingSm: '1rem',
|
||||
cardGap: '1rem',
|
||||
sectionGap: '2rem',
|
||||
elementGap: '0.75rem',
|
||||
pagePaddingX: '2.5rem',
|
||||
pagePaddingY: '2.5rem',
|
||||
},
|
||||
};
|
||||
|
||||
const FONTS: Record<FontFamily, string> = {
|
||||
sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
|
||||
inter: '"Inter", sans-serif',
|
||||
roboto: '"Roboto", sans-serif',
|
||||
};
|
||||
|
||||
interface PaletteColors {
|
||||
light: {
|
||||
bgMain: string;
|
||||
bgCard: string;
|
||||
bgElevated: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
border: string;
|
||||
sidebarBg: string;
|
||||
sidebarText: string;
|
||||
};
|
||||
dark: {
|
||||
bgMain: string;
|
||||
bgCard: string;
|
||||
bgElevated: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
border: string;
|
||||
sidebarBg: string;
|
||||
sidebarText: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const COLOR_PALETTES: Record<ColorPalette, PaletteColors> = {
|
||||
default: {
|
||||
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',
|
||||
},
|
||||
},
|
||||
monochrome: {
|
||||
light: {
|
||||
bgMain: '#fafafa',
|
||||
bgCard: '#f5f5f5',
|
||||
bgElevated: '#ffffff',
|
||||
textPrimary: '#171717',
|
||||
textSecondary: '#737373',
|
||||
border: '#e5e5e5',
|
||||
sidebarBg: '#262626',
|
||||
sidebarText: '#fafafa',
|
||||
},
|
||||
dark: {
|
||||
bgMain: '#0a0a0a',
|
||||
bgCard: '#171717',
|
||||
bgElevated: '#262626',
|
||||
textPrimary: '#fafafa',
|
||||
textSecondary: '#a3a3a3',
|
||||
border: '#333333',
|
||||
sidebarBg: '#0a0a0a',
|
||||
sidebarText: '#fafafa',
|
||||
},
|
||||
},
|
||||
monochromeBlue: {
|
||||
light: {
|
||||
bgMain: '#f8fafc',
|
||||
bgCard: '#f1f5f9',
|
||||
bgElevated: '#ffffff',
|
||||
textPrimary: '#0f172a',
|
||||
textSecondary: '#64748b',
|
||||
border: '#e2e8f0',
|
||||
sidebarBg: '#1e293b',
|
||||
sidebarText: '#f8fafc',
|
||||
},
|
||||
dark: {
|
||||
bgMain: '#020617',
|
||||
bgCard: '#0f172a',
|
||||
bgElevated: '#1e293b',
|
||||
textPrimary: '#f8fafc',
|
||||
textSecondary: '#94a3b8',
|
||||
border: '#1e293b',
|
||||
sidebarBg: '#020617',
|
||||
sidebarText: '#f8fafc',
|
||||
},
|
||||
},
|
||||
sepia: {
|
||||
light: {
|
||||
bgMain: '#faf8f5',
|
||||
bgCard: '#f5f0e8',
|
||||
bgElevated: '#ffffff',
|
||||
textPrimary: '#3d3229',
|
||||
textSecondary: '#7a6f5f',
|
||||
border: '#e8e0d4',
|
||||
sidebarBg: '#4a3f33',
|
||||
sidebarText: '#faf8f5',
|
||||
},
|
||||
dark: {
|
||||
bgMain: '#1a1612',
|
||||
bgCard: '#2a241e',
|
||||
bgElevated: '#3a332b',
|
||||
textPrimary: '#f5efe6',
|
||||
textSecondary: '#b8a990',
|
||||
border: '#3a332b',
|
||||
sidebarBg: '#12100d',
|
||||
sidebarText: '#f5efe6',
|
||||
},
|
||||
},
|
||||
nord: {
|
||||
light: {
|
||||
bgMain: '#eceff4',
|
||||
bgCard: '#e5e9f0',
|
||||
bgElevated: '#ffffff',
|
||||
textPrimary: '#2e3440',
|
||||
textSecondary: '#4c566a',
|
||||
border: '#d8dee9',
|
||||
sidebarBg: '#3b4252',
|
||||
sidebarText: '#eceff4',
|
||||
},
|
||||
dark: {
|
||||
bgMain: '#2e3440',
|
||||
bgCard: '#3b4252',
|
||||
bgElevated: '#434c5e',
|
||||
textPrimary: '#eceff4',
|
||||
textSecondary: '#d8dee9',
|
||||
border: '#434c5e',
|
||||
sidebarBg: '#242933',
|
||||
sidebarText: '#eceff4',
|
||||
},
|
||||
},
|
||||
dracula: {
|
||||
light: {
|
||||
bgMain: '#f8f8f2',
|
||||
bgCard: '#f0f0e8',
|
||||
bgElevated: '#ffffff',
|
||||
textPrimary: '#282a36',
|
||||
textSecondary: '#6272a4',
|
||||
border: '#e0e0d8',
|
||||
sidebarBg: '#44475a',
|
||||
sidebarText: '#f8f8f2',
|
||||
},
|
||||
dark: {
|
||||
bgMain: '#282a36',
|
||||
bgCard: '#343746',
|
||||
bgElevated: '#44475a',
|
||||
textPrimary: '#f8f8f2',
|
||||
textSecondary: '#6272a4',
|
||||
border: '#44475a',
|
||||
sidebarBg: '#21222c',
|
||||
sidebarText: '#f8f8f2',
|
||||
},
|
||||
},
|
||||
solarized: {
|
||||
light: {
|
||||
bgMain: '#fdf6e3',
|
||||
bgCard: '#eee8d5',
|
||||
bgElevated: '#ffffff',
|
||||
textPrimary: '#073642',
|
||||
textSecondary: '#586e75',
|
||||
border: '#e0d8c0',
|
||||
sidebarBg: '#073642',
|
||||
sidebarText: '#fdf6e3',
|
||||
},
|
||||
dark: {
|
||||
bgMain: '#002b36',
|
||||
bgCard: '#073642',
|
||||
bgElevated: '#094050',
|
||||
textPrimary: '#fdf6e3',
|
||||
textSecondary: '#93a1a1',
|
||||
border: '#094050',
|
||||
sidebarBg: '#001e26',
|
||||
sidebarText: '#fdf6e3',
|
||||
},
|
||||
},
|
||||
github: {
|
||||
light: {
|
||||
bgMain: '#ffffff',
|
||||
bgCard: '#f6f8fa',
|
||||
bgElevated: '#ffffff',
|
||||
textPrimary: '#24292f',
|
||||
textSecondary: '#57606a',
|
||||
border: '#d0d7de',
|
||||
sidebarBg: '#24292f',
|
||||
sidebarText: '#f6f8fa',
|
||||
},
|
||||
dark: {
|
||||
bgMain: '#0d1117',
|
||||
bgCard: '#161b22',
|
||||
bgElevated: '#21262d',
|
||||
textPrimary: '#c9d1d9',
|
||||
textSecondary: '#8b949e',
|
||||
border: '#30363d',
|
||||
sidebarBg: '#010409',
|
||||
sidebarText: '#c9d1d9',
|
||||
},
|
||||
},
|
||||
ocean: {
|
||||
light: {
|
||||
bgMain: '#f0f9ff',
|
||||
bgCard: '#e0f2fe',
|
||||
bgElevated: '#ffffff',
|
||||
textPrimary: '#0c4a6e',
|
||||
textSecondary: '#0369a1',
|
||||
border: '#bae6fd',
|
||||
sidebarBg: '#0c4a6e',
|
||||
sidebarText: '#f0f9ff',
|
||||
},
|
||||
dark: {
|
||||
bgMain: '#082f49',
|
||||
bgCard: '#0c4a6e',
|
||||
bgElevated: '#0369a1',
|
||||
textPrimary: '#e0f2fe',
|
||||
textSecondary: '#7dd3fc',
|
||||
border: '#0369a1',
|
||||
sidebarBg: '#042038',
|
||||
sidebarText: '#e0f2fe',
|
||||
},
|
||||
},
|
||||
forest: {
|
||||
light: {
|
||||
bgMain: '#f0fdf4',
|
||||
bgCard: '#dcfce7',
|
||||
bgElevated: '#ffffff',
|
||||
textPrimary: '#14532d',
|
||||
textSecondary: '#166534',
|
||||
border: '#bbf7d0',
|
||||
sidebarBg: '#14532d',
|
||||
sidebarText: '#f0fdf4',
|
||||
},
|
||||
dark: {
|
||||
bgMain: '#052e16',
|
||||
bgCard: '#14532d',
|
||||
bgElevated: '#166534',
|
||||
textPrimary: '#dcfce7',
|
||||
textSecondary: '#86efac',
|
||||
border: '#166534',
|
||||
sidebarBg: '#022c22',
|
||||
sidebarText: '#dcfce7',
|
||||
},
|
||||
},
|
||||
midnight: {
|
||||
light: {
|
||||
bgMain: '#f5f3ff',
|
||||
bgCard: '#ede9fe',
|
||||
bgElevated: '#ffffff',
|
||||
textPrimary: '#1e1b4b',
|
||||
textSecondary: '#4338ca',
|
||||
border: '#c7d2fe',
|
||||
sidebarBg: '#1e1b4b',
|
||||
sidebarText: '#f5f3ff',
|
||||
},
|
||||
dark: {
|
||||
bgMain: '#0c0a1d',
|
||||
bgCard: '#1e1b4b',
|
||||
bgElevated: '#312e81',
|
||||
textPrimary: '#e0e7ff',
|
||||
textSecondary: '#a5b4fc',
|
||||
border: '#312e81',
|
||||
sidebarBg: '#050311',
|
||||
sidebarText: '#e0e7ff',
|
||||
},
|
||||
},
|
||||
sunset: {
|
||||
light: {
|
||||
bgMain: '#fff7ed',
|
||||
bgCard: '#ffedd5',
|
||||
bgElevated: '#ffffff',
|
||||
textPrimary: '#7c2d12',
|
||||
textSecondary: '#c2410c',
|
||||
border: '#fed7aa',
|
||||
sidebarBg: '#7c2d12',
|
||||
sidebarText: '#fff7ed',
|
||||
},
|
||||
dark: {
|
||||
bgMain: '#1c0a04',
|
||||
bgCard: '#431407',
|
||||
bgElevated: '#7c2d12',
|
||||
textPrimary: '#fed7aa',
|
||||
textSecondary: '#fdba74',
|
||||
border: '#7c2d12',
|
||||
sidebarBg: '#0f0502',
|
||||
sidebarText: '#fed7aa',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
return (localStorage.getItem('theme') as Theme) || 'light';
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GLOBAL SETTINGS (from database, admin-controlled)
|
||||
// These use registry defaults and are loaded from backend via loadThemeFromBackend()
|
||||
// ============================================================================
|
||||
const [accentColor, setAccentColorState] = useState<AccentColor>('auto');
|
||||
const [borderRadius, setBorderRadiusState] = useState<BorderRadius>('large');
|
||||
const [sidebarStyle, setSidebarStyleState] = useState<SidebarStyle>('default');
|
||||
const [density, setDensityState] = useState<Density>('compact');
|
||||
const [fontFamily, setFontFamilyState] = useState<FontFamily>('sans');
|
||||
const [colorPalette, setColorPaletteState] = useState<ColorPalette>('monochrome');
|
||||
const [customColors, setCustomColorsState] = useState<Partial<PaletteColors>>({});
|
||||
const [darkModeLocation, setDarkModeLocationState] = useState<ControlLocation>('sidebar');
|
||||
const [languageLocation, setLanguageLocationState] = useState<ControlLocation>('sidebar');
|
||||
const [showDarkModeToggle, setShowDarkModeToggleState] = useState<boolean>(true);
|
||||
const [showLanguageToggle, setShowLanguageToggleState] = useState<boolean>(false);
|
||||
const [showDarkModeLogin, setShowDarkModeLoginState] = useState<boolean>(true);
|
||||
const [showLanguageLogin, setShowLanguageLoginState] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const colors = ACCENT_COLORS[accentColor];
|
||||
// For 'auto' accent, use dark colors in dark mode
|
||||
const main = (theme === 'dark' && colors.darkMain) ? colors.darkMain : colors.main;
|
||||
const hover = (theme === 'dark' && colors.darkHover) ? colors.darkHover : colors.hover;
|
||||
root.style.setProperty('--color-accent', main);
|
||||
root.style.setProperty('--color-accent-hover', hover);
|
||||
}, [accentColor, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const radii = BORDER_RADII[borderRadius];
|
||||
root.style.setProperty('--radius-sm', radii.sm);
|
||||
root.style.setProperty('--radius-md', radii.md);
|
||||
root.style.setProperty('--radius-lg', radii.lg);
|
||||
}, [borderRadius]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const d = DENSITIES[density];
|
||||
|
||||
// Component heights
|
||||
root.style.setProperty('--height-button', d.button);
|
||||
root.style.setProperty('--height-input', d.input);
|
||||
root.style.setProperty('--height-nav-item', d.nav);
|
||||
root.style.setProperty('--btn-height', d.button);
|
||||
|
||||
// Density scale factor (for calc() usage)
|
||||
root.style.setProperty('--density-scale', d.scale.toString());
|
||||
|
||||
// Semantic spacing
|
||||
root.style.setProperty('--card-padding', d.cardPadding);
|
||||
root.style.setProperty('--card-padding-sm', d.cardPaddingSm);
|
||||
root.style.setProperty('--card-gap', d.cardGap);
|
||||
root.style.setProperty('--section-gap', d.sectionGap);
|
||||
root.style.setProperty('--element-gap', d.elementGap);
|
||||
|
||||
// Page padding
|
||||
root.style.setProperty('--page-padding-x', d.pagePaddingX);
|
||||
root.style.setProperty('--page-padding-y', d.pagePaddingY);
|
||||
}, [density]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--font-sans', FONTS[fontFamily]);
|
||||
}, [fontFamily]);
|
||||
|
||||
// Apply color palette with overrides
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const palette = COLOR_PALETTES[colorPalette];
|
||||
const baseColors = theme === 'dark' ? palette.dark : palette.light;
|
||||
|
||||
// Apply overrides if they exist for the current theme mode
|
||||
const overrides = theme === 'dark' ? customColors.dark : customColors.light;
|
||||
const appColors = { ...baseColors, ...overrides };
|
||||
|
||||
root.style.setProperty('--color-bg-main', appColors.bgMain);
|
||||
root.style.setProperty('--color-bg-card', appColors.bgCard);
|
||||
root.style.setProperty('--color-bg-elevated', appColors.bgElevated);
|
||||
root.style.setProperty('--color-text-primary', appColors.textPrimary);
|
||||
root.style.setProperty('--color-text-secondary', appColors.textSecondary);
|
||||
root.style.setProperty('--color-border', appColors.border);
|
||||
|
||||
// Determine sidebar colors based on sidebarStyle
|
||||
let sidebarBg, sidebarText, sidebarBorder;
|
||||
|
||||
if (sidebarStyle === 'light') {
|
||||
// Always Light: Use light palette colors (bgCard for slight contrast vs bgMain if main is white)
|
||||
// Check for light mode overrides
|
||||
const lightOverrides = customColors.light || {};
|
||||
const lightBase = palette.light;
|
||||
const lightColors = { ...lightBase, ...lightOverrides };
|
||||
|
||||
sidebarBg = lightColors.bgCard;
|
||||
sidebarText = lightColors.textPrimary;
|
||||
sidebarBorder = lightColors.border;
|
||||
} else {
|
||||
// Default or Always Dark: Use the theme's defined sidebar color (which is typically dark)
|
||||
// This respects the user's preference for the default dark sidebar in light mode
|
||||
sidebarBg = appColors.sidebarBg;
|
||||
sidebarText = appColors.sidebarText;
|
||||
|
||||
// Increase visibility for monochrome dark
|
||||
if (colorPalette === 'monochrome' && theme === 'dark') {
|
||||
sidebarBorder = 'rgba(255, 255, 255, 0.12)';
|
||||
} else {
|
||||
sidebarBorder = 'rgba(255, 255, 255, 0.05)';
|
||||
}
|
||||
}
|
||||
|
||||
root.style.setProperty('--color-bg-sidebar', sidebarBg);
|
||||
root.style.setProperty('--color-text-sidebar', sidebarText);
|
||||
root.style.setProperty('--color-sidebar-border', sidebarBorder);
|
||||
}, [colorPalette, theme, sidebarStyle, customColors]);
|
||||
|
||||
// Load theme from backend when user is authenticated
|
||||
const loadThemeFromBackend = useCallback(async () => {
|
||||
try {
|
||||
const themeData = await settingsAPI.getTheme();
|
||||
if (themeData.theme_accent_color) {
|
||||
setAccentColorState(themeData.theme_accent_color as AccentColor);
|
||||
}
|
||||
if (themeData.theme_border_radius) {
|
||||
setBorderRadiusState(themeData.theme_border_radius as BorderRadius);
|
||||
}
|
||||
if (themeData.theme_sidebar_style) {
|
||||
setSidebarStyleState(themeData.theme_sidebar_style as SidebarStyle);
|
||||
}
|
||||
if (themeData.theme_density) {
|
||||
setDensityState(themeData.theme_density as Density);
|
||||
}
|
||||
if (themeData.theme_font_family) {
|
||||
setFontFamilyState(themeData.theme_font_family as FontFamily);
|
||||
}
|
||||
if (themeData.theme_color_palette) {
|
||||
setColorPaletteState(themeData.theme_color_palette as ColorPalette);
|
||||
}
|
||||
if (themeData.theme_dark_mode_location) {
|
||||
setDarkModeLocationState(themeData.theme_dark_mode_location as ControlLocation);
|
||||
}
|
||||
if (themeData.theme_language_location) {
|
||||
setLanguageLocationState(themeData.theme_language_location as ControlLocation);
|
||||
}
|
||||
if (themeData.theme_show_dark_mode_toggle !== undefined) {
|
||||
const val = themeData.theme_show_dark_mode_toggle as unknown;
|
||||
setShowDarkModeToggleState(val === true || val === 'true' || val === 'True');
|
||||
}
|
||||
if (themeData.theme_show_language_toggle !== undefined) {
|
||||
const val = themeData.theme_show_language_toggle as unknown;
|
||||
setShowLanguageToggleState(val === true || val === 'true' || val === 'True');
|
||||
}
|
||||
if (themeData.theme_show_dark_mode_login !== undefined) {
|
||||
const val = themeData.theme_show_dark_mode_login as unknown;
|
||||
setShowDarkModeLoginState(val === true || val === 'true' || val === 'True');
|
||||
}
|
||||
if (themeData.theme_show_language_login !== undefined) {
|
||||
const val = themeData.theme_show_language_login as unknown;
|
||||
setShowLanguageLoginState(val === true || val === 'true' || val === 'True');
|
||||
}
|
||||
if (themeData.theme_custom_colors) {
|
||||
try {
|
||||
const parsed = typeof themeData.theme_custom_colors === 'string'
|
||||
? JSON.parse(themeData.theme_custom_colors)
|
||||
: themeData.theme_custom_colors;
|
||||
setCustomColorsState(parsed as Partial<PaletteColors>);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse custom colors:', e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load theme from backend:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save theme to backend (admin only) - accepts optional overrides for immediate save
|
||||
const saveThemeToBackend = useCallback(async (overrides?: Partial<{
|
||||
accentColor: AccentColor;
|
||||
borderRadius: BorderRadius;
|
||||
sidebarStyle: SidebarStyle;
|
||||
density: Density;
|
||||
fontFamily: FontFamily;
|
||||
colorPalette: ColorPalette;
|
||||
customColors: Partial<PaletteColors>;
|
||||
darkModeLocation: ControlLocation;
|
||||
languageLocation: ControlLocation;
|
||||
showDarkModeToggle: boolean;
|
||||
showLanguageToggle: boolean;
|
||||
showDarkModeLogin: boolean;
|
||||
showLanguageLogin: boolean;
|
||||
}>) => {
|
||||
try {
|
||||
await settingsAPI.updateTheme({
|
||||
theme_accent_color: overrides?.accentColor ?? accentColor,
|
||||
theme_border_radius: overrides?.borderRadius ?? borderRadius,
|
||||
theme_sidebar_style: overrides?.sidebarStyle ?? sidebarStyle,
|
||||
theme_density: overrides?.density ?? density,
|
||||
theme_font_family: overrides?.fontFamily ?? fontFamily,
|
||||
theme_color_palette: overrides?.colorPalette ?? colorPalette,
|
||||
theme_custom_colors: JSON.stringify(overrides?.customColors ?? customColors),
|
||||
theme_dark_mode_location: overrides?.darkModeLocation ?? darkModeLocation,
|
||||
theme_language_location: overrides?.languageLocation ?? languageLocation,
|
||||
theme_show_dark_mode_toggle: String(overrides?.showDarkModeToggle ?? showDarkModeToggle),
|
||||
theme_show_language_toggle: String(overrides?.showLanguageToggle ?? showLanguageToggle),
|
||||
theme_show_dark_mode_login: String(overrides?.showDarkModeLogin ?? showDarkModeLogin),
|
||||
theme_show_language_login: String(overrides?.showLanguageLogin ?? showLanguageLogin),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save theme to backend:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [accentColor, borderRadius, sidebarStyle, density, fontFamily, colorPalette, customColors, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle, showDarkModeLogin, showLanguageLogin]);
|
||||
|
||||
// Auto-load theme from backend when token exists
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
loadThemeFromBackend();
|
||||
}
|
||||
}, [loadThemeFromBackend]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
|
||||
};
|
||||
|
||||
const setAccentColor = (color: AccentColor) => {
|
||||
setAccentColorState(color);
|
||||
};
|
||||
|
||||
const setBorderRadius = (radius: BorderRadius) => {
|
||||
setBorderRadiusState(radius);
|
||||
};
|
||||
|
||||
const setSidebarStyle = (style: SidebarStyle) => {
|
||||
setSidebarStyleState(style);
|
||||
};
|
||||
|
||||
const setDensity = (d: Density) => {
|
||||
setDensityState(d);
|
||||
};
|
||||
|
||||
const setFontFamily = (font: FontFamily) => {
|
||||
setFontFamilyState(font);
|
||||
};
|
||||
|
||||
const setColorPalette = (palette: ColorPalette) => {
|
||||
setColorPaletteState(palette);
|
||||
};
|
||||
|
||||
const setCustomColors = (colors: Partial<PaletteColors>) => {
|
||||
setCustomColorsState(colors);
|
||||
};
|
||||
|
||||
const setDarkModeLocation = (location: ControlLocation) => {
|
||||
setDarkModeLocationState(location);
|
||||
};
|
||||
|
||||
const setLanguageLocation = (location: ControlLocation) => {
|
||||
setLanguageLocationState(location);
|
||||
};
|
||||
|
||||
const setShowDarkModeToggle = (show: boolean) => {
|
||||
setShowDarkModeToggleState(show);
|
||||
};
|
||||
|
||||
const setShowLanguageToggle = (show: boolean) => {
|
||||
setShowLanguageToggleState(show);
|
||||
};
|
||||
|
||||
const setShowDarkModeLogin = (show: boolean) => {
|
||||
setShowDarkModeLoginState(show);
|
||||
};
|
||||
|
||||
const setShowLanguageLogin = (show: boolean) => {
|
||||
setShowLanguageLoginState(show);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
theme,
|
||||
accentColor,
|
||||
borderRadius,
|
||||
sidebarStyle,
|
||||
density,
|
||||
fontFamily,
|
||||
colorPalette,
|
||||
customColors,
|
||||
darkModeLocation,
|
||||
languageLocation,
|
||||
showDarkModeToggle,
|
||||
showLanguageToggle,
|
||||
showDarkModeLogin,
|
||||
showLanguageLogin,
|
||||
toggleTheme,
|
||||
setAccentColor,
|
||||
setBorderRadius,
|
||||
setSidebarStyle,
|
||||
setDensity,
|
||||
setFontFamily,
|
||||
setColorPalette,
|
||||
setCustomColors,
|
||||
setDarkModeLocation,
|
||||
setLanguageLocation,
|
||||
setShowDarkModeToggle,
|
||||
setShowLanguageToggle,
|
||||
setShowDarkModeLogin,
|
||||
setShowLanguageLogin,
|
||||
loadThemeFromBackend,
|
||||
saveThemeToBackend,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
119
frontend/src/contexts/ViewModeContext.tsx
Normal file
119
frontend/src/contexts/ViewModeContext.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type ViewMode = 'admin' | 'user';
|
||||
|
||||
interface ViewModeContextType {
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
toggleViewMode: () => void;
|
||||
isUserModeEnabled: boolean;
|
||||
setUserModeEnabled: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const ViewModeContext = createContext<ViewModeContextType | undefined>(undefined);
|
||||
|
||||
export function ViewModeProvider({ children }: { children: ReactNode }) {
|
||||
// viewMode is user preference - stored in localStorage
|
||||
const [viewMode, setViewModeState] = useState<ViewMode>(() => {
|
||||
const saved = localStorage.getItem('viewMode');
|
||||
return (saved as ViewMode) || 'admin';
|
||||
});
|
||||
|
||||
// isUserModeEnabled is a GLOBAL setting - comes from database, default false
|
||||
const [isUserModeEnabled, setUserModeEnabledState] = useState<boolean>(false);
|
||||
|
||||
const setViewMode = (mode: ViewMode) => {
|
||||
setViewModeState(mode);
|
||||
localStorage.setItem('viewMode', mode);
|
||||
};
|
||||
|
||||
const toggleViewMode = () => {
|
||||
const newMode = viewMode === 'admin' ? 'user' : 'admin';
|
||||
setViewMode(newMode);
|
||||
};
|
||||
|
||||
const setUserModeEnabled = async (enabled: boolean) => {
|
||||
setUserModeEnabledState(enabled);
|
||||
|
||||
// If disabling, reset to admin view
|
||||
if (!enabled && viewMode === 'user') {
|
||||
setViewMode('admin');
|
||||
}
|
||||
|
||||
// Save to backend (this is a global setting)
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
await fetch('/api/v1/settings/user_mode_enabled', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ value: enabled }),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save user mode setting:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Sync viewMode (user preference) with localStorage on mount
|
||||
const savedMode = localStorage.getItem('viewMode');
|
||||
if (savedMode) setViewModeState(savedMode as ViewMode);
|
||||
|
||||
// Fetch user_mode_enabled (global setting) from backend
|
||||
const fetchUserModeSetting = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
// No token = use default (false)
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/settings/user_mode_enabled', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.value !== undefined) {
|
||||
const val = data.value;
|
||||
const isEnabled = val === true || val === 'true' || val === 'True';
|
||||
setUserModeEnabledState(isEnabled);
|
||||
}
|
||||
}
|
||||
// If 404 or error, keep default (false)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user mode setting:', error);
|
||||
// Keep default (false)
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserModeSetting();
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
viewMode,
|
||||
setViewMode,
|
||||
toggleViewMode,
|
||||
isUserModeEnabled,
|
||||
setUserModeEnabled,
|
||||
};
|
||||
|
||||
return (
|
||||
<ViewModeContext.Provider value={value}>
|
||||
{children}
|
||||
</ViewModeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useViewMode() {
|
||||
const context = useContext(ViewModeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useViewMode must be used within a ViewModeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
35
frontend/src/hooks/useDocumentTitle.ts
Normal file
35
frontend/src/hooks/useDocumentTitle.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from 'react';
|
||||
import siteConfig from '../config/site.config';
|
||||
|
||||
/**
|
||||
* Hook to set the document title
|
||||
* @param title - Page-specific title (will be appended to site name)
|
||||
* @param useFullTitle - If true, use title as-is without appending site name
|
||||
*/
|
||||
export function useDocumentTitle(title?: string, useFullTitle = false): void {
|
||||
useEffect(() => {
|
||||
if (useFullTitle && title) {
|
||||
document.title = title;
|
||||
} else if (title) {
|
||||
document.title = `${title} | ${siteConfig.name}`;
|
||||
} else {
|
||||
document.title = siteConfig.meta.title;
|
||||
}
|
||||
|
||||
// Cleanup: restore default title on unmount
|
||||
return () => {
|
||||
document.title = siteConfig.meta.title;
|
||||
};
|
||||
}, [title, useFullTitle]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the document title imperatively (for use outside React components)
|
||||
*/
|
||||
export function setDocumentTitle(title?: string): void {
|
||||
if (title) {
|
||||
document.title = `${title} | ${siteConfig.name}`;
|
||||
} else {
|
||||
document.title = siteConfig.meta.title;
|
||||
}
|
||||
}
|
||||
71
frontend/src/index.css
Normal file
71
frontend/src/index.css
Normal file
@@ -0,0 +1,71 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap');
|
||||
|
||||
/* ==========================================
|
||||
MAIN STYLES
|
||||
========================================== */
|
||||
|
||||
/* Import Theme System */
|
||||
@import './styles/theme/index.css';
|
||||
|
||||
/* Global font rendering settings */
|
||||
:root {
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
|
||||
/* ========== SCROLLBAR STYLES ========== */
|
||||
|
||||
/* Custom scrollbar - semi-transparent overlay style */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(128, 128, 128, 0.4);
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(128, 128, 128, 0.6);
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(128, 128, 128, 0.4) transparent;
|
||||
}
|
||||
|
||||
/* Main content scrollbar - overlay style for symmetric margins */
|
||||
.main-content {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Material Icons */
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: var(--icon-lg);
|
||||
line-height: var(--leading-none);
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-feature-settings: 'liga';
|
||||
}
|
||||
314
frontend/src/locales/en.json
Normal file
314
frontend/src/locales/en.json
Normal file
@@ -0,0 +1,314 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "App Service",
|
||||
"tagline": "Service Management"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"logout": "Logout",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"email": "Email",
|
||||
"loginTitle": "Login",
|
||||
"registerTitle": "Register",
|
||||
"alreadyHaveAccount": "Already have an account? Login",
|
||||
"dontHaveAccount": "Don't have an account? Register",
|
||||
"authenticationFailed": "Authentication failed"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Welcome",
|
||||
"profile": "Profile",
|
||||
"userId": "User ID",
|
||||
"status": "Status",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
"feature1": "Feature 1",
|
||||
"feature2": "Feature 2",
|
||||
"feature3": "Feature 3",
|
||||
"settings": "Settings",
|
||||
"admin": "Administration",
|
||||
"users": "Users"
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"close": "Close",
|
||||
"search": "Search",
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"all": "All",
|
||||
"reset": "Reset"
|
||||
},
|
||||
"features": {
|
||||
"feature1": "Feature 1 Management",
|
||||
"feature2": "Feature 2 Manager",
|
||||
"feature3": "Feature 3 Integration",
|
||||
"comingSoon": "Coming Soon"
|
||||
},
|
||||
"featuresPage": {
|
||||
"title": "Features",
|
||||
"subtitle": "Configure application features"
|
||||
},
|
||||
"sourcesPage": {
|
||||
"title": "Sources",
|
||||
"subtitle": "Manage data sources",
|
||||
"comingSoon": "Data sources management coming soon..."
|
||||
},
|
||||
"admin": {
|
||||
"panel": "Admin Panel",
|
||||
"description": "Manage users and application settings",
|
||||
"userManagement": "User Management",
|
||||
"systemSettings": "System Settings",
|
||||
"generalTab": "General",
|
||||
"usersTab": "Users",
|
||||
"playlistsTab": "Playlists",
|
||||
"viewMode": "Interface",
|
||||
"userModeToggle": "User Mode Button",
|
||||
"userModeToggleDesc": "Show a button in the sidebar to quickly switch to user view",
|
||||
"sidebarMode": "Sidebar Mode",
|
||||
"sidebarModeDesc": "Choose how to display the sidebar",
|
||||
"sidebarModeCollapsed": "Always Collapsed",
|
||||
"sidebarModeCollapsedDesc": "Always small",
|
||||
"sidebarModeDynamic": "Dynamic",
|
||||
"sidebarModeDynamicDesc": "Expands on hover",
|
||||
"sidebarModeToggle": "Toggleable",
|
||||
"sidebarModeToggleDesc": "Collapsible",
|
||||
"adminView": "Admin",
|
||||
"userView": "User",
|
||||
"modulesSection": "Features",
|
||||
"playlistsDesc": "Manage playlists for streaming",
|
||||
"downloadsDesc": "Download and manage offline content",
|
||||
"chromecastDesc": "Cast content to devices",
|
||||
"moduleDefaultDesc": "Enable or disable this feature for users",
|
||||
"darkModeSettings": "Dark Mode Settings",
|
||||
"enableDarkModeToggle": "Enable Dark Mode Toggle",
|
||||
"enableDarkModeToggleDesc": "Allow users to switch themes",
|
||||
"showOnLoginScreen": "Show on Login Screen",
|
||||
"showOnLoginScreenDesc": "Display toggle on login page",
|
||||
"controlLocation": "Control Location",
|
||||
"controlLocationDesc": "Show in Sidebar (ON) or User Menu (OFF)",
|
||||
"languageSettings": "Language Settings",
|
||||
"enableLanguageSelector": "Enable Language Selector",
|
||||
"enableLanguageSelectorDesc": "Allow users to change language",
|
||||
"showLanguageOnLoginDesc": "Display selector on login page",
|
||||
"adminRole": "Admin",
|
||||
"userRole": "User",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
},
|
||||
"usersPage": {
|
||||
"title": "Users",
|
||||
"addUser": "Add user",
|
||||
"editUser": "Edit user",
|
||||
"name": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"passwordHintCreate": "(min 8 chars)",
|
||||
"passwordHintEdit": "(leave blank to keep current password)",
|
||||
"status": "Status",
|
||||
"role": "Role",
|
||||
"isActive": "Active",
|
||||
"isSuperuser": "Superuser",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"superuser": "Superuser",
|
||||
"regular": "User",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"save": "Save",
|
||||
"confirmDelete": "Delete this user?",
|
||||
"selfDeleteWarning": "You cannot delete your own account",
|
||||
"noUsers": "No users yet",
|
||||
"errorLoading": "Failed to load users",
|
||||
"saveError": "Unable to save user",
|
||||
"passwordRequired": "Password is required to create a new user",
|
||||
"passwordTooShort": "Password must be at least 8 characters",
|
||||
"searchPlaceholder": "Search by username or email",
|
||||
"filterAll": "All",
|
||||
"createdAt": "Created At",
|
||||
"lastLogin": "Last Login",
|
||||
"permissions": "Module Permissions",
|
||||
"customPermissions": "Custom",
|
||||
"usingDefaultPermissions": "Using global settings from General tab",
|
||||
"moduleDisabledGlobally": "This module is disabled globally",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"user": {
|
||||
"admin": "Admin",
|
||||
"superuser": "Superuser"
|
||||
},
|
||||
"theme": {
|
||||
"title": "Theme Editor",
|
||||
"subtitle": "Customize the look and feel of the application",
|
||||
"colorsTab": "Colors",
|
||||
"appearanceTab": "Appearance",
|
||||
"previewTab": "Preview",
|
||||
"advancedTab": "Advanced",
|
||||
"accentColor": "Accent Color",
|
||||
"borderRadius": "Border Radius",
|
||||
"sidebarStyle": "Sidebar Style",
|
||||
"density": "Density",
|
||||
"pageMargin": "Page Margin",
|
||||
"fontFamily": "Font Family",
|
||||
"preview": "Preview",
|
||||
"previewTitle": "Theme Preview",
|
||||
"previewText": "This is a live preview of your selected theme settings.",
|
||||
"previewCard": "Preview Card",
|
||||
"previewDescription": "This is how your content will look with the selected theme settings.",
|
||||
"primaryButton": "Primary Button",
|
||||
"ghostButton": "Ghost Button",
|
||||
"inputPlaceholder": "Input field...",
|
||||
"badge": "Badge",
|
||||
"toggleMenu": "Toggle menu",
|
||||
"advancedColors": "Custom Colors",
|
||||
"customColors": "Custom Colors",
|
||||
"customColorsDesc": "Fine-tune specific colors for light and dark modes.",
|
||||
"advancedColorsDescription": "Customize every theme color in detail",
|
||||
"lightThemeColors": "Light Theme Colors",
|
||||
"lightMode": "Light Mode",
|
||||
"darkThemeColors": "Dark Theme Colors",
|
||||
"darkMode": "Dark Mode",
|
||||
"background": "Main Background",
|
||||
"backgroundCard": "Card Background",
|
||||
"backgroundElevated": "Elevated Background",
|
||||
"textPrimary": "Primary Text",
|
||||
"textSecondary": "Secondary Text",
|
||||
"border": "Border",
|
||||
"pickColor": "Pick color",
|
||||
"useEyedropper": "Use eyedropper",
|
||||
"resetColors": "Reset to defaults",
|
||||
"sidebar": "Sidebar",
|
||||
"sidebarBackground": "Sidebar Background",
|
||||
"sidebarText": "Sidebar Text",
|
||||
"colors": {
|
||||
"blue": "Blue",
|
||||
"blueDesc": "Classic & Professional",
|
||||
"purple": "Purple",
|
||||
"purpleDesc": "Creative & Modern",
|
||||
"green": "Green",
|
||||
"greenDesc": "Fresh & Natural",
|
||||
"orange": "Orange",
|
||||
"orangeDesc": "Energetic & Bold",
|
||||
"pink": "Pink",
|
||||
"pinkDesc": "Playful & Vibrant",
|
||||
"red": "Red",
|
||||
"redDesc": "Dynamic & Powerful",
|
||||
"teal": "Teal",
|
||||
"tealDesc": "Calm & Refreshing",
|
||||
"amber": "Amber",
|
||||
"amberDesc": "Warm & Inviting",
|
||||
"indigo": "Indigo",
|
||||
"indigoDesc": "Deep & Mysterious",
|
||||
"cyan": "Cyan",
|
||||
"cyanDesc": "Cool & Electric",
|
||||
"rose": "Rose",
|
||||
"roseDesc": "Romantic & Soft",
|
||||
"auto": "Auto",
|
||||
"autoDesc": "Adapts to theme"
|
||||
},
|
||||
"radius": {
|
||||
"small": "Small",
|
||||
"smallDesc": "Sharp edges",
|
||||
"medium": "Medium",
|
||||
"mediumDesc": "Balanced",
|
||||
"large": "Large",
|
||||
"largeDesc": "Soft curves"
|
||||
},
|
||||
"sidebarOptions": {
|
||||
"default": "Default",
|
||||
"defaultDesc": "Adaptive theme",
|
||||
"dark": "Dark",
|
||||
"darkDesc": "Always dark",
|
||||
"light": "Light",
|
||||
"lightDesc": "Always light"
|
||||
},
|
||||
"densityOptions": {
|
||||
"compact": "Compact",
|
||||
"compactDesc": "More content",
|
||||
"comfortable": "Comfortable",
|
||||
"comfortableDesc": "Balanced",
|
||||
"spacious": "Spacious",
|
||||
"spaciousDesc": "More breathing room"
|
||||
},
|
||||
"fontOptions": {
|
||||
"system": "System",
|
||||
"systemDesc": "Default system font",
|
||||
"inter": "Inter",
|
||||
"interDesc": "Modern & clean",
|
||||
"roboto": "Roboto",
|
||||
"robotoDesc": "Google's classic"
|
||||
},
|
||||
"palettes": {
|
||||
"default": "Default",
|
||||
"defaultDesc": "Standard modern palette",
|
||||
"monochrome": "Monochrome",
|
||||
"monochromeDesc": "Pure black & white",
|
||||
"monochromeBlue": "Slate",
|
||||
"monochromeBlueDesc": "Elegant blue-gray tones",
|
||||
"sepia": "Sepia",
|
||||
"sepiaDesc": "Warm vintage tones",
|
||||
"nord": "Nord",
|
||||
"nordDesc": "Arctic inspired colors",
|
||||
"dracula": "Dracula",
|
||||
"draculaDesc": "Dark gothic theme",
|
||||
"solarized": "Solarized",
|
||||
"solarizedDesc": "Easy on the eyes",
|
||||
"github": "GitHub",
|
||||
"githubDesc": "Clean & minimal",
|
||||
"ocean": "Ocean",
|
||||
"oceanDesc": "Cool blue depths",
|
||||
"forest": "Forest",
|
||||
"forestDesc": "Natural green tones",
|
||||
"midnight": "Midnight",
|
||||
"midnightDesc": "Deep purple nights",
|
||||
"sunset": "Sunset",
|
||||
"sunsetDesc": "Warm orange glow"
|
||||
},
|
||||
"colorPalette": "Color Palette",
|
||||
"sectionDesc": {
|
||||
"accentColor": "Choose your primary accent color",
|
||||
"colorPalette": "Choose a complete color scheme",
|
||||
"borderRadius": "Adjust corner roundness",
|
||||
"sidebarStyle": "Sidebar color scheme",
|
||||
"density": "Interface spacing",
|
||||
"fontFamily": "Typography style",
|
||||
"preview": "See how your theme looks"
|
||||
},
|
||||
"sampleCard": "Sample Card",
|
||||
"sampleCardDesc": "This is another example card to demonstrate how your theme will look across different components.",
|
||||
"totalItems": "Total Items",
|
||||
"successRate": "Success Rate",
|
||||
"currentColor": "Current Color",
|
||||
"apply": "Apply"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Manage application preferences",
|
||||
"authentication": "Authentication",
|
||||
"allowRegistration": "Allow User Registration",
|
||||
"allowRegistrationDesc": "Allow new users to register accounts autonomously",
|
||||
"showLogo": "Show Logo in Sidebar",
|
||||
"showLogoDesc": "Display the application logo at the top of the sidebar when in dynamic or expanded mode",
|
||||
"language": "Language",
|
||||
"languageDesc": "Select your preferred language",
|
||||
"english": "English",
|
||||
"italian": "Italiano",
|
||||
"comingSoon": "User settings will be available soon...",
|
||||
"placeholderTitle": "Settings"
|
||||
},
|
||||
"feature1": {
|
||||
"title": "Feature 1",
|
||||
"subtitle": "Feature 1 management",
|
||||
"comingSoon": "Feature coming soon...",
|
||||
"management": "Feature 1 Management"
|
||||
}
|
||||
}
|
||||
314
frontend/src/locales/it.json
Normal file
314
frontend/src/locales/it.json
Normal file
@@ -0,0 +1,314 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "App Service",
|
||||
"tagline": "Gestione Servizio"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Accedi",
|
||||
"register": "Registrati",
|
||||
"logout": "Esci",
|
||||
"username": "Nome utente",
|
||||
"password": "Password",
|
||||
"email": "Email",
|
||||
"loginTitle": "Accedi",
|
||||
"registerTitle": "Registrati",
|
||||
"alreadyHaveAccount": "Hai già un account? Accedi",
|
||||
"dontHaveAccount": "Non hai un account? Registrati",
|
||||
"authenticationFailed": "Autenticazione fallita"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Benvenuto",
|
||||
"profile": "Profilo",
|
||||
"userId": "ID Utente",
|
||||
"status": "Stato",
|
||||
"active": "Attivo",
|
||||
"inactive": "Inattivo"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
"feature1": "Funzione 1",
|
||||
"feature2": "Funzione 2",
|
||||
"feature3": "Funzione 3",
|
||||
"settings": "Impostazioni",
|
||||
"admin": "Amministrazione",
|
||||
"users": "Utenti"
|
||||
},
|
||||
"common": {
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla",
|
||||
"delete": "Elimina",
|
||||
"edit": "Modifica",
|
||||
"close": "Chiudi",
|
||||
"search": "Cerca",
|
||||
"loading": "Caricamento...",
|
||||
"error": "Errore",
|
||||
"success": "Successo",
|
||||
"all": "Tutti",
|
||||
"reset": "Reimposta"
|
||||
},
|
||||
"features": {
|
||||
"feature1": "Gestione Funzione 1",
|
||||
"feature2": "Gestore Funzione 2",
|
||||
"feature3": "Integrazione Funzione 3",
|
||||
"comingSoon": "Prossimamente"
|
||||
},
|
||||
"featuresPage": {
|
||||
"title": "Funzionalità",
|
||||
"subtitle": "Configura le funzionalità dell'applicazione"
|
||||
},
|
||||
"sourcesPage": {
|
||||
"title": "Sorgenti",
|
||||
"subtitle": "Gestisci le sorgenti dati",
|
||||
"comingSoon": "Gestione sorgenti dati in arrivo..."
|
||||
},
|
||||
"admin": {
|
||||
"panel": "Pannello Admin",
|
||||
"description": "Gestisci utenti e impostazioni dell'applicazione",
|
||||
"userManagement": "Gestione Utenti",
|
||||
"systemSettings": "Impostazioni Sistema",
|
||||
"generalTab": "Generali",
|
||||
"usersTab": "Utenti",
|
||||
"playlistsTab": "Playlist",
|
||||
"viewMode": "Interfaccia",
|
||||
"userModeToggle": "Pulsante Modalità Utente",
|
||||
"userModeToggleDesc": "Mostra un pulsante nella sidebar per passare alla vista utente",
|
||||
"sidebarMode": "Modalità Sidebar",
|
||||
"sidebarModeDesc": "Scegli come visualizzare la barra laterale",
|
||||
"sidebarModeCollapsed": "Sempre Ristretta",
|
||||
"sidebarModeCollapsedDesc": "Sempre piccola",
|
||||
"sidebarModeDynamic": "Dinamica",
|
||||
"sidebarModeDynamicDesc": "Si espande al passaggio del mouse",
|
||||
"sidebarModeToggle": "Commutabile",
|
||||
"sidebarModeToggleDesc": "Richiudibile",
|
||||
"adminView": "Admin",
|
||||
"userView": "Utente",
|
||||
"modulesSection": "Funzionalità",
|
||||
"playlistsDesc": "Gestione delle playlist per lo streaming",
|
||||
"downloadsDesc": "Scarica e gestisci contenuti offline",
|
||||
"chromecastDesc": "Trasmetti contenuti su dispositivi",
|
||||
"moduleDefaultDesc": "Abilita o disabilita questa funzionalità per gli utenti",
|
||||
"darkModeSettings": "Impostazioni Modalità Scura",
|
||||
"enableDarkModeToggle": "Abilita Toggle Modalità Scura",
|
||||
"enableDarkModeToggleDesc": "Consenti agli utenti di cambiare tema",
|
||||
"showOnLoginScreen": "Mostra nella schermata di login",
|
||||
"showOnLoginScreenDesc": "Mostra il toggle nella pagina di login",
|
||||
"controlLocation": "Posizione Controllo",
|
||||
"controlLocationDesc": "Mostra nella Sidebar (ON) o nel Menu Utente (OFF)",
|
||||
"languageSettings": "Impostazioni Lingua",
|
||||
"enableLanguageSelector": "Abilita Selettore Lingua",
|
||||
"enableLanguageSelectorDesc": "Consenti agli utenti di cambiare lingua",
|
||||
"showLanguageOnLoginDesc": "Mostra il selettore nella pagina di login",
|
||||
"adminRole": "Admin",
|
||||
"userRole": "Utente",
|
||||
"active": "Attivo",
|
||||
"inactive": "Disattivo"
|
||||
},
|
||||
"usersPage": {
|
||||
"title": "Utenti",
|
||||
"addUser": "Aggiungi utente",
|
||||
"editUser": "Modifica utente",
|
||||
"name": "Nome utente",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"passwordHintCreate": "(min 8 caratteri)",
|
||||
"passwordHintEdit": "(lascia vuoto per mantenere la password attuale)",
|
||||
"status": "Stato",
|
||||
"role": "Ruolo",
|
||||
"isActive": "Attivo",
|
||||
"isSuperuser": "Superutente",
|
||||
"active": "Attivo",
|
||||
"inactive": "Inattivo",
|
||||
"superuser": "Superutente",
|
||||
"regular": "Utente",
|
||||
"actions": "Azioni",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"save": "Salva",
|
||||
"confirmDelete": "Eliminare questo utente?",
|
||||
"selfDeleteWarning": "Non puoi eliminare il tuo account",
|
||||
"noUsers": "Nessun utente presente",
|
||||
"errorLoading": "Caricamento utenti fallito",
|
||||
"saveError": "Impossibile salvare l'utente",
|
||||
"passwordRequired": "La password è obbligatoria per creare un nuovo utente",
|
||||
"passwordTooShort": "La password deve essere di almeno 8 caratteri",
|
||||
"searchPlaceholder": "Cerca per username o email",
|
||||
"filterAll": "Tutti",
|
||||
"createdAt": "Data Creazione",
|
||||
"lastLogin": "Ultimo Accesso",
|
||||
"permissions": "Permessi Moduli",
|
||||
"customPermissions": "Personalizza",
|
||||
"usingDefaultPermissions": "Usa impostazioni globali da Generali",
|
||||
"moduleDisabledGlobally": "Questo modulo è disabilitato globalmente",
|
||||
"disabled": "Disabilitato"
|
||||
},
|
||||
"user": {
|
||||
"admin": "Admin",
|
||||
"superuser": "Superutente"
|
||||
},
|
||||
"theme": {
|
||||
"title": "Editor Temi",
|
||||
"subtitle": "Personalizza l'aspetto dell'applicazione",
|
||||
"colorsTab": "Colori",
|
||||
"appearanceTab": "Aspetto",
|
||||
"previewTab": "Anteprima",
|
||||
"advancedTab": "Avanzato",
|
||||
"accentColor": "Colore Accento",
|
||||
"borderRadius": "Raggio Bordo",
|
||||
"sidebarStyle": "Stile Sidebar",
|
||||
"density": "Densità",
|
||||
"pageMargin": "Margine Pagina",
|
||||
"fontFamily": "Font",
|
||||
"preview": "Anteprima",
|
||||
"previewTitle": "Anteprima Tema",
|
||||
"previewText": "Questa è un'anteprima dal vivo delle impostazioni del tema selezionate.",
|
||||
"previewCard": "Card di Anteprima",
|
||||
"previewDescription": "Ecco come apparirà il contenuto con le impostazioni del tema selezionate.",
|
||||
"primaryButton": "Pulsante Primario",
|
||||
"ghostButton": "Pulsante Ghost",
|
||||
"inputPlaceholder": "Campo di input...",
|
||||
"badge": "Badge",
|
||||
"toggleMenu": "Apri/chiudi menu",
|
||||
"advancedColors": "Colori Personalizzati",
|
||||
"customColors": "Colori Personalizzati",
|
||||
"customColorsDesc": "Regola i colori specifici per le modalità chiara e scura.",
|
||||
"advancedColorsDescription": "Personalizza ogni colore del tema in dettaglio",
|
||||
"lightThemeColors": "Colori Tema Chiaro",
|
||||
"lightMode": "Modalità Chiara",
|
||||
"darkThemeColors": "Colori Tema Scuro",
|
||||
"darkMode": "Modalità Scura",
|
||||
"background": "Sfondo Principale",
|
||||
"backgroundCard": "Sfondo Card",
|
||||
"backgroundElevated": "Sfondo Elevato",
|
||||
"textPrimary": "Testo Primario",
|
||||
"textSecondary": "Testo Secondario",
|
||||
"border": "Bordo",
|
||||
"pickColor": "Seleziona colore",
|
||||
"useEyedropper": "Usa contagocce",
|
||||
"resetColors": "Ripristina predefiniti",
|
||||
"sidebar": "Sidebar",
|
||||
"sidebarBackground": "Sfondo Sidebar",
|
||||
"sidebarText": "Testo Sidebar",
|
||||
"colors": {
|
||||
"blue": "Blu",
|
||||
"blueDesc": "Classico e Professionale",
|
||||
"purple": "Viola",
|
||||
"purpleDesc": "Creativo e Moderno",
|
||||
"green": "Verde",
|
||||
"greenDesc": "Fresco e Naturale",
|
||||
"orange": "Arancione",
|
||||
"orangeDesc": "Energico e Audace",
|
||||
"pink": "Rosa",
|
||||
"pinkDesc": "Giocoso e Vivace",
|
||||
"red": "Rosso",
|
||||
"redDesc": "Dinamico e Potente",
|
||||
"teal": "Teal",
|
||||
"tealDesc": "Calmo e Rinfrescante",
|
||||
"amber": "Ambra",
|
||||
"amberDesc": "Caldo e Accogliente",
|
||||
"indigo": "Indaco",
|
||||
"indigoDesc": "Profondo e Misterioso",
|
||||
"cyan": "Ciano",
|
||||
"cyanDesc": "Fresco e Elettrico",
|
||||
"rose": "Rosa Antico",
|
||||
"roseDesc": "Romantico e Delicato",
|
||||
"auto": "Auto",
|
||||
"autoDesc": "Si adatta al tema"
|
||||
},
|
||||
"radius": {
|
||||
"small": "Piccolo",
|
||||
"smallDesc": "Angoli netti",
|
||||
"medium": "Medio",
|
||||
"mediumDesc": "Bilanciato",
|
||||
"large": "Grande",
|
||||
"largeDesc": "Curve morbide"
|
||||
},
|
||||
"sidebarOptions": {
|
||||
"default": "Predefinito",
|
||||
"defaultDesc": "Tema adattivo",
|
||||
"dark": "Scuro",
|
||||
"darkDesc": "Sempre scuro",
|
||||
"light": "Chiaro",
|
||||
"lightDesc": "Sempre chiaro"
|
||||
},
|
||||
"densityOptions": {
|
||||
"compact": "Compatto",
|
||||
"compactDesc": "Più contenuto",
|
||||
"comfortable": "Confortevole",
|
||||
"comfortableDesc": "Bilanciato",
|
||||
"spacious": "Spazioso",
|
||||
"spaciousDesc": "Più respiro"
|
||||
},
|
||||
"fontOptions": {
|
||||
"system": "Sistema",
|
||||
"systemDesc": "Font di sistema",
|
||||
"inter": "Inter",
|
||||
"interDesc": "Moderno e pulito",
|
||||
"roboto": "Roboto",
|
||||
"robotoDesc": "Il classico di Google"
|
||||
},
|
||||
"palettes": {
|
||||
"default": "Predefinito",
|
||||
"defaultDesc": "Palette moderna standard",
|
||||
"monochrome": "Monocromatico",
|
||||
"monochromeDesc": "Bianco e nero puro",
|
||||
"monochromeBlue": "Ardesia",
|
||||
"monochromeBlueDesc": "Eleganti toni grigio-blu",
|
||||
"sepia": "Seppia",
|
||||
"sepiaDesc": "Toni vintage caldi",
|
||||
"nord": "Nord",
|
||||
"nordDesc": "Colori ispirati all'Artico",
|
||||
"dracula": "Dracula",
|
||||
"draculaDesc": "Tema gotico scuro",
|
||||
"solarized": "Solarized",
|
||||
"solarizedDesc": "Facile per gli occhi",
|
||||
"github": "GitHub",
|
||||
"githubDesc": "Pulito e minimale",
|
||||
"ocean": "Oceano",
|
||||
"oceanDesc": "Profondità blu fredde",
|
||||
"forest": "Foresta",
|
||||
"forestDesc": "Toni verdi naturali",
|
||||
"midnight": "Mezzanotte",
|
||||
"midnightDesc": "Notti viola profonde",
|
||||
"sunset": "Tramonto",
|
||||
"sunsetDesc": "Bagliore arancione caldo"
|
||||
},
|
||||
"colorPalette": "Palette Colori",
|
||||
"sectionDesc": {
|
||||
"accentColor": "Scegli il tuo colore principale",
|
||||
"colorPalette": "Scegli uno schema colori completo",
|
||||
"borderRadius": "Regola l'arrotondamento degli angoli",
|
||||
"sidebarStyle": "Schema colori sidebar",
|
||||
"density": "Spaziatura interfaccia",
|
||||
"fontFamily": "Stile tipografico",
|
||||
"preview": "Vedi come appare il tuo tema"
|
||||
},
|
||||
"sampleCard": "Card di Esempio",
|
||||
"sampleCardDesc": "Questa è un'altra card di esempio per mostrare come apparirà il tema su diversi componenti.",
|
||||
"totalItems": "Elementi Totali",
|
||||
"successRate": "Tasso di Successo",
|
||||
"currentColor": "Colore Attuale",
|
||||
"apply": "Applica"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
"subtitle": "Gestisci le preferenze dell'applicazione",
|
||||
"authentication": "Autenticazione",
|
||||
"allowRegistration": "Consenti Registrazione Utenti",
|
||||
"allowRegistrationDesc": "Consenti ai nuovi utenti di registrarsi autonomamente",
|
||||
"showLogo": "Mostra Logo nella Sidebar",
|
||||
"showLogoDesc": "Mostra il logo dell'applicazione in alto nella sidebar quando in modalità dinamica o espansa",
|
||||
"language": "Lingua",
|
||||
"languageDesc": "Seleziona la tua lingua preferita",
|
||||
"english": "Inglese",
|
||||
"italian": "Italiano",
|
||||
"comingSoon": "Le impostazioni utente saranno disponibili a breve...",
|
||||
"placeholderTitle": "Impostazioni"
|
||||
},
|
||||
"feature1": {
|
||||
"title": "Funzione 1",
|
||||
"subtitle": "Gestione Funzione 1",
|
||||
"comingSoon": "Funzionalità in arrivo...",
|
||||
"management": "Gestione Funzione 1"
|
||||
}
|
||||
}
|
||||
18
frontend/src/main.tsx
Normal file
18
frontend/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import siteConfig from './config/site.config'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
// Set initial document title and meta from config
|
||||
document.title = siteConfig.meta.title
|
||||
const metaDescription = document.querySelector('meta[name="description"]')
|
||||
if (metaDescription) {
|
||||
metaDescription.setAttribute('content', siteConfig.meta.description)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
110
frontend/src/modules/index.ts
Normal file
110
frontend/src/modules/index.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface Module {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
path: string;
|
||||
component: ReactNode;
|
||||
enabled: boolean;
|
||||
requiresAuth: boolean;
|
||||
requiresAdmin?: boolean;
|
||||
}
|
||||
|
||||
export interface ModuleCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
modules: Module[];
|
||||
}
|
||||
|
||||
// Define available modules
|
||||
export const appModules: ModuleCategory[] = [
|
||||
{
|
||||
id: 'main',
|
||||
name: 'Main Features',
|
||||
modules: [
|
||||
{
|
||||
id: 'dashboard',
|
||||
name: 'sidebar.dashboard',
|
||||
icon: 'dashboard',
|
||||
path: '/dashboard',
|
||||
component: null, // Will be lazy loaded
|
||||
enabled: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
id: 'feature1',
|
||||
name: 'sidebar.feature1',
|
||||
icon: 'playlist_play',
|
||||
path: '/feature1',
|
||||
component: null,
|
||||
enabled: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
id: 'feature2',
|
||||
name: 'sidebar.feature2',
|
||||
icon: 'download',
|
||||
path: '/feature2',
|
||||
component: null,
|
||||
enabled: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
id: 'feature3',
|
||||
name: 'sidebar.feature3',
|
||||
icon: 'cast',
|
||||
path: '/feature3',
|
||||
component: null,
|
||||
enabled: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
name: 'Administration',
|
||||
modules: [
|
||||
{
|
||||
id: 'users',
|
||||
name: 'sidebar.users',
|
||||
icon: 'group',
|
||||
path: '/admin/users',
|
||||
component: null,
|
||||
enabled: true,
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
name: 'sidebar.settings',
|
||||
icon: 'settings',
|
||||
path: '/settings',
|
||||
component: null,
|
||||
enabled: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Helper to get enabled modules
|
||||
export function getEnabledModules(isAdmin: boolean = false): Module[] {
|
||||
const allModules = appModules.flatMap((category) => category.modules);
|
||||
return allModules.filter((module) => {
|
||||
if (!module.enabled) return false;
|
||||
if (module.requiresAdmin && !isAdmin) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to get modules by category
|
||||
export function getModulesByCategory(categoryId: string, isAdmin: boolean = false): Module[] {
|
||||
const category = appModules.find((cat) => cat.id === categoryId);
|
||||
if (!category) return [];
|
||||
return category.modules.filter((module) => {
|
||||
if (!module.enabled) return false;
|
||||
if (module.requiresAdmin && !isAdmin) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
2260
frontend/src/styles/AdminPanel.css
Normal file
2260
frontend/src/styles/AdminPanel.css
Normal file
File diff suppressed because it is too large
Load Diff
84
frontend/src/styles/Dashboard.css
Normal file
84
frontend/src/styles/Dashboard.css
Normal file
@@ -0,0 +1,84 @@
|
||||
/* ==========================================
|
||||
DASHBOARD PAGE
|
||||
Specific styles for dashboard page
|
||||
Layout rules are in Layout.css
|
||||
========================================== */
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Stat Card */
|
||||
.stat-card {
|
||||
background: var(--color-bg-card);
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
margin-top: 0;
|
||||
color: var(--color-text-primary);
|
||||
border-bottom: 2px solid var(--color-accent);
|
||||
padding-bottom: 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-card p {
|
||||
margin: 0.5rem 0;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Admin Card Variant */
|
||||
.admin-card {
|
||||
border: 2px solid var(--color-accent);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
/* Status Indicators */
|
||||
.status-active {
|
||||
color: var(--color-success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
color: var(--color-error);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ========== RESPONSIVE DESIGN ========== */
|
||||
|
||||
/* Tablet */
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
431
frontend/src/styles/Layout.css
Normal file
431
frontend/src/styles/Layout.css
Normal file
@@ -0,0 +1,431 @@
|
||||
/* ==========================================
|
||||
UNIFIED PAGE LAYOUT SYSTEM
|
||||
Standard layout classes for all pages
|
||||
========================================== */
|
||||
|
||||
/* ========== BASE LAYOUT ========== */
|
||||
|
||||
/* App container - contains sidebar and main content */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-main);
|
||||
}
|
||||
|
||||
/* Main content area - adjusts for sidebar */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: margin-left 0.3s ease;
|
||||
}
|
||||
|
||||
/* Adjust main content when sidebar is collapsed */
|
||||
.sidebar.collapsed~.main-content {
|
||||
margin-left: var(--sidebar-width-collapsed);
|
||||
}
|
||||
|
||||
/* ========== PAGE STRUCTURE ========== */
|
||||
|
||||
/* Page header container - centered with symmetric padding */
|
||||
.page-tabs-container,
|
||||
.admin-tabs-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.75rem var(--page-padding-x);
|
||||
background: var(--color-bg-main);
|
||||
}
|
||||
|
||||
/* Page header slider - rounded pill style (like admin panel) */
|
||||
.page-tabs-slider,
|
||||
.admin-tabs-slider {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Page title section inside slider */
|
||||
.page-title-section,
|
||||
.admin-title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-title-section .material-symbols-outlined,
|
||||
.admin-title-section .material-symbols-outlined {
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.page-title-text,
|
||||
.admin-title-text {
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Divider between title and tabs (for pages with tabs) */
|
||||
.page-tabs-divider,
|
||||
.admin-tabs-divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: var(--color-border);
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
/* Tab buttons (for pages with tabs) */
|
||||
.page-tab-btn,
|
||||
.admin-tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: var(--tab-padding);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--tab-font-size);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.page-tab-btn:focus,
|
||||
.admin-tab-btn:focus,
|
||||
.page-tab-btn:focus-visible,
|
||||
.admin-tab-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.page-tab-btn.active:focus,
|
||||
.admin-tab-btn.active:focus,
|
||||
.page-tab-btn.active:focus-visible,
|
||||
.admin-tab-btn.active:focus-visible {
|
||||
box-shadow: 0 2px 8px rgba(var(--color-accent-rgb), 0.3);
|
||||
}
|
||||
|
||||
.page-tab-btn .material-symbols-outlined,
|
||||
.admin-tab-btn .material-symbols-outlined {
|
||||
font-size: var(--icon-md);
|
||||
transition: inherit;
|
||||
}
|
||||
|
||||
.page-tab-btn:hover:not(.active),
|
||||
.admin-tab-btn:hover:not(.active) {
|
||||
color: var(--color-text-primary);
|
||||
background: rgba(var(--color-accent-rgb), 0.1);
|
||||
}
|
||||
|
||||
.page-tab-btn.active,
|
||||
.admin-tab-btn.active {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(var(--color-accent-rgb), 0.3);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Standard Section Title */
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Standard Section Header */
|
||||
.section-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Standard Page Section */
|
||||
.page-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.page-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ========== MOBILE MENU BUTTON ========== */
|
||||
|
||||
/* Hidden by default on desktop */
|
||||
.mobile-menu-btn {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-menu-btn:hover {
|
||||
background: rgba(var(--color-accent-rgb), 0.1);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.mobile-menu-btn .material-symbols-outlined {
|
||||
font-size: var(--icon-lg);
|
||||
}
|
||||
|
||||
/* ========== ACTION BUTTONS IN SLIDER ========== */
|
||||
|
||||
/* Action buttons that appear in the slider (like Add User) */
|
||||
.page-tabs-slider .btn-primary,
|
||||
.admin-tabs-slider .btn-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-left: auto;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-tabs-slider .btn-primary .material-symbols-outlined,
|
||||
.admin-tabs-slider .btn-primary .material-symbols-outlined {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* ========== PAGE CONTENT ========== */
|
||||
|
||||
/* Main content wrapper - with symmetric padding from sidebar edge to window edge */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: var(--page-padding-y) var(--page-padding-x);
|
||||
width: 100%;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Content wrapper for centered content with max-width (use inside page-content if needed) */
|
||||
.content-wrapper {
|
||||
max-width: var(--page-max-width);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Admin tab content (for tabbed interfaces) */
|
||||
.admin-tab-content {
|
||||
padding: var(--page-padding-y) var(--page-padding-x);
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ========== RESPONSIVE DESIGN ========== */
|
||||
|
||||
/* Tablet - reduce padding */
|
||||
@media (max-width: 1024px) {
|
||||
|
||||
.page-tabs-container,
|
||||
.admin-tabs-container {
|
||||
padding: var(--page-padding-y-tablet) var(--page-padding-x-tablet);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: var(--page-padding-y-tablet) var(--page-padding-x-tablet);
|
||||
}
|
||||
|
||||
.admin-tab-content {
|
||||
padding: var(--page-padding-y-tablet) var(--page-padding-x-tablet);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile - remove sidebar margin */
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Override collapsed state margin on mobile */
|
||||
.sidebar.collapsed~.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Show mobile menu button */
|
||||
.mobile-menu-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.page-tabs-container,
|
||||
.admin-tabs-container {
|
||||
padding: var(--page-padding-y-mobile) var(--page-padding-x-mobile);
|
||||
}
|
||||
|
||||
.page-tabs-slider,
|
||||
.admin-tabs-slider {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-title-section,
|
||||
.admin-title-section {
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.page-title-section .material-symbols-outlined,
|
||||
.admin-title-section .material-symbols-outlined {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
/* Hide divider on mobile */
|
||||
.page-tabs-divider,
|
||||
.admin-tabs-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Tabs on second row - full width */
|
||||
.page-tab-btn,
|
||||
.admin-tab-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Hide text on mobile, show only icons */
|
||||
.page-tab-btn span:not(.material-symbols-outlined),
|
||||
.admin-tab-btn span:not(.material-symbols-outlined) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-tab-btn .material-symbols-outlined,
|
||||
.admin-tab-btn .material-symbols-outlined {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
/* Action buttons in slider - icon only on mobile */
|
||||
.page-tabs-slider .btn-primary,
|
||||
.admin-tabs-slider .btn-primary {
|
||||
padding: 0.5rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.page-tabs-slider .btn-primary span:not(.material-symbols-outlined),
|
||||
.admin-tabs-slider .btn-primary span:not(.material-symbols-outlined) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: var(--page-padding-y-mobile) var(--page-padding-x-mobile);
|
||||
}
|
||||
|
||||
.admin-tab-content {
|
||||
padding: var(--page-padding-y-mobile) var(--page-padding-x-mobile);
|
||||
}
|
||||
}
|
||||
|
||||
/* Small mobile - further reduce spacing */
|
||||
@media (max-width: 480px) {
|
||||
|
||||
.page-tabs-container,
|
||||
.admin-tabs-container {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-tab-content {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== TAB CONTENT ANIMATIONS ========== */
|
||||
|
||||
/* Prevent layout shift when switching tabs */
|
||||
.admin-tab-content {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 0.25s ease;
|
||||
}
|
||||
|
||||
.admin-tab-content>div {
|
||||
opacity: 0;
|
||||
animation: tabFadeIn 0.25s ease forwards;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@keyframes tabFadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== UTILITY COMPONENTS ========== */
|
||||
|
||||
/* Tab Content Placeholder (for "coming soon" pages) */
|
||||
.tab-content-placeholder {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tab-content-placeholder .placeholder-icon {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab-content-placeholder .placeholder-icon .material-symbols-outlined {
|
||||
font-size: 80px;
|
||||
color: var(--color-accent);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tab-content-placeholder h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.tab-content-placeholder p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
189
frontend/src/styles/Login.css
Normal file
189
frontend/src/styles/Login.css
Normal file
@@ -0,0 +1,189 @@
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-main);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--color-bg-card);
|
||||
padding: 2rem;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
.login-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
color: var(--color-accent);
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.login-tagline {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-theme,
|
||||
.btn-language {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-theme .material-symbols-outlined,
|
||||
.btn-language .material-symbols-outlined {
|
||||
font-size: var(--icon-sm);
|
||||
}
|
||||
|
||||
.btn-theme:hover,
|
||||
.btn-language:hover {
|
||||
background: var(--color-bg-card);
|
||||
border-color: var(--color-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1rem;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-primary);
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-footer-action {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-footer-action .material-symbols-outlined {
|
||||
font-size: var(--icon-sm);
|
||||
}
|
||||
|
||||
.btn-footer-action:hover {
|
||||
background: var(--color-bg-card);
|
||||
border-color: var(--color-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(245, 101, 101, 0.1);
|
||||
color: var(--color-error);
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
border: 1px solid var(--color-error);
|
||||
}
|
||||
51
frontend/src/styles/MobileHeader.css
Normal file
51
frontend/src/styles/MobileHeader.css
Normal file
@@ -0,0 +1,51 @@
|
||||
.mobile-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-bg-card);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 998;
|
||||
height: 56px;
|
||||
/* Slightly reduced height */
|
||||
}
|
||||
|
||||
.btn-hamburger {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.btn-hamburger:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.btn-hamburger .material-symbols-outlined {
|
||||
font-size: var(--icon-lg);
|
||||
}
|
||||
|
||||
.mobile-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
123
frontend/src/styles/Settings.css
Normal file
123
frontend/src/styles/Settings.css
Normal file
@@ -0,0 +1,123 @@
|
||||
.settings-root .settings-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.settings-root .settings-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.settings-root .settings-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-root h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--color-text-primary);
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-root .setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.settings-root .setting-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.settings-root .setting-info {
|
||||
flex: 1;
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.settings-root .setting-info h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.settings-root .setting-info p {
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 52px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--color-border);
|
||||
transition: 0.3s ease;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
transition: 0.3s ease;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked+.toggle-slider {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
input:focus+.toggle-slider {
|
||||
box-shadow: 0 0 0 2px var(--color-accent-hover);
|
||||
}
|
||||
|
||||
input:checked+.toggle-slider:before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
input:disabled+.toggle-slider {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.settings-root .setting-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.settings-root .setting-info {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
175
frontend/src/styles/SettingsPage.css
Normal file
175
frontend/src/styles/SettingsPage.css
Normal file
@@ -0,0 +1,175 @@
|
||||
/* Settings Page for all users */
|
||||
.settings-page-root {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.settings-page-root .page-header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-page-root .page-subtitle {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.settings-page-root .page-content {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
/* Settings Sections */
|
||||
.settings-section-modern {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-section-modern:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Section title uses standard .section-title from Layout.css */
|
||||
|
||||
.setting-item-modern {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.setting-item-modern:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.setting-item-modern:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.setting-info-modern {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setting-info-modern h4 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.setting-info-modern p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.setting-icon-modern {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.setting-icon-modern .material-symbols-outlined {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.select-modern {
|
||||
padding: 0.5rem 2rem 0.5rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24' viewBox='0 0 24 24' width='24' fill='%236b7280'%3E%3Cpath d='M0 0h24v24H0z' fill='none'/%3E%3Cpath d='M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.select-modern:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px rgba(var(--color-accent-rgb), 0.2);
|
||||
}
|
||||
|
||||
/* Modern Toggle */
|
||||
.toggle-modern {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 52px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-modern input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider-modern {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--color-border);
|
||||
transition: 0.3s ease;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.toggle-slider-modern:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
transition: 0.3s ease;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toggle-modern input:checked+.toggle-slider-modern {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.toggle-modern input:focus+.toggle-slider-modern {
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-accent-rgb), 0.2);
|
||||
}
|
||||
|
||||
.toggle-modern input:checked+.toggle-slider-modern:before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.toggle-modern input:disabled+.toggle-slider-modern {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.setting-item-modern {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
1021
frontend/src/styles/Sidebar.css
Normal file
1021
frontend/src/styles/Sidebar.css
Normal file
File diff suppressed because it is too large
Load Diff
1375
frontend/src/styles/ThemeSettings.css
Normal file
1375
frontend/src/styles/ThemeSettings.css
Normal file
File diff suppressed because it is too large
Load Diff
686
frontend/src/styles/Users.css
Normal file
686
frontend/src/styles/Users.css
Normal file
@@ -0,0 +1,686 @@
|
||||
/* Users Page Styles */
|
||||
|
||||
.users-root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.users-root .page-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.users-root .page-subtitle {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.users-root .users-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.users-root .page-header-row .btn-primary {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
align-self: center;
|
||||
height: 42px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.users-root .users-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--toolbar-gap);
|
||||
margin-bottom: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.users-root .input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--element-gap);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.users-root .input-group input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.users-root .users-badges {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
gap: var(--element-gap) !important;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.users-root .users-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.users-root .users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.users-root .users-table th,
|
||||
.users-root .users-table td {
|
||||
padding: var(--table-cell-padding);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.users-root .users-table tbody tr:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.users-root .users-table th {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-elevated);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.users-root .users-table tbody tr:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
/* User Cell */
|
||||
.users-root .user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--element-gap-lg);
|
||||
}
|
||||
|
||||
.users-root .user-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.users-root .user-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.users-root .user-name {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.users-root .user-email,
|
||||
.users-root .user-id {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.users-root .users-actions {
|
||||
display: flex;
|
||||
gap: var(--element-gap);
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.users-root .btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.users-root .btn-primary:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.users-root .btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-main);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.users-root .btn-ghost:hover {
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.users-root .btn-ghost.danger {
|
||||
border-color: var(--color-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.users-root .btn-ghost:disabled,
|
||||
.users-root .btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.users-root .users-alert {
|
||||
background: rgba(220, 38, 38, 0.12);
|
||||
border: 1px solid var(--color-error);
|
||||
color: var(--color-error);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.users-root .users-alert.in-modal {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.users-root .users-empty {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-8);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.users-root .badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--badge-padding);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
font-size: var(--badge-font-size);
|
||||
}
|
||||
|
||||
.users-root .badge-success {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #0f9c75;
|
||||
}
|
||||
|
||||
.users-root .badge-muted {
|
||||
background: rgba(220, 38, 38, 0.15);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.users-root .badge-accent {
|
||||
background: rgba(129, 140, 248, 0.16);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.users-root .badge-neutral {
|
||||
background: rgba(74, 85, 104, 0.12);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Modal Backdrop */
|
||||
.users-modal-backdrop {
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
z-index: var(--z-modal-backdrop);
|
||||
padding: var(--space-4);
|
||||
backdrop-filter: blur(2px);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
/* Modal */
|
||||
.users-modal {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-2xl);
|
||||
width: calc(100% - var(--space-8));
|
||||
max-width: 520px;
|
||||
padding: var(--space-6);
|
||||
position: relative;
|
||||
z-index: var(--z-modal);
|
||||
animation: modalSlideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.users-modal .modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
position: relative;
|
||||
padding-right: var(--space-10);
|
||||
}
|
||||
|
||||
.users-modal .modal-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.users-modal .btn-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.users-modal .btn-close {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: 0.35rem;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-main);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.users-modal .btn-close:hover {
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.users-modal .users-form {
|
||||
margin-top: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--element-gap-lg);
|
||||
}
|
||||
|
||||
.users-modal .form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.users-modal .form-row input {
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-main);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.users-modal .form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--element-gap-lg);
|
||||
}
|
||||
|
||||
.users-modal .checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--element-gap-lg);
|
||||
font-weight: 600;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-main);
|
||||
}
|
||||
|
||||
.users-modal .checkbox-row input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.users-modal .helper-text {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 400;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.users-modal .modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--element-gap);
|
||||
}
|
||||
|
||||
/* Mobile Card View */
|
||||
.desktop-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.users-mobile-list {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.users-mobile-list .btn-ghost {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.users-mobile-list .btn-ghost.danger {
|
||||
color: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.user-card-mobile {
|
||||
padding: var(--card-padding);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.user-card-mobile:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.user-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.user-card-header .user-cell {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--element-gap);
|
||||
margin-bottom: var(--space-4);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-bg-main);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.user-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.user-info-row .label {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.user-info-row .value {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--element-gap);
|
||||
}
|
||||
|
||||
/* Toolbar Right */
|
||||
.users-root .toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--element-gap-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
|
||||
/* Sortable Headers */
|
||||
.users-root .users-table th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.users-root .users-table th.sortable:hover {
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.users-root .users-table th.sortable::after {
|
||||
content: '↕';
|
||||
font-size: 0.7rem;
|
||||
margin-left: 0.35rem;
|
||||
opacity: 0.3;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.users-root .users-table th.sortable:hover::after {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.users-root .sort-icon {
|
||||
font-size: 14px !important;
|
||||
margin-left: 0.3rem;
|
||||
color: var(--color-accent);
|
||||
vertical-align: -2px;
|
||||
font-variation-settings: 'wght' 500;
|
||||
}
|
||||
|
||||
/* Hide default arrow when sort icon is shown */
|
||||
.users-root .users-table th.sortable:has(.sort-icon)::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Superuser Styling */
|
||||
.users-root .superuser-row {
|
||||
background: rgba(129, 140, 248, 0.04);
|
||||
}
|
||||
|
||||
.users-root .superuser-row:hover {
|
||||
background: rgba(129, 140, 248, 0.08) !important;
|
||||
}
|
||||
|
||||
.users-root .user-avatar.superuser {
|
||||
background: linear-gradient(135deg, var(--color-accent), #a78bfa);
|
||||
box-shadow: 0 2px 8px rgba(129, 140, 248, 0.3);
|
||||
}
|
||||
|
||||
/* Users Divider */
|
||||
.users-root .users-divider {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.users-root .users-divider td {
|
||||
padding: 0 !important;
|
||||
border-bottom: none !important;
|
||||
background: var(--color-border);
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.users-root .divider-line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile User Cards */
|
||||
.users-root .mobile-users-list {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.users-root .mobile-user-card {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--card-padding);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.users-root .mobile-user-card.superuser-card {
|
||||
background: rgba(129, 140, 248, 0.04);
|
||||
}
|
||||
|
||||
.users-root .mobile-user-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--element-gap-lg);
|
||||
margin-bottom: var(--element-gap-lg);
|
||||
}
|
||||
|
||||
.users-root .mobile-user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.users-root .mobile-user-name {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.users-root .mobile-user-email {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.users-root .mobile-user-actions {
|
||||
display: flex;
|
||||
gap: var(--element-gap);
|
||||
}
|
||||
|
||||
.users-root .mobile-user-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--element-gap);
|
||||
padding-top: var(--element-gap-lg);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.users-root .mobile-badges-row {
|
||||
display: flex;
|
||||
gap: var(--element-gap);
|
||||
}
|
||||
|
||||
.users-root .mobile-dates {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.users-root .mobile-date-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.users-root .mobile-date-item .material-symbols-outlined {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.users-root .mobile-users-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.users-root .users-card {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.users-root .mobile-users-list {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.users-root .users-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.users-root .input-group {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.users-root .users-badges {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.users-root .toolbar-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
133
frontend/src/styles/theme/README.md
Normal file
133
frontend/src/styles/theme/README.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Theme System
|
||||
|
||||
Sistema modulare per la gestione del tema dell'applicazione Service Name.
|
||||
|
||||
## Struttura
|
||||
|
||||
```
|
||||
theme/
|
||||
├── colors.css # Palette colori (light/dark)
|
||||
├── dimensions.css # Spacing, sizing, border radius, z-index
|
||||
├── typography.css # Font sizes, weights, line heights
|
||||
├── effects.css # Shadows, transitions, animations
|
||||
├── index.css # Importa tutti i moduli
|
||||
└── README.md # Questa documentazione
|
||||
```
|
||||
|
||||
## Come Usare
|
||||
|
||||
### Modificare i Colori del Tema
|
||||
|
||||
Apri `colors.css` e modifica le variabili CSS:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--color-primary: #2d3748; /* Colore primario light theme */
|
||||
--color-accent: #4c51bf; /* Colore accent */
|
||||
/* ... */
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--color-primary: #d1d5db; /* Colore primario dark theme */
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
### Aggiungere un Nuovo Tema
|
||||
|
||||
1. Aggiungi un nuovo selettore in `colors.css`:
|
||||
|
||||
```css
|
||||
[data-theme='ocean'] {
|
||||
--color-primary: #006994;
|
||||
--color-accent: #0ea5e9;
|
||||
--color-bg-main: #f0f9ff;
|
||||
/* ... definisci tutti i colori ... */
|
||||
}
|
||||
```
|
||||
|
||||
2. Modifica il context del tema per includere il nuovo tema nell'elenco
|
||||
|
||||
### Modificare le Dimensioni
|
||||
|
||||
Apri `dimensions.css`:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--space-4: 1rem; /* Spacing base */
|
||||
--sidebar-width: 260px; /* Larghezza sidebar */
|
||||
--height-nav-item: 40px; /* Altezza nav items */
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
### Modificare la Tipografia
|
||||
|
||||
Apri `typography.css`:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--text-base: 0.875rem; /* Dimensione testo base */
|
||||
--weight-medium: 500; /* Peso font medio */
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
### Modificare Effetti e Transizioni
|
||||
|
||||
Apri `effects.css`:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
||||
--transition-slow: 0.3s ease;
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
## Convenzioni
|
||||
|
||||
### Naming
|
||||
|
||||
- Usa prefissi descrittivi: `--color-`, `--space-`, `--text-`, `--shadow-`
|
||||
- Usa scala numerica per dimensioni: `-sm`, `-md`, `-lg`, `-xl`
|
||||
- Usa nomi semantici per colori: `primary`, `accent`, `success`, `error`
|
||||
|
||||
### Valori
|
||||
|
||||
- Spacing basato su multipli di 4px (0.25rem)
|
||||
- Colori in formato esadecimale (#rrggbb)
|
||||
- Dimensioni in rem/px dove appropriato
|
||||
- Transizioni sempre con easing function
|
||||
|
||||
## Esempi d'Uso nei Componenti
|
||||
|
||||
```css
|
||||
/* Sidebar.css */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: var(--color-bg-sidebar);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
```
|
||||
|
||||
## Vantaggi
|
||||
|
||||
✅ **Centralizzato**: Tutti i valori in un posto
|
||||
✅ **Consistente**: Stessa palette ovunque
|
||||
✅ **Manutenibile**: Facile cambiare tema
|
||||
✅ **Scalabile**: Aggiungi nuovi temi facilmente
|
||||
✅ **Type-safe**: Le variabili CSS prevengono errori
|
||||
✅ **Performance**: Cambio tema istantaneo (solo CSS)
|
||||
67
frontend/src/styles/theme/colors.css
Normal file
67
frontend/src/styles/theme/colors.css
Normal file
@@ -0,0 +1,67 @@
|
||||
/* ==========================================
|
||||
THEME COLORS
|
||||
========================================== */
|
||||
|
||||
:root {
|
||||
/* Light Theme - Primary Colors */
|
||||
--color-primary: #2d3748;
|
||||
--color-primary-hover: #1a202c;
|
||||
--color-secondary: #4a5568;
|
||||
--color-accent: #4c51bf;
|
||||
--color-accent-rgb: 76, 81, 191;
|
||||
--color-accent-hover: #4338ca;
|
||||
|
||||
/* Light Theme - Background Colors */
|
||||
--color-bg-main: #f7fafc;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-bg-elevated: #f8fafc;
|
||||
--color-bg-sidebar: #2d3748;
|
||||
|
||||
/* Light Theme - Text Colors */
|
||||
--color-text-primary: #1a202c;
|
||||
--color-text-secondary: #374151;
|
||||
--color-text-muted: #6b7280;
|
||||
--color-text-inverse: #ffffff;
|
||||
|
||||
/* Light Theme - Border Colors */
|
||||
--color-border: #e5e7eb;
|
||||
--color-border-hover: #d1d5db;
|
||||
|
||||
/* Semantic Colors (same for both themes) */
|
||||
--color-success: #059669;
|
||||
--color-error: #dc2626;
|
||||
--color-warning: #d97706;
|
||||
--color-info: #0284c7;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
/* Dark Theme - Primary Colors */
|
||||
--color-primary: #d1d5db;
|
||||
--color-primary-hover: #f3f4f6;
|
||||
--color-secondary: #9ca3af;
|
||||
--color-accent: #3d3f9e;
|
||||
--color-accent-rgb: 61, 63, 158;
|
||||
--color-accent-hover: #4e50b2;
|
||||
|
||||
/* Dark Theme - Background Colors */
|
||||
--color-bg-main: #0f172a;
|
||||
--color-bg-card: #1e293b;
|
||||
--color-bg-elevated: #334155;
|
||||
--color-bg-sidebar: #0c1222;
|
||||
|
||||
/* Dark Theme - Text Colors */
|
||||
--color-text-primary: #f1f5f9;
|
||||
--color-text-secondary: #cbd5e1;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-text-inverse: #ffffff;
|
||||
|
||||
/* Dark Theme - Border Colors */
|
||||
--color-border: #334155;
|
||||
--color-border-hover: #475569;
|
||||
|
||||
/* Semantic Colors */
|
||||
--color-success: #10b981;
|
||||
--color-error: #ef4444;
|
||||
--color-warning: #f59e0b;
|
||||
--color-info: #06b6d4;
|
||||
}
|
||||
154
frontend/src/styles/theme/dimensions.css
Normal file
154
frontend/src/styles/theme/dimensions.css
Normal file
@@ -0,0 +1,154 @@
|
||||
/* ==========================================
|
||||
DIMENSIONS & SPACING
|
||||
========================================== */
|
||||
|
||||
:root {
|
||||
/* Spacing Scale (based on 0.25rem = 4px) */
|
||||
--space-0: 0;
|
||||
--space-1: 0.25rem;
|
||||
/* 4px */
|
||||
--space-2: 0.5rem;
|
||||
/* 8px */
|
||||
--space-3: 0.75rem;
|
||||
/* 12px */
|
||||
--space-4: 1rem;
|
||||
/* 16px */
|
||||
--space-5: 1.25rem;
|
||||
/* 20px */
|
||||
--space-6: 1.5rem;
|
||||
/* 24px */
|
||||
--space-8: 2rem;
|
||||
/* 32px */
|
||||
--space-10: 2.5rem;
|
||||
/* 40px */
|
||||
--space-12: 3rem;
|
||||
/* 48px */
|
||||
--space-16: 4rem;
|
||||
/* 64px */
|
||||
--space-20: 5rem;
|
||||
/* 80px */
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Component Heights */
|
||||
--height-button: 36px;
|
||||
--height-input: 40px;
|
||||
--height-nav-item: 40px;
|
||||
--height-header: 70px;
|
||||
|
||||
/* Sidebar Dimensions */
|
||||
/* Sidebar is positioned at left: 1rem (16px) with these widths */
|
||||
/* margin-left = left offset (16px) + sidebar width */
|
||||
--sidebar-width: 276px;
|
||||
/* 16px offset + 260px width */
|
||||
--sidebar-width-collapsed: 96px;
|
||||
/* 16px offset + 80px width */
|
||||
--sidebar-mobile-width: 280px;
|
||||
|
||||
/* Page Layout Spacing */
|
||||
--page-padding-x: 2rem;
|
||||
/* Horizontal padding for page content */
|
||||
--page-padding-y: 2rem;
|
||||
/* Vertical padding for page content */
|
||||
--page-padding-x-tablet: 1.5rem;
|
||||
--page-padding-y-tablet: 1.5rem;
|
||||
--page-padding-x-mobile: 1rem;
|
||||
--page-padding-y-mobile: 1rem;
|
||||
--page-max-width: 1400px;
|
||||
/* Maximum content width */
|
||||
|
||||
/* Container Widths */
|
||||
--container-sm: 640px;
|
||||
--container-md: 768px;
|
||||
--container-lg: 1024px;
|
||||
--container-xl: 1280px;
|
||||
|
||||
/* Z-index Scale */
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-fixed: 300;
|
||||
--z-modal-backdrop: 1000;
|
||||
--z-modal: 1001;
|
||||
--z-popover: 1002;
|
||||
--z-tooltip: 1003;
|
||||
|
||||
/* Icon Sizes */
|
||||
--icon-xs: 16px;
|
||||
--icon-sm: 18px;
|
||||
--icon-md: 20px;
|
||||
--icon-lg: 24px;
|
||||
--icon-xl: 32px;
|
||||
|
||||
/* Button Sizes */
|
||||
--btn-padding-sm: 0.5rem 1rem;
|
||||
--btn-padding-md: 0.625rem 1.25rem;
|
||||
--btn-font-size: 0.95rem;
|
||||
--btn-font-size-sm: 0.875rem;
|
||||
--btn-height: 36px;
|
||||
--btn-height-sm: 32px;
|
||||
|
||||
/* Icon Button Sizes */
|
||||
--btn-icon-sm: 32px;
|
||||
--btn-icon-md: 36px;
|
||||
--btn-icon-lg: 48px;
|
||||
|
||||
/* Badge Sizes */
|
||||
--badge-padding: 0.25rem 0.625rem;
|
||||
--badge-font-size: 0.8rem;
|
||||
|
||||
/* Semantic Spacing - Cards & Containers */
|
||||
--card-padding: 0.875rem;
|
||||
/* Reduced from 1rem */
|
||||
--card-padding-sm: 0.625rem;
|
||||
/* Reduced from 0.75rem */
|
||||
--card-gap: 0.625rem;
|
||||
/* Reduced from 0.75rem */
|
||||
--card-gap-lg: 0.875rem;
|
||||
/* Reduced from 1rem */
|
||||
|
||||
/* Semantic Spacing - Sections */
|
||||
--section-gap: 1.25rem;
|
||||
/* Reduced from 1.5rem */
|
||||
--section-gap-sm: 0.875rem;
|
||||
/* Reduced from 1rem */
|
||||
|
||||
/* Semantic Spacing - Elements */
|
||||
--element-gap: 0.375rem;
|
||||
/* Reduced from 0.5rem */
|
||||
--element-gap-sm: 0.25rem;
|
||||
/* Kept same */
|
||||
--element-gap-lg: 0.625rem;
|
||||
/* Reduced from 0.75rem */
|
||||
|
||||
/* Semantic Spacing - Toolbar & Header */
|
||||
--toolbar-gap: 0.625rem;
|
||||
/* Reduced from 0.75rem */
|
||||
--toolbar-padding: 0.75rem;
|
||||
/* Reduced from 1rem */
|
||||
|
||||
/* Semantic Spacing - Table */
|
||||
--table-cell-padding: 0.625rem 0.875rem;
|
||||
/* Reduced from 0.75rem 1rem */
|
||||
--table-cell-padding-sm: 0.5rem 0.75rem;
|
||||
/* Kept same */
|
||||
|
||||
/* Tab Sizes */
|
||||
--tab-padding: 0.625rem 1rem;
|
||||
/* Reduced from 0.75rem 1.25rem */
|
||||
--tab-font-size: 0.95rem;
|
||||
|
||||
/* Input Sizes */
|
||||
--input-padding: 0.625rem 0.875rem;
|
||||
/* Reduced from 0.75rem 1rem */
|
||||
--input-font-size: 0.95rem;
|
||||
|
||||
/* Breakpoints (for reference in media queries) */
|
||||
--breakpoint-mobile: 768px;
|
||||
--breakpoint-tablet: 1024px;
|
||||
--breakpoint-desktop: 1280px;
|
||||
}
|
||||
46
frontend/src/styles/theme/effects.css
Normal file
46
frontend/src/styles/theme/effects.css
Normal file
@@ -0,0 +1,46 @@
|
||||
/* ==========================================
|
||||
EFFECTS (Shadows, Transitions, Animations)
|
||||
========================================== */
|
||||
|
||||
:root {
|
||||
/* Box Shadows */
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.15);
|
||||
--shadow-2xl: 0 25px 50px rgba(0, 0, 0, 0.25);
|
||||
--shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-base: 0.2s ease;
|
||||
--transition-slow: 0.3s ease;
|
||||
--transition-slower: 0.5s ease;
|
||||
|
||||
/* Transition Properties */
|
||||
--transition: all 0.2s ease;
|
||||
--transition-colors: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease;
|
||||
--transition-transform: transform 0.2s ease;
|
||||
--transition-opacity: opacity 0.2s ease;
|
||||
|
||||
/* Animation Durations */
|
||||
--duration-fast: 150ms;
|
||||
--duration-base: 200ms;
|
||||
--duration-slow: 300ms;
|
||||
--duration-slower: 500ms;
|
||||
|
||||
/* Easing Functions */
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
/* Dark Theme - Stronger Shadows */
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.6);
|
||||
--shadow-2xl: 0 25px 50px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
105
frontend/src/styles/theme/index.css
Normal file
105
frontend/src/styles/theme/index.css
Normal file
@@ -0,0 +1,105 @@
|
||||
/* ==========================================
|
||||
THEME SYSTEM
|
||||
Import all theme variables
|
||||
========================================== */
|
||||
|
||||
/* Import theme modules */
|
||||
@import './colors.css';
|
||||
@import './dimensions.css';
|
||||
@import './typography.css';
|
||||
@import './effects.css';
|
||||
|
||||
/* Import layout system */
|
||||
@import '../Layout.css';
|
||||
|
||||
/* Global Styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--color-bg-main);
|
||||
color: var(--color-text-primary);
|
||||
transition: background-color var(--transition-slow), color var(--transition-slow);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* Utility for selectable text */
|
||||
.selectable {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--color-accent);
|
||||
text-decoration: inherit;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: var(--weight-medium);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
font-size: var(--text-3xl);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-main);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-border-hover);
|
||||
}
|
||||
42
frontend/src/styles/theme/typography.css
Normal file
42
frontend/src/styles/theme/typography.css
Normal file
@@ -0,0 +1,42 @@
|
||||
/* ==========================================
|
||||
TYPOGRAPHY
|
||||
========================================== */
|
||||
|
||||
:root {
|
||||
/* Font Families */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
--font-mono: 'Fira Code', 'Courier New', monospace;
|
||||
|
||||
/* Font Sizes */
|
||||
--text-xs: 0.7rem; /* 11.2px */
|
||||
--text-sm: 0.75rem; /* 12px */
|
||||
--text-base: 0.875rem; /* 14px */
|
||||
--text-md: 0.95rem; /* 15.2px */
|
||||
--text-lg: 1rem; /* 16px */
|
||||
--text-xl: 1.125rem; /* 18px */
|
||||
--text-2xl: 1.25rem; /* 20px */
|
||||
--text-3xl: 1.5rem; /* 24px */
|
||||
--text-4xl: 2rem; /* 32px */
|
||||
--text-5xl: 2.5rem; /* 40px */
|
||||
|
||||
/* Font Weights */
|
||||
--weight-normal: 400;
|
||||
--weight-medium: 500;
|
||||
--weight-semibold: 600;
|
||||
--weight-bold: 700;
|
||||
|
||||
/* Line Heights */
|
||||
--leading-none: 1;
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.75;
|
||||
--leading-loose: 2;
|
||||
|
||||
/* Letter Spacing */
|
||||
--tracking-tight: -0.025em;
|
||||
--tracking-normal: 0;
|
||||
--tracking-wide: 0.025em;
|
||||
--tracking-wider: 0.05em;
|
||||
--tracking-widest: 0.1em;
|
||||
}
|
||||
56
frontend/src/types/index.ts
Normal file
56
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export type UserPermissions = Record<string, boolean>;
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
permissions: UserPermissions;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_login: string | null;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UserCreate {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
is_active?: boolean;
|
||||
is_superuser?: boolean;
|
||||
permissions?: UserPermissions;
|
||||
}
|
||||
|
||||
export interface UserUpdatePayload {
|
||||
username?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
is_active?: boolean;
|
||||
is_superuser?: boolean;
|
||||
permissions?: UserPermissions;
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
register: (username: string, email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user