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",