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

Configure Feed

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

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