From fe7c18e6c6893f4b980e02808f63b9b52c0e4fb9 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sat, 18 Apr 2026 22:12:32 +0300 Subject: [PATCH] feat: add swipe functionality for sidebar in mobile --- ui/app/layouts/default.vue | 144 ++++++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 1 deletion(-) diff --git a/ui/app/layouts/default.vue b/ui/app/layouts/default.vue index c7e7fd2f..744071a0 100644 --- a/ui/app/layouts/default.vue +++ b/ui/app/layouts/default.vue @@ -64,6 +64,7 @@ :ui="{ base: 'relative flex min-h-full overflow-visible' }" > import type { NavigationMenuItem } from '@nuxt/ui'; -import { ref, onMounted, readonly } from 'vue'; +import { ref, onBeforeUnmount, onMounted, readonly } from 'vue'; import { useStorage } from '@vueuse/core'; import moment from 'moment'; +import { useMediaQuery } from '~/composables/useMediaQuery'; import type { toastPosition } from '~/composables/useNotification'; import type { YTDLPOption } from '~/types/ytdlp'; import { useDialog } from '~/composables/useDialog'; @@ -510,6 +512,10 @@ type SidebarSection = { }; type ColorModePreference = 'system' | 'light' | 'dark'; +type SwipeMode = 'open' | 'close'; + +const MOBILE_SIDEBAR_EDGE_WIDTH = 32; +const MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE = 64; const socket = useSocketStore(); const config = useConfigStore(); @@ -525,7 +531,18 @@ const show_settings = ref(false); const checkingUpdates = ref(false); const updateCheckMessage = ref('Up to date - Click to check'); const showRouteSearch = ref(false); +const sidebarOpen = ref(false); const { alertDialog, confirmDialog } = useDialog(); +const isMobile = useMediaQuery({ query: '(max-width: 1023px)' }); + +const SwipeState = { + mode: null as SwipeMode | null, + tracking: false, + startX: 0, + startY: 0, + endX: 0, + endY: 0, +}; const colorModePreferences: Array = ['system', 'light', 'dark']; @@ -578,6 +595,100 @@ const cycleColorMode = (): void => { colorMode.preference = nextColorModePreference.value; }; +const resetSwipe = (): void => { + SwipeState.mode = null; + SwipeState.tracking = false; + SwipeState.startX = 0; + SwipeState.startY = 0; + SwipeState.endX = 0; + SwipeState.endY = 0; +}; + +const updateSwipePosition = (touch?: Touch): void => { + if (!touch) { + return; + } + + SwipeState.endX = touch.clientX; + SwipeState.endY = touch.clientY; +}; + +const handleSwipeStart = (event: TouchEvent): void => { + if (!isMobile.value || event.touches.length !== 1) { + resetSwipe(); + return; + } + + const touch = event.touches[0]; + + if (!touch) { + resetSwipe(); + return; + } + + const swipeMode: SwipeMode | null = sidebarOpen.value + ? 'close' + : touch.clientX <= MOBILE_SIDEBAR_EDGE_WIDTH + ? 'open' + : null; + + if (!swipeMode) { + resetSwipe(); + return; + } + + SwipeState.mode = swipeMode; + SwipeState.tracking = true; + SwipeState.startX = touch.clientX; + SwipeState.startY = touch.clientY; + updateSwipePosition(touch); +}; + +const handleSwipeMove = (event: TouchEvent): void => { + if (!SwipeState.tracking || event.touches.length !== 1) { + return; + } + + updateSwipePosition(event.touches[0]); +}; + +const completeSwipe = (): void => { + if (!SwipeState.tracking) { + return; + } + + const swipeMode = SwipeState.mode; + const deltaX = SwipeState.endX - SwipeState.startX; + const deltaY = SwipeState.endY - SwipeState.startY; + const isHorizontalOpenSwipe = + swipeMode === 'open' && + deltaX >= MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE && + deltaX > Math.abs(deltaY); + const isHorizontalCloseSwipe = + swipeMode === 'close' && + deltaX <= -MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE && + Math.abs(deltaX) > Math.abs(deltaY); + + resetSwipe(); + + if (isHorizontalOpenSwipe) { + sidebarOpen.value = true; + } + + if (isHorizontalCloseSwipe) { + sidebarOpen.value = false; + } +}; + +const handleSwipeEnd = (event: TouchEvent): void => { + updateSwipePosition(event.changedTouches[0]); + completeSwipe(); +}; + +const handleSwipeCancel = (): void => { + resetSwipe(); +}; + const dashboardSidebarUi = { root: 'border-r border-default bg-default/95 backdrop-blur-sm', header: 'border-b border-default px-2.5 py-3', @@ -819,6 +930,22 @@ const checkForUpdates = async () => { }; onMounted(async () => { + document.addEventListener('touchstart', handleSwipeStart, { + passive: true, + capture: true, + }); + document.addEventListener('touchmove', handleSwipeMove, { + passive: true, + capture: true, + }); + document.addEventListener('touchend', handleSwipeEnd, { + passive: true, + capture: true, + }); + document.addEventListener('touchcancel', handleSwipeCancel, { + passive: true, + capture: true, + }); syncShellModeClass(); try { @@ -841,8 +968,23 @@ onMounted(async () => { } catch {} }); +onBeforeUnmount(() => { + document.removeEventListener('touchstart', handleSwipeStart, true); + document.removeEventListener('touchmove', handleSwipeMove, true); + document.removeEventListener('touchend', handleSwipeEnd, true); + document.removeEventListener('touchcancel', handleSwipeCancel, true); +}); + watch(bg_enable, async (v) => await handleImage(v)); watch(simpleMode, () => syncShellModeClass()); +watch(isMobile, (v) => { + if (v) { + return; + } + + sidebarOpen.value = false; + resetSwipe(); +}); watch(bg_opacity, () => { if (false === bg_enable.value) { return;