diff --git a/package-lock.json b/package-lock.json index d946c4d..381abdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "react-day-picker": "^8.10.0", "react-dom": "^18", "react-icons": "^5.0.1", + "react-swipeable": "^7.0.1", "react-virtualized": "^9.22.5", "sass": "^1.72.0", "tailwind-merge": "^2.2.2", @@ -9644,6 +9645,14 @@ } } }, + "node_modules/react-swipeable": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.1.tgz", + "integrity": "sha512-RKB17JdQzvECfnVj9yDZsiYn3vH0eyva/ZbrCZXZR0qp66PBRhtg4F9yJcJTWYT5Adadi+x4NoG53BxKHwIYLQ==", + "peerDependencies": { + "react": "^16.8.3 || ^17 || ^18" + } + }, "node_modules/react-textarea-autosize": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz", diff --git a/package.json b/package.json index 45114ea..dcfe316 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "react-day-picker": "^8.10.0", "react-dom": "^18", "react-icons": "^5.0.1", + "react-swipeable": "^7.0.1", "react-virtualized": "^9.22.5", "sass": "^1.72.0", "tailwind-merge": "^2.2.2", diff --git a/src/app/(with-header)/header-sidebar.tsx b/src/app/(with-header)/header-sidebar.tsx index 3750d6b..83c5899 100644 --- a/src/app/(with-header)/header-sidebar.tsx +++ b/src/app/(with-header)/header-sidebar.tsx @@ -2,7 +2,7 @@ import { Avatar, Badge, Button, Divider, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Navbar, Popover, PopoverContent, PopoverTrigger, Tooltip } from '@nextui-org/react'; import { Bars3Icon, ChevronLeftIcon, XMarkIcon } from '@heroicons/react/20/solid'; -import { Fragment, useEffect, useMemo, useState } from 'react'; +import { Fragment, RefCallback, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { ThemeSwitcherDropdown, ThemeSwitcherSwitch } from '@/components/theme-switcher'; import { AdjustmentsHorizontalIcon } from '@heroicons/react/24/solid'; @@ -18,6 +18,7 @@ import { BellAlertIcon } from '@heroicons/react/24/solid'; import { FriendRequest, acceptFriendRequest, getFriendRequests, rejectFriendRequest } from '@/actions/friend'; import { useWindowListener } from '@/helpers/use-window-listener'; import { useErrorModal } from '@/components/error-modal'; +import { useSwipeable } from 'react-swipeable'; export type HeaderSidebarProps = { children?: React.ReactNode, @@ -41,6 +42,8 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => { const [friendRequests, setFriendRequests] = useState([]); const [isNotificationsOpen, _setNotificationsOpen] = useState(false); const [userDropdownOpen, setUserDropdownOpen] = useState(false); + const [menuTranslate, setMenuTranslate] = useState(null); + const [notificationsTranslate, setNotificationsTranslate] = useState(null); const path = pathname === '/' ? (user?.homepage ?? '/dashboard') : pathname; @@ -51,6 +54,56 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => { const setError = useErrorModal(); + const { ref } = useSwipeable({ + touchEventOptions: { passive: false }, + onSwiped: e => { + const speedX = Math.abs(e.vxvy[0]); + const speedY = Math.abs(e.vxvy[1]); + const fastSwipe = speedX > 0.75 && speedX > speedY; + + if (menuTranslate && ((Math.abs(menuTranslate) > 384 * 0.5) || fastSwipe)) { + if (isMenuOpen && window.location.hash === '#sidebar') + router.back(); + setMenuOpen(!isMenuOpen); + } else if (notificationsTranslate && ((Math.abs(notificationsTranslate) > window.innerWidth * 0.4 || fastSwipe))) { + if (isNotificationsOpen && window.location.hash === '#notifications') + router.back(); + setNotificationsOpen(!isNotificationsOpen); + } + + setMenuTranslate(null); + setNotificationsTranslate(null); + }, + onSwipeStart: e => { + if (e.dir === 'Down' || e.dir === 'Up') return; + + const allMenusClosed = !isMenuOpen && !isNotificationsOpen; + const xPercent = e.initial[0] / window.innerWidth; + + if ((isMenuOpen && e.dir === 'Left') || (allMenusClosed && e.dir === 'Right' && xPercent <= 0.6)) { + setMenuTranslate(e.deltaX); + e.event.preventDefault(); + } else if ((isNotificationsOpen && e.dir === 'Right' || (allMenusClosed && e.dir === 'Left' && xPercent >= 0.4))) { + setNotificationsTranslate(e.deltaX); + e.event.preventDefault(); + } + }, + onSwiping: e => { + if (menuTranslate !== null) { + setMenuTranslate(e.deltaX); + e.event.preventDefault(); + } else if (notificationsTranslate !== null) { + setNotificationsTranslate(e.deltaX); + e.event.preventDefault(); + } + } + }) as { ref: RefCallback; }; + + useEffect(() => { + ref(document); + return () => ref({} as any); + }, [ref]); + useEffect(() => { if (user) getFriendRequests().then(setFriendRequests); @@ -78,7 +131,7 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => { setNotificationsOpen(window.location.hash === '#notifications'); }, []); - const renderHeaderLink = (route: Subroute) => { + const renderHeaderLink = useCallback((route: Subroute) => { const linkStyle = path?.startsWith(route.url) ? 'font-semibold text-primary' : 'hover:text-secondary'; @@ -112,7 +165,7 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => { className={`mr-4 transition ${linkStyle}`}> {route.name} ); - }; + }, [cookies, routeGroup, path]); const notifications = useMemo(() => { if (!friendRequests.length) return null; @@ -147,27 +200,119 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => { }); }, [friendRequests]); - return (<> - {/* begin sidebar */} -
+ const topNavbar = useMemo(() => { + return ( +
+ +
+ {routeGroup.routes?.filter(filter).map(renderHeaderLink)} +
+
+ {routeGroup !== MAIN_ROUTES &&
+ {MAIN_ROUTES.routes.filter(filter).map(renderHeaderLink)} +
} + {user ? <> + {friendRequests.length ? + +
+ +
+ +
+
+
+
+ + {notifications} + +
: No notifications}> + + } + + { + if (!o || breakpoint) + setUserDropdownOpen(o); + else + setNotificationsOpen(true); + }}> + +
+ + + +
+
+ + + {user.username} + + + + Settings + + + + + Friends + + + +
setTheme(theme === 'dark' ? 'light' : 'dark')}> + Dark Theme + + +
+
+ +
logout({ redirectTo: '/' })}> + Logout +
+
+
+
+ : + <> + + + + } +
+
); + }, [breakpoint, routeGroup, friendRequests, userDropdownOpen, isNotificationsOpen, user, notifications]); + + const leftSidebar = useMemo(() => { + return (
{ setMenuOpen(false); - router.back(); + if (window.location.hash === '#sidebar') + router.back(); }} /> -
+
@@ -212,21 +357,24 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => { {user && - - } + + }
-
- {/* end sidebar */} +
); + }, [isMenuOpen, router, menuTranslate, routeGroup, user, path, cookies]); - {/* being mobile notifications */} -
-
+ const rightSidebar = useMemo(() => { + return (
+
setNotificationsOpen(false)}> @@ -241,7 +389,8 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => { @@ -277,100 +426,17 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => { Logout
-
- {/* end mobile notifications */} +
); + }, [isNotificationsOpen, notificationsTranslate, user, friendRequests]); + return (<> + { leftSidebar } + { rightSidebar } {/* begin top navbar */}
- -
- -
- {routeGroup.routes?.filter(filter).map(renderHeaderLink)} -
-
- {routeGroup !== MAIN_ROUTES &&
- {MAIN_ROUTES.routes.filter(filter).map(renderHeaderLink)} -
} - {user ? <> - {friendRequests.length ? - -
- -
- -
-
-
-
- - { notifications } - -
: No notifications}> - - } + {topNavbar} - - - { - if (!o || breakpoint) - setUserDropdownOpen(o); - else - setNotificationsOpen(true); - }}> - -
- - - -
-
- - - {user.username} - - - - Settings - - - - - Friends - - - -
setTheme(theme === 'dark' ? 'light' : 'dark')}> - Dark Theme - - -
-
- -
logout({ redirectTo: '/' })}> - Logout -
-
-
-
- : - <> - - - - } -
-
- -
+
{children}