Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { useState, useRef, useEffect } from "react";
2import { Link, useLocation } from "react-router-dom";
3import { useAuth } from "../context/AuthContext";
4import { useTheme } from "../context/ThemeContext";
5import {
6 Home,
7 Search,
8 Folder,
9 Bell,
10 PenSquare,
11 User,
12 LogOut,
13 ChevronDown,
14 Highlighter,
15 Bookmark,
16 Sun,
17 Moon,
18 Monitor,
19 ExternalLink,
20 Menu,
21 X,
22} from "lucide-react";
23import {
24 SiFirefox,
25 SiGooglechrome,
26 SiGithub,
27 SiBluesky,
28 SiDiscord,
29} from "react-icons/si";
30import { FaEdge } from "react-icons/fa";
31import tangledLogo from "../assets/tangled.svg";
32import { getUnreadNotificationCount } from "../api/client";
33import logo from "../assets/logo.svg";
34
35const isFirefox =
36 typeof navigator !== "undefined" && /Firefox/i.test(navigator.userAgent);
37const isEdge =
38 typeof navigator !== "undefined" && /Edg/i.test(navigator.userAgent);
39
40function getExtensionInfo() {
41 if (isFirefox) {
42 return {
43 url: "https://addons.mozilla.org/en-US/firefox/addon/margin/",
44 icon: SiFirefox,
45 label: "Firefox",
46 };
47 }
48 if (isEdge) {
49 return {
50 url: "https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn",
51 icon: FaEdge,
52 label: "Edge",
53 };
54 }
55 return {
56 url: "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/",
57 icon: SiGooglechrome,
58 label: "Chrome",
59 };
60}
61
62export default function TopNav() {
63 const { user, isAuthenticated, logout, loading } = useAuth();
64 const { theme, setTheme } = useTheme();
65 const location = useLocation();
66 const [userMenuOpen, setUserMenuOpen] = useState(false);
67 const [moreMenuOpen, setMoreMenuOpen] = useState(false);
68 const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
69 const [unreadCount, setUnreadCount] = useState(0);
70 const userMenuRef = useRef(null);
71 const moreMenuRef = useRef(null);
72
73 const isActive = (path) => {
74 if (path === "/") return location.pathname === "/";
75 return location.pathname.startsWith(path);
76 };
77
78 const ext = getExtensionInfo();
79 const ExtIcon = ext.icon;
80
81 useEffect(() => {
82 if (isAuthenticated) {
83 getUnreadNotificationCount()
84 .then((data) => setUnreadCount(data.count || 0))
85 .catch(() => {});
86 const interval = setInterval(() => {
87 getUnreadNotificationCount()
88 .then((data) => setUnreadCount(data.count || 0))
89 .catch(() => {});
90 }, 60000);
91 return () => clearInterval(interval);
92 }
93 }, [isAuthenticated]);
94
95 useEffect(() => {
96 const handleClickOutside = (e) => {
97 if (userMenuRef.current && !userMenuRef.current.contains(e.target)) {
98 setUserMenuOpen(false);
99 }
100 if (moreMenuRef.current && !moreMenuRef.current.contains(e.target)) {
101 setMoreMenuOpen(false);
102 }
103 };
104 document.addEventListener("mousedown", handleClickOutside);
105 return () => document.removeEventListener("mousedown", handleClickOutside);
106 }, []);
107
108 const closeMobileMenu = () => setMobileMenuOpen(false);
109
110 const getInitials = () => {
111 if (user?.displayName)
112 return user.displayName.substring(0, 2).toUpperCase();
113 if (user?.handle) return user.handle.substring(0, 2).toUpperCase();
114 return "U";
115 };
116
117 const cycleTheme = () => {
118 const next =
119 theme === "system" ? "light" : theme === "light" ? "dark" : "system";
120 setTheme(next);
121 };
122
123 return (
124 <header className="top-nav">
125 <div className="top-nav-inner">
126 <Link to="/home" className="top-nav-logo">
127 <img src={logo} alt="Margin" />
128 <span>Margin</span>
129 </Link>
130
131 <nav className="top-nav-links">
132 <Link
133 to="/home"
134 className={`top-nav-link ${isActive("/home") ? "active" : ""}`}
135 >
136 Home
137 </Link>
138 <Link
139 to="/url"
140 className={`top-nav-link ${isActive("/url") ? "active" : ""}`}
141 >
142 Browse
143 </Link>
144 {isAuthenticated && (
145 <>
146 <Link
147 to="/highlights"
148 className={`top-nav-link ${isActive("/highlights") ? "active" : ""}`}
149 >
150 Highlights
151 </Link>
152 <Link
153 to="/bookmarks"
154 className={`top-nav-link ${isActive("/bookmarks") ? "active" : ""}`}
155 >
156 Bookmarks
157 </Link>
158 <Link
159 to="/collections"
160 className={`top-nav-link ${isActive("/collections") ? "active" : ""}`}
161 >
162 Collections
163 </Link>
164 </>
165 )}
166 </nav>
167
168 <div className="top-nav-actions">
169 <a
170 href={ext.url}
171 target="_blank"
172 rel="noopener noreferrer"
173 className="top-nav-link extension-link"
174 title={`Get ${ext.label} Extension`}
175 >
176 <ExtIcon size={16} />
177 <span>Get Extension</span>
178 </a>
179
180 <div className="top-nav-dropdown" ref={moreMenuRef}>
181 <button
182 className="top-nav-icon-btn"
183 onClick={() => setMoreMenuOpen(!moreMenuOpen)}
184 title="More"
185 >
186 <ChevronDown size={18} />
187 </button>
188 {moreMenuOpen && (
189 <div className="dropdown-menu dropdown-right">
190 <a
191 href="https://github.com/margin-at/margin"
192 target="_blank"
193 rel="noopener noreferrer"
194 className="dropdown-item"
195 >
196 <SiGithub size={16} />
197 GitHub
198 <ExternalLink size={12} className="dropdown-external" />
199 </a>
200 <a
201 href="https://tangled.sh/@margin.at/margin"
202 target="_blank"
203 rel="noopener noreferrer"
204 className="dropdown-item"
205 >
206 <span className="tangled-icon-wrapper">
207 <img src={tangledLogo} alt="" />
208 </span>
209 Tangled
210 <ExternalLink size={12} className="dropdown-external" />
211 </a>
212 <a
213 href="https://bsky.app/profile/margin.at"
214 target="_blank"
215 rel="noopener noreferrer"
216 className="dropdown-item"
217 >
218 <SiBluesky size={16} />
219 Bluesky
220 <ExternalLink size={12} className="dropdown-external" />
221 </a>
222 <a
223 href="https://discord.gg/ZQbkGqwzBH"
224 target="_blank"
225 rel="noopener noreferrer"
226 className="dropdown-item"
227 >
228 <SiDiscord size={16} />
229 Discord
230 <ExternalLink size={12} className="dropdown-external" />
231 </a>
232 <div className="dropdown-divider" />
233 <button className="dropdown-item" onClick={cycleTheme}>
234 {theme === "system" && <Monitor size={16} />}
235 {theme === "dark" && <Moon size={16} />}
236 {theme === "light" && <Sun size={16} />}
237 Theme: {theme}
238 </button>
239 <div className="dropdown-divider" />
240 <Link
241 to="/privacy"
242 className="dropdown-item"
243 onClick={() => setMoreMenuOpen(false)}
244 >
245 Privacy
246 </Link>
247 <Link
248 to="/terms"
249 className="dropdown-item"
250 onClick={() => setMoreMenuOpen(false)}
251 >
252 Terms
253 </Link>
254 </div>
255 )}
256 </div>
257
258 {isAuthenticated && (
259 <>
260 <Link
261 to="/notifications"
262 className="top-nav-icon-btn"
263 onClick={() => setUnreadCount(0)}
264 title="Notifications"
265 >
266 <Bell size={18} />
267 {unreadCount > 0 && <span className="notif-dot" />}
268 </Link>
269
270 <Link to="/new" className="top-nav-new-btn">
271 <PenSquare size={16} />
272 <span>New</span>
273 </Link>
274 </>
275 )}
276
277 {!loading &&
278 (isAuthenticated ? (
279 <div className="top-nav-dropdown" ref={userMenuRef}>
280 <button
281 className="top-nav-avatar"
282 onClick={() => setUserMenuOpen(!userMenuOpen)}
283 >
284 {user?.avatar ? (
285 <img src={user.avatar} alt={user.displayName} />
286 ) : (
287 <span>{getInitials()}</span>
288 )}
289 </button>
290 {userMenuOpen && (
291 <div className="dropdown-menu dropdown-right">
292 <div className="dropdown-user-info">
293 <span className="dropdown-user-name">
294 {user?.displayName || user?.handle}
295 </span>
296 <span className="dropdown-user-handle">
297 @{user?.handle}
298 </span>
299 </div>
300 <div className="dropdown-divider" />
301 <Link
302 to={`/profile/${user?.did}`}
303 className="dropdown-item"
304 onClick={() => setUserMenuOpen(false)}
305 >
306 <User size={16} />
307 View Profile
308 </Link>
309 <button
310 onClick={() => {
311 logout();
312 setUserMenuOpen(false);
313 }}
314 className="dropdown-item danger"
315 >
316 <LogOut size={16} />
317 Sign Out
318 </button>
319 </div>
320 )}
321 </div>
322 ) : (
323 <Link to="/login" className="top-nav-new-btn">
324 Sign In
325 </Link>
326 ))}
327
328 <button
329 className="top-nav-mobile-toggle"
330 onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
331 >
332 {mobileMenuOpen ? <X size={22} /> : <Menu size={22} />}
333 </button>
334 </div>
335 </div>
336
337 {mobileMenuOpen && (
338 <div className="mobile-menu">
339 <Link
340 to="/home"
341 className={`mobile-menu-link ${isActive("/home") ? "active" : ""}`}
342 onClick={closeMobileMenu}
343 >
344 <Home size={20} /> Home
345 </Link>
346 <Link
347 to="/url"
348 className={`mobile-menu-link ${isActive("/url") ? "active" : ""}`}
349 onClick={closeMobileMenu}
350 >
351 <Search size={20} /> Browse
352 </Link>
353 {isAuthenticated && (
354 <>
355 <Link
356 to="/highlights"
357 className={`mobile-menu-link ${isActive("/highlights") ? "active" : ""}`}
358 onClick={closeMobileMenu}
359 >
360 <Highlighter size={20} /> Highlights
361 </Link>
362 <Link
363 to="/bookmarks"
364 className={`mobile-menu-link ${isActive("/bookmarks") ? "active" : ""}`}
365 onClick={closeMobileMenu}
366 >
367 <Bookmark size={20} /> Bookmarks
368 </Link>
369 <Link
370 to="/collections"
371 className={`mobile-menu-link ${isActive("/collections") ? "active" : ""}`}
372 onClick={closeMobileMenu}
373 >
374 <Folder size={20} /> Collections
375 </Link>
376 <Link
377 to="/notifications"
378 className={`mobile-menu-link ${isActive("/notifications") ? "active" : ""}`}
379 onClick={closeMobileMenu}
380 >
381 <Bell size={20} /> Notifications
382 {unreadCount > 0 && (
383 <span className="notification-badge">{unreadCount}</span>
384 )}
385 </Link>
386 <Link
387 to="/new"
388 className={`mobile-menu-link ${isActive("/new") ? "active" : ""}`}
389 onClick={closeMobileMenu}
390 >
391 <PenSquare size={20} /> New
392 </Link>
393 </>
394 )}
395 <div className="mobile-menu-divider" />
396 <a
397 href={ext.url}
398 target="_blank"
399 rel="noopener noreferrer"
400 className="mobile-menu-link"
401 >
402 <ExtIcon size={20} /> Get Extension
403 </a>
404 </div>
405 )}
406 </header>
407 );
408}