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