Initial commit

This commit is contained in:
2025-12-04 22:24:47 +01:00
commit 453ce10494
106 changed files with 17145 additions and 0 deletions

19
frontend/src/App.css Normal file
View 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
View 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;

View 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;

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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);
}

View 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;
}

View 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;
}

View 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
View 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';
}

View 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"
}
}

View 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
View 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>,
)

View 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;
});
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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;
}
}

View 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;
}

View 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);
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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;
}
}

View 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)

View 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;
}

View 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;
}

View 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);
}

View 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);
}

View 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;
}

View 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;
}