tangled
alpha
login
or
join now
margin.at
/
margin
87
fork
atom
Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
87
fork
atom
overview
issues
4
pulls
1
pipelines
Introducing Margin Search
scanash.com
3 weeks ago
e87fc075
b926e092
+546
-2
6 changed files
expand all
collapse all
unified
split
backend
internal
api
handler.go
db
queries_search.go
web
src
App.tsx
api
client.ts
components
navigation
RightSidebar.tsx
views
core
Search.tsx
+41
backend/internal/api/handler.go
···
79
79
r.Get("/users/{did}/tags", h.HandleGetUserTags)
80
80
81
81
r.Get("/trending-tags", h.HandleGetTrendingTags)
82
82
+
r.Get("/search", h.Search)
82
83
83
84
r.Get("/replies", h.GetReplies)
84
85
r.Get("/likes", h.GetLikeCount)
···
1475
1476
}
1476
1477
return result
1477
1478
}
1479
1479
+
1480
1480
+
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
1481
1481
+
query := r.URL.Query().Get("q")
1482
1482
+
if query == "" {
1483
1483
+
http.Error(w, "q parameter required", http.StatusBadRequest)
1484
1484
+
return
1485
1485
+
}
1486
1486
+
1487
1487
+
creator := r.URL.Query().Get("creator")
1488
1488
+
limit := parseIntParam(r, "limit", 50)
1489
1489
+
offset := parseIntParam(r, "offset", 0)
1490
1490
+
viewerDID := h.getViewerDID(r)
1491
1491
+
1492
1492
+
annotations, _ := h.db.SearchAnnotations(query, creator, limit, offset)
1493
1493
+
highlights, _ := h.db.SearchHighlights(query, creator, limit, offset)
1494
1494
+
bookmarks, _ := h.db.SearchBookmarks(query, creator, limit, offset)
1495
1495
+
1496
1496
+
hydratedAnnotations, _ := hydrateAnnotations(h.db, annotations, viewerDID)
1497
1497
+
hydratedHighlights, _ := hydrateHighlights(h.db, highlights, viewerDID)
1498
1498
+
hydratedBookmarks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID)
1499
1499
+
1500
1500
+
var feed []interface{}
1501
1501
+
for _, a := range hydratedAnnotations {
1502
1502
+
feed = append(feed, a)
1503
1503
+
}
1504
1504
+
for _, hl := range hydratedHighlights {
1505
1505
+
feed = append(feed, hl)
1506
1506
+
}
1507
1507
+
for _, b := range hydratedBookmarks {
1508
1508
+
feed = append(feed, b)
1509
1509
+
}
1510
1510
+
1511
1511
+
sortFeed(feed)
1512
1512
+
1513
1513
+
w.Header().Set("Content-Type", "application/json")
1514
1514
+
json.NewEncoder(w).Encode(map[string]interface{}{
1515
1515
+
"items": feed,
1516
1516
+
"fetchedCount": len(feed),
1517
1517
+
})
1518
1518
+
}
+125
backend/internal/db/queries_search.go
···
1
1
+
package db
2
2
+
3
3
+
func (db *DB) SearchAnnotations(query string, authorDID string, limit, offset int) ([]Annotation, error) {
4
4
+
pattern := "%" + query + "%"
5
5
+
6
6
+
var baseQuery string
7
7
+
var args []interface{}
8
8
+
9
9
+
if authorDID != "" {
10
10
+
baseQuery = db.Rebind(`
11
11
+
SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid
12
12
+
FROM annotations
13
13
+
WHERE author_did = ?
14
14
+
AND (body_value LIKE ? OR target_source LIKE ? OR target_title LIKE ? OR tags_json LIKE ? OR selector_json LIKE ?)
15
15
+
ORDER BY created_at DESC
16
16
+
LIMIT ? OFFSET ?
17
17
+
`)
18
18
+
args = []interface{}{authorDID, pattern, pattern, pattern, pattern, pattern, limit, offset}
19
19
+
} else {
20
20
+
baseQuery = db.Rebind(`
21
21
+
SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid
22
22
+
FROM annotations
23
23
+
WHERE body_value LIKE ? OR target_source LIKE ? OR target_title LIKE ? OR tags_json LIKE ? OR selector_json LIKE ?
24
24
+
ORDER BY created_at DESC
25
25
+
LIMIT ? OFFSET ?
26
26
+
`)
27
27
+
args = []interface{}{pattern, pattern, pattern, pattern, pattern, limit, offset}
28
28
+
}
29
29
+
30
30
+
rows, err := db.Query(baseQuery, args...)
31
31
+
if err != nil {
32
32
+
return nil, err
33
33
+
}
34
34
+
defer rows.Close()
35
35
+
36
36
+
return scanAnnotations(rows)
37
37
+
}
38
38
+
39
39
+
func (db *DB) SearchHighlights(query string, authorDID string, limit, offset int) ([]Highlight, error) {
40
40
+
pattern := "%" + query + "%"
41
41
+
42
42
+
var baseQuery string
43
43
+
var args []interface{}
44
44
+
45
45
+
if authorDID != "" {
46
46
+
baseQuery = db.Rebind(`
47
47
+
SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid
48
48
+
FROM highlights
49
49
+
WHERE author_did = ?
50
50
+
AND (target_source LIKE ? OR target_title LIKE ? OR selector_json LIKE ? OR tags_json LIKE ?)
51
51
+
ORDER BY created_at DESC
52
52
+
LIMIT ? OFFSET ?
53
53
+
`)
54
54
+
args = []interface{}{authorDID, pattern, pattern, pattern, pattern, limit, offset}
55
55
+
} else {
56
56
+
baseQuery = db.Rebind(`
57
57
+
SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid
58
58
+
FROM highlights
59
59
+
WHERE target_source LIKE ? OR target_title LIKE ? OR selector_json LIKE ? OR tags_json LIKE ?
60
60
+
ORDER BY created_at DESC
61
61
+
LIMIT ? OFFSET ?
62
62
+
`)
63
63
+
args = []interface{}{pattern, pattern, pattern, pattern, limit, offset}
64
64
+
}
65
65
+
66
66
+
rows, err := db.Query(baseQuery, args...)
67
67
+
if err != nil {
68
68
+
return nil, err
69
69
+
}
70
70
+
defer rows.Close()
71
71
+
72
72
+
var highlights []Highlight
73
73
+
for rows.Next() {
74
74
+
var h Highlight
75
75
+
if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil {
76
76
+
return nil, err
77
77
+
}
78
78
+
highlights = append(highlights, h)
79
79
+
}
80
80
+
return highlights, nil
81
81
+
}
82
82
+
83
83
+
func (db *DB) SearchBookmarks(query string, authorDID string, limit, offset int) ([]Bookmark, error) {
84
84
+
pattern := "%" + query + "%"
85
85
+
86
86
+
var baseQuery string
87
87
+
var args []interface{}
88
88
+
89
89
+
if authorDID != "" {
90
90
+
baseQuery = db.Rebind(`
91
91
+
SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid
92
92
+
FROM bookmarks
93
93
+
WHERE author_did = ?
94
94
+
AND (source LIKE ? OR title LIKE ? OR description LIKE ? OR tags_json LIKE ?)
95
95
+
ORDER BY created_at DESC
96
96
+
LIMIT ? OFFSET ?
97
97
+
`)
98
98
+
args = []interface{}{authorDID, pattern, pattern, pattern, pattern, limit, offset}
99
99
+
} else {
100
100
+
baseQuery = db.Rebind(`
101
101
+
SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid
102
102
+
FROM bookmarks
103
103
+
WHERE source LIKE ? OR title LIKE ? OR description LIKE ? OR tags_json LIKE ?
104
104
+
ORDER BY created_at DESC
105
105
+
LIMIT ? OFFSET ?
106
106
+
`)
107
107
+
args = []interface{}{pattern, pattern, pattern, pattern, limit, offset}
108
108
+
}
109
109
+
110
110
+
rows, err := db.Query(baseQuery, args...)
111
111
+
if err != nil {
112
112
+
return nil, err
113
113
+
}
114
114
+
defer rows.Close()
115
115
+
116
116
+
var bookmarks []Bookmark
117
117
+
for rows.Next() {
118
118
+
var b Bookmark
119
119
+
if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil {
120
120
+
return nil, err
121
121
+
}
122
122
+
bookmarks = append(bookmarks, b)
123
123
+
}
124
124
+
return bookmarks, nil
125
125
+
}
+10
web/src/App.tsx
···
22
22
} from "./routes/wrappers";
23
23
import About from "./views/About";
24
24
import AdminModeration from "./views/core/AdminModeration";
25
25
+
import Search from "./views/core/Search";
25
26
26
27
function RootRoute() {
27
28
const user = useStore($user);
···
56
57
}
57
58
/>
58
59
<Route path="/my-feed" element={<Navigate to="/home" replace />} />
60
60
+
61
61
+
<Route
62
62
+
path="/search"
63
63
+
element={
64
64
+
<AppLayout>
65
65
+
<Search />
66
66
+
</AppLayout>
67
67
+
}
68
68
+
/>
59
69
60
70
<Route
61
71
path="/annotations"
+28
web/src/api/client.ts
···
244
244
};
245
245
}
246
246
247
247
+
export async function searchItems(
248
248
+
query: string,
249
249
+
options: { creator?: string; limit?: number; offset?: number } = {},
250
250
+
): Promise<FeedResponse> {
251
251
+
const params = new URLSearchParams();
252
252
+
params.append("q", query);
253
253
+
if (options.creator) params.append("creator", options.creator);
254
254
+
if (options.limit) params.append("limit", options.limit.toString());
255
255
+
if (options.offset) params.append("offset", options.offset.toString());
256
256
+
257
257
+
try {
258
258
+
const res = await apiRequest(`/api/search?${params.toString()}`, {
259
259
+
skipAuthRedirect: true,
260
260
+
});
261
261
+
if (!res.ok) throw new Error("Search failed");
262
262
+
const data = await res.json();
263
263
+
const items: AnnotationItem[] = (data.items || []).map(normalizeItem);
264
264
+
return {
265
265
+
items,
266
266
+
hasMore: items.length >= (options.limit || 50),
267
267
+
fetchedCount: items.length,
268
268
+
};
269
269
+
} catch (e) {
270
270
+
console.error("Search error:", e);
271
271
+
return { items: [], hasMore: false, fetchedCount: 0 };
272
272
+
}
273
273
+
}
274
274
+
247
275
export async function getFeed({
248
276
source,
249
277
type = "all",
+4
-2
web/src/components/navigation/RightSidebar.tsx
···
120
120
const q = searchQuery.trim();
121
121
if (looksLikeUrl(q)) {
122
122
navigate(`/url/${encodeURIComponent(q)}`);
123
123
-
} else {
123
123
+
} else if (q.includes(".")) {
124
124
navigate(`/profile/${encodeURIComponent(q)}`);
125
125
+
} else {
126
126
+
navigate(`/search?q=${encodeURIComponent(q)}`);
125
127
}
126
128
setSearchQuery("");
127
129
setSuggestions([]);
···
176
178
suggestions.length > 0 &&
177
179
setShowSuggestions(true)
178
180
}
179
179
-
placeholder="Search users or URLs..."
181
181
+
placeholder="Search..."
180
182
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-surface-200/60 dark:border-surface-700/60"
181
183
/>
182
184
+338
web/src/views/core/Search.tsx
···
1
1
+
import React, { useState, useEffect, useCallback, useRef } from "react";
2
2
+
import { useSearchParams } from "react-router-dom";
3
3
+
import {
4
4
+
Search as SearchIcon,
5
5
+
Loader2,
6
6
+
SlidersHorizontal,
7
7
+
MessageSquareText,
8
8
+
Highlighter,
9
9
+
Bookmark,
10
10
+
} from "lucide-react";
11
11
+
import { clsx } from "clsx";
12
12
+
import { useStore } from "@nanostores/react";
13
13
+
import { searchItems } from "../../api/client";
14
14
+
import type { AnnotationItem } from "../../types";
15
15
+
import Card from "../../components/common/Card";
16
16
+
import { EmptyState } from "../../components/ui";
17
17
+
import LayoutToggle from "../../components/ui/LayoutToggle";
18
18
+
import { $user } from "../../store/auth";
19
19
+
import { $feedLayout } from "../../store/feedLayout";
20
20
+
21
21
+
export default function Search() {
22
22
+
const [searchParams, setSearchParams] = useSearchParams();
23
23
+
const initialQuery = searchParams.get("q") || "";
24
24
+
const user = useStore($user);
25
25
+
const layout = useStore($feedLayout);
26
26
+
27
27
+
const [query, setQuery] = useState(initialQuery);
28
28
+
const [results, setResults] = useState<AnnotationItem[]>([]);
29
29
+
const [loading, setLoading] = useState(false);
30
30
+
const [hasMore, setHasMore] = useState(false);
31
31
+
const [offset, setOffset] = useState(0);
32
32
+
const [myItemsOnly, setMyItemsOnly] = useState(false);
33
33
+
const [activeFilter, setActiveFilter] = useState<string | undefined>(
34
34
+
undefined,
35
35
+
);
36
36
+
const [platform, setPlatform] = useState<"all" | "margin" | "semble">("all");
37
37
+
const inputRef = useRef<HTMLInputElement>(null);
38
38
+
const myItemsRef = useRef(myItemsOnly);
39
39
+
const fetchIdRef = useRef(0);
40
40
+
41
41
+
useEffect(() => {
42
42
+
myItemsRef.current = myItemsOnly;
43
43
+
}, [myItemsOnly]);
44
44
+
45
45
+
const filters = [
46
46
+
{ id: "all", label: "All", icon: null },
47
47
+
{ id: "commenting", label: "Annotations", icon: MessageSquareText },
48
48
+
{ id: "highlighting", label: "Highlights", icon: Highlighter },
49
49
+
{ id: "bookmarking", label: "Bookmarks", icon: Bookmark },
50
50
+
];
51
51
+
52
52
+
const doSearch = useCallback(
53
53
+
async (q: string, newOffset = 0, append = false) => {
54
54
+
if (!q.trim()) {
55
55
+
setResults([]);
56
56
+
return;
57
57
+
}
58
58
+
const id = ++fetchIdRef.current;
59
59
+
setLoading(true);
60
60
+
const data = await searchItems(q.trim(), {
61
61
+
creator: myItemsRef.current && user ? user.did : undefined,
62
62
+
limit: 30,
63
63
+
offset: newOffset,
64
64
+
});
65
65
+
if (id !== fetchIdRef.current) return;
66
66
+
if (append) {
67
67
+
setResults((prev) => [...prev, ...data.items]);
68
68
+
} else {
69
69
+
setResults(data.items);
70
70
+
}
71
71
+
setHasMore(data.hasMore);
72
72
+
setOffset(newOffset + data.items.length);
73
73
+
setLoading(false);
74
74
+
},
75
75
+
[user],
76
76
+
);
77
77
+
78
78
+
useEffect(() => {
79
79
+
if (initialQuery) {
80
80
+
// eslint-disable-next-line react-hooks/set-state-in-effect
81
81
+
doSearch(initialQuery);
82
82
+
}
83
83
+
}, [initialQuery, doSearch]);
84
84
+
85
85
+
const handleSubmit = (e: React.FormEvent) => {
86
86
+
e.preventDefault();
87
87
+
if (query.trim()) {
88
88
+
setSearchParams({ q: query.trim() });
89
89
+
doSearch(query.trim());
90
90
+
}
91
91
+
};
92
92
+
93
93
+
const handleDelete = (uri: string) => {
94
94
+
setResults((prev) => prev.filter((item) => item.uri !== uri));
95
95
+
};
96
96
+
97
97
+
const handleFilterChange = (id: string) => {
98
98
+
setActiveFilter(id === "all" ? undefined : id);
99
99
+
};
100
100
+
101
101
+
const filteredResults = results.filter((item) => {
102
102
+
if (activeFilter && item.motivation !== activeFilter) return false;
103
103
+
if (platform === "margin" && item.uri?.includes("network.cosmik"))
104
104
+
return false;
105
105
+
if (platform === "semble" && !item.uri?.includes("network.cosmik"))
106
106
+
return false;
107
107
+
return true;
108
108
+
});
109
109
+
110
110
+
return (
111
111
+
<div className="mx-auto max-w-2xl xl:max-w-none">
112
112
+
<form onSubmit={handleSubmit} className="mb-4">
113
113
+
<div className="relative">
114
114
+
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
115
115
+
<SearchIcon
116
116
+
className="text-surface-400 dark:text-surface-500"
117
117
+
size={18}
118
118
+
/>
119
119
+
</div>
120
120
+
<input
121
121
+
ref={inputRef}
122
122
+
type="text"
123
123
+
value={query}
124
124
+
onChange={(e) => setQuery(e.target.value)}
125
125
+
placeholder="Search annotations, highlights, bookmarks..."
126
126
+
autoFocus
127
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
128
+
/>
129
129
+
</div>
130
130
+
</form>
131
131
+
132
132
+
{initialQuery && (
133
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
134
+
<div className="flex items-center gap-1.5 flex-wrap">
135
135
+
{filters.map((f) => {
136
136
+
const isActive =
137
137
+
f.id === "all" ? !activeFilter : activeFilter === f.id;
138
138
+
return (
139
139
+
<button
140
140
+
key={f.id}
141
141
+
onClick={() => handleFilterChange(f.id)}
142
142
+
className={clsx(
143
143
+
"inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-all",
144
144
+
isActive
145
145
+
? "bg-primary-600 dark:bg-primary-500 text-white border-transparent shadow-sm"
146
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
147
+
)}
148
148
+
>
149
149
+
{f.icon && <f.icon size={12} />}
150
150
+
{f.label}
151
151
+
</button>
152
152
+
);
153
153
+
})}
154
154
+
155
155
+
{user && (
156
156
+
<button
157
157
+
type="button"
158
158
+
onClick={() => {
159
159
+
const next = !myItemsOnly;
160
160
+
setMyItemsOnly(next);
161
161
+
myItemsRef.current = next;
162
162
+
if (initialQuery) {
163
163
+
doSearch(initialQuery);
164
164
+
}
165
165
+
}}
166
166
+
className={clsx(
167
167
+
"inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-all",
168
168
+
myItemsOnly
169
169
+
? "bg-primary-600 dark:bg-primary-500 text-white border-transparent shadow-sm"
170
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
171
+
)}
172
172
+
>
173
173
+
<SlidersHorizontal size={12} />
174
174
+
Mine
175
175
+
</button>
176
176
+
)}
177
177
+
178
178
+
<div className="ml-auto flex items-center gap-1.5">
179
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
180
+
<button
181
181
+
onClick={() =>
182
182
+
setPlatform(platform === "margin" ? "all" : "margin")
183
183
+
}
184
184
+
title="Margin only"
185
185
+
className={clsx(
186
186
+
"flex items-center justify-center w-7 h-7 rounded-md transition-all group",
187
187
+
platform === "margin"
188
188
+
? "bg-white dark:bg-surface-700 shadow-sm"
189
189
+
: "hover:bg-surface-100 dark:hover:bg-surface-700/50",
190
190
+
)}
191
191
+
>
192
192
+
{platform === "margin" ? (
193
193
+
<img
194
194
+
src="/logo.svg"
195
195
+
alt="Margin"
196
196
+
className="w-4 h-4 transition-all"
197
197
+
/>
198
198
+
) : (
199
199
+
<div
200
200
+
className="w-4 h-4 bg-surface-400 dark:bg-surface-500 group-hover:bg-surface-600 dark:group-hover:bg-surface-300 transition-colors"
201
201
+
style={{
202
202
+
maskImage: "url(/logo.svg)",
203
203
+
WebkitMaskImage: "url(/logo.svg)",
204
204
+
maskSize: "contain",
205
205
+
WebkitMaskSize: "contain",
206
206
+
maskRepeat: "no-repeat",
207
207
+
WebkitMaskRepeat: "no-repeat",
208
208
+
maskPosition: "center",
209
209
+
WebkitMaskPosition: "center",
210
210
+
}}
211
211
+
/>
212
212
+
)}
213
213
+
</button>
214
214
+
<button
215
215
+
onClick={() =>
216
216
+
setPlatform(platform === "semble" ? "all" : "semble")
217
217
+
}
218
218
+
title="Semble only"
219
219
+
className={clsx(
220
220
+
"flex items-center justify-center w-7 h-7 rounded-md transition-all group",
221
221
+
platform === "semble"
222
222
+
? "bg-white dark:bg-surface-700 shadow-sm"
223
223
+
: "hover:bg-surface-100 dark:hover:bg-surface-700/50",
224
224
+
)}
225
225
+
>
226
226
+
{platform === "semble" ? (
227
227
+
<img
228
228
+
src="/semble-logo.svg"
229
229
+
alt="Semble"
230
230
+
className="w-4 h-4 transition-all"
231
231
+
/>
232
232
+
) : (
233
233
+
<div
234
234
+
className="w-4 h-4 bg-surface-400 dark:bg-surface-500 group-hover:bg-surface-600 dark:group-hover:bg-surface-300 transition-colors"
235
235
+
style={{
236
236
+
maskImage: "url(/semble-logo.svg)",
237
237
+
WebkitMaskImage: "url(/semble-logo.svg)",
238
238
+
maskSize: "contain",
239
239
+
WebkitMaskSize: "contain",
240
240
+
maskRepeat: "no-repeat",
241
241
+
WebkitMaskRepeat: "no-repeat",
242
242
+
maskPosition: "center",
243
243
+
WebkitMaskPosition: "center",
244
244
+
}}
245
245
+
/>
246
246
+
)}
247
247
+
</button>
248
248
+
</div>
249
249
+
<LayoutToggle className="hidden sm:inline-flex" />
250
250
+
</div>
251
251
+
</div>
252
252
+
</div>
253
253
+
)}
254
254
+
255
255
+
{loading && results.length === 0 && (
256
256
+
<div className="flex items-center justify-center py-20 animate-fade-in">
257
257
+
<Loader2 className="animate-spin text-surface-400" size={24} />
258
258
+
</div>
259
259
+
)}
260
260
+
261
261
+
{loading && results.length > 0 && (
262
262
+
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
263
263
+
<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">
264
264
+
<Loader2
265
265
+
className="animate-spin text-primary-600 dark:text-primary-400"
266
266
+
size={24}
267
267
+
/>
268
268
+
</div>
269
269
+
</div>
270
270
+
)}
271
271
+
272
272
+
{!loading && initialQuery && filteredResults.length === 0 && (
273
273
+
<EmptyState
274
274
+
icon={<SearchIcon size={48} />}
275
275
+
title="No results found"
276
276
+
message={`Nothing matched "${initialQuery}". Try different keywords.`}
277
277
+
/>
278
278
+
)}
279
279
+
280
280
+
{filteredResults.length > 0 && (
281
281
+
<div
282
282
+
className={clsx(
283
283
+
"transition-opacity duration-200 relative",
284
284
+
loading ? "opacity-40 pointer-events-none" : "opacity-100",
285
285
+
)}
286
286
+
>
287
287
+
<p className="text-xs text-surface-400 dark:text-surface-500 font-medium mb-3 px-1">
288
288
+
{filteredResults.length}
289
289
+
{hasMore ? "+" : ""} results for “{initialQuery}”
290
290
+
</p>
291
291
+
292
292
+
{layout === "mosaic" ? (
293
293
+
<div className="columns-1 sm:columns-2 gap-3 space-y-3">
294
294
+
{filteredResults.map((item) => (
295
295
+
<div key={item.uri} className="break-inside-avoid">
296
296
+
<Card item={item} onDelete={handleDelete} layout="mosaic" />
297
297
+
</div>
298
298
+
))}
299
299
+
</div>
300
300
+
) : (
301
301
+
<div className="space-y-3">
302
302
+
{filteredResults.map((item) => (
303
303
+
<Card
304
304
+
key={item.uri}
305
305
+
item={item}
306
306
+
onDelete={handleDelete}
307
307
+
layout="list"
308
308
+
/>
309
309
+
))}
310
310
+
</div>
311
311
+
)}
312
312
+
313
313
+
{hasMore && (
314
314
+
<button
315
315
+
onClick={() => doSearch(initialQuery, offset, true)}
316
316
+
disabled={loading}
317
317
+
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"
318
318
+
>
319
319
+
{loading ? (
320
320
+
<Loader2 className="animate-spin mx-auto" size={16} />
321
321
+
) : (
322
322
+
"Load more"
323
323
+
)}
324
324
+
</button>
325
325
+
)}
326
326
+
</div>
327
327
+
)}
328
328
+
329
329
+
{!initialQuery && !loading && (
330
330
+
<EmptyState
331
331
+
icon={<SearchIcon size={48} />}
332
332
+
title="Search your library"
333
333
+
message="Find annotations, highlights, and bookmarks by keyword, URL, or tag."
334
334
+
/>
335
335
+
)}
336
336
+
</div>
337
337
+
);
338
338
+
}