tangled
alpha
login
or
join now
margin.at
/
margin
Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
76
fork
atom
overview
issues
2
pulls
pipelines
support user search in url page
scanash.com
2 weeks ago
87cca4da
3be7368d
+198
-11
1 changed file
expand all
collapse all
unified
split
web
src
pages
Url.jsx
+198
-11
web/src/pages/Url.jsx
···
1
-
import { useState } from "react";
2
-
import { Link } from "react-router-dom";
3
import AnnotationCard, { HighlightCard } from "../components/AnnotationCard";
4
-
import { getByTarget } from "../api/client";
5
import { useAuth } from "../context/AuthContext";
6
import { PenIcon, AlertIcon, SearchIcon } from "../components/Icons";
7
import { Copy, Check, ExternalLink } from "lucide-react";
8
9
export default function Url() {
10
const { user } = useAuth();
0
11
const [url, setUrl] = useState("");
12
const [annotations, setAnnotations] = useState([]);
13
const [highlights, setHighlights] = useState([]);
···
17
const [activeTab, setActiveTab] = useState("all");
18
const [copied, setCopied] = useState(false);
19
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
20
const handleSearch = async (e) => {
21
e.preventDefault();
22
if (!url.trim()) return;
23
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
24
try {
25
-
setLoading(true);
26
-
setError(null);
27
-
setSearched(true);
28
const data = await getByTarget(url);
29
setAnnotations(data.annotations || []);
30
setHighlights(data.highlights || []);
···
87
return (
88
<div className="url-page">
89
<div className="page-header">
90
-
<h1 className="page-title">Browse by URL</h1>
91
<p className="page-description">
92
-
See annotations and highlights for any webpage
0
93
</p>
94
</div>
95
96
-
<form onSubmit={handleSearch} className="url-input-wrapper">
0
0
0
0
97
<div className="url-input-container">
98
<input
99
-
type="url"
0
100
value={url}
101
onChange={(e) => setUrl(e.target.value)}
102
-
placeholder="https://example.com/article"
0
103
className="url-input"
0
104
required
105
/>
106
<button type="submit" className="btn btn-primary" disabled={loading}>
107
{loading ? "Searching..." : "Search"}
108
</button>
109
</div>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
110
</form>
111
112
{error && (
···
1
+
import { useState, useEffect, useRef } from "react";
2
+
import { Link, useNavigate } from "react-router-dom";
3
import AnnotationCard, { HighlightCard } from "../components/AnnotationCard";
4
+
import { getByTarget, searchActors } from "../api/client";
5
import { useAuth } from "../context/AuthContext";
6
import { PenIcon, AlertIcon, SearchIcon } from "../components/Icons";
7
import { Copy, Check, ExternalLink } from "lucide-react";
8
9
export default function Url() {
10
const { user } = useAuth();
11
+
const navigate = useNavigate();
12
const [url, setUrl] = useState("");
13
const [annotations, setAnnotations] = useState([]);
14
const [highlights, setHighlights] = useState([]);
···
18
const [activeTab, setActiveTab] = useState("all");
19
const [copied, setCopied] = useState(false);
20
21
+
const [suggestions, setSuggestions] = useState([]);
22
+
const [showSuggestions, setShowSuggestions] = useState(false);
23
+
const [selectedIndex, setSelectedIndex] = useState(-1);
24
+
const inputRef = useRef(null);
25
+
const suggestionsRef = useRef(null);
26
+
27
+
useEffect(() => {
28
+
const timer = setTimeout(async () => {
29
+
const isUrl = url.includes("http") || url.includes("://");
30
+
if (url.length >= 2 && !isUrl) {
31
+
try {
32
+
const data = await searchActors(url);
33
+
setSuggestions(data.actors || []);
34
+
setShowSuggestions(true);
35
+
} catch {
36
+
// ignore
37
+
}
38
+
} else {
39
+
setSuggestions([]);
40
+
setShowSuggestions(false);
41
+
}
42
+
}, 300);
43
+
return () => clearTimeout(timer);
44
+
}, [url]);
45
+
46
+
useEffect(() => {
47
+
const handleClickOutside = (e) => {
48
+
if (
49
+
suggestionsRef.current &&
50
+
!suggestionsRef.current.contains(e.target) &&
51
+
inputRef.current &&
52
+
!inputRef.current.contains(e.target)
53
+
) {
54
+
setShowSuggestions(false);
55
+
}
56
+
};
57
+
document.addEventListener("mousedown", handleClickOutside);
58
+
return () => document.removeEventListener("mousedown", handleClickOutside);
59
+
}, []);
60
+
61
+
const handleKeyDown = (e) => {
62
+
if (!showSuggestions || suggestions.length === 0) return;
63
+
64
+
if (e.key === "ArrowDown") {
65
+
e.preventDefault();
66
+
setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
67
+
} else if (e.key === "ArrowUp") {
68
+
e.preventDefault();
69
+
setSelectedIndex((prev) => Math.max(prev - 1, -1));
70
+
} else if (e.key === "Enter" && selectedIndex >= 0) {
71
+
e.preventDefault();
72
+
selectSuggestion(suggestions[selectedIndex]);
73
+
} else if (e.key === "Escape") {
74
+
setShowSuggestions(false);
75
+
}
76
+
};
77
+
78
+
const selectSuggestion = (actor) => {
79
+
navigate(`/profile/${encodeURIComponent(actor.handle)}`);
80
+
};
81
+
82
const handleSearch = async (e) => {
83
e.preventDefault();
84
if (!url.trim()) return;
85
86
+
setLoading(true);
87
+
setError(null);
88
+
setSearched(true);
89
+
90
+
const isProtocol = url.startsWith("http://") || url.startsWith("https://");
91
+
if (!isProtocol) {
92
+
try {
93
+
const actorRes = await searchActors(url);
94
+
if (actorRes?.actors?.length > 0) {
95
+
const match = actorRes.actors[0];
96
+
navigate(`/profile/${encodeURIComponent(match.handle)}`);
97
+
return;
98
+
}
99
+
} catch {
100
+
// ignore
101
+
}
102
+
}
103
+
104
try {
0
0
0
105
const data = await getByTarget(url);
106
setAnnotations(data.annotations || []);
107
setHighlights(data.highlights || []);
···
164
return (
165
<div className="url-page">
166
<div className="page-header">
167
+
<h1 className="page-title">Explore</h1>
168
<p className="page-description">
169
+
Search for a URL to view its context layer, or find a user by their
170
+
handle
171
</p>
172
</div>
173
174
+
<form
175
+
onSubmit={handleSearch}
176
+
className="url-input-wrapper"
177
+
style={{ position: "relative" }}
178
+
>
179
<div className="url-input-container">
180
<input
181
+
ref={inputRef}
182
+
type="text"
183
value={url}
184
onChange={(e) => setUrl(e.target.value)}
185
+
onKeyDown={handleKeyDown}
186
+
placeholder="https://... or handle"
187
className="url-input"
188
+
autoComplete="off"
189
required
190
/>
191
<button type="submit" className="btn btn-primary" disabled={loading}>
192
{loading ? "Searching..." : "Search"}
193
</button>
194
</div>
195
+
196
+
{showSuggestions && suggestions.length > 0 && (
197
+
<div
198
+
className="login-suggestions"
199
+
ref={suggestionsRef}
200
+
style={{
201
+
position: "absolute",
202
+
top: "100%",
203
+
left: 0,
204
+
right: 0,
205
+
marginTop: "8px",
206
+
width: "100%",
207
+
zIndex: 50,
208
+
background: "var(--bg-primary)",
209
+
borderRadius: "12px",
210
+
boxShadow: "var(--shadow-lg)",
211
+
border: "1px solid var(--border)",
212
+
maxHeight: "300px",
213
+
overflowY: "auto",
214
+
}}
215
+
>
216
+
{suggestions.map((actor, index) => (
217
+
<button
218
+
key={actor.did}
219
+
type="button"
220
+
className={`login-suggestion ${index === selectedIndex ? "selected" : ""}`}
221
+
onClick={() => selectSuggestion(actor)}
222
+
style={{
223
+
width: "100%",
224
+
textAlign: "left",
225
+
padding: "12px",
226
+
display: "flex",
227
+
alignItems: "center",
228
+
gap: "12px",
229
+
border: "none",
230
+
background:
231
+
index === selectedIndex
232
+
? "var(--bg-secondary)"
233
+
: "transparent",
234
+
cursor: "pointer",
235
+
}}
236
+
>
237
+
<div
238
+
className="login-suggestion-avatar"
239
+
style={{
240
+
width: 32,
241
+
height: 32,
242
+
borderRadius: "50%",
243
+
overflow: "hidden",
244
+
background: "var(--bg-tertiary)",
245
+
}}
246
+
>
247
+
{actor.avatar ? (
248
+
<img
249
+
src={actor.avatar}
250
+
alt=""
251
+
style={{
252
+
width: "100%",
253
+
height: "100%",
254
+
objectFit: "cover",
255
+
}}
256
+
/>
257
+
) : (
258
+
<div
259
+
style={{
260
+
display: "flex",
261
+
alignItems: "center",
262
+
justifyContent: "center",
263
+
height: "100%",
264
+
fontSize: "0.8rem",
265
+
}}
266
+
>
267
+
{(actor.displayName || actor.handle)
268
+
.substring(0, 2)
269
+
.toUpperCase()}
270
+
</div>
271
+
)}
272
+
</div>
273
+
<div
274
+
className="login-suggestion-info"
275
+
style={{ display: "flex", flexDirection: "column" }}
276
+
>
277
+
<span
278
+
className="login-suggestion-name"
279
+
style={{ fontWeight: 600, fontSize: "0.95rem" }}
280
+
>
281
+
{actor.displayName || actor.handle}
282
+
</span>
283
+
<span
284
+
className="login-suggestion-handle"
285
+
style={{
286
+
color: "var(--text-secondary)",
287
+
fontSize: "0.85rem",
288
+
}}
289
+
>
290
+
@{actor.handle}
291
+
</span>
292
+
</div>
293
+
</button>
294
+
))}
295
+
</div>
296
+
)}
297
</form>
298
299
{error && (