Add module ordering feature with drag-and-drop

- Add modules_order setting in backend settings registry
- Update ModulesContext with moduleOrder state and save functions
- Create configuration tab in Features page with drag-and-drop reordering
- Make feature tabs dynamic based on order (updates in real-time)
- Sort sidebar modules based on saved order
- Add order-cards CSS with vertical layout
- Update API client to support string[] type for modules_order

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-15 21:25:43 +01:00
parent ba53e0eff0
commit 15f211493d
8 changed files with 384 additions and 68 deletions

View File

@@ -2,40 +2,32 @@ 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 { useModules, TOGGLEABLE_MODULES } from '../../contexts/ModulesContext';
import type { ModuleId } from '../../contexts/ModulesContext';
import Feature1Tab from '../../components/admin/Feature1Tab';
import '../../styles/AdminPanel.css';
type TabId = 'feature1' | 'feature2' | 'feature3';
type TabId = 'config' | 'feature1' | 'feature2' | 'feature3';
export default function Features() {
const { user: currentUser } = useAuth();
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
const { moduleStates, setModuleEnabled, saveModulesToBackend, hasInitialized, isLoading } = useModules();
const [activeTab, setActiveTab] = useState<TabId>('feature1');
const { moduleStates, moduleOrder, setModuleEnabled, setModuleOrder, saveModulesToBackend, saveModuleOrder, hasInitialized, isLoading } = useModules();
const [activeTab, setActiveTab] = useState<TabId>('config');
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 }));
};
const [draggedItem, setDraggedItem] = useState<string | null>(null);
const [localOrder, setLocalOrder] = useState<string[]>([]);
const [hasOrderChanges, setHasOrderChanges] = useState(false);
// Sync local order with context order
useEffect(() => {
if (moduleOrder.length > 0) {
setLocalOrder(moduleOrder);
setHasOrderChanges(false);
}
}, [moduleOrder]);
// Keep saveRef updated with latest function
useEffect(() => {
saveRef.current = saveModulesToBackend;
@@ -118,11 +110,118 @@ export default function Features() {
);
};
// Drag and drop handlers for ordering
const handleDragStart = (e: React.DragEvent, moduleId: string) => {
setDraggedItem(moduleId);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', moduleId);
// Add dragging class after a small delay for visual feedback
setTimeout(() => {
(e.target as HTMLElement).classList.add('dragging');
}, 0);
};
const handleDragEnd = (e: React.DragEvent) => {
setDraggedItem(null);
(e.target as HTMLElement).classList.remove('dragging');
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent, targetModuleId: string) => {
e.preventDefault();
if (!draggedItem || draggedItem === targetModuleId) return;
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);
setLocalOrder(newOrder);
setHasOrderChanges(true);
};
const handleApplyOrder = async () => {
try {
setModuleOrder(localOrder);
await saveModuleOrder(localOrder);
setHasOrderChanges(false);
} catch (error) {
console.error('Failed to save order:', error);
}
};
const getModuleInfo = (moduleId: string) => {
const module = TOGGLEABLE_MODULES.find(m => m.id === moduleId);
return module || { id: moduleId, icon: 'extension', defaultEnabled: true };
};
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>
</div>
<div className="order-cards">
{localOrder.map((moduleId, index) => {
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)}
>
<div className="order-card-preview">
<span className="order-card-number">{index + 1}</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>
);
})}
</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>
);
};
const renderTabContent = () => {
if (!hasInitialized || isLoading) {
return <div className="loading">Loading...</div>;
}
switch (activeTab) {
case 'config':
return renderConfigTab();
case 'feature1':
return (
<>
@@ -163,52 +262,37 @@ export default function Features() {
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">
<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="admin-title-section">
<div className="page-title-section">
<span className="material-symbols-outlined">extension</span>
<span className="admin-title-text">{t.featuresPage.title}</span>
<span className="page-title-text">{t.featuresPage.title}</span>
</div>
<div className="admin-tabs-divider"></div>
<div className="page-tabs-divider"></div>
<button
className={`admin-tab-btn ${activeTab === 'feature1' ? 'active' : ''}`}
onClick={() => setActiveTab('feature1')}
onMouseEnter={(e) => handleTabMouseEnter(t.sidebar.feature1, e)}
onMouseLeave={handleTabMouseLeave}
className={`page-tab-btn ${activeTab === 'config' ? 'active' : ''}`}
onClick={() => setActiveTab('config')}
>
<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>
<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;
return (
<button
key={moduleId}
className={`page-tab-btn ${activeTab === moduleId ? 'active' : ''}`}
onClick={() => setActiveTab(moduleId as TabId)}
>
<span className="material-symbols-outlined">{moduleInfo.icon}</span>
<span>{moduleName}</span>
</button>
);
})}
</div>
</div>