(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { useStore } from "@nanostores/react";
2import {
3 Bell,
4 Bookmark,
5 Folder,
6 Highlighter,
7 Home,
8 LogOut,
9 MessageSquareText,
10 PenSquare,
11 Search,
12 Settings,
13 User,
14 X,
15} from "lucide-react";
16import { useEffect, useState } from "react";
17import { getUnreadNotificationCount } from "../../api/client";
18import { $user, logout } from "../../store/auth";
19import { AppleIcon } from "../common/Icons";
20import { useTranslation } from "react-i18next";
21
22interface MobileNavProps {
23 currentPath?: string;
24 onNavigate?: (path: string) => void;
25}
26
27export default function MobileNav({
28 currentPath: initialPath,
29 onNavigate,
30}: MobileNavProps) {
31 const { t } = useTranslation();
32 const user = useStore($user);
33 const [currentPath, setCurrentPath] = useState(initialPath || "/");
34 const [isMenuOpen, setIsMenuOpen] = useState(false);
35 const [unreadCount, setUnreadCount] = useState(0);
36
37 const isAuthenticated = !!user;
38
39 const isActive = (path: string) => {
40 if (path === "/") return currentPath === "/";
41 return currentPath.startsWith(path);
42 };
43
44 useEffect(() => {
45 if (isAuthenticated) {
46 getUnreadNotificationCount()
47 .then((count) => setUnreadCount(count || 0))
48 .catch(() => {});
49 }
50 }, [isAuthenticated]);
51
52 const closeMenu = () => setIsMenuOpen(false);
53
54 return (
55 <>
56 {isMenuOpen && (
57 <div
58 className="fixed inset-0 bg-black/40 z-40 md:hidden"
59 onClick={closeMenu}
60 />
61 )}
62
63 {isMenuOpen && (
64 <div
65 className="fixed left-0 right-0 z-50 md:hidden animate-slide-up"
66 style={{ bottom: "calc(3.5rem + env(safe-area-inset-bottom))" }}
67 >
68 <div className="mx-2 mb-2 bg-white dark:bg-surface-900 rounded-2xl shadow-xl border border-surface-200 dark:border-surface-700 overflow-hidden">
69 <div className="flex justify-center pt-3 pb-1">
70 <div className="w-8 h-1 bg-surface-200 dark:bg-surface-600 rounded-full" />
71 </div>
72
73 <div className="p-2">
74 {isAuthenticated && user ? (
75 <>
76 <a
77 href={`/profile/${user.did}`}
78 className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors"
79 onClick={(e) => {
80 if (onNavigate) {
81 e.preventDefault();
82 onNavigate(`/profile/${user.did}`);
83 }
84 closeMenu();
85 }}
86 >
87 {user.avatar ? (
88 <img
89 src={user.avatar}
90 alt=""
91 className="w-9 h-9 rounded-full object-cover shrink-0"
92 />
93 ) : (
94 <div className="w-9 h-9 rounded-full bg-surface-100 dark:bg-surface-700 flex items-center justify-center shrink-0">
95 <User size={16} className="text-surface-500" />
96 </div>
97 )}
98 <div className="flex flex-col min-w-0">
99 <span className="font-semibold text-surface-900 dark:text-white text-sm truncate">
100 {user.displayName || user.handle}
101 </span>
102 <span className="text-xs text-surface-400 dark:text-surface-500 truncate">
103 @{user.handle}
104 </span>
105 </div>
106 </a>
107
108 <div className="h-px bg-surface-100 dark:bg-surface-700 my-1 mx-3" />
109
110 <div className="grid grid-cols-2 gap-1">
111 {[
112 {
113 href: "/annotations",
114 icon: MessageSquareText,
115 label: t("nav.annotations"),
116 },
117 {
118 href: "/highlights",
119 icon: Highlighter,
120 label: t("nav.highlights"),
121 },
122 {
123 href: "/bookmarks",
124 icon: Bookmark,
125 label: t("nav.bookmarks"),
126 },
127 {
128 href: "/collections",
129 icon: Folder,
130 label: t("nav.collections"),
131 },
132 ].map(({ href, icon: Icon, label }) => (
133 <a
134 key={href}
135 href={href}
136 className="flex items-center gap-2.5 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors text-surface-700 dark:text-surface-200"
137 onClick={(e) => {
138 if (onNavigate) {
139 e.preventDefault();
140 onNavigate(href);
141 }
142 closeMenu();
143 }}
144 >
145 <Icon size={16} className="shrink-0" />
146 <span className="text-sm font-medium truncate">
147 {label}
148 </span>
149 </a>
150 ))}
151 </div>
152
153 <div className="h-px bg-surface-100 dark:bg-surface-700 my-1 mx-3" />
154
155 <div className="flex gap-1">
156 <a
157 href="/settings"
158 className="flex-1 flex items-center gap-2.5 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors text-surface-700 dark:text-surface-200"
159 onClick={(e) => {
160 if (onNavigate) {
161 e.preventDefault();
162 onNavigate("/settings");
163 }
164 closeMenu();
165 }}
166 >
167 <Settings size={16} className="shrink-0" />
168 <span className="text-sm font-medium">
169 {t("nav.settings")}
170 </span>
171 </a>
172
173 <a
174 href="https://www.icloud.com/shortcuts/1e33ebf52f55431fae1e187cfe9738c3"
175 target="_blank"
176 rel="noopener noreferrer"
177 className="flex-1 flex items-center gap-2.5 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors text-surface-700 dark:text-surface-200"
178 onClick={closeMenu}
179 >
180 <AppleIcon size={16} />
181 <span className="text-sm font-medium">
182 {t("mobileNav.iosShortcut")}
183 </span>
184 </a>
185 </div>
186
187 <div className="h-px bg-surface-100 dark:bg-surface-700 my-1 mx-3" />
188
189 <button
190 className="w-full flex items-center gap-2.5 p-3 rounded-xl hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors text-red-500 dark:text-red-400"
191 onClick={() => {
192 logout();
193 closeMenu();
194 }}
195 >
196 <LogOut size={16} className="shrink-0" />
197 <span className="text-sm font-medium">
198 {t("nav.logOut")}
199 </span>
200 </button>
201 </>
202 ) : (
203 <>
204 <a
205 href="/login"
206 className="flex items-center gap-2.5 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors text-surface-700 dark:text-surface-200"
207 onClick={closeMenu}
208 >
209 <User size={16} className="shrink-0" />
210 <span className="text-sm font-medium">
211 {t("nav.signIn")}
212 </span>
213 </a>
214 {[
215 {
216 href: "/collections",
217 icon: Folder,
218 label: t("nav.collections"),
219 },
220 {
221 href: "/settings",
222 icon: Settings,
223 label: t("nav.settings"),
224 },
225 ].map(({ href, icon: Icon, label }) => (
226 <a
227 key={href}
228 href={href}
229 className="flex items-center gap-2.5 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors text-surface-700 dark:text-surface-200"
230 onClick={(e) => {
231 if (onNavigate) {
232 e.preventDefault();
233 onNavigate(href);
234 }
235 closeMenu();
236 }}
237 >
238 <Icon size={16} className="shrink-0" />
239 <span className="text-sm font-medium">{label}</span>
240 </a>
241 ))}
242
243 <div className="h-px bg-surface-100 dark:bg-surface-700 my-1 mx-3" />
244
245 <a
246 href="https://www.icloud.com/shortcuts/1e33ebf52f55431fae1e187cfe9738c3"
247 target="_blank"
248 rel="noopener noreferrer"
249 className="flex items-center gap-2.5 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors text-surface-700 dark:text-surface-200"
250 onClick={closeMenu}
251 >
252 <AppleIcon size={16} />
253 <span className="text-sm font-medium">
254 {t("mobileNav.iosShortcut")}
255 </span>
256 </a>
257 </>
258 )}
259 </div>
260 </div>
261 </div>
262 )}
263
264 <nav
265 className="fixed bottom-0 left-0 right-0 bg-white/95 dark:bg-surface-900/95 backdrop-blur-md border-t border-surface-200 dark:border-surface-800 flex items-center justify-around z-50 md:hidden"
266 style={{
267 height: "calc(3.5rem + env(safe-area-inset-bottom))",
268 paddingBottom: "env(safe-area-inset-bottom)",
269 }}
270 >
271 <a
272 href="/home"
273 className="flex flex-col items-center justify-center w-14 h-14 gap-0.5 transition-colors"
274 onClick={(e) => {
275 if (onNavigate) {
276 e.preventDefault();
277 onNavigate("/home");
278 }
279 setCurrentPath("/home");
280 closeMenu();
281 }}
282 >
283 <div
284 className={`p-2 rounded-xl transition-colors ${
285 isActive("/home")
286 ? "bg-primary-50 dark:bg-primary-950/50 text-primary-600 dark:text-primary-400"
287 : "text-surface-400 dark:text-surface-500"
288 }`}
289 >
290 <Home size={22} strokeWidth={isActive("/home") ? 2 : 1.5} />
291 </div>
292 </a>
293
294 <a
295 href="/search"
296 className="flex flex-col items-center justify-center w-14 h-14 gap-0.5 transition-colors"
297 onClick={(e) => {
298 if (onNavigate) {
299 e.preventDefault();
300 onNavigate("/search");
301 }
302 setCurrentPath("/search");
303 closeMenu();
304 }}
305 >
306 <div
307 className={`p-2 rounded-xl transition-colors ${
308 isActive("/search")
309 ? "bg-primary-50 dark:bg-primary-950/50 text-primary-600 dark:text-primary-400"
310 : "text-surface-400 dark:text-surface-500"
311 }`}
312 >
313 <Search size={22} strokeWidth={isActive("/search") ? 2 : 1.5} />
314 </div>
315 </a>
316
317 {isAuthenticated ? (
318 <a
319 href="/new"
320 className="flex items-center justify-center w-11 h-11 rounded-2xl bg-primary-600 dark:bg-primary-600 text-white shadow-md active:scale-95 transition-transform"
321 onClick={(e) => {
322 if (onNavigate) {
323 e.preventDefault();
324 onNavigate("/new");
325 }
326 setCurrentPath("/new");
327 closeMenu();
328 }}
329 >
330 <PenSquare size={18} strokeWidth={2} />
331 </a>
332 ) : (
333 <a
334 href="/login"
335 className="flex items-center justify-center w-11 h-11 rounded-2xl bg-primary-600 text-white shadow-md active:scale-95 transition-transform"
336 onClick={closeMenu}
337 >
338 <User size={18} strokeWidth={2} />
339 </a>
340 )}
341
342 {isAuthenticated ? (
343 <a
344 href="/notifications"
345 className="flex flex-col items-center justify-center w-14 h-14 gap-0.5 relative transition-colors"
346 onClick={(e) => {
347 if (onNavigate) {
348 e.preventDefault();
349 onNavigate("/notifications");
350 }
351 setCurrentPath("/notifications");
352 closeMenu();
353 }}
354 >
355 <div
356 className={`p-2 rounded-xl transition-colors relative ${
357 isActive("/notifications")
358 ? "bg-primary-50 dark:bg-primary-950/50 text-primary-600 dark:text-primary-400"
359 : "text-surface-400 dark:text-surface-500"
360 }`}
361 >
362 <Bell
363 size={22}
364 strokeWidth={isActive("/notifications") ? 2 : 1.5}
365 />
366 {unreadCount > 0 && (
367 <span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full ring-2 ring-white dark:ring-surface-900" />
368 )}
369 </div>
370 </a>
371 ) : (
372 <div className="w-14" />
373 )}
374
375 <button
376 className="flex flex-col items-center justify-center w-14 h-14 gap-0.5 transition-colors"
377 onClick={() => setIsMenuOpen(!isMenuOpen)}
378 >
379 <div
380 className={`p-2 rounded-xl transition-colors ${
381 isMenuOpen
382 ? "bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-200"
383 : "text-surface-400 dark:text-surface-500"
384 }`}
385 >
386 {isMenuOpen ? (
387 <X size={22} strokeWidth={1.5} />
388 ) : (
389 <svg
390 width="22"
391 height="22"
392 viewBox="0 0 22 22"
393 fill="none"
394 xmlns="http://www.w3.org/2000/svg"
395 >
396 <circle cx="4" cy="11" r="1.75" fill="currentColor" />
397 <circle cx="11" cy="11" r="1.75" fill="currentColor" />
398 <circle cx="18" cy="11" r="1.75" fill="currentColor" />
399 </svg>
400 )}
401 </div>
402 </button>
403 </nav>
404 </>
405 );
406}