(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useState, useEffect } from "react";
2import { useParams } from "react-router-dom";
3import { getUserTargetItems } from "../../api/client";
4import type { AnnotationItem, UserProfile } from "../../types";
5import Card from "../../components/common/Card";
6import {
7 PenTool,
8 Highlighter,
9 Search,
10 AlertTriangle,
11 ExternalLink,
12} from "lucide-react";
13import { clsx } from "clsx";
14import { getAvatarUrl } from "../../api/client";
15
16export default function UserUrlPage() {
17 const params = useParams();
18 const handle = params.handle;
19 const urlPath = params["*"];
20 const targetUrl = urlPath || "";
21
22 const [profile, setProfile] = useState<UserProfile | null>(null);
23 const [annotations, setAnnotations] = useState<AnnotationItem[]>([]);
24 const [highlights, setHighlights] = useState<AnnotationItem[]>([]);
25 const [loading, setLoading] = useState(true);
26 const [error, setError] = useState<string | null>(null);
27 const [activeTab, setActiveTab] = useState<
28 "all" | "annotations" | "highlights"
29 >("all");
30
31 useEffect(() => {
32 async function fetchData() {
33 if (!targetUrl || !handle) {
34 setLoading(false);
35 return;
36 }
37
38 try {
39 setLoading(true);
40 setError(null);
41
42 const profileRes = await fetch(
43 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`,
44 );
45
46 let did = handle;
47 if (profileRes.ok) {
48 const profileData = await profileRes.json();
49 setProfile(profileData);
50 did = profileData.did;
51 }
52
53 const decodedUrl = decodeURIComponent(targetUrl);
54
55 const data = await getUserTargetItems(did, decodedUrl);
56 setAnnotations(data.annotations || []);
57 setHighlights(data.highlights || []);
58 } catch (err) {
59 setError(err instanceof Error ? err.message : "Unknown error");
60 } finally {
61 setLoading(false);
62 }
63 }
64 fetchData();
65 }, [handle, targetUrl]);
66
67 const displayName = profile?.displayName || profile?.handle || handle;
68 const displayHandle =
69 profile?.handle || (handle?.startsWith("did:") ? null : handle);
70 const avatarUrl = getAvatarUrl(profile?.did, profile?.avatar);
71
72 const getInitial = () => {
73 return (displayName || displayHandle || "??")
74 ?.substring(0, 2)
75 .toUpperCase();
76 };
77
78 const totalItems = annotations.length + highlights.length;
79 const bskyProfileUrl = displayHandle
80 ? `https://bsky.app/profile/${displayHandle}`
81 : `https://bsky.app/profile/${handle}`;
82
83 const renderResults = () => {
84 if (activeTab === "annotations" && annotations.length === 0) {
85 return (
86 <div className="flex flex-col items-center justify-center p-12 text-center bg-surface-50 border border-dashed border-surface-200 rounded-2xl">
87 <div className="w-12 h-12 bg-surface-100 rounded-full flex items-center justify-center text-surface-400 mb-4">
88 <PenTool size={24} />
89 </div>
90 <h3 className="text-lg font-medium text-surface-600">
91 No annotations
92 </h3>
93 </div>
94 );
95 }
96
97 if (activeTab === "highlights" && highlights.length === 0) {
98 return (
99 <div className="flex flex-col items-center justify-center p-12 text-center bg-surface-50 border border-dashed border-surface-200 rounded-2xl">
100 <div className="w-12 h-12 bg-surface-100 rounded-full flex items-center justify-center text-surface-400 mb-4">
101 <Highlighter size={24} />
102 </div>
103 <h3 className="text-lg font-medium text-surface-600">
104 No highlights
105 </h3>
106 </div>
107 );
108 }
109
110 return (
111 <div className="space-y-6">
112 {(activeTab === "all" || activeTab === "annotations") &&
113 annotations.map((a) => <Card key={a.uri} item={a} />)}
114 {(activeTab === "all" || activeTab === "highlights") &&
115 highlights.map((h) => <Card key={h.uri} item={h} />)}
116 </div>
117 );
118 };
119
120 if (!targetUrl) {
121 return (
122 <div className="max-w-2xl mx-auto py-20 text-center">
123 <div className="w-16 h-16 bg-surface-100 rounded-full flex items-center justify-center mx-auto mb-4 text-surface-400">
124 <Search size={32} />
125 </div>
126 <h3 className="text-xl font-bold text-surface-900 mb-2">
127 No URL specified
128 </h3>
129 <p className="text-surface-500">
130 Please provide a URL to view annotations.
131 </p>
132 </div>
133 );
134 }
135
136 return (
137 <div className="max-w-3xl mx-auto pb-20">
138 <header className="flex items-center gap-6 mb-8 p-6 bg-white rounded-2xl border border-surface-200 shadow-sm">
139 <a
140 href={bskyProfileUrl}
141 target="_blank"
142 rel="noopener noreferrer"
143 className="shrink-0 hover:opacity-80 transition-opacity"
144 >
145 {avatarUrl ? (
146 <img
147 src={avatarUrl}
148 alt={displayName}
149 className="w-20 h-20 rounded-full object-cover border-4 border-surface-50"
150 />
151 ) : (
152 <div className="w-20 h-20 rounded-full bg-surface-100 flex items-center justify-center text-2xl font-bold text-surface-500 border-4 border-surface-50">
153 {getInitial()}
154 </div>
155 )}
156 </a>
157 <div className="flex-1">
158 <h1 className="text-2xl font-bold text-surface-900 mb-1">
159 {displayName}
160 </h1>
161 {displayHandle && (
162 <a
163 href={bskyProfileUrl}
164 target="_blank"
165 rel="noopener noreferrer"
166 className="text-surface-500 hover:text-primary-600 transition-colors bg-surface-50 hover:bg-primary-50 px-2 py-1 rounded-md text-sm inline-flex items-center gap-1"
167 >
168 @{displayHandle} <ExternalLink size={12} />
169 </a>
170 )}
171 </div>
172 </header>
173
174 <div className="mb-8 p-4 bg-surface-50 border border-surface-200 rounded-xl flex flex-col sm:flex-row sm:items-center gap-4">
175 <span className="text-sm font-semibold text-surface-500 uppercase tracking-wide">
176 Annotations on:
177 </span>
178 <a
179 href={decodeURIComponent(targetUrl)}
180 target="_blank"
181 rel="noopener noreferrer"
182 className="text-primary-600 hover:text-primary-700 hover:underline font-medium truncate flex-1 block"
183 >
184 {decodeURIComponent(targetUrl)}
185 </a>
186 </div>
187
188 {loading && (
189 <div className="flex justify-center py-12">
190 <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
191 </div>
192 )}
193
194 {error && (
195 <div className="mb-8 bg-red-50 text-red-600 p-4 rounded-xl flex items-start gap-3 border border-red-100">
196 <AlertTriangle className="shrink-0 mt-0.5" size={18} />
197 <p>{error}</p>
198 </div>
199 )}
200
201 {!loading && !error && totalItems === 0 && (
202 <div className="text-center py-16 bg-surface-50 rounded-2xl border border-dashed border-surface-200">
203 <div className="w-12 h-12 bg-surface-100 rounded-full flex items-center justify-center mx-auto mb-4 text-surface-400">
204 <PenTool size={24} />
205 </div>
206 <h3 className="text-lg font-bold text-surface-900 mb-1">
207 No items found
208 </h3>
209 <p className="text-surface-500">
210 {displayName} hasn't annotated this page yet.
211 </p>
212 </div>
213 )}
214
215 {!loading && !error && totalItems > 0 && (
216 <div className="animate-fade-in">
217 <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
218 <h2 className="text-xl font-bold text-surface-900">
219 {totalItems} item{totalItems !== 1 ? "s" : ""}
220 </h2>
221 <div className="flex bg-surface-100 p-1 rounded-xl self-start md:self-auto">
222 <button
223 className={clsx(
224 "px-4 py-1.5 rounded-lg text-sm font-medium transition-all",
225 activeTab === "all"
226 ? "bg-white text-surface-900 shadow-sm"
227 : "text-surface-500 hover:text-surface-700",
228 )}
229 onClick={() => setActiveTab("all")}
230 >
231 All ({totalItems})
232 </button>
233 <button
234 className={clsx(
235 "px-4 py-1.5 rounded-lg text-sm font-medium transition-all",
236 activeTab === "annotations"
237 ? "bg-white text-surface-900 shadow-sm"
238 : "text-surface-500 hover:text-surface-700",
239 )}
240 onClick={() => setActiveTab("annotations")}
241 >
242 Annotations ({annotations.length})
243 </button>
244 <button
245 className={clsx(
246 "px-4 py-1.5 rounded-lg text-sm font-medium transition-all",
247 activeTab === "highlights"
248 ? "bg-white text-surface-900 shadow-sm"
249 : "text-surface-500 hover:text-surface-700",
250 )}
251 onClick={() => setActiveTab("highlights")}
252 >
253 Highlights ({highlights.length})
254 </button>
255 </div>
256 </div>
257 <div className="space-y-6">{renderResults()}</div>
258 </div>
259 )}
260 </div>
261 );
262}