(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}