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