From e83d62a2407d6b8a616b93393b7e54fd57d2089a Mon Sep 17 00:00:00 2001 From: matteoscrugli Date: Sun, 21 Dec 2025 23:08:29 +0100 Subject: [PATCH] Refactor SwipeTabs for smoother touch transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite SwipeTabs with improved touch handling and animation - Use translate3d with smoother easing curve (280ms) - Add coalesced pointer events for high refresh rate displays - Clean up redundant CSS styles across multiple files - Add page-max-width dimension variable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/components/SwipeTabs.tsx | 760 ++++++++++-------- frontend/src/components/admin/SettingsTab.tsx | 2 +- frontend/src/pages/Notifications.tsx | 2 +- frontend/src/pages/Settings.tsx | 2 +- frontend/src/pages/admin/Features.tsx | 2 +- frontend/src/pages/admin/Settings.tsx | 2 +- frontend/src/styles/APIKeys.css | 4 - frontend/src/styles/AdminAnalytics.css | 4 - frontend/src/styles/AdminAudit.css | 4 - frontend/src/styles/AdminPanel.css | 9 +- frontend/src/styles/Layout.css | 38 +- frontend/src/styles/Notifications.css | 4 - frontend/src/styles/theme/dimensions.css | 2 + 13 files changed, 486 insertions(+), 349 deletions(-) diff --git a/frontend/src/components/SwipeTabs.tsx b/frontend/src/components/SwipeTabs.tsx index b9b5265..bb3e46e 100644 --- a/frontend/src/components/SwipeTabs.tsx +++ b/frontend/src/components/SwipeTabs.tsx @@ -1,326 +1,458 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import type { CSSProperties, ReactNode } from 'react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type PointerEvent, + type ReactNode, + type TouchEvent, + type TransitionEvent +} from 'react'; -type SwipeTabsProps = { - tabs: readonly T[]; - activeTab: T; - onTabChange: (tab: T) => void; - renderPanel: (tab: T) => ReactNode; - className?: string; - panelClassName?: string; - renderWindow?: number; - threshold?: number; - directionRatio?: number; - respectScrollableTargets?: boolean; +type SwipeTabsProps = { + tabs: T[]; + activeTab: T; + onTabChange: (tab: T) => void; + renderPanel: (tab: T) => ReactNode; + className?: string; + panelClassName?: string; + swipeDisabled?: boolean; + threshold?: number; + renderWindow?: number; }; -const SCROLL_END_DELAY = 120; -const COARSE_POINTER_QUERY = '(pointer: coarse)'; -const NO_HOVER_QUERY = '(hover: none)'; -const FALLBACK_WIDTH_QUERY = '(max-width: 1024px)'; +const DRAG_START_DISTANCE = 3; +const EDGE_RESISTANCE = 0.35; +const DEFAULT_THRESHOLD = 0.2; +const DEFAULT_RENDER_WINDOW = 1; -const getSwipeEnabled = () => { - if (typeof window === 'undefined' || !window.matchMedia) return false; - const coarsePointer = window.matchMedia(COARSE_POINTER_QUERY).matches; - const noHover = window.matchMedia(NO_HOVER_QUERY).matches; - if (coarsePointer || noHover) return true; - const supportsTouch = ('ontouchstart' in window) - || (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0); - if (!supportsTouch) return false; - return window.matchMedia(FALLBACK_WIDTH_QUERY).matches; -}; - -export function SwipeTabs({ - tabs, - activeTab, - onTabChange, - renderPanel, - className, - panelClassName, - renderWindow = 0, -}: SwipeTabsProps) { - const containerRef = useRef(null); - const panelRefs = useRef(new Map()); - const activeIndex = tabs.indexOf(activeTab); - const safeActiveIndex = activeIndex >= 0 ? activeIndex : 0; - const maxIndex = Math.max(0, tabs.length - 1); - - const [containerWidth, setContainerWidth] = useState(0); - const [containerHeight, setContainerHeight] = useState(null); - const [isScrolling, setIsScrolling] = useState(false); - const [scrollPosition, setScrollPosition] = useState(0); - const [swipeEnabled, setSwipeEnabled] = useState(() => getSwipeEnabled()); - - const scrollEndTimer = useRef(null); - const scrollRaf = useRef(null); - const scrollPositionRef = useRef(0); - const isUserScrolling = useRef(false); - const hasMounted = useRef(false); - const lastReportedIndex = useRef(safeActiveIndex); - - const prefersReducedMotion = useMemo(() => { - if (typeof window === 'undefined' || !window.matchMedia) return true; - return window.matchMedia('(prefers-reduced-motion: reduce)').matches; - }, []); - - useEffect(() => { - if (typeof window === 'undefined' || !window.matchMedia) return; - const pointerMql = window.matchMedia(COARSE_POINTER_QUERY); - const hoverMql = window.matchMedia(NO_HOVER_QUERY); - const widthMql = window.matchMedia(FALLBACK_WIDTH_QUERY); - const update = () => setSwipeEnabled(getSwipeEnabled()); - update(); - - const addListener = (mql: MediaQueryList) => { - if (mql.addEventListener) { - mql.addEventListener('change', update); - return; - } - mql.addListener(update); - }; - - const removeListener = (mql: MediaQueryList) => { - if (mql.removeEventListener) { - mql.removeEventListener('change', update); - return; - } - mql.removeListener(update); - }; - - addListener(pointerMql); - addListener(hoverMql); - addListener(widthMql); - - return () => { - removeListener(pointerMql); - removeListener(hoverMql); - removeListener(widthMql); - }; - }, []); - - useEffect(() => { - if (!containerRef.current) return; - const element = containerRef.current; - const updateSize = () => { - const styles = window.getComputedStyle(element); - const paddingX = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight); - const width = element.clientWidth - (Number.isFinite(paddingX) ? paddingX : 0); - setContainerWidth(width > 0 ? width : element.clientWidth); - }; - updateSize(); - - if (typeof ResizeObserver === 'undefined') { - window.addEventListener('resize', updateSize); - return () => window.removeEventListener('resize', updateSize); - } - - const observer = new ResizeObserver(updateSize); - observer.observe(element); - return () => observer.disconnect(); - }, []); - - useEffect(() => { - const panel = panelRefs.current.get(activeTab); - if (!panel) { - setContainerHeight(null); - return; - } - - const updateHeight = () => setContainerHeight(panel.offsetHeight); - updateHeight(); - - if (typeof ResizeObserver === 'undefined') { - window.addEventListener('resize', updateHeight); - return () => window.removeEventListener('resize', updateHeight); - } - - const observer = new ResizeObserver(updateHeight); - observer.observe(panel); - return () => observer.disconnect(); - }, [activeTab]); - - useEffect(() => { - return () => { - if (scrollEndTimer.current !== null) { - window.clearTimeout(scrollEndTimer.current); - } - if (scrollRaf.current !== null) { - cancelAnimationFrame(scrollRaf.current); - } - }; - }, []); - - useEffect(() => { - lastReportedIndex.current = safeActiveIndex; - }, [safeActiveIndex]); - - const scrollToIndex = useCallback((index: number, behavior: ScrollBehavior) => { - const container = containerRef.current; - if (!container || containerWidth <= 0) return; - const targetLeft = index * containerWidth; - if (Math.abs(container.scrollLeft - targetLeft) <= 1) return; - container.scrollTo({ left: targetLeft, behavior }); - }, [containerWidth]); - - const scheduleScrollPositionUpdate = useCallback(() => { - if (scrollRaf.current !== null) return; - scrollRaf.current = window.requestAnimationFrame(() => { - scrollRaf.current = null; - setScrollPosition(scrollPositionRef.current); - }); - }, []); - - const handleScroll = useCallback(() => { - if (!swipeEnabled) return; - const container = containerRef.current; - if (!container || containerWidth <= 0) return; - - if (!isUserScrolling.current) { - isUserScrolling.current = true; - setIsScrolling(true); - } - - const rawIndex = container.scrollLeft / containerWidth; - scrollPositionRef.current = rawIndex; - scheduleScrollPositionUpdate(); - - const roundedIndex = Math.round(rawIndex); - const clampedIndex = Math.max(0, Math.min(roundedIndex, maxIndex)); - if (tabs[clampedIndex] && clampedIndex !== lastReportedIndex.current) { - lastReportedIndex.current = clampedIndex; - onTabChange(tabs[clampedIndex]); - } - - if (scrollEndTimer.current !== null) { - window.clearTimeout(scrollEndTimer.current); - } - - scrollEndTimer.current = window.setTimeout(() => { - const rawIndex = container.scrollLeft / containerWidth; - const nextIndex = Math.round(rawIndex); - const clampedIndex = Math.max(0, Math.min(nextIndex, maxIndex)); - const targetLeft = clampedIndex * containerWidth; - - if (Math.abs(container.scrollLeft - targetLeft) > 1) { - container.scrollTo({ left: targetLeft, behavior: 'auto' }); - } - - if (tabs[clampedIndex] && clampedIndex !== lastReportedIndex.current) { - lastReportedIndex.current = clampedIndex; - onTabChange(tabs[clampedIndex]); - } - - scrollPositionRef.current = targetLeft / containerWidth; - scheduleScrollPositionUpdate(); - - isUserScrolling.current = false; - setIsScrolling(false); - }, SCROLL_END_DELAY); - }, [activeTab, containerWidth, maxIndex, onTabChange, scheduleScrollPositionUpdate, swipeEnabled, tabs]); - - useLayoutEffect(() => { - if (!swipeEnabled) return; - const container = containerRef.current; - if (!container || containerWidth <= 0) return; - if (isUserScrolling.current) return; - const behavior: ScrollBehavior = hasMounted.current && !prefersReducedMotion ? 'smooth' : 'auto'; - scrollToIndex(safeActiveIndex, behavior); - hasMounted.current = true; - const currentPosition = container.scrollLeft / containerWidth; - scrollPositionRef.current = currentPosition; - setScrollPosition(currentPosition); - }, [containerWidth, prefersReducedMotion, safeActiveIndex, scrollToIndex, swipeEnabled]); - - const visibleIndexes = useMemo(() => { - const indexes = new Set(); - if (!swipeEnabled) { - if (safeActiveIndex >= 0) { - indexes.add(safeActiveIndex); - } - return indexes; - } - const centers = new Set(); - centers.add(safeActiveIndex); - if (containerWidth <= 0) { - centers.add(0); - } - if (containerWidth > 0 && Number.isFinite(scrollPosition)) { - const base = Math.floor(scrollPosition); - const next = Math.ceil(scrollPosition); - centers.add(base); - centers.add(next); - } - centers.forEach((center) => { - for (let offset = -renderWindow; offset <= renderWindow; offset += 1) { - const index = center + offset; - if (index >= 0 && index < tabs.length) { - indexes.add(index); +const findTouchById = (touches: TouchList, id: number) => { + for (let i = 0; i < touches.length; i += 1) { + const touch = touches[i]; + if (touch.identifier === id) { + return touch; } - } + } + return null; +}; + +export function SwipeTabs({ + tabs, + activeTab, + onTabChange, + renderPanel, + className, + panelClassName, + swipeDisabled = false, + threshold = DEFAULT_THRESHOLD, + renderWindow = DEFAULT_RENDER_WINDOW +}: SwipeTabsProps) { + const containerRef = useRef(null); + const trackRef = useRef(null); + const activeIndex = useMemo(() => { + const index = tabs.indexOf(activeTab); + return index >= 0 ? index : 0; + }, [tabs, activeTab]); + + const [displayIndex, setDisplayIndex] = useState(activeIndex); + const displayIndexRef = useRef(displayIndex); + const widthRef = useRef(0); + const baseOffsetRef = useRef(0); + const dragOffsetRef = useRef(0); + const rafRef = useRef(0); + const isDraggingRef = useRef(false); + const hasMountedRef = useRef(false); + const transitionRef = useRef(null); + const transformRef = useRef(null); + const pendingIndexRef = useRef(null); + const isAnimatingRef = useRef(false); + const needsResetRef = useRef(false); + const dragRef = useRef({ + pointerId: null as number | null, + touchId: null as number | null, + startX: 0, + startY: 0, + startIndex: 0, + isActive: false, + isDragging: false }); - return indexes; - }, [containerWidth, renderWindow, safeActiveIndex, scrollPosition, swipeEnabled, tabs.length]); - const containerStyle: CSSProperties = { - height: !isScrolling && containerHeight ? `${containerHeight}px` : 'auto', - transition: !isScrolling && containerHeight ? 'height 160ms ease-out' : 'none', - }; + const applyTransform = useCallback((offset: number, animate: boolean) => { + const track = trackRef.current; + if (!track) return; + const translateX = baseOffsetRef.current + offset; + const transform = `translate3d(${translateX}px, 0, 0)`; + if (transformRef.current !== transform) { + track.style.transform = transform; + transformRef.current = transform; + } + const transition = animate + ? 'transform 280ms cubic-bezier(0.2, 0.7, 0.2, 1)' + : 'none'; + if (transitionRef.current !== transition) { + track.style.transition = transition; + transitionRef.current = transition; + } + }, []); - if (!swipeEnabled) { - return ( -
-
-
-
{ - if (node) { - panelRefs.current.set(activeTab, node); - } else { - panelRefs.current.delete(activeTab); - } - }} - > - {renderPanel(activeTab)} -
-
-
-
+ const measureWidth = useCallback(() => { + const width = containerRef.current?.getBoundingClientRect().width || 0; + if (width && width !== widthRef.current) { + widthRef.current = width; + baseOffsetRef.current = -width; + applyTransform(dragOffsetRef.current, false); + } + }, [applyTransform]); + + useLayoutEffect(() => { + measureWidth(); + const node = containerRef.current; + if (!node) return undefined; + let observer: ResizeObserver | null = null; + if (typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(() => measureWidth()); + observer.observe(node); + } else if (typeof window !== 'undefined') { + window.addEventListener('resize', measureWidth); + } + return () => { + observer?.disconnect(); + if (typeof window !== 'undefined') { + window.removeEventListener('resize', measureWidth); + } + }; + }, [measureWidth]); + + useLayoutEffect(() => { + displayIndexRef.current = displayIndex; + if (!needsResetRef.current) return; + needsResetRef.current = false; + dragOffsetRef.current = 0; + applyTransform(0, false); + }, [applyTransform, displayIndex]); + + useEffect(() => { + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + } + }; + }, []); + + const resetToIndex = useCallback( + (index: number) => { + displayIndexRef.current = index; + setDisplayIndex(index); + dragOffsetRef.current = 0; + applyTransform(0, false); + }, + [applyTransform] ); - } - return ( -
-
- {tabs.map((tab, index) => { - const shouldRender = visibleIndexes.has(index); - return ( -
-
{ - if (node) { - panelRefs.current.set(tab, node); - } else { - panelRefs.current.delete(tab); - } - }} - > - {shouldRender ? renderPanel(tab) : null} -
+ const animateToIndex = useCallback( + (index: number) => { + if (index === displayIndexRef.current) { + dragOffsetRef.current = 0; + applyTransform(0, true); + return; + } + const delta = index - displayIndexRef.current; + const width = widthRef.current || 0; + if (Math.abs(delta) !== 1 || width === 0) { + resetToIndex(index); + return; + } + pendingIndexRef.current = index; + isAnimatingRef.current = true; + dragOffsetRef.current = 0; + applyTransform(delta > 0 ? -width : width, true); + }, + [applyTransform, resetToIndex] + ); + + useLayoutEffect(() => { + if (isDraggingRef.current || isAnimatingRef.current) return; + if (!hasMountedRef.current) { + hasMountedRef.current = true; + resetToIndex(activeIndex); + return; + } + if (activeIndex !== displayIndexRef.current) { + const distance = Math.abs(activeIndex - displayIndexRef.current); + if (distance === 1) { + animateToIndex(activeIndex); + } else { + resetToIndex(activeIndex); + } + } + }, [activeIndex, animateToIndex, resetToIndex]); + + const scheduleDragUpdate = useCallback(() => { + if (rafRef.current) return; + rafRef.current = requestAnimationFrame(() => { + rafRef.current = 0; + applyTransform(dragOffsetRef.current, false); + }); + }, [applyTransform]); + + const setDraggingClass = useCallback((value: boolean) => { + const node = containerRef.current; + if (!node) return; + node.classList.toggle('swipe-tabs--dragging', value); + }, []); + + const startDrag = useCallback( + (x: number, y: number, pointerId: number | null, touchId: number | null) => { + if (swipeDisabled || tabs.length <= 1) return; + if (isAnimatingRef.current) return; + measureWidth(); + dragRef.current.pointerId = pointerId; + dragRef.current.touchId = touchId; + dragRef.current.startX = x; + dragRef.current.startY = y; + dragRef.current.startIndex = displayIndexRef.current; + dragRef.current.isActive = true; + dragRef.current.isDragging = false; + dragOffsetRef.current = 0; + }, + [measureWidth, swipeDisabled, tabs.length] + ); + + const updateDrag = useCallback( + (x: number, y: number, preventDefault: () => void, pointerType?: string) => { + const state = dragRef.current; + if (!state.isActive) return; + const dx = x - state.startX; + const dy = y - state.startY; + + if (!state.isDragging) { + if (Math.abs(dx) < DRAG_START_DISTANCE && Math.abs(dy) < DRAG_START_DISTANCE) { + return; + } + if (Math.abs(dx) <= Math.abs(dy)) { + return; + } + state.isDragging = true; + isDraggingRef.current = true; + setDraggingClass(true); + } + + if (!state.isDragging) return; + + const width = widthRef.current || 1; + let offset = dx; + const atStart = state.startIndex <= 0; + const atEnd = state.startIndex >= tabs.length - 1; + if ((atStart && dx > 0) || (atEnd && dx < 0)) { + offset = dx * EDGE_RESISTANCE; + } + offset = Math.max(Math.min(offset, width), -width); + dragOffsetRef.current = offset; + scheduleDragUpdate(); + + if (pointerType !== 'mouse') { + preventDefault(); + } + }, + [scheduleDragUpdate, tabs.length] + ); + + const finishDrag = useCallback( + (cancelled: boolean) => { + const state = dragRef.current; + if (!state.isActive) return; + + state.isActive = false; + state.pointerId = null; + state.touchId = null; + + const wasDragging = state.isDragging; + state.isDragging = false; + isDraggingRef.current = false; + setDraggingClass(false); + + if (!wasDragging) return; + + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = 0; + } + + const width = widthRef.current || 1; + const dx = dragOffsetRef.current; + const thresholdPx = width * threshold; + let targetIndex = state.startIndex; + + if (!cancelled && Math.abs(dx) > thresholdPx) { + const direction = dx < 0 ? 1 : -1; + targetIndex = Math.min(Math.max(state.startIndex + direction, 0), tabs.length - 1); + } + + dragOffsetRef.current = 0; + if (targetIndex === state.startIndex) { + applyTransform(0, true); + } else { + pendingIndexRef.current = targetIndex; + isAnimatingRef.current = true; + applyTransform(targetIndex > state.startIndex ? -width : width, true); + } + + if (targetIndex !== state.startIndex) { + const nextTab = tabs[targetIndex]; + if (nextTab !== activeTab) { + onTabChange(nextTab); + } + } + }, + [activeTab, applyTransform, onTabChange, setDraggingClass, tabs, threshold] + ); + + const handlePointerDown = (event: PointerEvent) => { + if (event.pointerType === 'mouse' && event.button !== 0) return; + if (dragRef.current.isActive) return; + startDrag(event.clientX, event.clientY, event.pointerId, null); + if (containerRef.current?.setPointerCapture) { + try { + containerRef.current.setPointerCapture(event.pointerId); + } catch { + // Ignore pointer capture failures. + } + } + }; + + const handlePointerMove = (event: PointerEvent) => { + const state = dragRef.current; + if (!state.isActive || state.pointerId !== event.pointerId) return; + const nativeEvent = event.nativeEvent as globalThis.PointerEvent; + const coalesced = typeof nativeEvent.getCoalescedEvents === 'function' + ? nativeEvent.getCoalescedEvents() + : []; + const latest = coalesced.length > 0 ? coalesced[coalesced.length - 1] : nativeEvent; + updateDrag( + latest.clientX, + latest.clientY, + () => { + if (event.nativeEvent.cancelable) { + event.preventDefault(); + } + }, + event.pointerType + ); + }; + + const handlePointerUp = (event: PointerEvent) => { + if (dragRef.current.pointerId !== event.pointerId) return; + finishDrag(false); + if (containerRef.current?.hasPointerCapture?.(event.pointerId)) { + containerRef.current.releasePointerCapture(event.pointerId); + } + }; + + const handlePointerCancel = (event: PointerEvent) => { + if (dragRef.current.pointerId !== event.pointerId) return; + finishDrag(true); + if (containerRef.current?.hasPointerCapture?.(event.pointerId)) { + containerRef.current.releasePointerCapture(event.pointerId); + } + }; + + const handleTouchStart = (event: TouchEvent) => { + if (dragRef.current.isActive) return; + const touch = event.changedTouches[0]; + if (!touch) return; + startDrag(touch.clientX, touch.clientY, null, touch.identifier); + }; + + const handleTouchMove = (event: TouchEvent) => { + const state = dragRef.current; + if (!state.isActive || state.touchId === null) return; + const touch = findTouchById(event.changedTouches, state.touchId); + if (!touch) return; + updateDrag(touch.clientX, touch.clientY, () => { + if (event.nativeEvent.cancelable) { + event.preventDefault(); + } + }); + }; + + const handleTouchEnd = (event: TouchEvent) => { + const state = dragRef.current; + if (!state.isActive || state.touchId === null) return; + const touch = findTouchById(event.changedTouches, state.touchId); + if (!touch) return; + finishDrag(false); + }; + + const handleTouchCancel = (event: TouchEvent) => { + const state = dragRef.current; + if (!state.isActive || state.touchId === null) return; + const touch = findTouchById(event.changedTouches, state.touchId); + if (!touch) return; + finishDrag(true); + }; + + const handleTransitionEnd = (event: TransitionEvent) => { + if (event.propertyName !== 'transform') return; + if (!isAnimatingRef.current) return; + const nextIndex = pendingIndexRef.current; + pendingIndexRef.current = null; + isAnimatingRef.current = false; + if (nextIndex === null) return; + dragOffsetRef.current = 0; + displayIndexRef.current = nextIndex; + needsResetRef.current = true; + setDisplayIndex(nextIndex); + }; + + const swipeEnabled = !swipeDisabled && tabs.length > 1; + const rootClassName = [ + 'swipe-tabs', + swipeEnabled ? 'swipe-tabs--snap' : 'swipe-tabs--static', + className + ] + .filter(Boolean) + .join(' '); + const panelInnerClasses = ['swipe-tabs-panel-inner', panelClassName].filter(Boolean).join(' '); + const usePointerEvents = typeof window !== 'undefined' && 'PointerEvent' in window; + + const renderRadius = Math.max(1, renderWindow); + const prevIndex = renderRadius >= 1 && displayIndex > 0 ? displayIndex - 1 : null; + const nextIndex = + renderRadius >= 1 && displayIndex < tabs.length - 1 ? displayIndex + 1 : null; + const slotTabs = [ + prevIndex !== null ? { tab: tabs[prevIndex], index: prevIndex } : null, + { tab: tabs[displayIndex], index: displayIndex }, + nextIndex !== null ? { tab: tabs[nextIndex], index: nextIndex } : null + ]; + + return ( +
+
+ {slotTabs.map((slot, slotIndex) => { + const tab = slot?.tab ?? null; + const key = tab !== null ? String(tab) : `empty-${slotIndex}`; + return ( +
+
+ {tab !== null ? renderPanel(tab) : null} +
+
+ ); + })}
- ); - })} -
-
- ); +
+ ); } diff --git a/frontend/src/components/admin/SettingsTab.tsx b/frontend/src/components/admin/SettingsTab.tsx index e32e795..f322474 100644 --- a/frontend/src/components/admin/SettingsTab.tsx +++ b/frontend/src/components/admin/SettingsTab.tsx @@ -51,7 +51,7 @@ export default function SettingsTab() { } return ( -
+

{t.settings.authentication}

diff --git a/frontend/src/pages/Notifications.tsx b/frontend/src/pages/Notifications.tsx index 1994eb9..2d764fb 100644 --- a/frontend/src/pages/Notifications.tsx +++ b/frontend/src/pages/Notifications.tsx @@ -105,7 +105,7 @@ export default function Notifications() {
-
+
{t.notificationsPage.unreadOnly} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 81a2b76..43b301d 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -162,7 +162,7 @@ export default function Settings() {
-
+

{t.settings.preferences}

diff --git a/frontend/src/pages/admin/Features.tsx b/frontend/src/pages/admin/Features.tsx index 7ad0db2..d37fee7 100644 --- a/frontend/src/pages/admin/Features.tsx +++ b/frontend/src/pages/admin/Features.tsx @@ -119,7 +119,7 @@ export default function Features() { const userEnabled = state?.user ?? true; return ( -
+

{t.featuresPage?.visibility || 'Visibilità'}

diff --git a/frontend/src/pages/admin/Settings.tsx b/frontend/src/pages/admin/Settings.tsx index cd19f16..61ad1db 100644 --- a/frontend/src/pages/admin/Settings.tsx +++ b/frontend/src/pages/admin/Settings.tsx @@ -70,7 +70,7 @@ export default function Settings() {
-
+

{t.settings.authentication}

diff --git a/frontend/src/styles/APIKeys.css b/frontend/src/styles/APIKeys.css index fe1a345..5cae12d 100644 --- a/frontend/src/styles/APIKeys.css +++ b/frontend/src/styles/APIKeys.css @@ -1,7 +1,3 @@ -.api-keys-root .page-content { - max-width: var(--page-max-width); -} - /* Section Layout - matches theme-section spacing */ .api-keys-section { margin-bottom: 3rem; diff --git a/frontend/src/styles/AdminAnalytics.css b/frontend/src/styles/AdminAnalytics.css index 0f206d5..5ee4c99 100644 --- a/frontend/src/styles/AdminAnalytics.css +++ b/frontend/src/styles/AdminAnalytics.css @@ -1,7 +1,3 @@ -.admin-analytics-root .page-content { - max-width: 1100px; -} - .analytics-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); diff --git a/frontend/src/styles/AdminAudit.css b/frontend/src/styles/AdminAudit.css index 1d4609b..a19d9bf 100644 --- a/frontend/src/styles/AdminAudit.css +++ b/frontend/src/styles/AdminAudit.css @@ -1,7 +1,3 @@ -.admin-audit-root .page-content { - max-width: 1200px; -} - .audit-filters { display: flex; gap: 0.75rem; diff --git a/frontend/src/styles/AdminPanel.css b/frontend/src/styles/AdminPanel.css index 34b5f6b..57b72bb 100644 --- a/frontend/src/styles/AdminPanel.css +++ b/frontend/src/styles/AdminPanel.css @@ -1503,7 +1503,9 @@ /* Settings Tab */ .settings-tab-content { - max-width: 800px; + max-width: var(--page-max-width-narrow); + width: 100%; + margin: 0 auto; } .settings-section-modern { @@ -2483,7 +2485,9 @@ display: flex; flex-direction: column; gap: 0.75rem; - max-width: 600px; + width: 100%; + margin-left: auto; + margin-right: auto; } /* Order Card */ @@ -2672,4 +2676,3 @@ max-width: 100%; } } - diff --git a/frontend/src/styles/Layout.css b/frontend/src/styles/Layout.css index 9ff04a7..e3f02d7 100644 --- a/frontend/src/styles/Layout.css +++ b/frontend/src/styles/Layout.css @@ -276,12 +276,26 @@ body { width: 100%; } +.page-content--narrow, +.admin-tab-content--narrow, +.content-narrow { + max-width: var(--page-max-width-narrow); + margin: 0 auto; + width: 100%; +} + /* Remove double padding when theme-tab-content is nested inside admin-tab-content */ .admin-tab-content .theme-tab-content { padding: 0; max-width: none; } +.admin-tab-content .content-narrow { + max-width: var(--page-max-width-narrow); + margin: 0 auto; + width: 100%; +} + /* ========== RESPONSIVE DESIGN ========== */ /* Tablet - reduce padding */ @@ -510,12 +524,17 @@ body { .swipe-tabs-track { display: flex; width: 100%; + will-change: transform; + transform: translate3d(0, 0, 0); + backface-visibility: hidden; } .swipe-tabs-panel { + flex: 0 0 100%; width: 100%; min-height: 1px; background: var(--color-bg-main); + backface-visibility: hidden; } .swipe-tabs--static { @@ -523,19 +542,13 @@ body { } .swipe-tabs--static .swipe-tabs-panel { - flex: 0 0 100%; overflow: hidden; } .swipe-tabs--snap { - overflow-x: auto; - overflow-y: hidden; - touch-action: pan-x pan-y; - scroll-snap-type: x mandatory; + overflow: hidden; + touch-action: pan-y; overscroll-behavior-x: contain; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; - -ms-overflow-style: none; } .swipe-tabs--snap::-webkit-scrollbar { @@ -543,17 +556,20 @@ body { } .swipe-tabs--snap .swipe-tabs-panel { - flex: 0 0 100%; overflow: hidden; contain: paint; - scroll-snap-align: start; - scroll-snap-stop: always; } .swipe-tabs-panel-inner { width: 100%; } +.swipe-tabs--dragging .swipe-tabs-panel-inner { + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; +} + .admin-tab-content.swipe-tabs { display: block; } diff --git a/frontend/src/styles/Notifications.css b/frontend/src/styles/Notifications.css index 4263c62..75aad0b 100644 --- a/frontend/src/styles/Notifications.css +++ b/frontend/src/styles/Notifications.css @@ -1,7 +1,3 @@ -.notifications-root .page-content { - max-width: 900px; -} - .notifications-toolbar { display: flex; align-items: center; diff --git a/frontend/src/styles/theme/dimensions.css b/frontend/src/styles/theme/dimensions.css index cd46788..4e668f7 100644 --- a/frontend/src/styles/theme/dimensions.css +++ b/frontend/src/styles/theme/dimensions.css @@ -61,6 +61,8 @@ --page-padding-y-mobile: 1.25rem; --page-max-width: 1200px; /* Maximum content width */ + --page-max-width-narrow: 800px; + /* Narrow content width */ /* Container Widths */ --container-sm: 640px;