diff --git a/web/app/components/MobileNav.tsx b/web/app/components/MobileNav.tsx new file mode 100644 index 0000000..6c20017 --- /dev/null +++ b/web/app/components/MobileNav.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { twMerge } from 'tailwind-merge'; +import { MenuIcon, XIcon } from 'lucide-react'; + +export default function MobileNav() { + const [isOpen, setIsOpen] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(false); + + const menuItems = isAuthenticated + ? [ + { label: 'Dashboard', href: '/dashboard' }, + { label: 'Logout', onClick: () => setIsAuthenticated(false) }, + ] + : [{ label: 'Login', onClick: () => setIsAuthenticated(true) }]; + + + useEffect(() => { + document.body.style.overflow = isOpen ? 'hidden' : 'auto'; + return () => { + document.body.style.overflow = 'auto'; + }; + }, [isOpen]); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setIsOpen(false); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, []); + + + const throttledDragEnd = useCallback( + (() => { + let lastCall = 0; + return (_: any, info: any) => { + const now = Date.now(); + if (now - lastCall > 100) { + if (info.offset.x > 100) setIsOpen(false); + lastCall = now; + } + }; + })(), + [] + ); + + return ( + <> + + + + + {isOpen && ( + + + + + + )} + + + ); +} diff --git a/web/package-lock.json b/web/package-lock.json index 058cf5e..cb78f01 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,7 @@ "amqplib": "^0.10.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.10.5", "ioredis": "^5.6.1", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.488.0", @@ -4849,6 +4850,33 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/framer-motion": { + "version": "12.10.5", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.10.5.tgz", + "integrity": "sha512-p6VF1YkwWvNDFzg5IQ5lqPx11Td4TQ6LqDnshV7sWj0Nrp4dwz2/aEzmgh9WA9ridcTIJ625Fr0oiuhgqIoFwQ==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.10.5", + "motion-utils": "^12.9.4", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/form-data": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", @@ -7319,6 +7347,21 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion-dom": { + "version": "12.10.5", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.10.5.tgz", + "integrity": "sha512-F7XKmhxXEH/y3aWWf0N2w69wNSN+6PcJ1seqR1WolClmXpPhj+xwzs9j5CpsMFzeHR1D7irl3JcWMToPRwX6Hg==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.9.4" + } + }, + "node_modules/motion-utils": { + "version": "12.9.4", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.9.4.tgz", + "integrity": "sha512-BW3I65zeM76CMsfh3kHid9ansEJk9Qvl+K5cu4DVHKGsI52n76OJ4z2CUJUV+Mn3uEP9k1JJA3tClG0ggSrRcg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/web/package.json b/web/package.json index 871a8ec..baa836b 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "amqplib": "^0.10.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.10.5", "ioredis": "^5.6.1", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.488.0",