Add swipe-to-open sidebar on all pages + fix bottom bar styling
- Create SwipeableContent component for sidebar swipe on non-tab pages - Add swipe-to-close sidebar from overlay - Make swipe work from entire page (ignoring interactive elements) - Show title and divider on desktop when tab bar is at bottom - Hide title/divider only on mobile for bottom position 🤖 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,4 +1,4 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useCallback, type TouchEvent } from 'react';
|
||||||
import { NavLink, useNavigate } from 'react-router-dom';
|
import { NavLink, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
@@ -12,6 +12,9 @@ import { appModules } from '../modules';
|
|||||||
import UserMenu from './UserMenu';
|
import UserMenu from './UserMenu';
|
||||||
import '../styles/Sidebar.css';
|
import '../styles/Sidebar.css';
|
||||||
|
|
||||||
|
const SIDEBAR_WIDTH = 280;
|
||||||
|
const DRAG_START_DISTANCE = 3;
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { t, language, setLanguage } = useTranslation();
|
const { t, language, setLanguage } = useTranslation();
|
||||||
const { config } = useSiteConfig();
|
const { config } = useSiteConfig();
|
||||||
@@ -24,7 +27,11 @@ export default function Sidebar() {
|
|||||||
closeMobileMenu,
|
closeMobileMenu,
|
||||||
isHovered,
|
isHovered,
|
||||||
setIsHovered,
|
setIsHovered,
|
||||||
showLogo: showLogoContext
|
showLogo: showLogoContext,
|
||||||
|
sidebarDragProgress,
|
||||||
|
setSidebarDragProgress,
|
||||||
|
isSidebarDragging,
|
||||||
|
setIsSidebarDragging
|
||||||
} = useSidebar();
|
} = useSidebar();
|
||||||
const { viewMode, toggleViewMode, isUserModeEnabled } = useViewMode();
|
const { viewMode, toggleViewMode, isUserModeEnabled } = useViewMode();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -167,10 +174,137 @@ export default function Sidebar() {
|
|||||||
setTooltip((prev) => prev.visible ? { ...prev, text } : prev);
|
setTooltip((prev) => prev.visible ? { ...prev, text } : prev);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Swipe-to-close handlers for mobile sidebar
|
||||||
|
const closeDragRef = useRef({
|
||||||
|
touchId: null as number | null,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
isActive: false,
|
||||||
|
isDragging: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSidebarTouchStart = useCallback((event: TouchEvent<HTMLElement>) => {
|
||||||
|
// Only handle when sidebar is open on mobile
|
||||||
|
if (!isMobileOpen) return;
|
||||||
|
if (closeDragRef.current.isActive) return;
|
||||||
|
|
||||||
|
const touch = event.touches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
closeDragRef.current = {
|
||||||
|
touchId: touch.identifier,
|
||||||
|
startX: touch.clientX,
|
||||||
|
startY: touch.clientY,
|
||||||
|
isActive: true,
|
||||||
|
isDragging: false
|
||||||
|
};
|
||||||
|
}, [isMobileOpen]);
|
||||||
|
|
||||||
|
const handleSidebarTouchMove = useCallback((event: TouchEvent<HTMLElement>) => {
|
||||||
|
const state = closeDragRef.current;
|
||||||
|
if (!state.isActive || state.touchId === null) return;
|
||||||
|
|
||||||
|
const touch = Array.from(event.touches).find(t => t.identifier === state.touchId);
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
const dx = touch.clientX - state.startX;
|
||||||
|
const dy = touch.clientY - state.startY;
|
||||||
|
|
||||||
|
// Check if we should start dragging
|
||||||
|
if (!state.isDragging) {
|
||||||
|
if (Math.abs(dx) < DRAG_START_DISTANCE && Math.abs(dy) < DRAG_START_DISTANCE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only start horizontal drags going left (negative dx)
|
||||||
|
if (dx >= 0 || Math.abs(dx) <= Math.abs(dy)) {
|
||||||
|
state.isActive = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.isDragging = true;
|
||||||
|
// Initialize progress to 1 (fully open) before setting dragging state
|
||||||
|
setSidebarDragProgress(1);
|
||||||
|
setIsSidebarDragging(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate progress (1 = fully open, 0 = closed)
|
||||||
|
// dx is negative when swiping left
|
||||||
|
const progress = Math.max(0, Math.min(1, 1 + dx / SIDEBAR_WIDTH));
|
||||||
|
setSidebarDragProgress(progress);
|
||||||
|
}, [setSidebarDragProgress, setIsSidebarDragging]);
|
||||||
|
|
||||||
|
const handleSidebarTouchEnd = useCallback((event: TouchEvent<HTMLElement>) => {
|
||||||
|
const state = closeDragRef.current;
|
||||||
|
if (!state.isActive || state.touchId === null) return;
|
||||||
|
|
||||||
|
const touch = Array.from(event.changedTouches).find(t => t.identifier === state.touchId);
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
const wasDragging = state.isDragging;
|
||||||
|
const dx = touch.clientX - state.startX;
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
closeDragRef.current = {
|
||||||
|
touchId: null,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
isActive: false,
|
||||||
|
isDragging: false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!wasDragging) return;
|
||||||
|
|
||||||
|
setIsSidebarDragging(false);
|
||||||
|
setSidebarDragProgress(0);
|
||||||
|
|
||||||
|
// Close sidebar if dragged past 30% threshold (dx is negative)
|
||||||
|
if (dx < -SIDEBAR_WIDTH * 0.3) {
|
||||||
|
closeMobileMenu();
|
||||||
|
}
|
||||||
|
}, [closeMobileMenu, setSidebarDragProgress, setIsSidebarDragging]);
|
||||||
|
|
||||||
|
const handleSidebarTouchCancel = useCallback(() => {
|
||||||
|
if (closeDragRef.current.isDragging) {
|
||||||
|
setIsSidebarDragging(false);
|
||||||
|
setSidebarDragProgress(0);
|
||||||
|
}
|
||||||
|
closeDragRef.current = {
|
||||||
|
touchId: null,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
isActive: false,
|
||||||
|
isDragging: false
|
||||||
|
};
|
||||||
|
}, [setSidebarDragProgress, setIsSidebarDragging]);
|
||||||
|
|
||||||
|
// Calculate sidebar transform for close gesture (when dragging to close)
|
||||||
|
const getSidebarCloseTransform = () => {
|
||||||
|
if (!isSidebarDragging || !isMobileOpen) return undefined;
|
||||||
|
// When closing, progress goes from 1 (open) to 0 (closed)
|
||||||
|
// Transform: 0% when open (progress=1), -110% when closed (progress=0)
|
||||||
|
const translatePercent = (1 - sidebarDragProgress) * -110;
|
||||||
|
return `translateX(${translatePercent}%)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sidebarTransform = isSidebarDragging
|
||||||
|
? (isMobileOpen ? getSidebarCloseTransform() : `translateX(calc(-110% + ${sidebarDragProgress * 110}%))`)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile overlay */}
|
{/* Mobile overlay */}
|
||||||
<div className={`sidebar-overlay ${isMobileOpen ? 'visible' : ''}`} onClick={closeMobileMenu} />
|
<div
|
||||||
|
className={`sidebar-overlay ${isMobileOpen ? 'visible' : ''} ${isSidebarDragging ? 'dragging' : ''}`}
|
||||||
|
onClick={closeDragRef.current.isDragging ? undefined : closeMobileMenu}
|
||||||
|
onTouchStart={handleSidebarTouchStart}
|
||||||
|
onTouchMove={handleSidebarTouchMove}
|
||||||
|
onTouchEnd={handleSidebarTouchEnd}
|
||||||
|
onTouchCancel={handleSidebarTouchCancel}
|
||||||
|
style={isSidebarDragging ? {
|
||||||
|
opacity: sidebarDragProgress,
|
||||||
|
visibility: sidebarDragProgress > 0 ? 'visible' : 'hidden',
|
||||||
|
transition: 'none'
|
||||||
|
} : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Sidebar Tooltip */}
|
{/* Sidebar Tooltip */}
|
||||||
{tooltip.visible && (
|
{tooltip.visible && (
|
||||||
@@ -186,11 +320,19 @@ export default function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={`sidebar ${isCollapsed && !isMobileOpen ? 'collapsed' : ''} ${isMobileOpen ? 'open' : ''} ${sidebarMode === 'dynamic' ? 'dynamic' : ''} ${isDynamicExpanded ? 'expanded-force' : ''} ${sidebarMode === 'toggle' ? 'clickable' : ''}`}
|
className={`sidebar ${isCollapsed && !isMobileOpen ? 'collapsed' : ''} ${isMobileOpen ? 'open' : ''} ${sidebarMode === 'dynamic' ? 'dynamic' : ''} ${isDynamicExpanded ? 'expanded-force' : ''} ${sidebarMode === 'toggle' ? 'clickable' : ''} ${isSidebarDragging ? 'dragging' : ''}`}
|
||||||
data-collapsed={isCollapsed}
|
data-collapsed={isCollapsed}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
onClick={handleSidebarClick}
|
onClick={handleSidebarClick}
|
||||||
|
onTouchStart={handleSidebarTouchStart}
|
||||||
|
onTouchMove={handleSidebarTouchMove}
|
||||||
|
onTouchEnd={handleSidebarTouchEnd}
|
||||||
|
onTouchCancel={handleSidebarTouchCancel}
|
||||||
|
style={isSidebarDragging ? {
|
||||||
|
transform: sidebarTransform,
|
||||||
|
transition: 'none'
|
||||||
|
} : undefined}
|
||||||
>
|
>
|
||||||
<div className="sidebar-header">
|
<div className="sidebar-header">
|
||||||
<div className="sidebar-header-content">
|
<div className="sidebar-header-content">
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ type SwipeTabsProps<T extends string | number> = {
|
|||||||
renderWindow?: number;
|
renderWindow?: number;
|
||||||
scrollToTopOnChange?: boolean;
|
scrollToTopOnChange?: boolean;
|
||||||
onSwipePastStart?: () => void;
|
onSwipePastStart?: () => void;
|
||||||
|
// For 1:1 sidebar gesture tracking
|
||||||
|
onSwipePastStartProgress?: (progress: number) => void;
|
||||||
|
onSwipePastStartDragStart?: () => void;
|
||||||
|
onSwipePastStartDragEnd?: (shouldOpen: boolean) => void;
|
||||||
|
sidebarWidth?: number; // Width of sidebar for progress calculation
|
||||||
};
|
};
|
||||||
|
|
||||||
const DRAG_START_DISTANCE = 3;
|
const DRAG_START_DISTANCE = 3;
|
||||||
@@ -59,7 +64,11 @@ export function SwipeTabs<T extends string | number>({
|
|||||||
threshold = DEFAULT_THRESHOLD,
|
threshold = DEFAULT_THRESHOLD,
|
||||||
renderWindow = DEFAULT_RENDER_WINDOW,
|
renderWindow = DEFAULT_RENDER_WINDOW,
|
||||||
scrollToTopOnChange = true,
|
scrollToTopOnChange = true,
|
||||||
onSwipePastStart
|
onSwipePastStart,
|
||||||
|
onSwipePastStartProgress,
|
||||||
|
onSwipePastStartDragStart,
|
||||||
|
onSwipePastStartDragEnd,
|
||||||
|
sidebarWidth = 280
|
||||||
}: SwipeTabsProps<T>) {
|
}: SwipeTabsProps<T>) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const trackRef = useRef<HTMLDivElement>(null);
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -271,6 +280,9 @@ export function SwipeTabs<T extends string | number>({
|
|||||||
[cancelAnimationForDrag, measureWidth, swipeDisabled, tabs.length]
|
[cancelAnimationForDrag, measureWidth, swipeDisabled, tabs.length]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sidebarDragStartedRef = useRef(false);
|
||||||
|
const sidebarDragProgressRef = useRef(0);
|
||||||
|
|
||||||
const updateDrag = useCallback(
|
const updateDrag = useCallback(
|
||||||
(x: number, y: number, preventDefault: () => void, pointerType?: string) => {
|
(x: number, y: number, preventDefault: () => void, pointerType?: string) => {
|
||||||
const state = dragRef.current;
|
const state = dragRef.current;
|
||||||
@@ -305,10 +317,27 @@ export function SwipeTabs<T extends string | number>({
|
|||||||
const atStart = state.startIndex <= 0;
|
const atStart = state.startIndex <= 0;
|
||||||
const atEnd = state.startIndex >= tabs.length - 1;
|
const atEnd = state.startIndex >= tabs.length - 1;
|
||||||
// Don't apply resistance at start if onSwipePastStart is defined (sidebar can open)
|
// Don't apply resistance at start if onSwipePastStart is defined (sidebar can open)
|
||||||
const hasVirtualTabAtStart = !!onSwipePastStart;
|
const hasVirtualTabAtStart = !!onSwipePastStart || !!onSwipePastStartProgress;
|
||||||
if ((atStart && dx > 0 && !hasVirtualTabAtStart) || (atEnd && dx < 0)) {
|
|
||||||
|
// Calculate sidebar drag progress when swiping right on first tab
|
||||||
|
const isSidebarSwipe = atStart && dx > 0 && hasVirtualTabAtStart;
|
||||||
|
if (isSidebarSwipe) {
|
||||||
|
// Notify drag start
|
||||||
|
if (!sidebarDragStartedRef.current) {
|
||||||
|
sidebarDragStartedRef.current = true;
|
||||||
|
onSwipePastStartDragStart?.();
|
||||||
|
}
|
||||||
|
// Calculate progress (0 to 1) based on sidebar width
|
||||||
|
const progress = Math.min(1, dx / sidebarWidth);
|
||||||
|
sidebarDragProgressRef.current = progress;
|
||||||
|
onSwipePastStartProgress?.(progress);
|
||||||
|
// Don't move the page - only the sidebar moves
|
||||||
|
offset = 0;
|
||||||
|
} else if ((atStart && dx > 0) || (atEnd && dx < 0)) {
|
||||||
|
// Apply resistance at edges when not doing sidebar swipe
|
||||||
offset = dx * EDGE_RESISTANCE;
|
offset = dx * EDGE_RESISTANCE;
|
||||||
}
|
}
|
||||||
|
|
||||||
offset = Math.max(Math.min(offset, width), -width);
|
offset = Math.max(Math.min(offset, width), -width);
|
||||||
dragOffsetRef.current = offset;
|
dragOffsetRef.current = offset;
|
||||||
scheduleDragUpdate();
|
scheduleDragUpdate();
|
||||||
@@ -317,7 +346,7 @@ export function SwipeTabs<T extends string | number>({
|
|||||||
preventDefault();
|
preventDefault();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onSwipePastStart, scheduleDragUpdate, tabs.length]
|
[onSwipePastStart, onSwipePastStartProgress, onSwipePastStartDragStart, scheduleDragUpdate, sidebarWidth, tabs.length]
|
||||||
);
|
);
|
||||||
|
|
||||||
const finishDrag = useCallback(
|
const finishDrag = useCallback(
|
||||||
@@ -334,6 +363,12 @@ export function SwipeTabs<T extends string | number>({
|
|||||||
isDraggingRef.current = false;
|
isDraggingRef.current = false;
|
||||||
setDraggingClass(false);
|
setDraggingClass(false);
|
||||||
|
|
||||||
|
// Handle sidebar drag end
|
||||||
|
const wasSidebarDragging = sidebarDragStartedRef.current;
|
||||||
|
const sidebarProgress = sidebarDragProgressRef.current;
|
||||||
|
sidebarDragStartedRef.current = false;
|
||||||
|
sidebarDragProgressRef.current = 0;
|
||||||
|
|
||||||
if (!wasDragging) return;
|
if (!wasDragging) return;
|
||||||
|
|
||||||
if (rafRef.current) {
|
if (rafRef.current) {
|
||||||
@@ -346,12 +381,23 @@ export function SwipeTabs<T extends string | number>({
|
|||||||
const thresholdPx = width * threshold;
|
const thresholdPx = width * threshold;
|
||||||
let targetIndex = state.startIndex;
|
let targetIndex = state.startIndex;
|
||||||
|
|
||||||
if (!cancelled && Math.abs(dx) > thresholdPx) {
|
// Check if this was a sidebar gesture (using the flag, not dx which is 0 for sidebar swipes)
|
||||||
|
const shouldOpenSidebar = !cancelled && sidebarProgress > 0.3; // 30% threshold
|
||||||
|
|
||||||
|
if (wasSidebarDragging) {
|
||||||
|
// Call the new drag end callback with whether to open
|
||||||
|
onSwipePastStartDragEnd?.(shouldOpenSidebar);
|
||||||
|
|
||||||
|
// Also call legacy callback if sidebar should open
|
||||||
|
if (shouldOpenSidebar && onSwipePastStart) {
|
||||||
|
onSwipePastStart();
|
||||||
|
}
|
||||||
|
} else if (!cancelled && Math.abs(dx) > thresholdPx) {
|
||||||
const direction = dx < 0 ? 1 : -1;
|
const direction = dx < 0 ? 1 : -1;
|
||||||
targetIndex = Math.min(Math.max(state.startIndex + direction, 0), tabs.length - 1);
|
targetIndex = Math.min(Math.max(state.startIndex + direction, 0), tabs.length - 1);
|
||||||
|
|
||||||
// If swiping right on first tab, call onSwipePastStart
|
// If swiping right on first tab without new callbacks, call onSwipePastStart
|
||||||
if (state.startIndex === 0 && dx > 0 && onSwipePastStart) {
|
if (state.startIndex === 0 && dx > 0 && onSwipePastStart && !onSwipePastStartDragEnd) {
|
||||||
onSwipePastStart();
|
onSwipePastStart();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,7 +418,7 @@ export function SwipeTabs<T extends string | number>({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeTab, applyTransform, onTabChange, onSwipePastStart, setDraggingClass, tabs, threshold]
|
[activeTab, applyTransform, onTabChange, onSwipePastStart, onSwipePastStartDragEnd, setDraggingClass, sidebarWidth, tabs, threshold]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
|||||||
164
frontend/src/components/SwipeableContent.tsx
Normal file
164
frontend/src/components/SwipeableContent.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useCallback, useRef, type ReactNode, type TouchEvent } from 'react';
|
||||||
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
|
|
||||||
|
interface SwipeableContentProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
/** Sidebar width for progress calculation (default: 280px) */
|
||||||
|
sidebarWidth?: number;
|
||||||
|
/** Render as main element instead of div */
|
||||||
|
as?: 'main' | 'div';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAG_START_DISTANCE = 10;
|
||||||
|
|
||||||
|
// Elements that should block the swipe gesture
|
||||||
|
const shouldIgnoreSwipe = (target: EventTarget | null): boolean => {
|
||||||
|
if (!target) return false;
|
||||||
|
const element = target as Element;
|
||||||
|
if (typeof element.closest !== 'function') return false;
|
||||||
|
|
||||||
|
// Ignore swipe on interactive/scrollable elements
|
||||||
|
if (element.closest(
|
||||||
|
'[data-swipe-ignore="true"], .swipe-tabs, [data-radix-scroll-area-viewport], ' +
|
||||||
|
'[style*="overflow: auto"], [style*="overflow-x: auto"], [style*="overflow: scroll"], ' +
|
||||||
|
'[style*="overflow-x: scroll"], input[type="range"], .slider, .carousel'
|
||||||
|
)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SwipeableContent({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
sidebarWidth = 280,
|
||||||
|
as: Component = 'main'
|
||||||
|
}: SwipeableContentProps) {
|
||||||
|
const {
|
||||||
|
isMobileOpen,
|
||||||
|
openMobileMenu,
|
||||||
|
setSidebarDragProgress,
|
||||||
|
setIsSidebarDragging
|
||||||
|
} = useSidebar();
|
||||||
|
|
||||||
|
const dragRef = useRef({
|
||||||
|
touchId: null as number | null,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
isActive: false,
|
||||||
|
isDragging: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback((event: TouchEvent<HTMLDivElement>) => {
|
||||||
|
// Don't handle if sidebar is already open
|
||||||
|
if (isMobileOpen) return;
|
||||||
|
if (dragRef.current.isActive) return;
|
||||||
|
|
||||||
|
// Don't handle if touch is on an element that should block swipe
|
||||||
|
if (shouldIgnoreSwipe(event.target)) return;
|
||||||
|
|
||||||
|
const touch = event.touches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
dragRef.current = {
|
||||||
|
touchId: touch.identifier,
|
||||||
|
startX: touch.clientX,
|
||||||
|
startY: touch.clientY,
|
||||||
|
isActive: true,
|
||||||
|
isDragging: false
|
||||||
|
};
|
||||||
|
}, [isMobileOpen]);
|
||||||
|
|
||||||
|
const handleTouchMove = useCallback((event: TouchEvent<HTMLDivElement>) => {
|
||||||
|
const state = dragRef.current;
|
||||||
|
if (!state.isActive || state.touchId === null) return;
|
||||||
|
|
||||||
|
const touch = Array.from(event.touches).find(t => t.identifier === state.touchId);
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
const dx = touch.clientX - state.startX;
|
||||||
|
const dy = touch.clientY - state.startY;
|
||||||
|
|
||||||
|
// Check if we should start dragging
|
||||||
|
if (!state.isDragging) {
|
||||||
|
if (Math.abs(dx) < DRAG_START_DISTANCE && Math.abs(dy) < DRAG_START_DISTANCE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only start horizontal drags going right
|
||||||
|
if (dx <= 0 || Math.abs(dx) <= Math.abs(dy)) {
|
||||||
|
state.isActive = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.isDragging = true;
|
||||||
|
setIsSidebarDragging(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate progress (0 to 1)
|
||||||
|
const progress = Math.min(1, Math.max(0, dx / sidebarWidth));
|
||||||
|
setSidebarDragProgress(progress);
|
||||||
|
|
||||||
|
// Prevent scrolling while dragging
|
||||||
|
if (event.cancelable) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}, [sidebarWidth, setSidebarDragProgress, setIsSidebarDragging]);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback((event: TouchEvent<HTMLDivElement>) => {
|
||||||
|
const state = dragRef.current;
|
||||||
|
if (!state.isActive || state.touchId === null) return;
|
||||||
|
|
||||||
|
const touch = Array.from(event.changedTouches).find(t => t.identifier === state.touchId);
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
const wasDragging = state.isDragging;
|
||||||
|
const dx = touch.clientX - state.startX;
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
dragRef.current = {
|
||||||
|
touchId: null,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
isActive: false,
|
||||||
|
isDragging: false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!wasDragging) return;
|
||||||
|
|
||||||
|
setIsSidebarDragging(false);
|
||||||
|
setSidebarDragProgress(0);
|
||||||
|
|
||||||
|
// Open sidebar if dragged past 30% threshold
|
||||||
|
if (dx > sidebarWidth * 0.3) {
|
||||||
|
openMobileMenu();
|
||||||
|
}
|
||||||
|
}, [sidebarWidth, openMobileMenu, setSidebarDragProgress, setIsSidebarDragging]);
|
||||||
|
|
||||||
|
const handleTouchCancel = useCallback(() => {
|
||||||
|
if (dragRef.current.isDragging) {
|
||||||
|
setIsSidebarDragging(false);
|
||||||
|
setSidebarDragProgress(0);
|
||||||
|
}
|
||||||
|
dragRef.current = {
|
||||||
|
touchId: null,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
isActive: false,
|
||||||
|
isDragging: false
|
||||||
|
};
|
||||||
|
}, [setSidebarDragProgress, setIsSidebarDragging]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={className}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onTouchCancel={handleTouchCancel}
|
||||||
|
style={{ touchAction: 'pan-y' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,11 @@ interface SidebarContextType {
|
|||||||
setIsHovered: (isHovered: boolean) => void;
|
setIsHovered: (isHovered: boolean) => void;
|
||||||
showLogo: boolean;
|
showLogo: boolean;
|
||||||
setShowLogo: (show: boolean) => void;
|
setShowLogo: (show: boolean) => void;
|
||||||
|
// Mobile sidebar drag state for 1:1 gesture tracking
|
||||||
|
sidebarDragProgress: number; // 0 = closed, 1 = fully open
|
||||||
|
setSidebarDragProgress: (progress: number) => void;
|
||||||
|
isSidebarDragging: boolean;
|
||||||
|
setIsSidebarDragging: (dragging: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
||||||
@@ -45,6 +50,8 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
|
|||||||
const [sidebarMode, setSidebarModeState] = useState<SidebarMode>('toggle');
|
const [sidebarMode, setSidebarModeState] = useState<SidebarMode>('toggle');
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [showLogo, setShowLogo] = useState(false);
|
const [showLogo, setShowLogo] = useState(false);
|
||||||
|
const [sidebarDragProgress, setSidebarDragProgress] = useState(0);
|
||||||
|
const [isSidebarDragging, setIsSidebarDragging] = useState(false);
|
||||||
|
|
||||||
// Load sidebar mode from backend
|
// Load sidebar mode from backend
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -140,6 +147,10 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsHovered: (value: boolean) => setIsHovered(value),
|
setIsHovered: (value: boolean) => setIsHovered(value),
|
||||||
showLogo,
|
showLogo,
|
||||||
setShowLogo,
|
setShowLogo,
|
||||||
|
sidebarDragProgress,
|
||||||
|
setSidebarDragProgress,
|
||||||
|
isSidebarDragging,
|
||||||
|
setIsSidebarDragging,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
import TabsScroller from '../components/TabsScroller';
|
import TabsScroller from '../components/TabsScroller';
|
||||||
|
import { SwipeableContent } from '../components/SwipeableContent';
|
||||||
import { apiKeysAPI } from '../api/client';
|
import { apiKeysAPI } from '../api/client';
|
||||||
import type { ApiKeyItem } from '../api/client';
|
import type { ApiKeyItem } from '../api/client';
|
||||||
import '../styles/APIKeys.css';
|
import '../styles/APIKeys.css';
|
||||||
@@ -88,7 +89,7 @@ export default function APIKeys() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="main-content api-keys-root">
|
<SwipeableContent className="main-content api-keys-root">
|
||||||
<div className="page-tabs-container">
|
<div className="page-tabs-container">
|
||||||
<TabsScroller className="page-tabs-slider">
|
<TabsScroller className="page-tabs-slider">
|
||||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
@@ -183,6 +184,6 @@ export default function APIKeys() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</SwipeableContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
@@ -13,7 +13,23 @@ type TabId = 'general' | 'users';
|
|||||||
export default function AdminPanel({ initialTab = 'general' }: { initialTab?: TabId } = {}) {
|
export default function AdminPanel({ initialTab = 'general' }: { initialTab?: TabId } = {}) {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toggleMobileMenu, openMobileMenu } = useSidebar();
|
const { toggleMobileMenu, openMobileMenu, setSidebarDragProgress, setIsSidebarDragging } = useSidebar();
|
||||||
|
|
||||||
|
const handleSidebarDragStart = useCallback(() => {
|
||||||
|
setIsSidebarDragging(true);
|
||||||
|
}, [setIsSidebarDragging]);
|
||||||
|
|
||||||
|
const handleSidebarDragProgress = useCallback((progress: number) => {
|
||||||
|
setSidebarDragProgress(progress);
|
||||||
|
}, [setSidebarDragProgress]);
|
||||||
|
|
||||||
|
const handleSidebarDragEnd = useCallback((shouldOpen: boolean) => {
|
||||||
|
setIsSidebarDragging(false);
|
||||||
|
setSidebarDragProgress(0);
|
||||||
|
if (shouldOpen) {
|
||||||
|
openMobileMenu();
|
||||||
|
}
|
||||||
|
}, [openMobileMenu, setIsSidebarDragging, setSidebarDragProgress]);
|
||||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
|
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
|
||||||
const tabs: TabId[] = ['general', 'users'];
|
const tabs: TabId[] = ['general', 'users'];
|
||||||
const renderPanel = (tab: TabId) => (tab === 'general' ? <GeneralTab /> : <UsersTab />);
|
const renderPanel = (tab: TabId) => (tab === 'general' ? <GeneralTab /> : <UsersTab />);
|
||||||
@@ -57,7 +73,9 @@ export default function AdminPanel({ initialTab = 'general' }: { initialTab?: Ta
|
|||||||
onTabChange={setActiveTab}
|
onTabChange={setActiveTab}
|
||||||
renderPanel={renderPanel}
|
renderPanel={renderPanel}
|
||||||
panelClassName="admin-tab-content swipe-panel-content"
|
panelClassName="admin-tab-content swipe-panel-content"
|
||||||
onSwipePastStart={openMobileMenu}
|
onSwipePastStartDragStart={handleSidebarDragStart}
|
||||||
|
onSwipePastStartProgress={handleSidebarDragProgress}
|
||||||
|
onSwipePastStartDragEnd={handleSidebarDragEnd}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
import TabsScroller from '../components/TabsScroller';
|
import TabsScroller from '../components/TabsScroller';
|
||||||
|
import { SwipeableContent } from '../components/SwipeableContent';
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toggleMobileMenu } = useSidebar();
|
const { toggleMobileMenu } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="main-content">
|
<SwipeableContent className="main-content">
|
||||||
<div className="page-tabs-container">
|
<div className="page-tabs-container">
|
||||||
<TabsScroller className="page-tabs-slider">
|
<TabsScroller className="page-tabs-slider">
|
||||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
@@ -22,6 +23,6 @@ export default function Dashboard() {
|
|||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
{/* Dashboard content */}
|
{/* Dashboard content */}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</SwipeableContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
import TabsScroller from '../components/TabsScroller';
|
import TabsScroller from '../components/TabsScroller';
|
||||||
|
import { SwipeableContent } from '../components/SwipeableContent';
|
||||||
import '../styles/AdminPanel.css';
|
import '../styles/AdminPanel.css';
|
||||||
|
|
||||||
export default function Feature1() {
|
export default function Feature1() {
|
||||||
@@ -8,7 +9,7 @@ export default function Feature1() {
|
|||||||
const { toggleMobileMenu } = useSidebar();
|
const { toggleMobileMenu } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="main-content">
|
<SwipeableContent className="main-content">
|
||||||
<div className="page-tabs-container">
|
<div className="page-tabs-container">
|
||||||
<TabsScroller className="page-tabs-slider">
|
<TabsScroller className="page-tabs-slider">
|
||||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
@@ -30,6 +31,6 @@ export default function Feature1() {
|
|||||||
<p>{t.feature1.comingSoon}</p>
|
<p>{t.feature1.comingSoon}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</SwipeableContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
import TabsScroller from '../components/TabsScroller';
|
import TabsScroller from '../components/TabsScroller';
|
||||||
|
import { SwipeableContent } from '../components/SwipeableContent';
|
||||||
import '../styles/AdminPanel.css';
|
import '../styles/AdminPanel.css';
|
||||||
|
|
||||||
export default function Feature2() {
|
export default function Feature2() {
|
||||||
@@ -8,7 +9,7 @@ export default function Feature2() {
|
|||||||
const { toggleMobileMenu } = useSidebar();
|
const { toggleMobileMenu } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="main-content">
|
<SwipeableContent className="main-content">
|
||||||
<div className="page-tabs-container">
|
<div className="page-tabs-container">
|
||||||
<TabsScroller className="page-tabs-slider">
|
<TabsScroller className="page-tabs-slider">
|
||||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
@@ -30,6 +31,6 @@ export default function Feature2() {
|
|||||||
<p>{t.features.comingSoon}</p>
|
<p>{t.features.comingSoon}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</SwipeableContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
import TabsScroller from '../components/TabsScroller';
|
import TabsScroller from '../components/TabsScroller';
|
||||||
|
import { SwipeableContent } from '../components/SwipeableContent';
|
||||||
import '../styles/AdminPanel.css';
|
import '../styles/AdminPanel.css';
|
||||||
|
|
||||||
export default function Feature3() {
|
export default function Feature3() {
|
||||||
@@ -8,7 +9,7 @@ export default function Feature3() {
|
|||||||
const { toggleMobileMenu } = useSidebar();
|
const { toggleMobileMenu } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="main-content">
|
<SwipeableContent className="main-content">
|
||||||
<div className="page-tabs-container">
|
<div className="page-tabs-container">
|
||||||
<TabsScroller className="page-tabs-slider">
|
<TabsScroller className="page-tabs-slider">
|
||||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
@@ -30,6 +31,6 @@ export default function Feature3() {
|
|||||||
<p>{t.features.comingSoon}</p>
|
<p>{t.features.comingSoon}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</SwipeableContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
import TabsScroller from '../components/TabsScroller';
|
import TabsScroller from '../components/TabsScroller';
|
||||||
|
import { SwipeableContent } from '../components/SwipeableContent';
|
||||||
import { useNotifications } from '../contexts/NotificationsContext';
|
import { useNotifications } from '../contexts/NotificationsContext';
|
||||||
import { notificationsAPI } from '../api/client';
|
import { notificationsAPI } from '../api/client';
|
||||||
import type { NotificationItem } from '../api/client';
|
import type { NotificationItem } from '../api/client';
|
||||||
@@ -94,7 +95,7 @@ export default function Notifications() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="main-content notifications-root">
|
<SwipeableContent className="main-content notifications-root">
|
||||||
<div className="page-tabs-container">
|
<div className="page-tabs-container">
|
||||||
<TabsScroller className="page-tabs-slider">
|
<TabsScroller className="page-tabs-slider">
|
||||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
@@ -161,6 +162,6 @@ export default function Notifications() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</SwipeableContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
import TabsScroller from '../components/TabsScroller';
|
import TabsScroller from '../components/TabsScroller';
|
||||||
|
import { SwipeableContent } from '../components/SwipeableContent';
|
||||||
import '../styles/Search.css';
|
import '../styles/Search.css';
|
||||||
|
|
||||||
type SearchResult = {
|
type SearchResult = {
|
||||||
@@ -76,7 +77,7 @@ export default function Search() {
|
|||||||
: t.searchPage.hint;
|
: t.searchPage.hint;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="main-content search-root">
|
<SwipeableContent className="main-content search-root">
|
||||||
<div className="page-tabs-container">
|
<div className="page-tabs-container">
|
||||||
<TabsScroller className="page-tabs-slider">
|
<TabsScroller className="page-tabs-slider">
|
||||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
@@ -136,6 +137,6 @@ export default function Search() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</SwipeableContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
import TabsScroller from '../components/TabsScroller';
|
import TabsScroller from '../components/TabsScroller';
|
||||||
|
import { SwipeableContent } from '../components/SwipeableContent';
|
||||||
import { sessionsAPI, twoFactorAPI } from '../api/client';
|
import { sessionsAPI, twoFactorAPI } from '../api/client';
|
||||||
import type { UserSession } from '../api/client';
|
import type { UserSession } from '../api/client';
|
||||||
import '../styles/SettingsPage.css';
|
import '../styles/SettingsPage.css';
|
||||||
@@ -151,7 +152,7 @@ export default function Settings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="main-content settings-page-root">
|
<SwipeableContent className="main-content settings-page-root">
|
||||||
<div className="page-tabs-container">
|
<div className="page-tabs-container">
|
||||||
<TabsScroller className="page-tabs-slider">
|
<TabsScroller className="page-tabs-slider">
|
||||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
@@ -399,6 +400,6 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</SwipeableContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import Sidebar from '../components/Sidebar';
|
import Sidebar from '../components/Sidebar';
|
||||||
import TabsScroller from '../components/TabsScroller';
|
import TabsScroller from '../components/TabsScroller';
|
||||||
|
import { SwipeableContent } from '../components/SwipeableContent';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useTranslation } from '../contexts/LanguageContext';
|
import { useTranslation } from '../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../contexts/SidebarContext';
|
import { useSidebar } from '../contexts/SidebarContext';
|
||||||
@@ -180,7 +181,7 @@ export default function Users() {
|
|||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="main-content users-root">
|
<SwipeableContent className="main-content users-root">
|
||||||
<div className="page-tabs-container">
|
<div className="page-tabs-container">
|
||||||
<TabsScroller className="page-tabs-slider">
|
<TabsScroller className="page-tabs-slider">
|
||||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
@@ -297,7 +298,7 @@ export default function Users() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</SwipeableContent>
|
||||||
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="users-modal-backdrop" onClick={closeModal}>
|
<div className="users-modal-backdrop" onClick={closeModal}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useTranslation } from '../../contexts/LanguageContext';
|
import { useTranslation } from '../../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../../contexts/SidebarContext';
|
import { useSidebar } from '../../contexts/SidebarContext';
|
||||||
import TabsScroller from '../../components/TabsScroller';
|
import TabsScroller from '../../components/TabsScroller';
|
||||||
|
import { SwipeableContent } from '../../components/SwipeableContent';
|
||||||
import { analyticsAPI } from '../../api/client';
|
import { analyticsAPI } from '../../api/client';
|
||||||
import type { AnalyticsOverview } from '../../api/client';
|
import type { AnalyticsOverview } from '../../api/client';
|
||||||
import '../../styles/AdminAnalytics.css';
|
import '../../styles/AdminAnalytics.css';
|
||||||
@@ -45,7 +46,7 @@ export default function Analytics() {
|
|||||||
const maxActionCount = useMemo(() => Math.max(1, ...actions.map((a) => a.count)), [actions]);
|
const maxActionCount = useMemo(() => Math.max(1, ...actions.map((a) => a.count)), [actions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="main-content admin-analytics-root">
|
<SwipeableContent className="main-content admin-analytics-root">
|
||||||
<div className="page-tabs-container">
|
<div className="page-tabs-container">
|
||||||
<TabsScroller className="page-tabs-slider">
|
<TabsScroller className="page-tabs-slider">
|
||||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
@@ -149,6 +150,6 @@ export default function Analytics() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</SwipeableContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useTranslation } from '../../contexts/LanguageContext';
|
import { useTranslation } from '../../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../../contexts/SidebarContext';
|
import { useSidebar } from '../../contexts/SidebarContext';
|
||||||
import TabsScroller from '../../components/TabsScroller';
|
import TabsScroller from '../../components/TabsScroller';
|
||||||
|
import { SwipeableContent } from '../../components/SwipeableContent';
|
||||||
import { auditAPI } from '../../api/client';
|
import { auditAPI } from '../../api/client';
|
||||||
import type { AuditLogItem } from '../../api/client';
|
import type { AuditLogItem } from '../../api/client';
|
||||||
import '../../styles/AdminAudit.css';
|
import '../../styles/AdminAudit.css';
|
||||||
@@ -58,7 +59,7 @@ export default function AuditLogs() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="main-content admin-audit-root">
|
<SwipeableContent className="main-content admin-audit-root">
|
||||||
<div className="page-tabs-container">
|
<div className="page-tabs-container">
|
||||||
<TabsScroller className="page-tabs-slider">
|
<TabsScroller className="page-tabs-slider">
|
||||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
@@ -161,6 +162,6 @@ export default function AuditLogs() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</SwipeableContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,23 @@ const findTouchById = (touches: TouchList, id: number) => {
|
|||||||
export default function Features() {
|
export default function Features() {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toggleMobileMenu, openMobileMenu } = useSidebar();
|
const { toggleMobileMenu, openMobileMenu, setSidebarDragProgress, setIsSidebarDragging } = useSidebar();
|
||||||
|
|
||||||
|
const handleSidebarDragStart = useCallback(() => {
|
||||||
|
setIsSidebarDragging(true);
|
||||||
|
}, [setIsSidebarDragging]);
|
||||||
|
|
||||||
|
const handleSidebarDragProgress = useCallback((progress: number) => {
|
||||||
|
setSidebarDragProgress(progress);
|
||||||
|
}, [setSidebarDragProgress]);
|
||||||
|
|
||||||
|
const handleSidebarDragEnd = useCallback((shouldOpen: boolean) => {
|
||||||
|
setIsSidebarDragging(false);
|
||||||
|
setSidebarDragProgress(0);
|
||||||
|
if (shouldOpen) {
|
||||||
|
openMobileMenu();
|
||||||
|
}
|
||||||
|
}, [openMobileMenu, setIsSidebarDragging, setSidebarDragProgress]);
|
||||||
const { moduleStates, moduleOrder, setModuleEnabled, setModulePosition, setModuleOrder, saveModulesToBackend, saveModuleOrder, hasInitialized, isLoading } = useModules();
|
const { moduleStates, moduleOrder, setModuleEnabled, setModulePosition, setModuleOrder, saveModulesToBackend, saveModuleOrder, hasInitialized, isLoading } = useModules();
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('config');
|
const [activeTab, setActiveTab] = useState<TabId>('config');
|
||||||
const hasUserMadeChanges = useRef(false);
|
const hasUserMadeChanges = useRef(false);
|
||||||
@@ -806,7 +822,9 @@ export default function Features() {
|
|||||||
onTabChange={handleTabChange}
|
onTabChange={handleTabChange}
|
||||||
renderPanel={renderTabContent}
|
renderPanel={renderTabContent}
|
||||||
panelClassName="admin-tab-content swipe-panel-content"
|
panelClassName="admin-tab-content swipe-panel-content"
|
||||||
onSwipePastStart={openMobileMenu}
|
onSwipePastStartDragStart={handleSidebarDragStart}
|
||||||
|
onSwipePastStartProgress={handleSidebarDragProgress}
|
||||||
|
onSwipePastStartDragEnd={handleSidebarDragEnd}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from '../../contexts/LanguageContext';
|
|||||||
import { useSidebar } from '../../contexts/SidebarContext';
|
import { useSidebar } from '../../contexts/SidebarContext';
|
||||||
import Sidebar from '../../components/Sidebar';
|
import Sidebar from '../../components/Sidebar';
|
||||||
import TabsScroller from '../../components/TabsScroller';
|
import TabsScroller from '../../components/TabsScroller';
|
||||||
|
import { SwipeableContent } from '../../components/SwipeableContent';
|
||||||
import { settingsAPI } from '../../api/client';
|
import { settingsAPI } from '../../api/client';
|
||||||
import '../../styles/Settings.css';
|
import '../../styles/Settings.css';
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ export default function Settings() {
|
|||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="main-content settings-root">
|
<SwipeableContent className="main-content settings-root">
|
||||||
<div className="page-tabs-container">
|
<div className="page-tabs-container">
|
||||||
<TabsScroller className="page-tabs-slider">
|
<TabsScroller className="page-tabs-slider">
|
||||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
@@ -110,7 +111,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</SwipeableContent>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useAuth } from '../../contexts/AuthContext';
|
|||||||
import { useTranslation } from '../../contexts/LanguageContext';
|
import { useTranslation } from '../../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../../contexts/SidebarContext';
|
import { useSidebar } from '../../contexts/SidebarContext';
|
||||||
import TabsScroller from '../../components/TabsScroller';
|
import TabsScroller from '../../components/TabsScroller';
|
||||||
|
import { SwipeableContent } from '../../components/SwipeableContent';
|
||||||
import '../../styles/AdminPanel.css';
|
import '../../styles/AdminPanel.css';
|
||||||
|
|
||||||
export default function Sources() {
|
export default function Sources() {
|
||||||
@@ -14,7 +15,7 @@ export default function Sources() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="main-content admin-panel-root">
|
<SwipeableContent className="main-content admin-panel-root">
|
||||||
<div className="admin-tabs-container">
|
<div className="admin-tabs-container">
|
||||||
<TabsScroller className="admin-tabs-slider">
|
<TabsScroller className="admin-tabs-slider">
|
||||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||||
@@ -35,6 +36,6 @@ export default function Sources() {
|
|||||||
<p>{t.sourcesPage.comingSoon}</p>
|
<p>{t.sourcesPage.comingSoon}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</SwipeableContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { useTheme, COLOR_PALETTES } from '../../contexts/ThemeContext';
|
import { useTheme, COLOR_PALETTES } from '../../contexts/ThemeContext';
|
||||||
import type { AccentColor, BorderRadius, SidebarStyle, Density, FontFamily, ColorPalette, TabBarPosition } from '../../contexts/ThemeContext';
|
import type { AccentColor, BorderRadius, SidebarStyle, Density, FontFamily, ColorPalette, TabBarPosition } from '../../contexts/ThemeContext';
|
||||||
import { useTranslation } from '../../contexts/LanguageContext';
|
import { useTranslation } from '../../contexts/LanguageContext';
|
||||||
@@ -45,8 +45,24 @@ export default function ThemeSettings() {
|
|||||||
} = useTheme();
|
} = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { toggleMobileMenu, openMobileMenu, sidebarMode, setSidebarMode } = useSidebar();
|
const { toggleMobileMenu, openMobileMenu, sidebarMode, setSidebarMode, setSidebarDragProgress, setIsSidebarDragging } = useSidebar();
|
||||||
const isAdmin = user?.is_superuser || false;
|
const isAdmin = user?.is_superuser || false;
|
||||||
|
|
||||||
|
const handleSidebarDragStart = useCallback(() => {
|
||||||
|
setIsSidebarDragging(true);
|
||||||
|
}, [setIsSidebarDragging]);
|
||||||
|
|
||||||
|
const handleSidebarDragProgress = useCallback((progress: number) => {
|
||||||
|
setSidebarDragProgress(progress);
|
||||||
|
}, [setSidebarDragProgress]);
|
||||||
|
|
||||||
|
const handleSidebarDragEnd = useCallback((shouldOpen: boolean) => {
|
||||||
|
setIsSidebarDragging(false);
|
||||||
|
setSidebarDragProgress(0);
|
||||||
|
if (shouldOpen) {
|
||||||
|
openMobileMenu();
|
||||||
|
}
|
||||||
|
}, [openMobileMenu, setIsSidebarDragging, setSidebarDragProgress]);
|
||||||
const tabs: ThemeTab[] = isAdmin ? ['colors', 'appearance', 'preview', 'advanced'] : ['colors', 'appearance', 'preview'];
|
const tabs: ThemeTab[] = isAdmin ? ['colors', 'appearance', 'preview', 'advanced'] : ['colors', 'appearance', 'preview'];
|
||||||
const [tooltip, setTooltip] = useState<{ text: string; left: number; visible: boolean }>({
|
const [tooltip, setTooltip] = useState<{ text: string; left: number; visible: boolean }>({
|
||||||
text: '',
|
text: '',
|
||||||
@@ -820,7 +836,9 @@ export default function ThemeSettings() {
|
|||||||
onTabChange={setActiveTab}
|
onTabChange={setActiveTab}
|
||||||
renderPanel={renderPanel}
|
renderPanel={renderPanel}
|
||||||
panelClassName="admin-tab-content swipe-panel-content"
|
panelClassName="admin-tab-content swipe-panel-content"
|
||||||
onSwipePastStart={openMobileMenu}
|
onSwipePastStartDragStart={handleSidebarDragStart}
|
||||||
|
onSwipePastStartProgress={handleSidebarDragProgress}
|
||||||
|
onSwipePastStartDragEnd={handleSidebarDragEnd}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Color Picker Popup */}
|
{/* Color Picker Popup */}
|
||||||
|
|||||||
@@ -887,22 +887,6 @@ label,
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide title section when bar is at bottom (only on pages with tabs) */
|
|
||||||
[data-tab-position='bottom'] .page-tabs-slider[data-has-tabs='true'] .page-title-section,
|
|
||||||
[data-tab-position='bottom'] .page-tabs-slider[data-has-tabs='true'] .admin-title-section,
|
|
||||||
[data-tab-position='bottom'] .admin-tabs-slider[data-has-tabs='true'] .page-title-section,
|
|
||||||
[data-tab-position='bottom'] .admin-tabs-slider[data-has-tabs='true'] .admin-title-section {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide divider when bar is at bottom */
|
|
||||||
[data-tab-position='bottom'] .page-tabs-slider .page-tabs-divider,
|
|
||||||
[data-tab-position='bottom'] .page-tabs-slider .admin-tabs-divider,
|
|
||||||
[data-tab-position='bottom'] .admin-tabs-slider .page-tabs-divider,
|
|
||||||
[data-tab-position='bottom'] .admin-tabs-slider .admin-tabs-divider {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add bottom padding and remove top padding when bar is at bottom */
|
/* Add bottom padding and remove top padding when bar is at bottom */
|
||||||
[data-tab-position='bottom'] .main-content {
|
[data-tab-position='bottom'] .main-content {
|
||||||
padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
|
padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
|
||||||
@@ -926,7 +910,13 @@ label,
|
|||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep proper spacing for mobile menu button - inherits padding-left: 72px from mobile rules */
|
/* Hide divider on mobile when bar is at bottom */
|
||||||
|
[data-tab-position='bottom'] .page-tabs-slider .page-tabs-divider,
|
||||||
|
[data-tab-position='bottom'] .page-tabs-slider .admin-tabs-divider,
|
||||||
|
[data-tab-position='bottom'] .admin-tabs-slider .page-tabs-divider,
|
||||||
|
[data-tab-position='bottom'] .admin-tabs-slider .admin-tabs-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive position - top on desktop, bottom on mobile */
|
/* Responsive position - top on desktop, bottom on mobile */
|
||||||
|
|||||||
@@ -678,6 +678,16 @@ button.nav-item {
|
|||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* During drag, disable transitions for 1:1 gesture tracking */
|
||||||
|
.sidebar.dragging {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay.dragging {
|
||||||
|
transition: none !important;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reset ALL collapsed styles on mobile - sidebar should always be fully expanded when open */
|
/* Reset ALL collapsed styles on mobile - sidebar should always be fully expanded when open */
|
||||||
.sidebar.collapsed {
|
.sidebar.collapsed {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
|
|||||||
Reference in New Issue
Block a user