Fix Features config tab order management

- 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>
This commit is contained in:
2025-12-22 16:47:12 +01:00
parent e15b8ecd58
commit 6adcf75ef1
2 changed files with 111 additions and 57 deletions

View File

@@ -79,6 +79,11 @@ export function SwipeTabs<T extends string | number>({
isDragging: false isDragging: false
}); });
const shouldIgnoreSwipe = useCallback((target: EventTarget | null) => {
if (!target || typeof (target as Element).closest !== 'function') return false;
return Boolean((target as Element).closest('[data-swipe-ignore="true"]'));
}, []);
const applyTransform = useCallback((offset: number, animate: boolean) => { const applyTransform = useCallback((offset: number, animate: boolean) => {
const track = trackRef.current; const track = trackRef.current;
if (!track) return; if (!track) return;
@@ -310,6 +315,7 @@ export function SwipeTabs<T extends string | number>({
); );
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => { const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (swipeDisabled || shouldIgnoreSwipe(event.target)) return;
if (event.pointerType === 'mouse' && event.button !== 0) return; if (event.pointerType === 'mouse' && event.button !== 0) return;
if (dragRef.current.isActive) return; if (dragRef.current.isActive) return;
startDrag(event.clientX, event.clientY, event.pointerId, null); startDrag(event.clientX, event.clientY, event.pointerId, null);
@@ -359,6 +365,7 @@ export function SwipeTabs<T extends string | number>({
}; };
const handleTouchStart = (event: TouchEvent<HTMLDivElement>) => { const handleTouchStart = (event: TouchEvent<HTMLDivElement>) => {
if (swipeDisabled || shouldIgnoreSwipe(event.target)) return;
if (dragRef.current.isActive) return; if (dragRef.current.isActive) return;
const touch = event.changedTouches[0]; const touch = event.changedTouches[0];
if (!touch) return; if (!touch) return;

View File

@@ -24,39 +24,68 @@ export default function Features() {
const [hasOrderChanges, setHasOrderChanges] = useState(false); const [hasOrderChanges, setHasOrderChanges] = useState(false);
const tabsContainerRef = useRef<HTMLDivElement>(null); const tabsContainerRef = useRef<HTMLDivElement>(null);
const isUserEditing = useRef(false); // Track if user is actively editing const isUserEditing = useRef(false); // Track if user is actively editing
const initialOrderRef = useRef<string[]>([]);
const initialPositionsRef = useRef<Record<string, 'top' | 'bottom'>>({});
// Sync local order with context order (only when moduleOrder changes, not moduleStates) const buildFullOrder = useCallback((order: string[]) => {
useEffect(() => { const fullOrder = [...order];
if (moduleOrder.length > 0 && !isUserEditing.current) {
// Start with current order
const fullOrder = [...moduleOrder];
// Insert missing modules at their default positions from TOGGLEABLE_MODULES
TOGGLEABLE_MODULES.forEach((module, defaultIndex) => { TOGGLEABLE_MODULES.forEach((module, defaultIndex) => {
if (!fullOrder.includes(module.id)) { if (!fullOrder.includes(module.id)) {
// Insert at the default index position, or at end if index is beyond current length
const insertAt = Math.min(defaultIndex, fullOrder.length); const insertAt = Math.min(defaultIndex, fullOrder.length);
fullOrder.splice(insertAt, 0, module.id); fullOrder.splice(insertAt, 0, module.id);
} }
}); });
return fullOrder;
}, []);
setLocalOrder(fullOrder); const normalizePositions = useCallback((positions: Record<string, 'top' | 'bottom'>) => {
setHasOrderChanges(false); const normalized: Record<string, 'top' | 'bottom'> = {};
} TOGGLEABLE_MODULES.forEach(module => {
}, [moduleOrder]); normalized[module.id] = positions[module.id] || module.defaultPosition;
});
return normalized;
}, []);
// Sync positions from moduleStates only on initial load or when not editing const buildPositionsFromStates = useCallback(() => {
useEffect(() => {
if (!isUserEditing.current) {
const positions: Record<string, 'top' | 'bottom'> = {}; const positions: Record<string, 'top' | 'bottom'> = {};
TOGGLEABLE_MODULES.forEach(module => { TOGGLEABLE_MODULES.forEach(module => {
const state = moduleStates[module.id]; const state = moduleStates[module.id];
positions[module.id] = state?.position || module.defaultPosition; positions[module.id] = state?.position || module.defaultPosition;
}); });
setLocalPositions(positions); return positions;
}
}, [moduleStates]); }, [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 // Scroll active tab to center of container
const scrollActiveTabIntoView = useCallback((tabId: string) => { const scrollActiveTabIntoView = useCallback((tabId: string) => {
if (tabsContainerRef.current) { if (tabsContainerRef.current) {
@@ -159,18 +188,14 @@ export default function Features() {
// Drag and drop handlers for ordering // Drag and drop handlers for ordering
const handleDragStart = (e: React.DragEvent, moduleId: string) => { const handleDragStart = (e: React.DragEvent, moduleId: string) => {
beginOrderEdit();
setDraggedItem(moduleId); setDraggedItem(moduleId);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', moduleId); 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) => { const handleDragEnd = () => {
setDraggedItem(null); setDraggedItem(null);
(e.target as HTMLElement).classList.remove('dragging');
}; };
const handleDragOver = (e: React.DragEvent) => { const handleDragOver = (e: React.DragEvent) => {
@@ -185,30 +210,22 @@ export default function Features() {
const draggedPosition = localPositions[draggedItem] || 'top'; const draggedPosition = localPositions[draggedItem] || 'top';
// If dropping on same item, just change section if different const isSameItem = draggedItem === targetModuleId;
if (draggedItem === targetModuleId) { const positionChanged = draggedPosition !== targetSection;
if (draggedPosition !== targetSection) {
isUserEditing.current = true; // Mark as user editing if (isSameItem && !positionChanged) return;
hasUserMadeChanges.current = true;
setModulePosition(draggedItem as ModuleId, targetSection); beginOrderEdit();
if (positionChanged) {
// Update local positions immediately for UI responsiveness // Update local positions immediately for UI responsiveness
setLocalPositions(prev => ({ ...prev, [draggedItem]: targetSection })); setLocalPositions(prev => ({ ...prev, [draggedItem]: targetSection }));
setHasOrderChanges(true);
} }
if (isSameItem) {
setHasOrderChanges(true);
return; return;
} }
// Change position if moving to different section
if (draggedPosition !== targetSection) {
isUserEditing.current = true; // Mark as user editing
hasUserMadeChanges.current = true;
setModulePosition(draggedItem as ModuleId, targetSection);
// Update local positions immediately for UI responsiveness
setLocalPositions(prev => ({ ...prev, [draggedItem]: targetSection }));
}
// Reorder within the list // Reorder within the list
isUserEditing.current = true; // Mark as user editing
const newOrder = [...localOrder]; const newOrder = [...localOrder];
const draggedIndex = newOrder.indexOf(draggedItem); const draggedIndex = newOrder.indexOf(draggedItem);
const targetIndex = newOrder.indexOf(targetModuleId); const targetIndex = newOrder.indexOf(targetModuleId);
@@ -230,9 +247,7 @@ export default function Features() {
// Change position if moving to different section // Change position if moving to different section
if (draggedPosition !== section) { if (draggedPosition !== section) {
isUserEditing.current = true; // Mark as user editing beginOrderEdit();
hasUserMadeChanges.current = true;
setModulePosition(draggedItem as ModuleId, section);
// Update local positions immediately for UI responsiveness // Update local positions immediately for UI responsiveness
setLocalPositions(prev => ({ ...prev, [draggedItem]: section })); setLocalPositions(prev => ({ ...prev, [draggedItem]: section }));
setHasOrderChanges(true); setHasOrderChanges(true);
@@ -241,12 +256,30 @@ export default function Features() {
const handleApplyOrder = async () => { const handleApplyOrder = async () => {
try { 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); setModuleOrder(localOrder);
await saveModuleOrder(localOrder); await saveModuleOrder(localOrder);
} catch (error) { } catch (error) {
console.error('Failed to save order:', error); console.error('Failed to save order:', error);
} finally { } finally {
setDraggedItem(null); // Reset drag state setDraggedItem(null); // Reset drag state
initialOrderRef.current = buildFullOrder(localOrder);
initialPositionsRef.current = normalizePositions(localPositions);
isUserEditing.current = false; isUserEditing.current = false;
setHasOrderChanges(false); setHasOrderChanges(false);
} }
@@ -255,14 +288,13 @@ export default function Features() {
const handleCancelOrder = () => { const handleCancelOrder = () => {
setDraggedItem(null); // Reset drag state setDraggedItem(null); // Reset drag state
isUserEditing.current = false; // Done editing isUserEditing.current = false; // Done editing
setLocalOrder(moduleOrder); // Restore to currently applied state from context
// Reset positions from moduleStates const restoredOrder = buildFullOrder(moduleOrder);
const positions: Record<string, 'top' | 'bottom'> = {}; const restoredPositions = buildPositionsFromStates();
TOGGLEABLE_MODULES.forEach(module => { setLocalOrder(restoredOrder);
const state = moduleStates[module.id]; setLocalPositions(restoredPositions);
positions[module.id] = state?.position || module.defaultPosition; initialOrderRef.current = restoredOrder;
}); initialPositionsRef.current = restoredPositions;
setLocalPositions(positions);
setHasOrderChanges(false); setHasOrderChanges(false);
}; };
@@ -272,6 +304,7 @@ export default function Features() {
}; };
// Split modules by position for the config tab (using localPositions for immediate UI updates) // 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 topOrderModules = localOrder.filter(id => {
const position = localPositions[id]; const position = localPositions[id];
return !position || position === 'top'; return !position || position === 'top';
@@ -282,17 +315,30 @@ export default function Features() {
return position === 'bottom'; return position === 'bottom';
}); });
const tabIds = ['config', ...topOrderModules, ...bottomOrderModules] as TabId[]; // 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 = () => { const renderConfigTab = () => {
return ( return (
<div className="theme-tab-content"> <div className="theme-tab-content content-narrow">
<div className="theme-section"> <div className="theme-section">
<div className="section-header"> <div className="section-header">
<h3 className="section-title">{t.featuresPage?.topSection || 'Sezione Principale'}</h3> <h3 className="section-title">{t.featuresPage?.topSection || 'Sezione Principale'}</h3>
</div> </div>
<div <div
className="order-cards" className="order-cards"
data-swipe-ignore="true"
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleSectionDrop(e, 'top')} onDrop={(e) => handleSectionDrop(e, 'top')}
> >
@@ -333,6 +379,7 @@ export default function Features() {
</div> </div>
<div <div
className="order-cards" className="order-cards"
data-swipe-ignore="true"
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleSectionDrop(e, 'bottom')} onDrop={(e) => handleSectionDrop(e, 'bottom')}
> >
@@ -486,7 +533,7 @@ export default function Features() {
<span className="material-symbols-outlined">tune</span> <span className="material-symbols-outlined">tune</span>
<span>{t.featuresPage?.configTab || 'Configurazione'}</span> <span>{t.featuresPage?.configTab || 'Configurazione'}</span>
</button> </button>
{[...topOrderModules, ...bottomOrderModules].map((moduleId) => { {[...topAppliedModules, ...bottomAppliedModules].map((moduleId) => {
const moduleInfo = getModuleInfo(moduleId); const moduleInfo = getModuleInfo(moduleId);
const moduleName = t.sidebar[moduleId as keyof typeof t.sidebar] || moduleId; const moduleName = t.sidebar[moduleId as keyof typeof t.sidebar] || moduleId;
return ( return (