Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { useState, useEffect, useRef } from "react";
2import { Link, useNavigate } from "react-router-dom";
3import AnnotationCard, { HighlightCard } from "../components/AnnotationCard";
4import { getByTarget, searchActors } from "../api/client";
5import { useAuth } from "../context/AuthContext";
6import { PenIcon, AlertIcon, SearchIcon } from "../components/Icons";
7import { Copy, Check, ExternalLink } from "lucide-react";
8
9export 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([]);
15 const [loading, setLoading] = useState(false);
16 const [searched, setSearched] = useState(false);
17 const [error, setError] = useState(null);
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 {
105 const data = await getByTarget(url);
106 setAnnotations(data.annotations || []);
107 setHighlights(data.highlights || []);
108 } catch (err) {
109 setError(err.message);
110 } finally {
111 setLoading(false);
112 }
113 };
114
115 const myAnnotations = user
116 ? annotations.filter((a) => (a.creator?.did || a.author?.did) === user.did)
117 : [];
118 const myHighlights = user
119 ? highlights.filter((h) => (h.creator?.did || h.author?.did) === user.did)
120 : [];
121 const myItemsCount = myAnnotations.length + myHighlights.length;
122
123 const getShareUrl = () => {
124 if (!user?.handle || !url) return null;
125 return `${window.location.origin}/${user.handle}/url/${url}`;
126 };
127
128 const handleCopyShareLink = async () => {
129 const shareUrl = getShareUrl();
130 if (!shareUrl) return;
131 try {
132 await navigator.clipboard.writeText(shareUrl);
133 setCopied(true);
134 setTimeout(() => setCopied(false), 2000);
135 } catch {
136 prompt("Copy this link:", shareUrl);
137 }
138 };
139
140 const totalItems = annotations.length + highlights.length;
141
142 const renderResults = () => {
143 if (activeTab === "annotations" && annotations.length === 0) {
144 return (
145 <div className="empty-state">
146 <div className="empty-state-icon">
147 <PenIcon size={32} />
148 </div>
149 <h3 className="empty-state-title">No annotations</h3>
150 </div>
151 );
152 }
153
154 return (
155 <>
156 {(activeTab === "all" || activeTab === "annotations") &&
157 annotations.map((a) => <AnnotationCard key={a.id} annotation={a} />)}
158 {(activeTab === "all" || activeTab === "highlights") &&
159 highlights.map((h) => <HighlightCard key={h.id} highlight={h} />)}
160 </>
161 );
162 };
163
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 && (
300 <div className="empty-state">
301 <div className="empty-state-icon">
302 <AlertIcon size={32} />
303 </div>
304 <h3 className="empty-state-title">Error</h3>
305 <p className="empty-state-text">{error}</p>
306 </div>
307 )}
308
309 {searched && !loading && !error && totalItems === 0 && (
310 <div className="empty-state">
311 <div className="empty-state-icon">
312 <SearchIcon size={32} />
313 </div>
314 <h3 className="empty-state-title">No annotations found</h3>
315 <p className="empty-state-text">
316 Be the first to annotate this URL! Sign in to add your thoughts.
317 </p>
318 </div>
319 )}
320
321 {searched && totalItems > 0 && (
322 <>
323 <div className="url-results-header">
324 <h2 className="feed-title">
325 {totalItems} item{totalItems !== 1 ? "s" : ""}
326 </h2>
327 <div className="feed-filters">
328 <button
329 className={`filter-tab ${activeTab === "all" ? "active" : ""}`}
330 onClick={() => setActiveTab("all")}
331 >
332 All ({totalItems})
333 </button>
334 <button
335 className={`filter-tab ${activeTab === "annotations" ? "active" : ""}`}
336 onClick={() => setActiveTab("annotations")}
337 >
338 Annotations ({annotations.length})
339 </button>
340 <button
341 className={`filter-tab ${activeTab === "highlights" ? "active" : ""}`}
342 onClick={() => setActiveTab("highlights")}
343 >
344 Highlights ({highlights.length})
345 </button>
346 </div>
347 </div>
348
349 {user && myItemsCount > 0 && (
350 <div className="share-notes-banner">
351 <div className="share-notes-info">
352 <ExternalLink size={16} />
353 <span>
354 You have {myItemsCount} note{myItemsCount !== 1 ? "s" : ""} on
355 this page
356 </span>
357 </div>
358 <div className="share-notes-actions">
359 <Link
360 to={`/${user.handle}/url/${encodeURIComponent(url)}`}
361 className="btn btn-ghost btn-sm"
362 >
363 View
364 </Link>
365 <button
366 onClick={handleCopyShareLink}
367 className="btn btn-primary btn-sm"
368 >
369 {copied ? (
370 <>
371 <Check size={14} /> Copied!
372 </>
373 ) : (
374 <>
375 <Copy size={14} /> Copy Share Link
376 </>
377 )}
378 </button>
379 </div>
380 </div>
381 )}
382
383 <div className="feed-container">
384 <div className="feed">{renderResults()}</div>
385 </div>
386 </>
387 )}
388 </div>
389 );
390}