Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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 “{initialQuery}”
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}