- Tabs now use applied order (moduleOrder) instead of local pending order - Cards still show local order for drag preview feedback - Cancel button correctly restores to the applied state from context - Apply button updates both tabs and sidebar after saving 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
565 lines
25 KiB
TypeScript
565 lines
25 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { useAuth } from '../../contexts/AuthContext';
|
|
import { useTranslation } from '../../contexts/LanguageContext';
|
|
import { useSidebar } from '../../contexts/SidebarContext';
|
|
import { useModules, TOGGLEABLE_MODULES } from '../../contexts/ModulesContext';
|
|
import type { ModuleId } from '../../contexts/ModulesContext';
|
|
import Feature1Tab from '../../components/admin/Feature1Tab';
|
|
import { SwipeTabs } from '../../components/SwipeTabs';
|
|
import '../../styles/AdminPanel.css';
|
|
|
|
type TabId = 'config' | 'dashboard' | 'feature1' | 'feature2' | 'feature3' | 'search' | 'notifications';
|
|
|
|
export default function Features() {
|
|
const { user: currentUser } = useAuth();
|
|
const { t } = useTranslation();
|
|
const { toggleMobileMenu } = useSidebar();
|
|
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);
|
|
const [draggedItem, setDraggedItem] = useState<string | null>(null);
|
|
const [localOrder, setLocalOrder] = useState<string[]>([]);
|
|
const [localPositions, setLocalPositions] = useState<Record<string, 'top' | 'bottom'>>({});
|
|
const [hasOrderChanges, setHasOrderChanges] = useState(false);
|
|
const tabsContainerRef = useRef<HTMLDivElement>(null);
|
|
const isUserEditing = useRef(false); // Track if user is actively editing
|
|
const initialOrderRef = useRef<string[]>([]);
|
|
const initialPositionsRef = useRef<Record<string, 'top' | 'bottom'>>({});
|
|
|
|
const buildFullOrder = useCallback((order: string[]) => {
|
|
const fullOrder = [...order];
|
|
TOGGLEABLE_MODULES.forEach((module, defaultIndex) => {
|
|
if (!fullOrder.includes(module.id)) {
|
|
const insertAt = Math.min(defaultIndex, fullOrder.length);
|
|
fullOrder.splice(insertAt, 0, module.id);
|
|
}
|
|
});
|
|
return fullOrder;
|
|
}, []);
|
|
|
|
const normalizePositions = useCallback((positions: Record<string, 'top' | 'bottom'>) => {
|
|
const normalized: Record<string, 'top' | 'bottom'> = {};
|
|
TOGGLEABLE_MODULES.forEach(module => {
|
|
normalized[module.id] = positions[module.id] || module.defaultPosition;
|
|
});
|
|
return normalized;
|
|
}, []);
|
|
|
|
const buildPositionsFromStates = useCallback(() => {
|
|
const positions: Record<string, 'top' | 'bottom'> = {};
|
|
TOGGLEABLE_MODULES.forEach(module => {
|
|
const state = moduleStates[module.id];
|
|
positions[module.id] = state?.position || module.defaultPosition;
|
|
});
|
|
return positions;
|
|
}, [moduleStates]);
|
|
|
|
const beginOrderEdit = useCallback(() => {
|
|
if (!isUserEditing.current) {
|
|
const snapshotOrder = localOrder.length ? [...localOrder] : buildFullOrder(moduleOrder);
|
|
const snapshotPositions = Object.keys(localPositions).length
|
|
? { ...normalizePositions(localPositions) }
|
|
: buildPositionsFromStates();
|
|
initialOrderRef.current = snapshotOrder;
|
|
initialPositionsRef.current = snapshotPositions;
|
|
isUserEditing.current = true;
|
|
}
|
|
}, [buildFullOrder, buildPositionsFromStates, localOrder, localPositions, moduleOrder, normalizePositions]);
|
|
|
|
// Sync local order with context order (only when moduleOrder changes, not moduleStates)
|
|
useEffect(() => {
|
|
if (moduleOrder.length > 0 && !isUserEditing.current) {
|
|
const fullOrder = buildFullOrder(moduleOrder);
|
|
setLocalOrder(fullOrder);
|
|
initialOrderRef.current = [...fullOrder];
|
|
setHasOrderChanges(false);
|
|
}
|
|
}, [moduleOrder, buildFullOrder]);
|
|
|
|
// Sync positions from moduleStates only on initial load or when not editing
|
|
useEffect(() => {
|
|
if (!isUserEditing.current) {
|
|
const positions = buildPositionsFromStates();
|
|
setLocalPositions(positions);
|
|
initialPositionsRef.current = { ...positions };
|
|
}
|
|
}, [buildPositionsFromStates]);
|
|
|
|
// Scroll active tab to center of container
|
|
const scrollActiveTabIntoView = useCallback((tabId: string) => {
|
|
if (tabsContainerRef.current) {
|
|
const container = tabsContainerRef.current;
|
|
const activeButton = container.querySelector(`[data-tab-id="${tabId}"]`) as HTMLElement;
|
|
if (activeButton) {
|
|
const containerWidth = container.clientWidth;
|
|
const buttonLeft = activeButton.offsetLeft;
|
|
const buttonWidth = activeButton.offsetWidth;
|
|
// Calculate scroll position to center the button
|
|
const scrollLeft = buttonLeft - (containerWidth / 2) + (buttonWidth / 2);
|
|
container.scrollTo({ left: Math.max(0, scrollLeft), behavior: 'smooth' });
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
// Handle tab change with scroll
|
|
const handleTabChange = useCallback((tabId: TabId) => {
|
|
setActiveTab(tabId);
|
|
setTimeout(() => scrollActiveTabIntoView(tabId), 50);
|
|
}, [scrollActiveTabIntoView]);
|
|
// 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 renderModuleToggle = (moduleId: ModuleId) => {
|
|
const state = moduleStates[moduleId];
|
|
const adminEnabled = state?.admin ?? true;
|
|
const userEnabled = state?.user ?? true;
|
|
|
|
return (
|
|
<div className="theme-tab-content content-narrow">
|
|
<div className="theme-section">
|
|
<div className="section-header">
|
|
<h3 className="section-title">{t.featuresPage?.visibility || 'Visibilità'}</h3>
|
|
</div>
|
|
<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>
|
|
);
|
|
};
|
|
|
|
// Drag and drop handlers for ordering
|
|
const handleDragStart = (e: React.DragEvent, moduleId: string) => {
|
|
beginOrderEdit();
|
|
setDraggedItem(moduleId);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
e.dataTransfer.setData('text/plain', moduleId);
|
|
};
|
|
|
|
const handleDragEnd = () => {
|
|
setDraggedItem(null);
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent, targetModuleId: string, targetSection: 'top' | 'bottom') => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!draggedItem) return;
|
|
|
|
const draggedPosition = localPositions[draggedItem] || 'top';
|
|
|
|
const isSameItem = draggedItem === targetModuleId;
|
|
const positionChanged = draggedPosition !== targetSection;
|
|
|
|
if (isSameItem && !positionChanged) return;
|
|
|
|
beginOrderEdit();
|
|
if (positionChanged) {
|
|
// Update local positions immediately for UI responsiveness
|
|
setLocalPositions(prev => ({ ...prev, [draggedItem]: targetSection }));
|
|
}
|
|
if (isSameItem) {
|
|
setHasOrderChanges(true);
|
|
return;
|
|
}
|
|
|
|
// Reorder within the list
|
|
const newOrder = [...localOrder];
|
|
const draggedIndex = newOrder.indexOf(draggedItem);
|
|
const targetIndex = newOrder.indexOf(targetModuleId);
|
|
|
|
if (draggedIndex === -1 || targetIndex === -1) return;
|
|
|
|
newOrder.splice(draggedIndex, 1);
|
|
newOrder.splice(targetIndex, 0, draggedItem);
|
|
|
|
setLocalOrder(newOrder);
|
|
setHasOrderChanges(true);
|
|
};
|
|
|
|
const handleSectionDrop = (e: React.DragEvent, section: 'top' | 'bottom') => {
|
|
e.preventDefault();
|
|
if (!draggedItem) return;
|
|
|
|
const draggedPosition = localPositions[draggedItem] || 'top';
|
|
|
|
// Change position if moving to different section
|
|
if (draggedPosition !== section) {
|
|
beginOrderEdit();
|
|
// Update local positions immediately for UI responsiveness
|
|
setLocalPositions(prev => ({ ...prev, [draggedItem]: section }));
|
|
setHasOrderChanges(true);
|
|
}
|
|
};
|
|
|
|
const handleApplyOrder = async () => {
|
|
try {
|
|
const positionUpdates: Array<{ id: ModuleId; position: 'top' | 'bottom' }> = [];
|
|
TOGGLEABLE_MODULES.forEach(module => {
|
|
const desiredPosition = localPositions[module.id] || module.defaultPosition;
|
|
const currentPosition = moduleStates[module.id]?.position || module.defaultPosition;
|
|
if (desiredPosition !== currentPosition) {
|
|
positionUpdates.push({ id: module.id, position: desiredPosition });
|
|
}
|
|
});
|
|
|
|
if (positionUpdates.length > 0) {
|
|
hasUserMadeChanges.current = true;
|
|
positionUpdates.forEach(({ id, position }) => {
|
|
setModulePosition(id, position);
|
|
});
|
|
}
|
|
|
|
setModuleOrder(localOrder);
|
|
await saveModuleOrder(localOrder);
|
|
} catch (error) {
|
|
console.error('Failed to save order:', error);
|
|
} finally {
|
|
setDraggedItem(null); // Reset drag state
|
|
initialOrderRef.current = buildFullOrder(localOrder);
|
|
initialPositionsRef.current = normalizePositions(localPositions);
|
|
isUserEditing.current = false;
|
|
setHasOrderChanges(false);
|
|
}
|
|
};
|
|
|
|
const handleCancelOrder = () => {
|
|
setDraggedItem(null); // Reset drag state
|
|
isUserEditing.current = false; // Done editing
|
|
// Restore to currently applied state from context
|
|
const restoredOrder = buildFullOrder(moduleOrder);
|
|
const restoredPositions = buildPositionsFromStates();
|
|
setLocalOrder(restoredOrder);
|
|
setLocalPositions(restoredPositions);
|
|
initialOrderRef.current = restoredOrder;
|
|
initialPositionsRef.current = restoredPositions;
|
|
setHasOrderChanges(false);
|
|
};
|
|
|
|
const getModuleInfo = (moduleId: string) => {
|
|
const module = TOGGLEABLE_MODULES.find(m => m.id === moduleId);
|
|
return module || { id: moduleId, icon: 'extension', defaultEnabled: true };
|
|
};
|
|
|
|
// Split modules by position for the config tab (using localPositions for immediate UI updates)
|
|
// Local order for cards (changes during drag)
|
|
const topOrderModules = localOrder.filter(id => {
|
|
const position = localPositions[id];
|
|
return !position || position === 'top';
|
|
});
|
|
|
|
const bottomOrderModules = localOrder.filter(id => {
|
|
const position = localPositions[id];
|
|
return position === 'bottom';
|
|
});
|
|
|
|
// Applied order for tabs (only changes after Apply)
|
|
const appliedOrder = buildFullOrder(moduleOrder);
|
|
const appliedPositions = buildPositionsFromStates();
|
|
const topAppliedModules = appliedOrder.filter(id => {
|
|
const position = appliedPositions[id];
|
|
return !position || position === 'top';
|
|
});
|
|
const bottomAppliedModules = appliedOrder.filter(id => {
|
|
const position = appliedPositions[id];
|
|
return position === 'bottom';
|
|
});
|
|
|
|
const tabIds = ['config', ...topAppliedModules, ...bottomAppliedModules] as TabId[];
|
|
|
|
const renderConfigTab = () => {
|
|
return (
|
|
<div className="theme-tab-content content-narrow">
|
|
<div className="theme-section">
|
|
<div className="section-header">
|
|
<h3 className="section-title">{t.featuresPage?.topSection || 'Sezione Principale'}</h3>
|
|
</div>
|
|
<div
|
|
className="order-cards"
|
|
data-swipe-ignore="true"
|
|
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 (
|
|
<div
|
|
key={moduleId}
|
|
className={`order-card ${draggedItem === moduleId ? 'dragging' : ''}`}
|
|
draggable
|
|
onDragStart={(e) => handleDragStart(e, moduleId)}
|
|
onDragEnd={handleDragEnd}
|
|
onDragOver={handleDragOver}
|
|
onDrop={(e) => handleDrop(e, moduleId, 'top')}
|
|
>
|
|
<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>
|
|
</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>
|
|
</div>
|
|
|
|
<div className="theme-section">
|
|
<div className="section-header">
|
|
<h3 className="section-title">{t.featuresPage?.bottomSection || 'Sezione Inferiore'}</h3>
|
|
</div>
|
|
<div
|
|
className="order-cards"
|
|
data-swipe-ignore="true"
|
|
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>
|
|
</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-secondary" onClick={handleCancelOrder}>
|
|
<span className="material-symbols-outlined">close</span>
|
|
{t.featuresPage?.cancelOrder || 'Annulla'}
|
|
</button>
|
|
<button className="btn-primary" onClick={handleApplyOrder}>
|
|
<span className="material-symbols-outlined">check</span>
|
|
{t.featuresPage?.applyOrder || 'Applica'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderTabContent = (tabId: TabId) => {
|
|
if (!hasInitialized || isLoading) {
|
|
return <div className="loading">Loading...</div>;
|
|
}
|
|
switch (tabId) {
|
|
case 'config':
|
|
return renderConfigTab();
|
|
case 'dashboard':
|
|
return (
|
|
<>
|
|
{renderModuleToggle('dashboard')}
|
|
<div className="tab-content-placeholder">
|
|
<div className="placeholder-icon">
|
|
<span className="material-symbols-outlined">dashboard</span>
|
|
</div>
|
|
<h3>{t.sidebar.dashboard}</h3>
|
|
<p>{t.features.comingSoon}</p>
|
|
</div>
|
|
</>
|
|
);
|
|
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>
|
|
</>
|
|
);
|
|
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;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<main className="main-content admin-panel-root">
|
|
<div className="page-tabs-container">
|
|
<div className="page-tabs-slider" ref={tabsContainerRef}>
|
|
<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="page-title-text">{t.featuresPage.title}</span>
|
|
</div>
|
|
<div className="page-tabs-divider"></div>
|
|
<button
|
|
data-tab-id="config"
|
|
className={`page-tab-btn ${activeTab === 'config' ? 'active' : ''}`}
|
|
onClick={() => handleTabChange('config')}
|
|
>
|
|
<span className="material-symbols-outlined">tune</span>
|
|
<span>{t.featuresPage?.configTab || 'Configurazione'}</span>
|
|
</button>
|
|
{[...topAppliedModules, ...bottomAppliedModules].map((moduleId) => {
|
|
const moduleInfo = getModuleInfo(moduleId);
|
|
const moduleName = t.sidebar[moduleId as keyof typeof t.sidebar] || moduleId;
|
|
return (
|
|
<button
|
|
key={moduleId}
|
|
data-tab-id={moduleId}
|
|
className={`page-tab-btn ${activeTab === moduleId ? 'active' : ''}`}
|
|
onClick={() => handleTabChange(moduleId as TabId)}
|
|
>
|
|
<span className="material-symbols-outlined">{moduleInfo.icon}</span>
|
|
<span>{moduleName}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<SwipeTabs
|
|
className="admin-tab-swipe"
|
|
tabs={tabIds}
|
|
activeTab={activeTab}
|
|
onTabChange={handleTabChange}
|
|
renderPanel={renderTabContent}
|
|
panelClassName="admin-tab-content swipe-panel-content"
|
|
/>
|
|
</main>
|
|
);
|
|
}
|