Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 352 lines 14 kB view raw
1import React, { useState, useEffect, useCallback, useRef } from "react"; 2import { useSearchParams } from "react-router-dom"; 3import { 4 Search as SearchIcon, 5 Loader2, 6 SlidersHorizontal, 7 MessageSquareText, 8 Highlighter, 9 Bookmark, 10} from "lucide-react"; 11import { clsx } from "clsx"; 12import { useStore } from "@nanostores/react"; 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"; 20 21export default function Search() { 22 const [searchParams, setSearchParams] = useSearchParams(); 23 const initialQuery = searchParams.get("q") || ""; 24 const user = useStore($user); 25 const layout = useStore($feedLayout); 26 27 const [query, setQuery] = useState(initialQuery); 28 const [results, setResults] = useState<AnnotationItem[]>([]); 29 const [loading, setLoading] = useState(false); 30 const [hasMore, setHasMore] = useState(false); 31 const [offset, setOffset] = useState(0); 32 const [myItemsOnly, setMyItemsOnly] = useState(false); 33 const [activeFilter, setActiveFilter] = useState<string | undefined>( 34 undefined, 35 ); 36 const [platform, setPlatform] = useState<"all" | "margin" | "semble">("all"); 37 const inputRef = useRef<HTMLInputElement>(null); 38 const myItemsRef = useRef(myItemsOnly); 39 const fetchIdRef = useRef(0); 40 41 useEffect(() => { 42 myItemsRef.current = myItemsOnly; 43 }, [myItemsOnly]); 44 45 const filters = [ 46 { id: "all", label: "All", icon: null }, 47 { id: "commenting", label: "Annotations", icon: MessageSquareText }, 48 { id: "highlighting", label: "Highlights", icon: Highlighter }, 49 { id: "bookmarking", label: "Bookmarks", icon: Bookmark }, 50 ]; 51 52 const doSearch = useCallback( 53 async (q: string, newOffset = 0, append = false) => { 54 if (!q.trim()) { 55 setResults([]); 56 return; 57 } 58 const id = ++fetchIdRef.current; 59 setLoading(true); 60 const data = await searchItems(q.trim(), { 61 creator: myItemsRef.current && user ? user.did : undefined, 62 limit: 30, 63 offset: newOffset, 64 }); 65 if (id !== fetchIdRef.current) return; 66 if (append) { 67 setResults((prev) => [...prev, ...data.items]); 68 } else { 69 setResults(data.items); 70 } 71 setHasMore(data.hasMore); 72 setOffset(newOffset + data.items.length); 73 setLoading(false); 74 }, 75 [user], 76 ); 77 78 useEffect(() => { 79 if (initialQuery) { 80 // eslint-disable-next-line react-hooks/set-state-in-effect 81 doSearch(initialQuery); 82 } 83 }, [initialQuery, doSearch]); 84 85 const handleSubmit = (e: React.FormEvent) => { 86 e.preventDefault(); 87 if (query.trim()) { 88 setSearchParams({ q: query.trim() }); 89 doSearch(query.trim()); 90 } 91 }; 92 93 const handleDelete = (uri: string) => { 94 setResults((prev) => prev.filter((item) => item.uri !== uri)); 95 }; 96 97 const handleFilterChange = (id: string) => { 98 setActiveFilter(id === "all" ? undefined : id); 99 }; 100 101 const filteredResults = results.filter((item) => { 102 if (activeFilter && item.motivation !== activeFilter) return false; 103 if (platform === "margin" && item.uri?.includes("network.cosmik")) 104 return false; 105 if (platform === "semble" && !item.uri?.includes("network.cosmik")) 106 return false; 107 return true; 108 }); 109 110 return ( 111 <div className="mx-auto max-w-2xl xl:max-w-none"> 112 <form onSubmit={handleSubmit} className="mb-4"> 113 <div className="relative"> 114 <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none"> 115 <SearchIcon 116 className="text-surface-400 dark:text-surface-500" 117 size={18} 118 /> 119 </div> 120 <input 121 ref={inputRef} 122 type="text" 123 value={query} 124 onChange={(e) => setQuery(e.target.value)} 125 placeholder="Search annotations, highlights, bookmarks..." 126 autoFocus 127 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" 128 /> 129 </div> 130 </form> 131 132 {initialQuery && ( 133 <div className="sticky top-0 z-10 bg-white/95 dark:bg-surface-800/95 backdrop-blur-sm pb-3 mb-2 -mx-1 px-1 pt-1 space-y-2"> 134 <div className="flex items-center gap-1.5 flex-wrap"> 135 {filters.map((f) => { 136 const isActive = 137 f.id === "all" ? !activeFilter : activeFilter === f.id; 138 return ( 139 <button 140 key={f.id} 141 onClick={() => handleFilterChange(f.id)} 142 className={clsx( 143 "inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-all", 144 isActive 145 ? "bg-primary-600 dark:bg-primary-500 text-white border-transparent shadow-sm" 146 : "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", 147 )} 148 > 149 {f.icon && <f.icon size={12} />} 150 {f.label} 151 </button> 152 ); 153 })} 154 155 {user && ( 156 <button 157 type="button" 158 onClick={() => { 159 const next = !myItemsOnly; 160 setMyItemsOnly(next); 161 myItemsRef.current = next; 162 if (initialQuery) { 163 doSearch(initialQuery); 164 } 165 }} 166 className={clsx( 167 "inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-all", 168 myItemsOnly 169 ? "bg-primary-600 dark:bg-primary-500 text-white border-transparent shadow-sm" 170 : "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", 171 )} 172 > 173 <SlidersHorizontal size={12} /> 174 Mine 175 </button> 176 )} 177 178 <div className="ml-auto flex items-center gap-1.5"> 179 <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"> 180 <button 181 onClick={() => 182 setPlatform(platform === "margin" ? "all" : "margin") 183 } 184 title="Margin only" 185 className={clsx( 186 "relative flex items-center justify-center w-7 h-7 rounded-md transition-all group", 187 platform === "margin" 188 ? "bg-white dark:bg-surface-700 shadow-sm" 189 : "hover:bg-surface-100 dark:hover:bg-surface-700/50", 190 )} 191 > 192 {platform === "margin" ? ( 193 <img 194 src="/logo.svg" 195 alt="Margin" 196 className="w-4 h-4 transition-all" 197 /> 198 ) : ( 199 <> 200 <img 201 src="/logo.svg" 202 alt="Margin" 203 className="w-4 h-4 transition-all opacity-0 group-hover:opacity-100 absolute" 204 /> 205 <div 206 className="w-4 h-4 bg-surface-400 dark:bg-surface-500 group-hover:opacity-0 transition-all" 207 style={{ 208 maskImage: "url(/logo.svg)", 209 WebkitMaskImage: "url(/logo.svg)", 210 maskSize: "contain", 211 WebkitMaskSize: "contain", 212 maskRepeat: "no-repeat", 213 WebkitMaskRepeat: "no-repeat", 214 maskPosition: "center", 215 WebkitMaskPosition: "center", 216 }} 217 /> 218 </> 219 )} 220 </button> 221 <button 222 onClick={() => 223 setPlatform(platform === "semble" ? "all" : "semble") 224 } 225 title="Semble only" 226 className={clsx( 227 "relative flex items-center justify-center w-7 h-7 rounded-md transition-all group", 228 platform === "semble" 229 ? "bg-white dark:bg-surface-700 shadow-sm" 230 : "hover:bg-surface-100 dark:hover:bg-surface-700/50", 231 )} 232 > 233 {platform === "semble" ? ( 234 <img 235 src="/semble-logo.svg" 236 alt="Semble" 237 className="w-4 h-4 transition-all" 238 /> 239 ) : ( 240 <> 241 <img 242 src="/semble-logo.svg" 243 alt="Semble" 244 className="w-4 h-4 transition-all opacity-0 group-hover:opacity-100 absolute" 245 /> 246 <div 247 className="w-4 h-4 bg-surface-400 dark:bg-surface-500 group-hover:opacity-0 transition-all" 248 style={{ 249 maskImage: "url(/semble-logo.svg)", 250 WebkitMaskImage: "url(/semble-logo.svg)", 251 maskSize: "contain", 252 WebkitMaskSize: "contain", 253 maskRepeat: "no-repeat", 254 WebkitMaskRepeat: "no-repeat", 255 maskPosition: "center", 256 WebkitMaskPosition: "center", 257 }} 258 /> 259 </> 260 )} 261 </button> 262 </div> 263 <LayoutToggle className="hidden sm:inline-flex" /> 264 </div> 265 </div> 266 </div> 267 )} 268 269 {loading && results.length === 0 && ( 270 <div className="flex items-center justify-center py-20 animate-fade-in"> 271 <Loader2 className="animate-spin text-surface-400" size={24} /> 272 </div> 273 )} 274 275 {loading && results.length > 0 && ( 276 <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20"> 277 <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"> 278 <Loader2 279 className="animate-spin text-primary-600 dark:text-primary-400" 280 size={24} 281 /> 282 </div> 283 </div> 284 )} 285 286 {!loading && initialQuery && filteredResults.length === 0 && ( 287 <EmptyState 288 icon={<SearchIcon size={48} />} 289 title="No results found" 290 message={`Nothing matched "${initialQuery}". Try different keywords.`} 291 /> 292 )} 293 294 {filteredResults.length > 0 && ( 295 <div 296 className={clsx( 297 "transition-opacity duration-200 relative", 298 loading ? "opacity-40 pointer-events-none" : "opacity-100", 299 )} 300 > 301 <p className="text-xs text-surface-400 dark:text-surface-500 font-medium mb-3 px-1"> 302 {filteredResults.length} 303 {hasMore ? "+" : ""} results for &ldquo;{initialQuery}&rdquo; 304 </p> 305 306 {layout === "mosaic" ? ( 307 <div className="columns-1 sm:columns-2 gap-3 space-y-3"> 308 {filteredResults.map((item) => ( 309 <div key={item.uri} className="break-inside-avoid"> 310 <Card item={item} onDelete={handleDelete} layout="mosaic" /> 311 </div> 312 ))} 313 </div> 314 ) : ( 315 <div className="space-y-3"> 316 {filteredResults.map((item) => ( 317 <Card 318 key={item.uri} 319 item={item} 320 onDelete={handleDelete} 321 layout="list" 322 /> 323 ))} 324 </div> 325 )} 326 327 {hasMore && ( 328 <button 329 onClick={() => doSearch(initialQuery, offset, true)} 330 disabled={loading} 331 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" 332 > 333 {loading ? ( 334 <Loader2 className="animate-spin mx-auto" size={16} /> 335 ) : ( 336 "Load more" 337 )} 338 </button> 339 )} 340 </div> 341 )} 342 343 {!initialQuery && !loading && ( 344 <EmptyState 345 icon={<SearchIcon size={48} />} 346 title="Search your library" 347 message="Find annotations, highlights, and bookmarks by keyword, URL, or tag." 348 /> 349 )} 350 </div> 351 ); 352}