Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import {
2 AlertTriangle,
3 ExternalLink,
4 Highlighter,
5 Loader2,
6 PenTool,
7 Search,
8} from "lucide-react";
9import React, { useCallback, useEffect, useState } from "react";
10import { getUserTargetItems } from "../../api/client";
11import Card from "../../components/common/Card";
12import Avatar from "../../components/ui/Avatar";
13import { EmptyState, Tabs } from "../../components/ui";
14import type { AnnotationItem, UserProfile } from "../../types";
15
16interface UserUrlPageProps {
17 handle?: string;
18 urlPath?: string;
19}
20
21export default function UserUrlPage({ handle, urlPath }: UserUrlPageProps) {
22 const targetUrl = urlPath || "";
23
24 const [profile, setProfile] = useState<UserProfile | null>(null);
25 const [annotations, setAnnotations] = useState<AnnotationItem[]>([]);
26 const [highlights, setHighlights] = useState<AnnotationItem[]>([]);
27 const [loading, setLoading] = useState(true);
28 const [loadingMore, setLoadingMore] = useState(false);
29 const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
30 const [hasMore, setHasMore] = useState(false);
31 const [offset, setOffset] = useState(0);
32 const [error, setError] = useState<string | null>(null);
33 const [activeTab, setActiveTab] = useState<
34 "all" | "annotations" | "highlights"
35 >("all");
36
37 const LIMIT = 50;
38 const [resolvedDid, setResolvedDid] = useState<string | null>(null);
39
40 useEffect(() => {
41 async function fetchData() {
42 if (!targetUrl || !handle) {
43 setLoading(false);
44 return;
45 }
46
47 try {
48 setLoading(true);
49 setError(null);
50
51 const profileRes = await fetch(
52 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`,
53 );
54
55 let did = handle;
56 if (profileRes.ok) {
57 const profileData = await profileRes.json();
58 setProfile(profileData);
59 did = profileData.did;
60 }
61
62 const decodedUrl = decodeURIComponent(targetUrl);
63 setResolvedDid(did);
64
65 const data = await getUserTargetItems(did, decodedUrl, 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 : "Unknown error");
76 } finally {
77 setLoading(false);
78 }
79 }
80 fetchData();
81 }, [handle, targetUrl]);
82
83 const loadMore = useCallback(async () => {
84 if (!resolvedDid) return;
85 setLoadingMore(true);
86 setLoadMoreError(null);
87 try {
88 const decodedUrl = decodeURIComponent(targetUrl);
89 const data = await getUserTargetItems(
90 resolvedDid,
91 decodedUrl,
92 LIMIT,
93 offset,
94 );
95 const fetchedAnnotations = data.annotations || [];
96 const fetchedHighlights = data.highlights || [];
97 setAnnotations((prev) => [...prev, ...fetchedAnnotations]);
98 setHighlights((prev) => [...prev, ...fetchedHighlights]);
99 const totalFetched = fetchedAnnotations.length + fetchedHighlights.length;
100 setHasMore(totalFetched >= LIMIT);
101 setOffset((prev) => prev + totalFetched);
102 } catch (err) {
103 console.error("Failed to load more:", err);
104 const msg = err instanceof Error ? err.message : "Something went wrong";
105 setLoadMoreError(msg);
106 setTimeout(() => setLoadMoreError(null), 5000);
107 } finally {
108 setLoadingMore(false);
109 }
110 }, [resolvedDid, targetUrl, offset]);
111
112 const displayName = profile?.displayName || profile?.handle || handle;
113 const displayHandle =
114 profile?.handle || (handle?.startsWith("did:") ? null : handle);
115
116 const totalItems = annotations.length + highlights.length;
117 const decodedTargetUrl = decodeURIComponent(targetUrl);
118
119 const items = [
120 ...(activeTab === "all" || activeTab === "annotations" ? annotations : []),
121 ...(activeTab === "all" || activeTab === "highlights" ? highlights : []),
122 ];
123
124 if (activeTab === "all") {
125 items.sort((a, b) => {
126 const dateA = new Date(a.createdAt).getTime();
127 const dateB = new Date(b.createdAt).getTime();
128 return dateB - dateA;
129 });
130 }
131
132 if (!targetUrl) {
133 return (
134 <EmptyState
135 icon={<Search size={48} />}
136 title="No URL specified"
137 message="Please provide a URL to view annotations."
138 />
139 );
140 }
141
142 return (
143 <div className="max-w-2xl mx-auto pb-20 animate-fade-in">
144 <div className="card p-5 mb-4">
145 <div className="flex items-start gap-4">
146 <a
147 href={`/profile/${displayHandle || handle}`}
148 className="shrink-0 hover:opacity-80 transition-opacity"
149 >
150 <Avatar
151 did={profile?.did}
152 avatar={profile?.avatar}
153 size="lg"
154 className="ring-4 ring-surface-100 dark:ring-surface-800"
155 />
156 </a>
157 <div className="flex-1 min-w-0">
158 <a
159 href={`/profile/${displayHandle || handle}`}
160 className="hover:underline"
161 >
162 <h1 className="text-xl font-bold text-surface-900 dark:text-white truncate">
163 {displayName}
164 </h1>
165 </a>
166 {displayHandle && (
167 <p className="text-surface-500 dark:text-surface-400">
168 @{displayHandle}
169 </p>
170 )}
171 </div>
172 </div>
173
174 <div className="mt-4 pt-4 border-t border-surface-100 dark:border-surface-700">
175 <div className="flex items-center gap-2 text-sm">
176 <span className="text-surface-400 dark:text-surface-500 font-medium shrink-0">
177 on
178 </span>
179 <a
180 href={decodedTargetUrl}
181 target="_blank"
182 rel="noopener noreferrer"
183 className="text-primary-600 dark:text-primary-400 hover:underline truncate flex items-center gap-1"
184 >
185 <span className="truncate">{decodedTargetUrl}</span>
186 <ExternalLink size={12} className="shrink-0" />
187 </a>
188 </div>
189 </div>
190 </div>
191
192 {loading && (
193 <div className="flex flex-col items-center justify-center py-20">
194 <Loader2
195 className="animate-spin text-primary-600 dark:text-primary-400 mb-4"
196 size={32}
197 />
198 <p className="text-surface-500 dark:text-surface-400">
199 Loading annotations...
200 </p>
201 </div>
202 )}
203
204 {error && (
205 <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">
206 <AlertTriangle className="shrink-0 mt-0.5" size={18} />
207 <p>{error}</p>
208 </div>
209 )}
210
211 {!loading && !error && totalItems === 0 && (
212 <EmptyState
213 icon={<PenTool size={32} />}
214 title="No items found"
215 message={`${displayName} hasn't annotated this page yet.`}
216 />
217 )}
218
219 {!loading && !error && totalItems > 0 && (
220 <div>
221 <div className="mb-6">
222 <Tabs
223 tabs={[
224 { id: "all", label: "All" },
225 { id: "annotations", label: "Annotations" },
226 { id: "highlights", label: "Highlights" },
227 ]}
228 activeTab={activeTab}
229 onChange={(id: string) =>
230 setActiveTab(id as "all" | "annotations" | "highlights")
231 }
232 />
233 </div>
234
235 <div className="space-y-4">
236 {activeTab === "annotations" && annotations.length === 0 && (
237 <EmptyState
238 icon={<PenTool size={32} />}
239 title="No annotations"
240 message={`${displayName} hasn't annotated this page yet.`}
241 />
242 )}
243 {activeTab === "highlights" && highlights.length === 0 && (
244 <EmptyState
245 icon={<Highlighter size={32} />}
246 title="No highlights"
247 message={`${displayName} hasn't highlighted this page yet.`}
248 />
249 )}
250
251 {items.map((item) => (
252 <Card key={item.uri} item={item} />
253 ))}
254 </div>
255
256 {hasMore && (
257 <div className="flex flex-col items-center gap-2 py-6">
258 {loadMoreError && (
259 <p className="text-sm text-red-500 dark:text-red-400">
260 Failed to load more: {loadMoreError}
261 </p>
262 )}
263 <button
264 onClick={loadMore}
265 disabled={loadingMore}
266 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"
267 >
268 {loadingMore ? (
269 <>
270 <Loader2 size={16} className="animate-spin" />
271 Loading...
272 </>
273 ) : (
274 "Load more"
275 )}
276 </button>
277 </div>
278 )}
279 </div>
280 )}
281 </div>
282 );
283}