Add comprehensive backend features and mobile UI improvements
Backend: - Add 2FA authentication with TOTP support - Add API keys management system - Add audit logging for security events - Add file upload/management system - Add notifications system with preferences - Add session management - Add webhooks integration - Add analytics endpoints - Add export functionality - Add password policy enforcement - Add new database migrations for core tables Frontend: - Add module position system (top/bottom sidebar sections) - Add search and notifications module configuration tabs - Add mobile logo replacing hamburger menu - Center page title absolutely when no tabs present - Align sidebar footer toggles with navigation items - Add lighter icon color in dark theme for mobile - Add API keys management page - Add notifications page with context - Add admin analytics and audit logs pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
155
frontend/src/pages/admin/Analytics.tsx
Normal file
155
frontend/src/pages/admin/Analytics.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from '../../contexts/LanguageContext';
|
||||
import { useSidebar } from '../../contexts/SidebarContext';
|
||||
import { analyticsAPI } from '../../api/client';
|
||||
import type { AnalyticsOverview } from '../../api/client';
|
||||
import '../../styles/AdminAnalytics.css';
|
||||
|
||||
export default function Analytics() {
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
|
||||
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
|
||||
const [dailyStats, setDailyStats] = useState<{ date: string; active_users: number; new_users: number }[]>([]);
|
||||
const [actions, setActions] = useState<{ action: string; count: number }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [o, usersActivity, breakdown] = await Promise.all([
|
||||
analyticsAPI.overview(),
|
||||
analyticsAPI.userActivity(7),
|
||||
analyticsAPI.actionsBreakdown(24),
|
||||
]);
|
||||
setOverview(o);
|
||||
setDailyStats(usersActivity.daily_stats || []);
|
||||
setActions(breakdown.actions || []);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const maxActiveUsers = useMemo(() => Math.max(1, ...dailyStats.map((d) => d.active_users)), [dailyStats]);
|
||||
const maxNewUsers = useMemo(() => Math.max(1, ...dailyStats.map((d) => d.new_users)), [dailyStats]);
|
||||
const maxActionCount = useMemo(() => Math.max(1, ...actions.map((a) => a.count)), [actions]);
|
||||
|
||||
return (
|
||||
<main className="main-content admin-analytics-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">analytics</span>
|
||||
<span className="page-title-text">{t.analyticsPage.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{loading || !overview ? (
|
||||
<div className="loading">{t.common.loading}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="analytics-cards">
|
||||
<div className="analytics-card">
|
||||
<div className="analytics-card-title">{t.analyticsPage.usersTotal}</div>
|
||||
<div className="analytics-card-value">{overview.users.total}</div>
|
||||
<div className="analytics-card-sub">
|
||||
{t.analyticsPage.usersActive}: {overview.users.active}
|
||||
</div>
|
||||
</div>
|
||||
<div className="analytics-card">
|
||||
<div className="analytics-card-title">{t.analyticsPage.sessionsActive}</div>
|
||||
<div className="analytics-card-value">{overview.sessions.active}</div>
|
||||
</div>
|
||||
<div className="analytics-card">
|
||||
<div className="analytics-card-title">{t.analyticsPage.logins24h}</div>
|
||||
<div className="analytics-card-value">{overview.security.logins_24h}</div>
|
||||
<div className="analytics-card-sub">
|
||||
{t.analyticsPage.failedLogins24h}: {overview.security.failed_logins_24h}
|
||||
</div>
|
||||
</div>
|
||||
<div className="analytics-card">
|
||||
<div className="analytics-card-title">{t.analyticsPage.notificationsUnread}</div>
|
||||
<div className="analytics-card-value">{overview.notifications.unread_total}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="analytics-grid">
|
||||
<div className="analytics-panel">
|
||||
<h3 className="section-title">{t.analyticsPage.userActivity7d}</h3>
|
||||
<div className="mini-chart">
|
||||
{dailyStats.map((d) => (
|
||||
<div key={d.date} className="mini-chart-row">
|
||||
<div className="mini-chart-label">{d.date}</div>
|
||||
<div className="mini-chart-bars">
|
||||
<div
|
||||
className="mini-bar bar-accent"
|
||||
style={{ width: `${Math.round((d.active_users / maxActiveUsers) * 100)}%` }}
|
||||
title={`${t.analyticsPage.usersActive}: ${d.active_users}`}
|
||||
/>
|
||||
<div
|
||||
className="mini-bar bar-muted"
|
||||
style={{ width: `${Math.round((d.new_users / maxNewUsers) * 100)}%` }}
|
||||
title={`${t.analyticsPage.usersNew}: ${d.new_users}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mini-chart-values">
|
||||
<span>{d.active_users}</span>
|
||||
<span>{d.new_users}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mini-chart-legend">
|
||||
<span className="legend-item"><span className="legend-dot accent" />{t.analyticsPage.usersActive}</span>
|
||||
<span className="legend-item"><span className="legend-dot muted" />{t.analyticsPage.usersNew}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="analytics-panel">
|
||||
<h3 className="section-title">{t.analyticsPage.actions24h}</h3>
|
||||
<div className="mini-chart">
|
||||
{actions.slice(0, 12).map((a) => (
|
||||
<div key={a.action} className="mini-chart-row">
|
||||
<div className="mini-chart-label">{a.action}</div>
|
||||
<div className="mini-chart-bars">
|
||||
<div
|
||||
className="mini-bar bar-accent"
|
||||
style={{ width: `${Math.round((a.count / maxActionCount) * 100)}%` }}
|
||||
title={`${a.count}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mini-chart-values">
|
||||
<span>{a.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="analytics-footnote">
|
||||
{t.analyticsPage.generatedAt}: {new Date(overview.generated_at).toLocaleString()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
167
frontend/src/pages/admin/AuditLogs.tsx
Normal file
167
frontend/src/pages/admin/AuditLogs.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from '../../contexts/LanguageContext';
|
||||
import { useSidebar } from '../../contexts/SidebarContext';
|
||||
import { auditAPI } from '../../api/client';
|
||||
import type { AuditLogItem } from '../../api/client';
|
||||
import '../../styles/AdminAudit.css';
|
||||
|
||||
export default function AuditLogs() {
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
|
||||
const [items, setItems] = useState<AuditLogItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [action, setAction] = useState('');
|
||||
const [resourceType, setResourceType] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
const params = useMemo(() => {
|
||||
const p: Record<string, unknown> = { page, page_size: pageSize };
|
||||
if (username.trim()) p.username = username.trim();
|
||||
if (action.trim()) p.action = action.trim();
|
||||
if (resourceType.trim()) p.resource_type = resourceType.trim();
|
||||
if (status.trim()) p.status = status.trim();
|
||||
return p;
|
||||
}, [page, pageSize, username, action, resourceType, status]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await auditAPI.list(params);
|
||||
setItems(data.items || []);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params]);
|
||||
|
||||
const resetFilters = () => {
|
||||
setUsername('');
|
||||
setAction('');
|
||||
setResourceType('');
|
||||
setStatus('');
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="main-content admin-audit-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">history</span>
|
||||
<span className="page-title-text">{t.auditPage.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
<div className="audit-filters">
|
||||
<input
|
||||
className="audit-input"
|
||||
placeholder={t.auditPage.username}
|
||||
value={username}
|
||||
onChange={(e) => { setUsername(e.target.value); setPage(1); }}
|
||||
/>
|
||||
<input
|
||||
className="audit-input"
|
||||
placeholder={t.auditPage.action}
|
||||
value={action}
|
||||
onChange={(e) => { setAction(e.target.value); setPage(1); }}
|
||||
/>
|
||||
<input
|
||||
className="audit-input"
|
||||
placeholder={t.auditPage.resourceType}
|
||||
value={resourceType}
|
||||
onChange={(e) => { setResourceType(e.target.value); setPage(1); }}
|
||||
/>
|
||||
<select
|
||||
className="audit-input"
|
||||
value={status}
|
||||
onChange={(e) => { setStatus(e.target.value); setPage(1); }}
|
||||
>
|
||||
<option value="">{t.auditPage.anyStatus}</option>
|
||||
<option value="success">{t.auditPage.statusSuccess}</option>
|
||||
<option value="failure">{t.auditPage.statusFailure}</option>
|
||||
<option value="pending">{t.auditPage.statusPending}</option>
|
||||
<option value="error">{t.auditPage.statusError}</option>
|
||||
</select>
|
||||
<button className="audit-reset-btn" onClick={resetFilters} disabled={loading}>
|
||||
{t.common.reset}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">{t.common.loading}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="audit-empty">{t.auditPage.empty}</div>
|
||||
) : (
|
||||
<div className="audit-table-card">
|
||||
<table className="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t.auditPage.time}</th>
|
||||
<th>{t.auditPage.user}</th>
|
||||
<th>{t.auditPage.action}</th>
|
||||
<th>{t.auditPage.resource}</th>
|
||||
<th>{t.auditPage.status}</th>
|
||||
<th>{t.auditPage.ip}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td className="mono">{new Date(log.created_at).toLocaleString()}</td>
|
||||
<td>{log.username || '—'}</td>
|
||||
<td className="mono">{log.action}</td>
|
||||
<td className="mono">{log.resource_type || '—'}{log.resource_id ? `:${log.resource_id}` : ''}</td>
|
||||
<td>
|
||||
<span className={`badge ${
|
||||
log.status === 'success' ? 'badge-success' :
|
||||
log.status === 'failure' ? 'badge-muted' :
|
||||
log.status === 'error' ? 'badge-error' :
|
||||
log.status === 'pending' ? 'badge-warning' :
|
||||
'badge-neutral'
|
||||
}`}>{log.status}</span>
|
||||
</td>
|
||||
<td className="mono">{log.ip_address || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="audit-pagination">
|
||||
<button className="btn-link" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading || page <= 1}>
|
||||
{t.auditPage.prev}
|
||||
</button>
|
||||
<span className="audit-page-indicator">
|
||||
{t.auditPage.page} {page}
|
||||
</span>
|
||||
<button className="btn-link" onClick={() => setPage((p) => p + 1)} disabled={loading || items.length < pageSize}>
|
||||
{t.auditPage.next}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ import type { ModuleId } from '../../contexts/ModulesContext';
|
||||
import Feature1Tab from '../../components/admin/Feature1Tab';
|
||||
import '../../styles/AdminPanel.css';
|
||||
|
||||
type TabId = 'config' | 'feature1' | 'feature2' | 'feature3';
|
||||
type TabId = 'config' | 'feature1' | 'feature2' | 'feature3' | 'search' | 'notifications';
|
||||
|
||||
export default function Features() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
const { moduleStates, moduleOrder, setModuleEnabled, setModuleOrder, saveModulesToBackend, saveModuleOrder, hasInitialized, isLoading } = useModules();
|
||||
const { moduleStates, moduleOrder, setModuleEnabled, setModulePosition, setModuleOrder, saveModulesToBackend, saveModuleOrder, hasInitialized, isLoading } = useModules();
|
||||
const [activeTab, setActiveTab] = useState<TabId>('config');
|
||||
const hasUserMadeChanges = useRef(false);
|
||||
const saveRef = useRef(saveModulesToBackend);
|
||||
@@ -63,47 +63,44 @@ export default function Features() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 className="theme-tab-content">
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.featuresPage?.visibility || 'Visibilità'}</h3>
|
||||
</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 className="feature-config-options">
|
||||
<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>
|
||||
</div>
|
||||
@@ -131,17 +128,35 @@ export default function Features() {
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetModuleId: string) => {
|
||||
const handleDrop = (e: React.DragEvent, targetModuleId: string, targetSection: 'top' | 'bottom') => {
|
||||
e.preventDefault();
|
||||
if (!draggedItem || draggedItem === targetModuleId) return;
|
||||
e.stopPropagation();
|
||||
if (!draggedItem) return;
|
||||
|
||||
const draggedPosition = moduleStates[draggedItem as ModuleId]?.position || 'top';
|
||||
|
||||
// If dropping on same item, just change section if different
|
||||
if (draggedItem === targetModuleId) {
|
||||
if (draggedPosition !== targetSection) {
|
||||
hasUserMadeChanges.current = true;
|
||||
setModulePosition(draggedItem as ModuleId, targetSection);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Change position if moving to different section
|
||||
if (draggedPosition !== targetSection) {
|
||||
hasUserMadeChanges.current = true;
|
||||
setModulePosition(draggedItem as ModuleId, targetSection);
|
||||
}
|
||||
|
||||
// Reorder within the list
|
||||
const newOrder = [...localOrder];
|
||||
const draggedIndex = newOrder.indexOf(draggedItem);
|
||||
const targetIndex = newOrder.indexOf(targetModuleId);
|
||||
|
||||
if (draggedIndex === -1 || targetIndex === -1) return;
|
||||
|
||||
// Remove dragged item and insert at target position
|
||||
newOrder.splice(draggedIndex, 1);
|
||||
newOrder.splice(targetIndex, 0, draggedItem);
|
||||
|
||||
@@ -149,6 +164,19 @@ export default function Features() {
|
||||
setHasOrderChanges(true);
|
||||
};
|
||||
|
||||
const handleSectionDrop = (e: React.DragEvent, section: 'top' | 'bottom') => {
|
||||
e.preventDefault();
|
||||
if (!draggedItem) return;
|
||||
|
||||
const draggedPosition = moduleStates[draggedItem as ModuleId]?.position || 'top';
|
||||
|
||||
// Change position if moving to different section
|
||||
if (draggedPosition !== section) {
|
||||
hasUserMadeChanges.current = true;
|
||||
setModulePosition(draggedItem as ModuleId, section);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyOrder = async () => {
|
||||
try {
|
||||
setModuleOrder(localOrder);
|
||||
@@ -164,15 +192,30 @@ export default function Features() {
|
||||
return module || { id: moduleId, icon: 'extension', defaultEnabled: true };
|
||||
};
|
||||
|
||||
// Split modules by position for the config tab
|
||||
const topOrderModules = localOrder.filter(id => {
|
||||
const state = moduleStates[id as ModuleId];
|
||||
return !state || state.position === 'top';
|
||||
});
|
||||
|
||||
const bottomOrderModules = localOrder.filter(id => {
|
||||
const state = moduleStates[id as ModuleId];
|
||||
return state && state.position === 'bottom';
|
||||
});
|
||||
|
||||
const renderConfigTab = () => {
|
||||
return (
|
||||
<div className="theme-tab-content">
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.featuresPage?.orderSection || 'Ordine nella Sidebar'}</h3>
|
||||
<h3 className="section-title">{t.featuresPage?.topSection || 'Sezione Principale'}</h3>
|
||||
</div>
|
||||
<div className="order-cards">
|
||||
{localOrder.map((moduleId, index) => {
|
||||
<div
|
||||
className="order-cards"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleSectionDrop(e, 'top')}
|
||||
>
|
||||
{topOrderModules.map((moduleId) => {
|
||||
const moduleInfo = getModuleInfo(moduleId);
|
||||
const moduleName = t.sidebar[moduleId as keyof typeof t.sidebar] || moduleId;
|
||||
return (
|
||||
@@ -183,34 +226,76 @@ export default function Features() {
|
||||
onDragStart={(e) => handleDragStart(e, moduleId)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, moduleId)}
|
||||
onDrop={(e) => handleDrop(e, moduleId, 'top')}
|
||||
>
|
||||
<div className="order-card-preview">
|
||||
<span className="order-card-number">{index + 1}</span>
|
||||
<span className="material-symbols-outlined">{moduleInfo.icon}</span>
|
||||
</div>
|
||||
<div className="order-card-info">
|
||||
<span className="order-card-name">{moduleName}</span>
|
||||
<span className="order-card-desc">{t.featuresPage?.orderDesc || 'Trascina per riordinare'}</span>
|
||||
</div>
|
||||
<div className="order-card-icon">
|
||||
<span className="material-symbols-outlined">{moduleInfo.icon}</span>
|
||||
</div>
|
||||
<div className="order-card-handle">
|
||||
<span className="material-symbols-outlined">drag_indicator</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{topOrderModules.length === 0 && (
|
||||
<div className="order-empty">{t.featuresPage?.noModulesTop || 'Nessun modulo in questa sezione'}</div>
|
||||
)}
|
||||
</div>
|
||||
{hasOrderChanges && (
|
||||
<div className="order-actions">
|
||||
<button className="btn-primary" onClick={handleApplyOrder}>
|
||||
<span className="material-symbols-outlined">check</span>
|
||||
{t.featuresPage?.applyOrder || 'Applica'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.featuresPage?.bottomSection || 'Sezione Inferiore'}</h3>
|
||||
</div>
|
||||
<div
|
||||
className="order-cards"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleSectionDrop(e, 'bottom')}
|
||||
>
|
||||
{bottomOrderModules.map((moduleId) => {
|
||||
const moduleInfo = getModuleInfo(moduleId);
|
||||
const moduleName = t.sidebar[moduleId as keyof typeof t.sidebar] || moduleId;
|
||||
return (
|
||||
<div
|
||||
key={moduleId}
|
||||
className={`order-card ${draggedItem === moduleId ? 'dragging' : ''}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, moduleId)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, moduleId, 'bottom')}
|
||||
>
|
||||
<div className="order-card-preview">
|
||||
<span className="material-symbols-outlined">{moduleInfo.icon}</span>
|
||||
</div>
|
||||
<div className="order-card-info">
|
||||
<span className="order-card-name">{moduleName}</span>
|
||||
<span className="order-card-desc">{t.featuresPage?.orderDesc || 'Trascina per riordinare'}</span>
|
||||
</div>
|
||||
<div className="order-card-handle">
|
||||
<span className="material-symbols-outlined">drag_indicator</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{bottomOrderModules.length === 0 && (
|
||||
<div className="order-empty">{t.featuresPage?.noModulesBottom || 'Nessun modulo in questa sezione'}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasOrderChanges && (
|
||||
<div className="order-actions">
|
||||
<button className="btn-primary" onClick={handleApplyOrder}>
|
||||
<span className="material-symbols-outlined">check</span>
|
||||
{t.featuresPage?.applyOrder || 'Applica'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -255,6 +340,32 @@ export default function Features() {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case 'search':
|
||||
return (
|
||||
<>
|
||||
{renderModuleToggle('search')}
|
||||
<div className="tab-content-placeholder">
|
||||
<div className="placeholder-icon">
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
</div>
|
||||
<h3>{t.sidebar.search}</h3>
|
||||
<p>{t.features.comingSoon}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case 'notifications':
|
||||
return (
|
||||
<>
|
||||
{renderModuleToggle('notifications')}
|
||||
<div className="tab-content-placeholder">
|
||||
<div className="placeholder-icon">
|
||||
<span className="material-symbols-outlined">notifications</span>
|
||||
</div>
|
||||
<h3>{t.sidebar.notifications}</h3>
|
||||
<p>{t.features.comingSoon}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -279,16 +390,15 @@ export default function Features() {
|
||||
<span className="material-symbols-outlined">tune</span>
|
||||
<span>{t.featuresPage?.configTab || 'Configurazione'}</span>
|
||||
</button>
|
||||
{(localOrder.length > 0 ? localOrder : ['feature1', 'feature2', 'feature3']).map((moduleId) => {
|
||||
const moduleInfo = getModuleInfo(moduleId);
|
||||
const moduleName = t.sidebar[moduleId as keyof typeof t.sidebar] || moduleId;
|
||||
{TOGGLEABLE_MODULES.map((module) => {
|
||||
const moduleName = t.sidebar[module.id as keyof typeof t.sidebar] || module.id;
|
||||
return (
|
||||
<button
|
||||
key={moduleId}
|
||||
className={`page-tab-btn ${activeTab === moduleId ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(moduleId as TabId)}
|
||||
key={module.id}
|
||||
className={`page-tab-btn ${activeTab === module.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(module.id as TabId)}
|
||||
>
|
||||
<span className="material-symbols-outlined">{moduleInfo.icon}</span>
|
||||
<span className="material-symbols-outlined">{module.icon}</span>
|
||||
<span>{moduleName}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user