Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { useState, useEffect } from "react";
2import { useParams } from "react-router-dom";
3import AnnotationCard, { HighlightCard } from "../components/AnnotationCard";
4import { getUserTargetItems } from "../api/client";
5import {
6 PenIcon,
7 HighlightIcon,
8 SearchIcon,
9 BlueskyIcon,
10} from "../components/Icons";
11
12export default function UserUrl() {
13 const { handle, "*": urlPath } = useParams();
14 const targetUrl = urlPath || "";
15
16 const [profile, setProfile] = useState(null);
17 const [annotations, setAnnotations] = useState([]);
18 const [highlights, setHighlights] = useState([]);
19 const [loading, setLoading] = useState(true);
20 const [error, setError] = useState(null);
21 const [activeTab, setActiveTab] = useState("all");
22
23 useEffect(() => {
24 async function fetchData() {
25 if (!targetUrl) {
26 setLoading(false);
27 return;
28 }
29
30 try {
31 setLoading(true);
32 setError(null);
33
34 const profileRes = await fetch(
35 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`,
36 );
37 let did = handle;
38 if (profileRes.ok) {
39 const profileData = await profileRes.json();
40 setProfile(profileData);
41 did = profileData.did;
42 }
43
44 const data = await getUserTargetItems(did, targetUrl);
45 setAnnotations(data.annotations || []);
46 setHighlights(data.highlights || []);
47 } catch (err) {
48 setError(err.message);
49 } finally {
50 setLoading(false);
51 }
52 }
53 fetchData();
54 }, [handle, targetUrl]);
55
56 const displayName = profile?.displayName || profile?.handle || handle;
57 const displayHandle =
58 profile?.handle || (handle?.startsWith("did:") ? null : handle);
59 const avatarUrl = profile?.avatar;
60
61 const getInitial = () => {
62 return (displayName || displayHandle || "??")
63 ?.substring(0, 2)
64 .toUpperCase();
65 };
66
67 const totalItems = annotations.length + highlights.length;
68 const bskyProfileUrl = displayHandle
69 ? `https://bsky.app/profile/${displayHandle}`
70 : `https://bsky.app/profile/${handle}`;
71
72 const renderResults = () => {
73 if (activeTab === "annotations" && annotations.length === 0) {
74 return (
75 <div className="empty-state">
76 <div className="empty-state-icon">
77 <PenIcon size={32} />
78 </div>
79 <h3 className="empty-state-title">No annotations</h3>
80 </div>
81 );
82 }
83
84 if (activeTab === "highlights" && highlights.length === 0) {
85 return (
86 <div className="empty-state">
87 <div className="empty-state-icon">
88 <HighlightIcon size={32} />
89 </div>
90 <h3 className="empty-state-title">No highlights</h3>
91 </div>
92 );
93 }
94
95 return (
96 <>
97 {(activeTab === "all" || activeTab === "annotations") &&
98 annotations.map((a) => <AnnotationCard key={a.uri} annotation={a} />)}
99 {(activeTab === "all" || activeTab === "highlights") &&
100 highlights.map((h) => <HighlightCard key={h.uri} highlight={h} />)}
101 </>
102 );
103 };
104
105 if (!targetUrl) {
106 return (
107 <div className="user-url-page">
108 <div className="empty-state">
109 <div className="empty-state-icon">
110 <SearchIcon size={32} />
111 </div>
112 <h3 className="empty-state-title">No URL specified</h3>
113 <p className="empty-state-text">
114 Please provide a URL to view annotations.
115 </p>
116 </div>
117 </div>
118 );
119 }
120
121 return (
122 <div className="user-url-page">
123 <header className="profile-header">
124 <a
125 href={bskyProfileUrl}
126 target="_blank"
127 rel="noopener noreferrer"
128 className="profile-avatar-link"
129 >
130 <div className="profile-avatar">
131 {avatarUrl ? (
132 <img src={avatarUrl} alt={displayName} />
133 ) : (
134 <span>{getInitial()}</span>
135 )}
136 </div>
137 </a>
138 <div className="profile-info">
139 <h1 className="profile-name">{displayName}</h1>
140 {displayHandle && (
141 <a
142 href={bskyProfileUrl}
143 target="_blank"
144 rel="noopener noreferrer"
145 className="profile-bluesky-link"
146 >
147 <BlueskyIcon size={16} />@{displayHandle}
148 </a>
149 )}
150 </div>
151 </header>
152
153 <div className="url-target-info">
154 <span className="url-target-label">Annotations on:</span>
155 <a
156 href={targetUrl}
157 target="_blank"
158 rel="noopener noreferrer"
159 className="url-target-link"
160 >
161 {targetUrl}
162 </a>
163 </div>
164
165 {loading && (
166 <div className="feed-container">
167 <div className="feed">
168 {[1, 2, 3].map((i) => (
169 <div key={i} className="card">
170 <div
171 className="skeleton skeleton-text"
172 style={{ width: "40%" }}
173 />
174 <div className="skeleton skeleton-text" />
175 <div
176 className="skeleton skeleton-text"
177 style={{ width: "60%" }}
178 />
179 </div>
180 ))}
181 </div>
182 </div>
183 )}
184
185 {error && (
186 <div className="empty-state">
187 <div className="empty-state-icon">⚠️</div>
188 <h3 className="empty-state-title">Error</h3>
189 <p className="empty-state-text">{error}</p>
190 </div>
191 )}
192
193 {!loading && !error && totalItems === 0 && (
194 <div className="empty-state">
195 <div className="empty-state-icon">
196 <SearchIcon size={32} />
197 </div>
198 <h3 className="empty-state-title">No items found</h3>
199 <p className="empty-state-text">
200 {displayName} hasn't annotated this page yet.
201 </p>
202 </div>
203 )}
204
205 {!loading && !error && totalItems > 0 && (
206 <>
207 <div className="url-results-header">
208 <h2 className="feed-title">
209 {totalItems} item{totalItems !== 1 ? "s" : ""}
210 </h2>
211 <div className="feed-filters">
212 <button
213 className={`filter-tab ${activeTab === "all" ? "active" : ""}`}
214 onClick={() => setActiveTab("all")}
215 >
216 All ({totalItems})
217 </button>
218 <button
219 className={`filter-tab ${activeTab === "annotations" ? "active" : ""}`}
220 onClick={() => setActiveTab("annotations")}
221 >
222 Annotations ({annotations.length})
223 </button>
224 <button
225 className={`filter-tab ${activeTab === "highlights" ? "active" : ""}`}
226 onClick={() => setActiveTab("highlights")}
227 >
228 Highlights ({highlights.length})
229 </button>
230 </div>
231 </div>
232 <div className="feed-container">
233 <div className="feed">{renderResults()}</div>
234 </div>
235 </>
236 )}
237 </div>
238 );
239}