(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

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}