Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 370 lines 14 kB view raw
1import React, { useCallback, useEffect, useRef, useState } from "react"; 2import { useNavigate } from "react-router-dom"; 3import { Search, Coffee, Heart, Globe } from "lucide-react"; 4import { 5 getTrendingTags, 6 searchActors, 7 type ActorSearchItem, 8 type Tag, 9} from "../../api/client"; 10import { Avatar } from "../ui"; 11 12function looksLikeUrl(query: string): boolean { 13 const q = query.trim().toLowerCase(); 14 return ( 15 q.startsWith("http://") || 16 q.startsWith("https://") || 17 /\.(com|org|net|io|dev|me|co|app|xyz|edu|gov)\b/.test(q) 18 ); 19} 20 21export default function RightSidebar() { 22 const navigate = useNavigate(); 23 const [tags, setTags] = useState<Tag[]>([]); 24 const [browser] = useState<"chrome" | "firefox" | "edge" | "other">(() => { 25 if (typeof navigator === "undefined") return "other"; 26 const ua = navigator.userAgent; 27 if (/Edg\//i.test(ua)) return "edge"; 28 if (/Firefox/i.test(ua)) return "firefox"; 29 if (/Chrome/i.test(ua)) return "chrome"; 30 return "other"; 31 }); 32 const [searchQuery, setSearchQuery] = useState(""); 33 const [suggestions, setSuggestions] = useState<ActorSearchItem[]>([]); 34 const [showSuggestions, setShowSuggestions] = useState(false); 35 const [selectedIndex, setSelectedIndex] = useState(-1); 36 37 const inputRef = useRef<HTMLInputElement>(null); 38 const suggestionsRef = useRef<HTMLDivElement>(null); 39 const isSelectionRef = useRef(false); 40 const latestQueryRef = useRef(searchQuery); 41 42 useEffect(() => { 43 latestQueryRef.current = searchQuery; 44 45 if (searchQuery.length < 3 || looksLikeUrl(searchQuery)) { 46 return; 47 } 48 49 if (isSelectionRef.current) { 50 isSelectionRef.current = false; 51 return; 52 } 53 54 const capturedQuery = searchQuery; 55 const timer = setTimeout(async () => { 56 try { 57 const data = await searchActors(capturedQuery); 58 if (capturedQuery !== latestQueryRef.current) return; 59 setSuggestions(data.actors || []); 60 setShowSuggestions((data.actors || []).length > 0); 61 setSelectedIndex(-1); 62 } catch (e) { 63 console.error("Search failed:", e); 64 } 65 }, 300); 66 67 return () => clearTimeout(timer); 68 }, [searchQuery]); 69 70 useEffect(() => { 71 const handleClickOutside = (e: MouseEvent) => { 72 if ( 73 suggestionsRef.current && 74 !suggestionsRef.current.contains(e.target as Node) && 75 inputRef.current && 76 !inputRef.current.contains(e.target as Node) 77 ) { 78 setShowSuggestions(false); 79 } 80 }; 81 document.addEventListener("mousedown", handleClickOutside); 82 return () => document.removeEventListener("mousedown", handleClickOutside); 83 }, []); 84 85 const selectSuggestion = useCallback( 86 (actor: ActorSearchItem) => { 87 isSelectionRef.current = true; 88 setSearchQuery(""); 89 setSuggestions([]); 90 setShowSuggestions(false); 91 navigate(`/profile/${encodeURIComponent(actor.handle)}`); 92 }, 93 [navigate], 94 ); 95 96 const handleKeyDown = useCallback( 97 (e: React.KeyboardEvent) => { 98 if (showSuggestions && suggestions.length > 0) { 99 if (e.key === "ArrowDown") { 100 e.preventDefault(); 101 setSelectedIndex((prev) => 102 Math.min(prev + 1, suggestions.length - 1), 103 ); 104 return; 105 } else if (e.key === "ArrowUp") { 106 e.preventDefault(); 107 setSelectedIndex((prev) => Math.max(prev - 1, -1)); 108 return; 109 } else if (e.key === "Enter" && selectedIndex >= 0) { 110 e.preventDefault(); 111 selectSuggestion(suggestions[selectedIndex]); 112 return; 113 } else if (e.key === "Escape") { 114 setShowSuggestions(false); 115 return; 116 } 117 } 118 119 if (e.key === "Enter" && searchQuery.trim()) { 120 const q = searchQuery.trim(); 121 if (looksLikeUrl(q)) { 122 navigate(`/url/${encodeURIComponent(q)}`); 123 } else if (q.includes(".")) { 124 navigate(`/profile/${encodeURIComponent(q)}`); 125 } else { 126 navigate(`/search?q=${encodeURIComponent(q)}`); 127 } 128 setSearchQuery(""); 129 setSuggestions([]); 130 setShowSuggestions(false); 131 } 132 }, 133 [ 134 showSuggestions, 135 suggestions, 136 selectedIndex, 137 searchQuery, 138 navigate, 139 selectSuggestion, 140 ], 141 ); 142 143 useEffect(() => { 144 getTrendingTags(10).then(setTags); 145 }, []); 146 147 const extensionLink = 148 browser === "firefox" 149 ? "https://addons.mozilla.org/en-US/firefox/addon/margin/" 150 : browser === "edge" 151 ? "https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn" 152 : "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa"; 153 154 return ( 155 <aside className="hidden xl:block w-[320px] shrink-0 sticky top-0 h-screen overflow-y-auto px-6 py-6"> 156 <div className="space-y-5"> 157 <div className="relative"> 158 <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> 159 <Search 160 className="text-surface-400 dark:text-surface-500" 161 size={15} 162 /> 163 </div> 164 <input 165 ref={inputRef} 166 type="text" 167 value={searchQuery} 168 onChange={(e) => { 169 setSearchQuery(e.target.value); 170 if (e.target.value.length < 3) { 171 setSuggestions([]); 172 setShowSuggestions(false); 173 } 174 }} 175 onKeyDown={handleKeyDown} 176 onFocus={() => 177 searchQuery.length >= 3 && 178 suggestions.length > 0 && 179 setShowSuggestions(true) 180 } 181 placeholder="Search..." 182 className="w-full bg-surface-100 dark:bg-surface-800/80 rounded-lg pl-9 pr-4 py-2 text-sm text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:bg-white dark:focus:bg-surface-800 transition-all border border-surface-200/60 dark:border-surface-700/60" 183 /> 184 185 {showSuggestions && suggestions.length > 0 && ( 186 <div 187 ref={suggestionsRef} 188 className="absolute top-[calc(100%+6px)] left-0 right-0 bg-white dark:bg-surface-900 border border-surface-200 dark:border-surface-700 rounded-xl shadow-xl overflow-hidden z-50 animate-fade-in max-h-[280px] overflow-y-auto" 189 > 190 {suggestions.map((actor, index) => ( 191 <button 192 key={actor.did} 193 type="button" 194 className={`w-full flex items-center gap-3 px-3.5 py-2.5 border-b border-surface-100 dark:border-surface-800 last:border-0 hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors text-left ${index === selectedIndex ? "bg-surface-50 dark:bg-surface-800" : ""}`} 195 onClick={() => selectSuggestion(actor)} 196 > 197 <Avatar src={actor.avatar} size="sm" /> 198 <div className="min-w-0 flex-1"> 199 <div className="font-semibold text-surface-900 dark:text-white truncate text-sm leading-tight"> 200 {actor.displayName || actor.handle} 201 </div> 202 <div className="text-surface-500 dark:text-surface-400 text-xs truncate"> 203 @{actor.handle} 204 </div> 205 </div> 206 </button> 207 ))} 208 </div> 209 )} 210 </div> 211 212 <div className="rounded-xl p-4 bg-gradient-to-br from-primary-50 to-primary-100/50 dark:from-primary-950/30 dark:to-primary-900/10 border border-primary-200/40 dark:border-primary-800/30"> 213 <h3 className="font-semibold text-sm mb-1 text-surface-900 dark:text-white"> 214 Get the Extension 215 </h3> 216 <p className="text-surface-500 dark:text-surface-400 text-xs mb-3 leading-relaxed"> 217 Highlight, annotate, and bookmark from any page. 218 </p> 219 <a 220 href={extensionLink} 221 target="_blank" 222 rel="noopener noreferrer" 223 className="flex items-center justify-center w-full px-4 py-2 bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-400 text-white dark:text-white rounded-lg transition-colors text-sm font-medium" 224 > 225 Download for{" "} 226 {browser === "firefox" 227 ? "Firefox" 228 : browser === "edge" 229 ? "Edge" 230 : "Chrome"} 231 </a> 232 </div> 233 234 <div className="rounded-xl p-3 border border-surface-200/60 dark:border-surface-700/60"> 235 <p className="text-surface-500 dark:text-surface-400 text-xs mb-2"> 236 Support Margin 237 </p> 238 <div className="flex flex-col gap-1.5"> 239 <div className="flex gap-1.5"> 240 <a 241 href="https://ko-fi.com/scan" 242 target="_blank" 243 rel="noopener noreferrer" 244 title="Ko-fi" 245 className="flex items-center justify-center flex-1 px-2 py-1.5 rounded-lg border border-surface-200/80 dark:border-surface-700/80 text-surface-500 dark:text-surface-400 hover:border-[#FF5E5B] hover:text-[#FF5E5B] dark:hover:border-[#FF5E5B] dark:hover:text-[#FF5E5B] text-xs font-medium transition-colors gap-1.5" 246 > 247 <Coffee size={13} className="shrink-0" /> 248 Ko-fi 249 </a> 250 <a 251 href="https://github.com/sponsors/margin-at" 252 target="_blank" 253 rel="noopener noreferrer" 254 title="GitHub Sponsors" 255 className="flex items-center justify-center flex-1 px-2 py-1.5 rounded-lg border border-surface-200/80 dark:border-surface-700/80 text-surface-500 dark:text-surface-400 hover:border-[#EA4AAA] hover:text-[#EA4AAA] dark:hover:border-[#EA4AAA] dark:hover:text-[#EA4AAA] text-xs font-medium transition-colors gap-1.5" 256 > 257 <Heart size={13} className="shrink-0" /> 258 GitHub 259 </a> 260 </div> 261 <a 262 href="https://opencollective.com/margin" 263 target="_blank" 264 rel="noopener noreferrer" 265 title="Open Collective" 266 className="flex items-center justify-center w-full px-2 py-1.5 rounded-lg border border-surface-200/80 dark:border-surface-700/80 text-surface-500 dark:text-surface-400 hover:border-[#7FADF2] hover:text-[#7FADF2] dark:hover:border-[#7FADF2] dark:hover:text-[#7FADF2] text-xs font-medium transition-colors gap-1.5" 267 > 268 <Globe size={13} className="shrink-0" /> 269 Open Collective 270 </a> 271 </div> 272 </div> 273 274 <div> 275 <h3 className="font-semibold text-sm px-1 mb-3 text-surface-900 dark:text-white tracking-tight"> 276 Trending 277 </h3> 278 {tags.length > 0 ? ( 279 <div className="flex flex-col"> 280 {tags.map((t) => ( 281 <a 282 key={t.tag} 283 href={`/home?tag=${encodeURIComponent(t.tag)}`} 284 className="px-2 py-2.5 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg transition-colors group" 285 > 286 <div className="font-semibold text-sm text-surface-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors"> 287 #{t.tag} 288 </div> 289 <div className="text-xs text-surface-400 dark:text-surface-500 mt-0.5"> 290 {t.count} {t.count === 1 ? "post" : "posts"} 291 </div> 292 </a> 293 ))} 294 </div> 295 ) : ( 296 <div className="px-2"> 297 <p className="text-sm text-surface-400 dark:text-surface-500"> 298 Nothing trending right now. 299 </p> 300 </div> 301 )} 302 </div> 303 304 <div className="px-1 pt-2"> 305 <div className="flex flex-wrap gap-x-3 gap-y-1 text-[12px] text-surface-400 dark:text-surface-500 leading-relaxed"> 306 <a 307 href="/about" 308 className="hover:underline hover:text-surface-600 dark:hover:text-surface-300" 309 > 310 About 311 </a> 312 <a 313 href="/privacy" 314 className="hover:underline hover:text-surface-600 dark:hover:text-surface-300" 315 > 316 Privacy 317 </a> 318 <a 319 href="/terms" 320 className="hover:underline hover:text-surface-600 dark:hover:text-surface-300" 321 > 322 Terms 323 </a> 324 <a 325 href="https://github.com/margin-at/margin" 326 target="_blank" 327 rel="noreferrer" 328 className="hover:underline hover:text-surface-600 dark:hover:text-surface-300" 329 > 330 GitHub 331 </a> 332 <a 333 href="https://tangled.org/margin.at/margin" 334 target="_blank" 335 rel="noreferrer" 336 className="hover:underline hover:text-surface-600 dark:hover:text-surface-300" 337 > 338 Tangled 339 </a> 340 <a 341 href="https://discord.gg/ZQbkGqwzBH" 342 target="_blank" 343 rel="noreferrer" 344 className="hover:underline hover:text-surface-600 dark:hover:text-surface-300" 345 > 346 Discord 347 </a> 348 <a 349 href="https://matrix.to/#/#margin:blep.cat" 350 target="_blank" 351 rel="noreferrer" 352 className="hover:underline hover:text-surface-600 dark:hover:text-surface-300" 353 > 354 Matrix 355 </a> 356 <a 357 href="https://stt.gg/wHnM6e3h" 358 target="_blank" 359 rel="noreferrer" 360 className="hover:underline hover:text-surface-600 dark:hover:text-surface-300" 361 > 362 Stoat 363 </a> 364 <span>© 2026 Margin</span> 365 </div> 366 </div> 367 </div> 368 </aside> 369 ); 370}