(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 main 446 lines 16 kB view raw
1import React, { useState, useEffect, useCallback, useRef } from "react"; 2import { 3 Search as SearchIcon, 4 Loader2, 5 SlidersHorizontal, 6 MessageSquareText, 7 Highlighter, 8 Bookmark, 9} from "lucide-react"; 10import { clsx } from "clsx"; 11import { useStore } from "@nanostores/react"; 12import { useTranslation } from "react-i18next"; 13import { searchItems } from "../../api/client"; 14import type { AnnotationItem } from "../../types"; 15import Card from "../../components/common/Card"; 16import { EmptyState } from "../../components/ui"; 17import LayoutToggle from "../../components/ui/LayoutToggle"; 18import { $user } from "../../store/auth"; 19import { $feedLayout } from "../../store/feedLayout"; 20import { analytics } from "../../lib/analytics"; 21 22const searchCache = new Map< 23 string, 24 { 25 results: AnnotationItem[]; 26 hasMore: boolean; 27 offset: number; 28 timestamp: number; 29 } 30>(); 31 32interface SearchProps { 33 initialQuery?: string; 34 initialResults?: AnnotationItem[]; 35 initialHasMore?: boolean; 36} 37 38export default function Search({ 39 initialQuery = "", 40 initialResults, 41 initialHasMore, 42}: SearchProps) { 43 const { t } = useTranslation(); 44 const user = useStore($user); 45 const layout = useStore($feedLayout); 46 47 const [query, setQuery] = useState(initialQuery); 48 const [results, setResults] = useState<AnnotationItem[]>( 49 initialResults || [], 50 ); 51 const [loading, setLoading] = useState(false); 52 const [hasMore, setHasMore] = useState(initialHasMore ?? false); 53 const [offset, setOffset] = useState(initialResults?.length ?? 0); 54 const [myItemsOnly, setMyItemsOnly] = useState(false); 55 const [activeFilter, setActiveFilter] = useState<string | undefined>( 56 undefined, 57 ); 58 const [platform, setPlatform] = useState<"all" | "margin" | "semble">("all"); 59 const inputRef = useRef<HTMLInputElement>(null); 60 const myItemsRef = useRef(myItemsOnly); 61 const fetchIdRef = useRef(0); 62 63 useEffect(() => { 64 myItemsRef.current = myItemsOnly; 65 }, [myItemsOnly]); 66 67 const filters = [ 68 { id: "all", label: t("search.filters.all"), icon: null }, 69 { 70 id: "commenting", 71 label: t("search.filters.annotations"), 72 icon: MessageSquareText, 73 }, 74 { 75 id: "highlighting", 76 label: t("search.filters.highlights"), 77 icon: Highlighter, 78 }, 79 { id: "bookmarking", label: t("search.filters.bookmarks"), icon: Bookmark }, 80 ]; 81 82 const doSearch = useCallback( 83 async (q: string, newOffset = 0, append = false) => { 84 if (!q.trim()) { 85 setResults([]); 86 return; 87 } 88 89 const cacheKey = JSON.stringify({ 90 q: q.trim(), 91 myItemsOnly: myItemsRef.current, 92 }); 93 94 if (!append && newOffset === 0) { 95 const cached = searchCache.get(cacheKey); 96 if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) { 97 setResults(cached.results); 98 setHasMore(cached.hasMore); 99 setOffset(cached.offset); 100 setLoading(false); 101 102 const id = ++fetchIdRef.current; 103 searchItems(q.trim(), { 104 creator: myItemsRef.current && user ? user.did : undefined, 105 limit: 30, 106 offset: newOffset, 107 }) 108 .then((data) => { 109 if (id !== fetchIdRef.current) return; 110 setResults(data.items); 111 setHasMore(data.hasMore); 112 setOffset(newOffset + data.items.length); 113 searchCache.set(cacheKey, { 114 results: data.items, 115 hasMore: data.hasMore, 116 offset: newOffset + data.items.length, 117 timestamp: Date.now(), 118 }); 119 }) 120 .catch(console.error); 121 122 return; 123 } 124 } 125 126 const id = ++fetchIdRef.current; 127 setLoading(true); 128 const data = await searchItems(q.trim(), { 129 creator: myItemsRef.current && user ? user.did : undefined, 130 limit: 30, 131 offset: newOffset, 132 }); 133 if (id !== fetchIdRef.current) return; 134 if (append) { 135 setResults((prev) => { 136 const newResults = [...prev, ...data.items]; 137 searchCache.set(cacheKey, { 138 results: newResults, 139 hasMore: data.hasMore, 140 offset: newOffset + data.items.length, 141 timestamp: Date.now(), 142 }); 143 return newResults; 144 }); 145 } else { 146 setResults(data.items); 147 searchCache.set(cacheKey, { 148 results: data.items, 149 hasMore: data.hasMore, 150 offset: newOffset + data.items.length, 151 timestamp: Date.now(), 152 }); 153 } 154 setHasMore(data.hasMore); 155 setOffset(newOffset + data.items.length); 156 setLoading(false); 157 }, 158 [user], 159 ); 160 161 const skipInitialSearch = useRef(!!initialResults); 162 useEffect(() => { 163 if (skipInitialSearch.current) { 164 skipInitialSearch.current = false; 165 return; 166 } 167 if (initialQuery) { 168 // eslint-disable-next-line react-hooks/set-state-in-effect 169 doSearch(initialQuery); 170 } 171 }, [initialQuery, doSearch]); 172 173 const handleSubmit = (e: React.FormEvent) => { 174 e.preventDefault(); 175 if (query.trim()) { 176 const url = new URL(window.location.href); 177 url.searchParams.set("q", query.trim()); 178 window.history.replaceState({}, "", url.toString()); 179 analytics.capture("search_performed", { query: query.trim() }); 180 doSearch(query.trim()); 181 } 182 }; 183 184 const handleDelete = (uri: string) => { 185 setResults((prev) => prev.filter((item) => item.uri !== uri)); 186 }; 187 188 const handleFilterChange = (id: string) => { 189 setActiveFilter(id === "all" ? undefined : id); 190 }; 191 192 const filteredResults = results.filter((item) => { 193 if (activeFilter && item.motivation !== activeFilter) return false; 194 if (platform === "margin" && item.uri?.includes("network.cosmik")) 195 return false; 196 if (platform === "semble" && !item.uri?.includes("network.cosmik")) 197 return false; 198 return true; 199 }); 200 201 return ( 202 <div className="mx-auto max-w-2xl xl:max-w-none"> 203 <form onSubmit={handleSubmit} className="mb-4"> 204 <div className="relative"> 205 <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none"> 206 <SearchIcon 207 className="text-surface-400 dark:text-surface-500" 208 size={18} 209 /> 210 </div> 211 <input 212 ref={inputRef} 213 type="text" 214 value={query} 215 onChange={(e) => setQuery(e.target.value)} 216 placeholder={t("search.placeholder")} 217 autoFocus 218 className="w-full pl-11 pr-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl text-sm focus:outline-none focus:border-primary-400 focus:ring-2 focus:ring-primary-400/20 placeholder:text-surface-400" 219 /> 220 </div> 221 </form> 222 223 {initialQuery && ( 224 <div className="sticky top-0 z-10 bg-white/90 dark:bg-surface-800/90 backdrop-blur-md pb-3 mb-2 -mx-1 px-1 pt-2 space-y-2"> 225 <div className="flex items-center gap-1.5 flex-wrap"> 226 {filters.map((f) => { 227 const isActive = 228 f.id === "all" ? !activeFilter : activeFilter === f.id; 229 return ( 230 <button 231 key={f.id} 232 onClick={() => handleFilterChange(f.id)} 233 className={clsx( 234 "inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-all", 235 isActive 236 ? "bg-primary-600 dark:bg-primary-500 text-white border-transparent shadow-sm" 237 : "bg-white dark:bg-surface-900 text-surface-500 dark:text-surface-400 border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-700 hover:text-primary-600 dark:hover:text-primary-400", 238 )} 239 > 240 {f.icon && <f.icon size={12} />} 241 {f.label} 242 </button> 243 ); 244 })} 245 246 {user && ( 247 <button 248 type="button" 249 onClick={() => { 250 const next = !myItemsOnly; 251 setMyItemsOnly(next); 252 myItemsRef.current = next; 253 if (initialQuery) { 254 doSearch(initialQuery); 255 } 256 }} 257 className={clsx( 258 "inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-all", 259 myItemsOnly 260 ? "bg-primary-600 dark:bg-primary-500 text-white border-transparent shadow-sm" 261 : "bg-white dark:bg-surface-900 text-surface-500 dark:text-surface-400 border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-700 hover:text-primary-600 dark:hover:text-primary-400", 262 )} 263 > 264 <SlidersHorizontal size={12} /> 265 {t("search.filters.mine")} 266 </button> 267 )} 268 269 <div className="ml-auto flex items-center gap-1.5"> 270 <div className="inline-flex items-center rounded-lg border border-surface-200 dark:border-surface-700 p-0.5 bg-surface-50 dark:bg-surface-800/60 hidden sm:inline-flex"> 271 <button 272 onClick={() => 273 setPlatform(platform === "margin" ? "all" : "margin") 274 } 275 title="Margin only" 276 className={clsx( 277 "relative flex items-center justify-center w-7 h-7 rounded-md transition-all group", 278 platform === "margin" 279 ? "bg-white dark:bg-surface-700 shadow-sm" 280 : "hover:bg-surface-100 dark:hover:bg-surface-700/50", 281 )} 282 > 283 {platform === "margin" ? ( 284 <img 285 src="/logo.svg" 286 alt="Margin" 287 className="w-4 h-4 transition-all" 288 /> 289 ) : ( 290 <> 291 <img 292 src="/logo.svg" 293 alt="Margin" 294 className="w-4 h-4 transition-all opacity-0 group-hover:opacity-100 absolute" 295 /> 296 <div 297 className="w-4 h-4 bg-surface-400 dark:bg-surface-500 group-hover:opacity-0 transition-all" 298 style={{ 299 maskImage: "url(/logo.svg)", 300 WebkitMaskImage: "url(/logo.svg)", 301 maskSize: "contain", 302 WebkitMaskSize: "contain", 303 maskRepeat: "no-repeat", 304 WebkitMaskRepeat: "no-repeat", 305 maskPosition: "center", 306 WebkitMaskPosition: "center", 307 }} 308 /> 309 </> 310 )} 311 </button> 312 <button 313 onClick={() => 314 setPlatform(platform === "semble" ? "all" : "semble") 315 } 316 title="Semble only" 317 className={clsx( 318 "relative flex items-center justify-center w-7 h-7 rounded-md transition-all group", 319 platform === "semble" 320 ? "bg-white dark:bg-surface-700 shadow-sm" 321 : "hover:bg-surface-100 dark:hover:bg-surface-700/50", 322 )} 323 > 324 {platform === "semble" ? ( 325 <img 326 src="/semble-logo.svg" 327 alt="Semble" 328 className="w-4 h-4 transition-all" 329 /> 330 ) : ( 331 <> 332 <img 333 src="/semble-logo.svg" 334 alt="Semble" 335 className="w-4 h-4 transition-all opacity-0 group-hover:opacity-100 absolute" 336 /> 337 <div 338 className="w-4 h-4 bg-surface-400 dark:bg-surface-500 group-hover:opacity-0 transition-all" 339 style={{ 340 maskImage: "url(/semble-logo.svg)", 341 WebkitMaskImage: "url(/semble-logo.svg)", 342 maskSize: "contain", 343 WebkitMaskSize: "contain", 344 maskRepeat: "no-repeat", 345 WebkitMaskRepeat: "no-repeat", 346 maskPosition: "center", 347 WebkitMaskPosition: "center", 348 }} 349 /> 350 </> 351 )} 352 </button> 353 </div> 354 <LayoutToggle className="hidden sm:inline-flex" /> 355 </div> 356 </div> 357 </div> 358 )} 359 360 {loading && results.length === 0 && ( 361 <div className="flex items-center justify-center py-20 animate-fade-in"> 362 <Loader2 className="animate-spin text-surface-400" size={24} /> 363 </div> 364 )} 365 366 {loading && results.length > 0 && ( 367 <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20"> 368 <div className="bg-white/90 dark:bg-surface-800/90 shadow-lg rounded-full p-3 backdrop-blur-sm animate-in fade-in zoom-in-95"> 369 <Loader2 370 className="animate-spin text-primary-600 dark:text-primary-400" 371 size={24} 372 /> 373 </div> 374 </div> 375 )} 376 377 {!loading && initialQuery && filteredResults.length === 0 && ( 378 <EmptyState 379 icon={<SearchIcon size={48} />} 380 title={t("search.noResults")} 381 message={t("search.noResultsMessage", { query: initialQuery })} 382 /> 383 )} 384 385 {filteredResults.length > 0 && ( 386 <div 387 className={clsx( 388 "transition-opacity duration-200 relative", 389 loading ? "opacity-40 pointer-events-none" : "opacity-100", 390 )} 391 > 392 <p className="text-xs text-surface-400 dark:text-surface-500 font-medium mb-3 px-1"> 393 {t("search.resultCount", { 394 count: filteredResults.length, 395 hasMore: hasMore ? "+" : "", 396 query: initialQuery, 397 })} 398 </p> 399 400 {layout === "mosaic" ? ( 401 <div className="columns-1 sm:columns-2 gap-3 space-y-3"> 402 {filteredResults.map((item) => ( 403 <div key={item.uri} className="break-inside-avoid"> 404 <Card item={item} onDelete={handleDelete} layout="mosaic" /> 405 </div> 406 ))} 407 </div> 408 ) : ( 409 <div className="space-y-3"> 410 {filteredResults.map((item) => ( 411 <Card 412 key={item.uri} 413 item={item} 414 onDelete={handleDelete} 415 layout="list" 416 /> 417 ))} 418 </div> 419 )} 420 421 {hasMore && ( 422 <button 423 onClick={() => doSearch(initialQuery, offset, true)} 424 disabled={loading} 425 className="w-full py-3 mt-3 text-sm font-medium text-primary-600 dark:text-primary-400 bg-surface-50 dark:bg-surface-800 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors disabled:opacity-50" 426 > 427 {loading ? ( 428 <Loader2 className="animate-spin mx-auto" size={16} /> 429 ) : ( 430 t("search.loadMore") 431 )} 432 </button> 433 )} 434 </div> 435 )} 436 437 {!initialQuery && !loading && ( 438 <EmptyState 439 icon={<SearchIcon size={48} />} 440 title={t("search.emptyTitle")} 441 message={t("search.emptyMessage")} 442 /> 443 )} 444 </div> 445 ); 446}