Refactor SwipeTabs for smoother touch transitions
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,326 +1,458 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import {
|
||||||
import type { CSSProperties, ReactNode } from 'react';
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type PointerEvent,
|
||||||
|
type ReactNode,
|
||||||
|
type TouchEvent,
|
||||||
|
type TransitionEvent
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
type SwipeTabsProps<T extends string> = {
|
type SwipeTabsProps<T extends string | number> = {
|
||||||
tabs: readonly T[];
|
tabs: T[];
|
||||||
activeTab: T;
|
activeTab: T;
|
||||||
onTabChange: (tab: T) => void;
|
onTabChange: (tab: T) => void;
|
||||||
renderPanel: (tab: T) => ReactNode;
|
renderPanel: (tab: T) => ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
panelClassName?: string;
|
panelClassName?: string;
|
||||||
renderWindow?: number;
|
swipeDisabled?: boolean;
|
||||||
threshold?: number;
|
threshold?: number;
|
||||||
directionRatio?: number;
|
renderWindow?: number;
|
||||||
respectScrollableTargets?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const SCROLL_END_DELAY = 120;
|
const DRAG_START_DISTANCE = 3;
|
||||||
const COARSE_POINTER_QUERY = '(pointer: coarse)';
|
const EDGE_RESISTANCE = 0.35;
|
||||||
const NO_HOVER_QUERY = '(hover: none)';
|
const DEFAULT_THRESHOLD = 0.2;
|
||||||
const FALLBACK_WIDTH_QUERY = '(max-width: 1024px)';
|
const DEFAULT_RENDER_WINDOW = 1;
|
||||||
|
|
||||||
const getSwipeEnabled = () => {
|
const findTouchById = (touches: TouchList, id: number) => {
|
||||||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
for (let i = 0; i < touches.length; i += 1) {
|
||||||
const coarsePointer = window.matchMedia(COARSE_POINTER_QUERY).matches;
|
const touch = touches[i];
|
||||||
const noHover = window.matchMedia(NO_HOVER_QUERY).matches;
|
if (touch.identifier === id) {
|
||||||
if (coarsePointer || noHover) return true;
|
return touch;
|
||||||
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<T extends string>({
|
|
||||||
tabs,
|
|
||||||
activeTab,
|
|
||||||
onTabChange,
|
|
||||||
renderPanel,
|
|
||||||
className,
|
|
||||||
panelClassName,
|
|
||||||
renderWindow = 0,
|
|
||||||
}: SwipeTabsProps<T>) {
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const panelRefs = useRef(new Map<T, HTMLDivElement>());
|
|
||||||
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<number | null>(null);
|
|
||||||
const [isScrolling, setIsScrolling] = useState(false);
|
|
||||||
const [scrollPosition, setScrollPosition] = useState(0);
|
|
||||||
const [swipeEnabled, setSwipeEnabled] = useState(() => getSwipeEnabled());
|
|
||||||
|
|
||||||
const scrollEndTimer = useRef<number | null>(null);
|
|
||||||
const scrollRaf = useRef<number | null>(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<number>();
|
|
||||||
if (!swipeEnabled) {
|
|
||||||
if (safeActiveIndex >= 0) {
|
|
||||||
indexes.add(safeActiveIndex);
|
|
||||||
}
|
|
||||||
return indexes;
|
|
||||||
}
|
|
||||||
const centers = new Set<number>();
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SwipeTabs<T extends string | number>({
|
||||||
|
tabs,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
renderPanel,
|
||||||
|
className,
|
||||||
|
panelClassName,
|
||||||
|
swipeDisabled = false,
|
||||||
|
threshold = DEFAULT_THRESHOLD,
|
||||||
|
renderWindow = DEFAULT_RENDER_WINDOW
|
||||||
|
}: SwipeTabsProps<T>) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const trackRef = useRef<HTMLDivElement>(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<string | null>(null);
|
||||||
|
const transformRef = useRef<string | null>(null);
|
||||||
|
const pendingIndexRef = useRef<number | null>(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 = {
|
const applyTransform = useCallback((offset: number, animate: boolean) => {
|
||||||
height: !isScrolling && containerHeight ? `${containerHeight}px` : 'auto',
|
const track = trackRef.current;
|
||||||
transition: !isScrolling && containerHeight ? 'height 160ms ease-out' : 'none',
|
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) {
|
const measureWidth = useCallback(() => {
|
||||||
return (
|
const width = containerRef.current?.getBoundingClientRect().width || 0;
|
||||||
<div
|
if (width && width !== widthRef.current) {
|
||||||
ref={containerRef}
|
widthRef.current = width;
|
||||||
className={`swipe-tabs swipe-tabs--static${className ? ` ${className}` : ''}`}
|
baseOffsetRef.current = -width;
|
||||||
style={containerStyle}
|
applyTransform(dragOffsetRef.current, false);
|
||||||
>
|
}
|
||||||
<div className="swipe-tabs-track">
|
}, [applyTransform]);
|
||||||
<div className="swipe-tabs-panel" aria-hidden={false}>
|
|
||||||
<div
|
useLayoutEffect(() => {
|
||||||
className={`swipe-tabs-panel-inner${panelClassName ? ` ${panelClassName}` : ''}`}
|
measureWidth();
|
||||||
ref={(node) => {
|
const node = containerRef.current;
|
||||||
if (node) {
|
if (!node) return undefined;
|
||||||
panelRefs.current.set(activeTab, node);
|
let observer: ResizeObserver | null = null;
|
||||||
} else {
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
panelRefs.current.delete(activeTab);
|
observer = new ResizeObserver(() => measureWidth());
|
||||||
}
|
observer.observe(node);
|
||||||
}}
|
} else if (typeof window !== 'undefined') {
|
||||||
>
|
window.addEventListener('resize', measureWidth);
|
||||||
{renderPanel(activeTab)}
|
}
|
||||||
</div>
|
return () => {
|
||||||
</div>
|
observer?.disconnect();
|
||||||
</div>
|
if (typeof window !== 'undefined') {
|
||||||
</div>
|
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 (
|
const animateToIndex = useCallback(
|
||||||
<div
|
(index: number) => {
|
||||||
ref={containerRef}
|
if (index === displayIndexRef.current) {
|
||||||
className={`swipe-tabs swipe-tabs--snap${className ? ` ${className}` : ''}${isScrolling ? ' is-scrolling' : ''}`}
|
dragOffsetRef.current = 0;
|
||||||
style={containerStyle}
|
applyTransform(0, true);
|
||||||
onScroll={handleScroll}
|
return;
|
||||||
>
|
}
|
||||||
<div className="swipe-tabs-track">
|
const delta = index - displayIndexRef.current;
|
||||||
{tabs.map((tab, index) => {
|
const width = widthRef.current || 0;
|
||||||
const shouldRender = visibleIndexes.has(index);
|
if (Math.abs(delta) !== 1 || width === 0) {
|
||||||
return (
|
resetToIndex(index);
|
||||||
<div key={tab} className="swipe-tabs-panel" aria-hidden={tab !== activeTab}>
|
return;
|
||||||
<div
|
}
|
||||||
className={`swipe-tabs-panel-inner${panelClassName ? ` ${panelClassName}` : ''}`}
|
pendingIndexRef.current = index;
|
||||||
ref={(node) => {
|
isAnimatingRef.current = true;
|
||||||
if (node) {
|
dragOffsetRef.current = 0;
|
||||||
panelRefs.current.set(tab, node);
|
applyTransform(delta > 0 ? -width : width, true);
|
||||||
} else {
|
},
|
||||||
panelRefs.current.delete(tab);
|
[applyTransform, resetToIndex]
|
||||||
}
|
);
|
||||||
}}
|
|
||||||
>
|
useLayoutEffect(() => {
|
||||||
{shouldRender ? renderPanel(tab) : null}
|
if (isDraggingRef.current || isAnimatingRef.current) return;
|
||||||
</div>
|
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<HTMLDivElement>) => {
|
||||||
|
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<HTMLDivElement>) => {
|
||||||
|
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<HTMLDivElement>) => {
|
||||||
|
if (dragRef.current.pointerId !== event.pointerId) return;
|
||||||
|
finishDrag(false);
|
||||||
|
if (containerRef.current?.hasPointerCapture?.(event.pointerId)) {
|
||||||
|
containerRef.current.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerCancel = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (dragRef.current.pointerId !== event.pointerId) return;
|
||||||
|
finishDrag(true);
|
||||||
|
if (containerRef.current?.hasPointerCapture?.(event.pointerId)) {
|
||||||
|
containerRef.current.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (event: TouchEvent<HTMLDivElement>) => {
|
||||||
|
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<HTMLDivElement>) => {
|
||||||
|
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<HTMLDivElement>) => {
|
||||||
|
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<HTMLDivElement>) => {
|
||||||
|
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<HTMLDivElement>) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={rootClassName}
|
||||||
|
onPointerDown={usePointerEvents ? handlePointerDown : undefined}
|
||||||
|
onPointerMove={usePointerEvents ? handlePointerMove : undefined}
|
||||||
|
onPointerUp={usePointerEvents ? handlePointerUp : undefined}
|
||||||
|
onPointerCancel={usePointerEvents ? handlePointerCancel : undefined}
|
||||||
|
onTouchStart={!usePointerEvents ? handleTouchStart : undefined}
|
||||||
|
onTouchMove={!usePointerEvents ? handleTouchMove : undefined}
|
||||||
|
onTouchEnd={!usePointerEvents ? handleTouchEnd : undefined}
|
||||||
|
onTouchCancel={!usePointerEvents ? handleTouchCancel : undefined}
|
||||||
|
>
|
||||||
|
<div className="swipe-tabs-track" ref={trackRef} onTransitionEnd={handleTransitionEnd}>
|
||||||
|
{slotTabs.map((slot, slotIndex) => {
|
||||||
|
const tab = slot?.tab ?? null;
|
||||||
|
const key = tab !== null ? String(tab) : `empty-${slotIndex}`;
|
||||||
|
return (
|
||||||
|
<section key={key} className="swipe-tabs-panel">
|
||||||
|
<div className={panelInnerClasses}>
|
||||||
|
{tab !== null ? renderPanel(tab) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default function SettingsTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-tab-content">
|
<div className="settings-tab-content content-narrow">
|
||||||
<div className="settings-section-modern">
|
<div className="settings-section-modern">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h3 className="section-title">{t.settings.authentication}</h3>
|
<h3 className="section-title">{t.settings.authentication}</h3>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export default function Notifications() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content page-content--narrow">
|
||||||
<div className="notifications-toolbar">
|
<div className="notifications-toolbar">
|
||||||
<div className="notifications-toggle">
|
<div className="notifications-toggle">
|
||||||
<span className="notifications-toggle-label">{t.notificationsPage.unreadOnly}</span>
|
<span className="notifications-toggle-label">{t.notificationsPage.unreadOnly}</span>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-content settings-tab-content">
|
<div className="page-content page-content--narrow settings-tab-content">
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">{t.settings.preferences}</h3>
|
<h3 className="section-title">{t.settings.preferences}</h3>
|
||||||
<div className="setting-item-modern">
|
<div className="setting-item-modern">
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export default function Features() {
|
|||||||
const userEnabled = state?.user ?? true;
|
const userEnabled = state?.user ?? true;
|
||||||
|
|
||||||
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?.visibility || 'Visibilità'}</h3>
|
<h3 className="section-title">{t.featuresPage?.visibility || 'Visibilità'}</h3>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content page-content--narrow">
|
||||||
<div className="settings-card">
|
<div className="settings-card">
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2>{t.settings.authentication}</h2>
|
<h2>{t.settings.authentication}</h2>
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
.api-keys-root .page-content {
|
|
||||||
max-width: var(--page-max-width);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section Layout - matches theme-section spacing */
|
/* Section Layout - matches theme-section spacing */
|
||||||
.api-keys-section {
|
.api-keys-section {
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
.admin-analytics-root .page-content {
|
|
||||||
max-width: 1100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-cards {
|
.analytics-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
.admin-audit-root .page-content {
|
|
||||||
max-width: 1200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-filters {
|
.audit-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -1503,7 +1503,9 @@
|
|||||||
|
|
||||||
/* Settings Tab */
|
/* Settings Tab */
|
||||||
.settings-tab-content {
|
.settings-tab-content {
|
||||||
max-width: 800px;
|
max-width: var(--page-max-width-narrow);
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section-modern {
|
.settings-section-modern {
|
||||||
@@ -2483,7 +2485,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
max-width: 600px;
|
width: 100%;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Order Card */
|
/* Order Card */
|
||||||
@@ -2672,4 +2676,3 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -276,12 +276,26 @@ body {
|
|||||||
width: 100%;
|
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 */
|
/* Remove double padding when theme-tab-content is nested inside admin-tab-content */
|
||||||
.admin-tab-content .theme-tab-content {
|
.admin-tab-content .theme-tab-content {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-tab-content .content-narrow {
|
||||||
|
max-width: var(--page-max-width-narrow);
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* ========== RESPONSIVE DESIGN ========== */
|
/* ========== RESPONSIVE DESIGN ========== */
|
||||||
|
|
||||||
/* Tablet - reduce padding */
|
/* Tablet - reduce padding */
|
||||||
@@ -510,12 +524,17 @@ body {
|
|||||||
.swipe-tabs-track {
|
.swipe-tabs-track {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
will-change: transform;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swipe-tabs-panel {
|
.swipe-tabs-panel {
|
||||||
|
flex: 0 0 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 1px;
|
min-height: 1px;
|
||||||
background: var(--color-bg-main);
|
background: var(--color-bg-main);
|
||||||
|
backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swipe-tabs--static {
|
.swipe-tabs--static {
|
||||||
@@ -523,19 +542,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.swipe-tabs--static .swipe-tabs-panel {
|
.swipe-tabs--static .swipe-tabs-panel {
|
||||||
flex: 0 0 100%;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swipe-tabs--snap {
|
.swipe-tabs--snap {
|
||||||
overflow-x: auto;
|
overflow: hidden;
|
||||||
overflow-y: hidden;
|
touch-action: pan-y;
|
||||||
touch-action: pan-x pan-y;
|
|
||||||
scroll-snap-type: x mandatory;
|
|
||||||
overscroll-behavior-x: contain;
|
overscroll-behavior-x: contain;
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.swipe-tabs--snap::-webkit-scrollbar {
|
.swipe-tabs--snap::-webkit-scrollbar {
|
||||||
@@ -543,17 +556,20 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.swipe-tabs--snap .swipe-tabs-panel {
|
.swipe-tabs--snap .swipe-tabs-panel {
|
||||||
flex: 0 0 100%;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
contain: paint;
|
contain: paint;
|
||||||
scroll-snap-align: start;
|
|
||||||
scroll-snap-stop: always;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.swipe-tabs-panel-inner {
|
.swipe-tabs-panel-inner {
|
||||||
width: 100%;
|
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 {
|
.admin-tab-content.swipe-tabs {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
.notifications-root .page-content {
|
|
||||||
max-width: 900px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notifications-toolbar {
|
.notifications-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -61,6 +61,8 @@
|
|||||||
--page-padding-y-mobile: 1.25rem;
|
--page-padding-y-mobile: 1.25rem;
|
||||||
--page-max-width: 1200px;
|
--page-max-width: 1200px;
|
||||||
/* Maximum content width */
|
/* Maximum content width */
|
||||||
|
--page-max-width-narrow: 800px;
|
||||||
|
/* Narrow content width */
|
||||||
|
|
||||||
/* Container Widths */
|
/* Container Widths */
|
||||||
--container-sm: 640px;
|
--container-sm: 640px;
|
||||||
|
|||||||
Reference in New Issue
Block a user