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