Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at ui-refactor 408 lines 13 kB view raw
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}