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