Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { useStore } from "@nanostores/react";
2import {
3 AlertTriangle,
4 Check,
5 Copy,
6 ExternalLink,
7 Globe,
8 Highlighter,
9 Loader2,
10 PenTool,
11 Search,
12 User,
13 Users,
14} from "lucide-react";
15import React, { useCallback, useEffect, useRef, useState } from "react";
16import { useNavigate, useParams } from "react-router-dom";
17import { getByTarget } from "../../api/client";
18import Card from "../../components/common/Card";
19import { Button, EmptyState, Input, Tabs } from "../../components/ui";
20import { $user } from "../../store/auth";
21import type { AnnotationItem } from "../../types";
22
23export default function UrlPage() {
24 const params = useParams();
25 const navigate = useNavigate();
26 const urlPath = params["*"];
27 const targetUrl = urlPath ? decodeURIComponent(urlPath) : "";
28
29 const [annotations, setAnnotations] = useState<AnnotationItem[]>([]);
30 const [highlights, setHighlights] = useState<AnnotationItem[]>([]);
31 const [loading, setLoading] = useState(true);
32 const [loadingMore, setLoadingMore] = useState(false);
33 const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
34 const [hasMore, setHasMore] = useState(false);
35 const [offset, setOffset] = useState(0);
36 const [error, setError] = useState<string | null>(null);
37 const [activeTab, setActiveTab] = useState<
38 "all" | "annotations" | "highlights"
39 >("all");
40 const [copied, setCopied] = useState(false);
41 const user = useStore($user);
42
43 const LIMIT = 50;
44 const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
45
46 useEffect(() => {
47 return () => {
48 if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
49 };
50 }, []);
51
52 useEffect(() => {
53 async function fetchData() {
54 if (!targetUrl) {
55 setLoading(false);
56 return;
57 }
58
59 try {
60 setLoading(true);
61 setError(null);
62
63 const data = await getByTarget(targetUrl, LIMIT, 0);
64 const fetchedAnnotations = data.annotations || [];
65 const fetchedHighlights = data.highlights || [];
66 setAnnotations(fetchedAnnotations);
67 setHighlights(fetchedHighlights);
68 const totalFetched =
69 fetchedAnnotations.length + fetchedHighlights.length;
70 setHasMore(totalFetched >= LIMIT);
71 setOffset(totalFetched);
72 } catch (err) {
73 setError(err instanceof Error ? err.message : "Failed to load data");
74 } finally {
75 setLoading(false);
76 }
77 }
78 fetchData();
79 }, [targetUrl]);
80
81 const loadMore = useCallback(async () => {
82 setLoadingMore(true);
83 setLoadMoreError(null);
84 try {
85 const data = await getByTarget(targetUrl, LIMIT, offset);
86 const fetchedAnnotations = data.annotations || [];
87 const fetchedHighlights = data.highlights || [];
88 setAnnotations((prev) => [...prev, ...fetchedAnnotations]);
89 setHighlights((prev) => [...prev, ...fetchedHighlights]);
90 const totalFetched = fetchedAnnotations.length + fetchedHighlights.length;
91 setHasMore(totalFetched >= LIMIT);
92 setOffset((prev) => prev + totalFetched);
93 } catch (err) {
94 console.error("Failed to load more:", err);
95 const msg = err instanceof Error ? err.message : "Something went wrong";
96 setLoadMoreError(msg);
97 if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
98 loadMoreTimerRef.current = setTimeout(() => setLoadMoreError(null), 5000);
99 } finally {
100 setLoadingMore(false);
101 }
102 }, [targetUrl, offset]);
103
104 const handleCopyLink = useCallback(async () => {
105 try {
106 await navigator.clipboard.writeText(window.location.href);
107 setCopied(true);
108 setTimeout(() => setCopied(false), 2000);
109 } catch (err) {
110 console.error("Failed to copy link:", err);
111 }
112 }, []);
113
114 const handleNavigateMyAnnotations = useCallback(async () => {
115 if (!user?.handle || !targetUrl) return;
116 navigate(`/${user.handle}/url/${encodeURIComponent(targetUrl)}`);
117 }, [user?.handle, targetUrl, navigate]);
118
119 const totalItems = annotations.length + highlights.length;
120
121 const uniqueAuthors = new Map<
122 string,
123 { did: string; handle?: string; displayName?: string; avatar?: string }
124 >();
125 [...annotations, ...highlights].forEach((item) => {
126 const author = item.author || item.creator;
127 if (author?.did && !uniqueAuthors.has(author.did)) {
128 uniqueAuthors.set(author.did, author);
129 }
130 });
131 const authorCount = uniqueAuthors.size;
132
133 const hostname = (() => {
134 try {
135 return new URL(targetUrl).hostname;
136 } catch {
137 return targetUrl;
138 }
139 })();
140
141 const favicon = targetUrl
142 ? `https://www.google.com/s2/favicons?domain=${hostname}&sz=32`
143 : null;
144
145 if (!targetUrl) {
146 return (
147 <div className="max-w-2xl mx-auto pb-20 animate-fade-in">
148 <div className="text-center py-10">
149 <div className="w-16 h-16 bg-primary-50 dark:bg-primary-900/20 rounded-2xl flex items-center justify-center mx-auto mb-6 rotate-3">
150 <Globe
151 size={32}
152 className="text-primary-600 dark:text-primary-400"
153 />
154 </div>
155 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-3">
156 URL Annotations
157 </h1>
158 <p className="text-surface-500 dark:text-surface-400 max-w-md mx-auto mb-8">
159 Enter a URL to see all public annotations and highlights from the
160 Margin community.
161 </p>
162
163 <form
164 onSubmit={(e) => {
165 e.preventDefault();
166 const formData = new FormData(e.currentTarget);
167 const q = (formData.get("q") as string)?.trim();
168 if (q) {
169 const encoded = encodeURIComponent(q);
170 navigate(`/url/${encoded}`);
171 }
172 }}
173 className="max-w-md mx-auto flex gap-2"
174 >
175 <div className="flex-1">
176 <Input
177 name="q"
178 placeholder="https://example.com/article"
179 className="w-full bg-surface-50 dark:bg-surface-800"
180 autoFocus
181 />
182 </div>
183 <Button type="submit">View</Button>
184 </form>
185 </div>
186 </div>
187 );
188 }
189
190 const items = [
191 ...(activeTab === "all" || activeTab === "annotations" ? annotations : []),
192 ...(activeTab === "all" || activeTab === "highlights" ? highlights : []),
193 ];
194
195 if (activeTab === "all") {
196 items.sort((a, b) => {
197 const dateA = new Date(a.createdAt).getTime();
198 const dateB = new Date(b.createdAt).getTime();
199 return dateB - dateA;
200 });
201 }
202
203 return (
204 <div className="max-w-3xl mx-auto pb-20 animate-fade-in">
205 <header className="mb-8 p-6 bg-white dark:bg-surface-800 rounded-2xl border border-surface-200 dark:border-surface-700 shadow-sm">
206 <div className="flex items-start gap-4">
207 {favicon && (
208 <img
209 src={favicon}
210 alt=""
211 className="w-8 h-8 rounded-lg mt-1 shrink-0"
212 onError={(e) => {
213 (e.target as HTMLImageElement).style.display = "none";
214 }}
215 />
216 )}
217 <div className="flex-1 min-w-0">
218 <h1 className="text-xl font-bold text-surface-900 dark:text-white mb-1 break-all">
219 {hostname}
220 </h1>
221 <a
222 href={targetUrl}
223 target="_blank"
224 rel="noopener noreferrer"
225 className="text-sm text-primary-600 dark:text-primary-400 hover:underline break-all flex items-center gap-1 leading-relaxed"
226 >
227 <span className="truncate">{targetUrl}</span>
228 <ExternalLink size={12} className="shrink-0" />
229 </a>
230 </div>
231 <div className="flex items-center gap-2 shrink-0">
232 {user && (
233 <button
234 onClick={handleNavigateMyAnnotations}
235 className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 text-surface-700 dark:text-surface-200 text-sm font-medium rounded-lg transition-colors"
236 title="See your annotations for this page"
237 >
238 <User size={14} /> My Annotations
239 </button>
240 )}
241 <button
242 onClick={handleCopyLink}
243 className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 text-surface-700 dark:text-surface-200 text-sm font-medium rounded-lg transition-colors"
244 title="Copy shareable link"
245 >
246 {copied ? <Check size={14} /> : <Copy size={14} />}
247 {copied ? "Copied!" : "Share"}
248 </button>
249 </div>
250 </div>
251
252 {!loading && totalItems > 0 && (
253 <div className="mt-4 pt-4 border-t border-surface-100 dark:border-surface-700 flex items-center gap-4 text-sm text-surface-500 dark:text-surface-400">
254 <span className="flex items-center gap-1.5">
255 <Users size={14} />
256 {authorCount} contributor{authorCount !== 1 ? "s" : ""}
257 </span>
258 </div>
259 )}
260 </header>
261
262 {loading && (
263 <div className="flex flex-col items-center justify-center py-20">
264 <Loader2
265 className="animate-spin text-primary-600 dark:text-primary-400 mb-4"
266 size={32}
267 />
268 <p className="text-surface-500 dark:text-surface-400">
269 Loading annotations...
270 </p>
271 </div>
272 )}
273
274 {error && (
275 <div className="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-xl flex items-start gap-3 border border-red-100 dark:border-red-900/30 mb-6">
276 <AlertTriangle className="shrink-0 mt-0.5" size={18} />
277 <p>{error}</p>
278 </div>
279 )}
280
281 {!loading && !error && totalItems === 0 && (
282 <EmptyState
283 icon={<Search size={48} />}
284 title="No annotations yet"
285 message="Nobody has annotated this page yet. Be the first — install the Margin extension and start annotating!"
286 />
287 )}
288
289 {!loading && !error && totalItems > 0 && (
290 <div>
291 <div className="mb-6">
292 <Tabs
293 tabs={[
294 { id: "all", label: "All" },
295 { id: "annotations", label: "Annotations" },
296 { id: "highlights", label: "Highlights" },
297 ]}
298 activeTab={activeTab}
299 onChange={(id: string) =>
300 setActiveTab(id as "all" | "annotations" | "highlights")
301 }
302 />
303 </div>
304
305 <div className="space-y-4">
306 {activeTab === "annotations" && annotations.length === 0 && (
307 <EmptyState
308 icon={<PenTool size={32} />}
309 title="No annotations"
310 message="There are no annotations for this page yet."
311 />
312 )}
313 {activeTab === "highlights" && highlights.length === 0 && (
314 <EmptyState
315 icon={<Highlighter size={32} />}
316 title="No highlights"
317 message="There are no highlights for this page yet."
318 />
319 )}
320
321 {items.map((item) => (
322 <Card key={item.uri} item={item} />
323 ))}
324 </div>
325
326 {hasMore && (
327 <div className="flex flex-col items-center gap-2 py-6">
328 {loadMoreError && (
329 <p className="text-sm text-red-500 dark:text-red-400">
330 Failed to load more: {loadMoreError}
331 </p>
332 )}
333 <button
334 onClick={loadMore}
335 disabled={loadingMore}
336 className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors disabled:opacity-50"
337 >
338 {loadingMore ? (
339 <>
340 <Loader2 size={16} className="animate-spin" />
341 Loading...
342 </>
343 ) : (
344 "Load more"
345 )}
346 </button>
347 </div>
348 )}
349 </div>
350 )}
351 </div>
352 );
353}