diff --git a/frontend/src/components/SwipeTabs.tsx b/frontend/src/components/SwipeTabs.tsx index 340c693..6edda47 100644 --- a/frontend/src/components/SwipeTabs.tsx +++ b/frontend/src/components/SwipeTabs.tsx @@ -35,7 +35,15 @@ const findTouchById = (touches: React.TouchList, id: number): React.Touch | null return touch; } } - return null; + return null; +}; + +const resolveSwipeTarget = (target: EventTarget | null): Element | null => { + if (!target) return null; + const element = target as Element; + if (typeof element.closest === 'function') return element; + const node = target as Node & { parentElement?: Element | null }; + return node.parentElement ?? null; }; export function SwipeTabs({ @@ -80,8 +88,18 @@ export function SwipeTabs({ }); 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 element = resolveSwipeTarget(target); + if (!element) return false; + if (element.closest( + '[data-swipe-ignore="true"], button, a, input, select, textarea, [role="button"], [role="link"], [role="switch"], [contenteditable="true"]' + )) { + return true; + } + const label = element.closest('label'); + if (label && label.querySelector('input, select, textarea, button')) { + return true; + } + return false; }, []); const applyTransform = useCallback((offset: number, animate: boolean) => { diff --git a/frontend/src/pages/admin/Features.tsx b/frontend/src/pages/admin/Features.tsx index ab90ad3..f6a8140 100644 --- a/frontend/src/pages/admin/Features.tsx +++ b/frontend/src/pages/admin/Features.tsx @@ -9,6 +9,17 @@ import { SwipeTabs } from '../../components/SwipeTabs'; import '../../styles/AdminPanel.css'; type TabId = 'config' | 'dashboard' | 'feature1' | 'feature2' | 'feature3' | 'search' | 'notifications'; +const ORDER_DRAG_THRESHOLD = 4; + +const findTouchById = (touches: TouchList, id: number) => { + for (let i = 0; i < touches.length; i += 1) { + const touch = touches.item(i); + if (touch && touch.identifier === id) { + return touch; + } + } + return null; +}; export default function Features() { const { user: currentUser } = useAuth(); @@ -26,6 +37,27 @@ export default function Features() { const isUserEditing = useRef(false); // Track if user is actively editing const initialOrderRef = useRef([]); const initialPositionsRef = useRef>({}); + const topOrderRef = useRef(null); + const bottomOrderRef = useRef(null); + const localOrderRef = useRef([]); + const localPositionsRef = useRef>({}); + const dragPointerRef = useRef({ + pointerId: null as number | null, + touchId: null as number | null, + activeId: null as string | null, + startX: 0, + startY: 0, + currentY: 0, + isDragging: false, + source: null as 'pointer' | 'touch' | null + }); + const globalListenersRef = useRef({ touch: false }); + const touchHandlersRef = useRef({ + move: (_event: TouchEvent) => {}, + end: (_event: TouchEvent) => {}, + cancel: (_event: TouchEvent) => {} + }); + const supportsPointerEvents = typeof window !== 'undefined' && 'PointerEvent' in window; const buildFullOrder = useCallback((order: string[]) => { const fullOrder = [...order]; @@ -86,6 +118,14 @@ export default function Features() { } }, [buildPositionsFromStates]); + useEffect(() => { + localOrderRef.current = localOrder; + }, [localOrder]); + + useEffect(() => { + localPositionsRef.current = localPositions; + }, [localPositions]); + // Scroll active tab to center of container const scrollActiveTabIntoView = useCallback((tabId: string) => { if (tabsContainerRef.current) { @@ -186,79 +226,248 @@ export default function Features() { ); }; - // Drag and drop handlers for ordering - const handleDragStart = (e: React.DragEvent, moduleId: string) => { + const getSectionForY = useCallback((y: number): 'top' | 'bottom' => { + const topRect = topOrderRef.current?.getBoundingClientRect(); + const bottomRect = bottomOrderRef.current?.getBoundingClientRect(); + const inTop = topRect && y >= topRect.top && y <= topRect.bottom; + const inBottom = bottomRect && y >= bottomRect.top && y <= bottomRect.bottom; + if (inTop) return 'top'; + if (inBottom) return 'bottom'; + if (topRect && bottomRect) { + const topDistance = Math.abs(y - (topRect.top + topRect.bottom) / 2); + const bottomDistance = Math.abs(y - (bottomRect.top + bottomRect.bottom) / 2); + return topDistance <= bottomDistance ? 'top' : 'bottom'; + } + return topRect ? 'top' : 'bottom'; + }, []); + + const getInsertIndex = useCallback((section: 'top' | 'bottom', y: number, draggedId: string) => { + const container = section === 'top' ? topOrderRef.current : bottomOrderRef.current; + if (!container) return 0; + const cards = Array.from(container.querySelectorAll('[data-module-id]')) + .filter(card => card.dataset.moduleId !== draggedId); + for (let i = 0; i < cards.length; i += 1) { + const rect = cards[i].getBoundingClientRect(); + const midpoint = rect.top + rect.height / 2; + if (y < midpoint) return i; + } + return cards.length; + }, []); + + const resetPointerDrag = useCallback(() => { + dragPointerRef.current.pointerId = null; + dragPointerRef.current.touchId = null; + dragPointerRef.current.activeId = null; + dragPointerRef.current.startX = 0; + dragPointerRef.current.startY = 0; + dragPointerRef.current.currentY = 0; + dragPointerRef.current.isDragging = false; + dragPointerRef.current.source = null; + }, []); + + const applyReorder = useCallback((activeId: string, dropY: number) => { + const targetSection = getSectionForY(dropY); + const insertIndex = getInsertIndex(targetSection, dropY, activeId); + const baseOrder = localOrderRef.current.length + ? localOrderRef.current + : buildFullOrder(moduleOrder); + const basePositions = Object.keys(localPositionsRef.current).length + ? localPositionsRef.current + : buildPositionsFromStates(); + const nextPositions = { ...basePositions, [activeId]: targetSection }; + const remaining = baseOrder.filter(id => id !== activeId); + const topList = remaining.filter(id => nextPositions[id] !== 'bottom'); + const bottomList = remaining.filter(id => nextPositions[id] === 'bottom'); + const targetList = targetSection === 'top' ? topList : bottomList; + const clampedIndex = Math.max(0, Math.min(insertIndex, targetList.length)); + targetList.splice(clampedIndex, 0, activeId); + const nextOrder = targetSection === 'top' + ? [...targetList, ...bottomList] + : [...topList, ...targetList]; + const orderChanged = nextOrder.length !== baseOrder.length + || nextOrder.some((id, index) => id !== baseOrder[index]); + const previousPosition = basePositions[activeId] || 'top'; + const positionChanged = previousPosition !== targetSection; + if (orderChanged || positionChanged) { + localOrderRef.current = nextOrder; + localPositionsRef.current = nextPositions; + setLocalPositions(nextPositions); + setLocalOrder(nextOrder); + setHasOrderChanges(true); + } + }, [buildFullOrder, buildPositionsFromStates, getInsertIndex, getSectionForY, moduleOrder]); + + const handleOrderPointerMove = useCallback((event: React.PointerEvent) => { + const dragState = dragPointerRef.current; + if (dragState.pointerId !== event.pointerId || !dragState.activeId || dragState.source !== 'pointer') return; + dragState.currentY = event.clientY; + const dx = event.clientX - dragState.startX; + const dy = event.clientY - dragState.startY; + if (!dragState.isDragging) { + if (Math.abs(dx) < ORDER_DRAG_THRESHOLD && Math.abs(dy) < ORDER_DRAG_THRESHOLD) { + return; + } + dragState.isDragging = true; + setDraggedItem(dragState.activeId); + } + if (dragState.isDragging) { + applyReorder(dragState.activeId, dragState.currentY); + } + if (dragState.isDragging && event.pointerType !== 'mouse' && event.cancelable) { + event.preventDefault(); + } + }, [applyReorder]); + + const handleOrderPointerUp = useCallback((event: React.PointerEvent) => { + const dragState = dragPointerRef.current; + if (dragState.pointerId !== event.pointerId || dragState.source !== 'pointer') return; + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + const activeId = dragState.activeId; + const wasDragging = dragState.isDragging; + const dropY = dragState.currentY; + resetPointerDrag(); + setDraggedItem(null); + if (!activeId || !wasDragging) return; + applyReorder(activeId, dropY); + }, [applyReorder, resetPointerDrag]); + + const handleOrderPointerCancel = useCallback((event: React.PointerEvent) => { + const dragState = dragPointerRef.current; + if (dragState.pointerId !== event.pointerId || dragState.source !== 'pointer') return; + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + resetPointerDrag(); + setDraggedItem(null); + }, [resetPointerDrag]); + + const addTouchListeners = useCallback(() => { + if (typeof window === 'undefined' || globalListenersRef.current.touch) return; + window.addEventListener('touchmove', touchHandlersRef.current.move, { passive: false }); + window.addEventListener('touchend', touchHandlersRef.current.end, { passive: false }); + window.addEventListener('touchcancel', touchHandlersRef.current.cancel, { passive: false }); + globalListenersRef.current.touch = true; + }, []); + + const removeTouchListeners = useCallback(() => { + if (typeof window === 'undefined' || !globalListenersRef.current.touch) return; + window.removeEventListener('touchmove', touchHandlersRef.current.move); + window.removeEventListener('touchend', touchHandlersRef.current.end); + window.removeEventListener('touchcancel', touchHandlersRef.current.cancel); + globalListenersRef.current.touch = false; + }, []); + + const handleOrderPointerDown = useCallback((event: React.PointerEvent, moduleId: string) => { + if (event.pointerType === 'mouse' && event.button !== 0) return; beginOrderEdit(); setDraggedItem(moduleId); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', moduleId); - }; + dragPointerRef.current.pointerId = event.pointerId; + dragPointerRef.current.touchId = null; + dragPointerRef.current.activeId = moduleId; + dragPointerRef.current.startX = event.clientX; + dragPointerRef.current.startY = event.clientY; + dragPointerRef.current.currentY = event.clientY; + dragPointerRef.current.isDragging = false; + dragPointerRef.current.source = 'pointer'; + if (event.currentTarget.setPointerCapture) { + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // Ignore pointer capture failures. + } + } + }, [beginOrderEdit]); - const handleDragEnd = () => { + const handleGlobalTouchMove = useCallback((event: TouchEvent) => { + const dragState = dragPointerRef.current; + if (dragState.source !== 'touch' || dragState.touchId === null || !dragState.activeId) return; + const touch = findTouchById(event.changedTouches, dragState.touchId); + if (!touch) return; + dragState.currentY = touch.clientY; + const dx = touch.clientX - dragState.startX; + const dy = touch.clientY - dragState.startY; + if (!dragState.isDragging) { + if (Math.abs(dx) < ORDER_DRAG_THRESHOLD && Math.abs(dy) < ORDER_DRAG_THRESHOLD) { + return; + } + dragState.isDragging = true; + setDraggedItem(dragState.activeId); + } + if (dragState.isDragging) { + applyReorder(dragState.activeId, dragState.currentY); + } + if (dragState.isDragging && event.cancelable) { + event.preventDefault(); + } + }, [applyReorder]); + + const handleGlobalTouchEnd = useCallback((event: TouchEvent) => { + const dragState = dragPointerRef.current; + if (dragState.source !== 'touch' || dragState.touchId === null) return; + const touch = findTouchById(event.changedTouches, dragState.touchId); + if (!touch) return; + const activeId = dragState.activeId; + const wasDragging = dragState.isDragging; + const dropY = dragState.currentY; + removeTouchListeners(); + resetPointerDrag(); setDraggedItem(null); - }; + if (!activeId || !wasDragging) return; + applyReorder(activeId, dropY); + }, [applyReorder, removeTouchListeners, resetPointerDrag]); - 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; + const handleGlobalTouchCancel = useCallback((event: TouchEvent) => { + const dragState = dragPointerRef.current; + if (dragState.source !== 'touch' || dragState.touchId === null) return; + const touch = findTouchById(event.changedTouches, dragState.touchId); + if (!touch) return; + removeTouchListeners(); + resetPointerDrag(); + setDraggedItem(null); + }, [removeTouchListeners, resetPointerDrag]); + const handleOrderTouchStart = useCallback((event: React.TouchEvent, moduleId: string) => { + if (event.changedTouches.length === 0) return; beginOrderEdit(); - if (positionChanged) { - // Update local positions immediately for UI responsiveness - setLocalPositions(prev => ({ ...prev, [draggedItem]: targetSection })); - } - if (isSameItem) { - setHasOrderChanges(true); - return; - } + setDraggedItem(moduleId); + const touch = event.changedTouches[0]; + dragPointerRef.current.pointerId = null; + dragPointerRef.current.touchId = touch.identifier; + dragPointerRef.current.activeId = moduleId; + dragPointerRef.current.startX = touch.clientX; + dragPointerRef.current.startY = touch.clientY; + dragPointerRef.current.currentY = touch.clientY; + dragPointerRef.current.isDragging = false; + dragPointerRef.current.source = 'touch'; + addTouchListeners(); + }, [addTouchListeners, beginOrderEdit]); - // Reorder within the list - const newOrder = [...localOrder]; - const draggedIndex = newOrder.indexOf(draggedItem); - const targetIndex = newOrder.indexOf(targetModuleId); + useEffect(() => { + touchHandlersRef.current = { + move: handleGlobalTouchMove, + end: handleGlobalTouchEnd, + cancel: handleGlobalTouchCancel + }; + }, [handleGlobalTouchCancel, handleGlobalTouchEnd, handleGlobalTouchMove]); - 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); - } - }; + useEffect(() => { + return () => { + removeTouchListeners(); + }; + }, [removeTouchListeners]); const handleApplyOrder = async () => { + const orderToApply = localOrderRef.current.length ? localOrderRef.current : localOrder; + const positionsToApply = Object.keys(localPositionsRef.current).length + ? localPositionsRef.current + : localPositions; try { + // Update positions in context first const positionUpdates: Array<{ id: ModuleId; position: 'top' | 'bottom' }> = []; TOGGLEABLE_MODULES.forEach(module => { - const desiredPosition = localPositions[module.id] || module.defaultPosition; + const desiredPosition = positionsToApply[module.id] || module.defaultPosition; const currentPosition = moduleStates[module.id]?.position || module.defaultPosition; if (desiredPosition !== currentPosition) { positionUpdates.push({ id: module.id, position: desiredPosition }); @@ -266,35 +475,52 @@ export default function Features() { }); if (positionUpdates.length > 0) { - hasUserMadeChanges.current = true; positionUpdates.forEach(({ id, position }) => { setModulePosition(id, position); }); } - setModuleOrder(localOrder); - await saveModuleOrder(localOrder); + // Update order in context + setModuleOrder(orderToApply); + + // Save both order and positions to backend immediately + await saveModuleOrder(orderToApply); + + // Save positions explicitly (don't rely on debounced effect) + if (positionUpdates.length > 0) { + await saveModulesToBackend(); + } } catch (error) { console.error('Failed to save order:', error); } finally { - setDraggedItem(null); // Reset drag state - initialOrderRef.current = buildFullOrder(localOrder); - initialPositionsRef.current = normalizePositions(localPositions); + setDraggedItem(null); + localOrderRef.current = orderToApply; + localPositionsRef.current = positionsToApply; + initialOrderRef.current = buildFullOrder(orderToApply); + initialPositionsRef.current = normalizePositions(positionsToApply); 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; + // Reset drag state + setDraggedItem(null); + isUserEditing.current = false; + + const restoredOrder = initialOrderRef.current.length + ? [...initialOrderRef.current] + : buildFullOrder(moduleOrder); + const restoredPositions = Object.keys(initialPositionsRef.current).length + ? { ...normalizePositions(initialPositionsRef.current) } + : buildPositionsFromStates(); + + localOrderRef.current = restoredOrder; + localPositionsRef.current = restoredPositions; + setLocalOrder([...restoredOrder]); + setLocalPositions({ ...restoredPositions }); + initialOrderRef.current = [...restoredOrder]; + initialPositionsRef.current = { ...restoredPositions }; setHasOrderChanges(false); }; @@ -339,8 +565,7 @@ export default function Features() {
handleSectionDrop(e, 'top')} + ref={topOrderRef} > {topOrderModules.map((moduleId) => { const moduleInfo = getModuleInfo(moduleId); @@ -349,11 +574,12 @@ export default function Features() {
handleDragStart(e, moduleId)} - onDragEnd={handleDragEnd} - onDragOver={handleDragOver} - onDrop={(e) => handleDrop(e, moduleId, 'top')} + data-module-id={moduleId} + onPointerDown={supportsPointerEvents ? (e) => handleOrderPointerDown(e, moduleId) : undefined} + onPointerMove={supportsPointerEvents ? handleOrderPointerMove : undefined} + onPointerUp={supportsPointerEvents ? handleOrderPointerUp : undefined} + onPointerCancel={supportsPointerEvents ? handleOrderPointerCancel : undefined} + onTouchStart={!supportsPointerEvents ? (e) => handleOrderTouchStart(e, moduleId) : undefined} >
{moduleInfo.icon} @@ -380,8 +606,7 @@ export default function Features() {
handleSectionDrop(e, 'bottom')} + ref={bottomOrderRef} > {bottomOrderModules.map((moduleId) => { const moduleInfo = getModuleInfo(moduleId); @@ -390,11 +615,12 @@ export default function Features() {
handleDragStart(e, moduleId)} - onDragEnd={handleDragEnd} - onDragOver={handleDragOver} - onDrop={(e) => handleDrop(e, moduleId, 'bottom')} + data-module-id={moduleId} + onPointerDown={supportsPointerEvents ? (e) => handleOrderPointerDown(e, moduleId) : undefined} + onPointerMove={supportsPointerEvents ? handleOrderPointerMove : undefined} + onPointerUp={supportsPointerEvents ? handleOrderPointerUp : undefined} + onPointerCancel={supportsPointerEvents ? handleOrderPointerCancel : undefined} + onTouchStart={!supportsPointerEvents ? (e) => handleOrderTouchStart(e, moduleId) : undefined} >
{moduleInfo.icon} @@ -415,7 +641,7 @@ export default function Features() {
{hasOrderChanges && ( -
+