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, Navigate } from "react-router-dom";
3import AnnotationCard, { HighlightCard } from "../components/AnnotationCard";
4import BookmarkCard from "../components/BookmarkCard";
5import { getLinkIconType, formatUrl } from "../utils/formatting";
6import {
7 getUserAnnotations,
8 getUserHighlights,
9 getUserBookmarks,
10 getCollections,
11 getProfile,
12 getAPIKeys,
13 createAPIKey,
14 deleteAPIKey,
15} from "../api/client";
16import { useAuth } from "../context/AuthContext";
17import EditProfileModal from "../components/EditProfileModal";
18import CollectionIcon from "../components/CollectionIcon";
19import CollectionRow from "../components/CollectionRow";
20import {
21 PenIcon,
22 HighlightIcon,
23 BookmarkIcon,
24 BlueskyIcon,
25 GithubIcon,
26 LinkedinIcon,
27 TangledIcon,
28 LinkIcon,
29} from "../components/Icons";
30
31function LinkIconComponent({ url }) {
32 const type = getLinkIconType(url);
33 switch (type) {
34 case "github":
35 return <GithubIcon size={14} />;
36 case "bluesky":
37 return <BlueskyIcon size={14} />;
38 case "linkedin":
39 return <LinkedinIcon size={14} />;
40 case "tangled":
41 return <TangledIcon size={14} />;
42 default:
43 return <LinkIcon size={14} />;
44 }
45}
46
47function KeyIcon({ size = 16 }) {
48 return (
49 <svg
50 width={size}
51 height={size}
52 viewBox="0 0 24 24"
53 fill="none"
54 stroke="currentColor"
55 strokeWidth="2"
56 strokeLinecap="round"
57 strokeLinejoin="round"
58 >
59 <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
60 </svg>
61 );
62}
63
64export default function Profile() {
65 const { handle: routeHandle } = useParams();
66 const { user, loading: authLoading } = useAuth();
67 const [activeTab, setActiveTab] = useState("annotations");
68 const [profile, setProfile] = useState(null);
69 const [annotations, setAnnotations] = useState([]);
70 const [highlights, setHighlights] = useState([]);
71 const [bookmarks, setBookmarks] = useState([]);
72 const [collections, setCollections] = useState([]);
73 const [apiKeys, setApiKeys] = useState([]);
74 const [newKeyName, setNewKeyName] = useState("");
75 const [newKey, setNewKey] = useState(null);
76 const [keysLoading, setKeysLoading] = useState(false);
77 const [loading, setLoading] = useState(true);
78 const [error, setError] = useState(null);
79 const [showEditModal, setShowEditModal] = useState(false);
80
81 const handle = routeHandle || user?.did || user?.handle;
82 const isOwnProfile = user && (user.did === handle || user.handle === handle);
83
84 useEffect(() => {
85 if (!handle) return;
86 async function fetchProfile() {
87 try {
88 setLoading(true);
89
90 const bskyPromise = fetch(
91 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`,
92 ).then((res) => (res.ok ? res.json() : null));
93
94 const marginPromise = getProfile(handle).catch(() => null);
95
96 const marginData = await marginPromise;
97 let did = handle.startsWith("did:") ? handle : marginData?.did;
98 if (!did) {
99 const bskyData = await bskyPromise;
100 if (bskyData) {
101 did = bskyData.did;
102 setProfile(bskyData);
103 }
104 } else {
105 if (marginData) {
106 setProfile((prev) => ({ ...prev, ...marginData }));
107 }
108 }
109
110 if (did) {
111 const [annData, hlData, bmData, collData] = await Promise.all([
112 getUserAnnotations(did),
113 getUserHighlights(did).catch(() => ({ items: [] })),
114 getUserBookmarks(did).catch(() => ({ items: [] })),
115 getCollections(did).catch(() => ({ items: [] })),
116 ]);
117 setAnnotations(annData.items || []);
118 setHighlights(hlData.items || []);
119 setBookmarks(bmData.items || []);
120 setCollections(collData.items || []);
121
122 const bskyData = await bskyPromise;
123 if (bskyData || marginData) {
124 setProfile((prev) => ({
125 ...(bskyData || {}),
126 ...prev,
127 ...(marginData || {}),
128 }));
129 }
130 }
131 } catch (err) {
132 console.error(err);
133 setError(err.message);
134 } finally {
135 setLoading(false);
136 }
137 }
138 fetchProfile();
139 }, [handle]);
140
141 useEffect(() => {
142 if (isOwnProfile && activeTab === "apikeys") {
143 loadAPIKeys();
144 }
145 }, [isOwnProfile, activeTab]);
146
147 const loadAPIKeys = async () => {
148 setKeysLoading(true);
149 try {
150 const data = await getAPIKeys();
151 setApiKeys(data.keys || []);
152 } catch {
153 setApiKeys([]);
154 } finally {
155 setKeysLoading(false);
156 }
157 };
158
159 const handleCreateKey = async () => {
160 if (!newKeyName.trim()) return;
161 try {
162 const data = await createAPIKey(newKeyName.trim());
163 setNewKey(data.key);
164 setNewKeyName("");
165 loadAPIKeys();
166 } catch (err) {
167 alert("Failed to create key: " + err.message);
168 }
169 };
170
171 const handleDeleteKey = async (id) => {
172 if (!confirm("Delete this API key? This cannot be undone.")) return;
173 try {
174 await deleteAPIKey(id);
175 loadAPIKeys();
176 } catch (err) {
177 alert("Failed to delete key: " + err.message);
178 }
179 };
180
181 if (authLoading) {
182 return (
183 <div className="profile-page">
184 <div className="feed-container">
185 <div className="feed">
186 {[1, 2, 3].map((i) => (
187 <div key={i} className="card">
188 <div
189 className="skeleton skeleton-text"
190 style={{ width: "40%" }}
191 />
192 <div className="skeleton skeleton-text" />
193 <div
194 className="skeleton skeleton-text"
195 style={{ width: "60%" }}
196 />
197 </div>
198 ))}
199 </div>
200 </div>
201 </div>
202 );
203 }
204
205 if (!handle) {
206 return <Navigate to="/login" replace />;
207 }
208
209 const displayName = profile?.displayName || profile?.handle || handle;
210 const displayHandle =
211 profile?.handle || (handle?.startsWith("did:") ? null : handle);
212 const avatarUrl = profile?.did
213 ? `/api/avatar/${encodeURIComponent(profile.did)}`
214 : null;
215
216 const getInitial = () => {
217 return (displayName || displayHandle || "??")
218 ?.substring(0, 2)
219 .toUpperCase();
220 };
221
222 const totalItems =
223 annotations.length +
224 highlights.length +
225 bookmarks.length +
226 collections.length;
227
228 const renderContent = () => {
229 if (activeTab === "annotations") {
230 if (annotations.length === 0) {
231 return (
232 <div className="empty-state">
233 <div className="empty-state-icon">
234 <PenIcon size={32} />
235 </div>
236 <h3 className="empty-state-title">No annotations</h3>
237 <p className="empty-state-text">
238 This user hasn't posted any annotations.
239 </p>
240 </div>
241 );
242 }
243 return annotations.map((a) => (
244 <AnnotationCard key={a.id} annotation={a} />
245 ));
246 }
247
248 if (activeTab === "highlights") {
249 if (highlights.length === 0) {
250 return (
251 <div className="empty-state">
252 <div className="empty-state-icon">
253 <HighlightIcon size={32} />
254 </div>
255 <h3 className="empty-state-title">No highlights</h3>
256 <p className="empty-state-text">
257 This user hasn't saved any highlights.
258 </p>
259 </div>
260 );
261 }
262 return highlights.map((h) => <HighlightCard key={h.id} highlight={h} />);
263 }
264
265 if (activeTab === "bookmarks") {
266 if (bookmarks.length === 0) {
267 return (
268 <div className="empty-state">
269 <div className="empty-state-icon">
270 <BookmarkIcon size={32} />
271 </div>
272 <h3 className="empty-state-title">No bookmarks</h3>
273 <p className="empty-state-text">
274 This user hasn't bookmarked any pages.
275 </p>
276 </div>
277 );
278 }
279 return bookmarks.map((b) => <BookmarkCard key={b.uri} bookmark={b} />);
280 }
281
282 if (activeTab === "collections") {
283 if (collections.length === 0) {
284 return (
285 <div className="empty-state">
286 <div className="empty-state-icon">
287 <CollectionIcon icon="folder" size={32} />
288 </div>
289 <h3 className="empty-state-title">No collections</h3>
290 <p className="empty-state-text">
291 This user hasn't created any collections.
292 </p>
293 </div>
294 );
295 }
296 return (
297 <div className="collections-list">
298 {collections.map((c) => (
299 <CollectionRow key={c.uri} collection={c} />
300 ))}
301 </div>
302 );
303 }
304
305 if (activeTab === "apikeys" && isOwnProfile) {
306 return (
307 <div className="api-keys-section">
308 <div className="card" style={{ marginBottom: "1rem" }}>
309 <h3 style={{ marginBottom: "0.5rem" }}>Create API Key</h3>
310 <p
311 style={{
312 color: "var(--text-muted)",
313 marginBottom: "1rem",
314 fontSize: "0.875rem",
315 }}
316 >
317 Use API keys to create bookmarks from iOS Shortcuts or other
318 tools.
319 </p>
320 <div style={{ display: "flex", gap: "0.5rem" }}>
321 <input
322 type="text"
323 value={newKeyName}
324 onChange={(e) => setNewKeyName(e.target.value)}
325 placeholder="Key name (e.g., iOS Shortcut)"
326 className="input"
327 style={{ flex: 1 }}
328 />
329 <button className="btn btn-primary" onClick={handleCreateKey}>
330 Generate
331 </button>
332 </div>
333 {newKey && (
334 <div
335 style={{
336 marginTop: "1rem",
337 padding: "1rem",
338 background: "var(--bg-secondary)",
339 borderRadius: "8px",
340 }}
341 >
342 <p
343 style={{
344 color: "var(--text-success)",
345 fontWeight: 500,
346 marginBottom: "0.5rem",
347 }}
348 >
349 ✓ Key created! Copy it now, you won't see it again.
350 </p>
351 <code
352 style={{
353 display: "block",
354 padding: "0.75rem",
355 background: "var(--bg-tertiary)",
356 borderRadius: "4px",
357 wordBreak: "break-all",
358 fontSize: "0.8rem",
359 }}
360 >
361 {newKey}
362 </code>
363 <button
364 className="btn btn-secondary"
365 style={{ marginTop: "0.5rem" }}
366 onClick={() => {
367 navigator.clipboard.writeText(newKey);
368 alert("Copied!");
369 }}
370 >
371 Copy to clipboard
372 </button>
373 </div>
374 )}
375 </div>
376
377 {keysLoading ? (
378 <div className="card">
379 <div className="skeleton skeleton-text" />
380 </div>
381 ) : apiKeys.length === 0 ? (
382 <div className="empty-state">
383 <div className="empty-state-icon">
384 <KeyIcon size={32} />
385 </div>
386 <h3 className="empty-state-title">No API keys</h3>
387 <p className="empty-state-text">
388 Create a key to use with iOS Shortcuts.
389 </p>
390 </div>
391 ) : (
392 <div className="card">
393 <h3 style={{ marginBottom: "1rem" }}>Your API Keys</h3>
394 {apiKeys.map((key) => (
395 <div
396 key={key.id}
397 style={{
398 display: "flex",
399 justifyContent: "space-between",
400 alignItems: "center",
401 padding: "0.75rem 0",
402 borderBottom: "1px solid var(--border-color)",
403 }}
404 >
405 <div>
406 <strong>{key.name}</strong>
407 <div
408 style={{
409 fontSize: "0.75rem",
410 color: "var(--text-muted)",
411 }}
412 >
413 Created {new Date(key.createdAt).toLocaleDateString()}
414 {key.lastUsedAt &&
415 ` • Last used ${new Date(key.lastUsedAt).toLocaleDateString()}`}
416 </div>
417 </div>
418 <button
419 className="btn btn-sm"
420 style={{
421 fontSize: "0.75rem",
422 padding: "0.25rem 0.5rem",
423 color: "#ef4444",
424 border: "1px solid #ef4444",
425 }}
426 onClick={() => handleDeleteKey(key.id)}
427 >
428 Revoke
429 </button>
430 </div>
431 ))}
432 </div>
433 )}
434
435 <div className="card" style={{ marginTop: "1rem" }}>
436 <h3 style={{ marginBottom: "0.5rem" }}>iOS Shortcut</h3>
437 <p
438 style={{
439 color: "var(--text-muted)",
440 marginBottom: "1rem",
441 fontSize: "0.875rem",
442 }}
443 >
444 Save bookmarks from Safari's share sheet.
445 </p>
446 <a
447 href="https://www.icloud.com/shortcuts/21c87edf29b046db892c9e57dac6d1fd"
448 target="_blank"
449 rel="noopener noreferrer"
450 className="btn btn-primary"
451 style={{
452 display: "inline-flex",
453 alignItems: "center",
454 gap: "0.5rem",
455 }}
456 >
457 <AppleIcon size={16} /> Get Shortcut
458 </a>
459 </div>
460 </div>
461 );
462 }
463 };
464
465 const bskyProfileUrl = displayHandle
466 ? `https://bsky.app/profile/${displayHandle}`
467 : `https://bsky.app/profile/${handle}`;
468
469 return (
470 <div className="profile-page">
471 <header className="profile-header">
472 <a
473 href={bskyProfileUrl}
474 target="_blank"
475 rel="noopener noreferrer"
476 className="profile-avatar-link"
477 >
478 <div className="profile-avatar">
479 {avatarUrl ? (
480 <img src={avatarUrl} alt={displayName} />
481 ) : (
482 <span>{getInitial()}</span>
483 )}
484 </div>
485 </a>
486 <div className="profile-info">
487 <h1 className="profile-name">{displayName}</h1>
488 {displayHandle && (
489 <a
490 href={bskyProfileUrl}
491 target="_blank"
492 rel="noopener noreferrer"
493 className="profile-bluesky-link"
494 >
495 <BlueskyIcon size={16} />@{displayHandle}
496 </a>
497 )}
498 <div className="profile-stats">
499 <span className="profile-stat">
500 <strong>{totalItems}</strong> items
501 </span>
502 <span className="profile-stat">
503 <strong>{annotations.length}</strong> annotations
504 </span>
505 <span className="profile-stat">
506 <strong>{highlights.length}</strong> highlights
507 </span>
508 </div>
509
510 {(profile?.bio || profile?.website || profile?.links?.length > 0) && (
511 <div className="profile-margin-details">
512 {profile.bio && <p className="profile-bio">{profile.bio}</p>}
513 <div className="profile-links">
514 {profile.website && (
515 <a
516 href={profile.website}
517 target="_blank"
518 rel="noopener noreferrer"
519 className="profile-link-chip main-website"
520 >
521 <LinkIcon size={14} /> {formatUrl(profile.website)}
522 </a>
523 )}
524 {profile.links?.map((link, i) => (
525 <a
526 key={i}
527 href={link}
528 target="_blank"
529 rel="noopener noreferrer"
530 className="profile-link-chip"
531 >
532 <LinkIconComponent url={link} /> {formatUrl(link)}
533 </a>
534 ))}
535 </div>
536 </div>
537 )}
538
539 {isOwnProfile && (
540 <button
541 className="btn btn-secondary btn-sm"
542 style={{ marginTop: "1rem", alignSelf: "flex-start" }}
543 onClick={() => setShowEditModal(true)}
544 >
545 Edit Profile
546 </button>
547 )}
548 </div>
549 </header>
550
551 {showEditModal && (
552 <EditProfileModal
553 profile={profile}
554 onClose={() => setShowEditModal(false)}
555 onUpdate={() => {
556 window.location.reload();
557 }}
558 />
559 )}
560
561 <div className="profile-tabs">
562 <button
563 className={`profile-tab ${activeTab === "annotations" ? "active" : ""}`}
564 onClick={() => setActiveTab("annotations")}
565 >
566 Annotations ({annotations.length})
567 </button>
568 <button
569 className={`profile-tab ${activeTab === "highlights" ? "active" : ""}`}
570 onClick={() => setActiveTab("highlights")}
571 >
572 Highlights ({highlights.length})
573 </button>
574 <button
575 className={`profile-tab ${activeTab === "bookmarks" ? "active" : ""}`}
576 onClick={() => setActiveTab("bookmarks")}
577 >
578 Bookmarks ({bookmarks.length})
579 </button>
580
581 <button
582 className={`profile-tab ${activeTab === "collections" ? "active" : ""}`}
583 onClick={() => setActiveTab("collections")}
584 >
585 Collections ({collections.length})
586 </button>
587
588 {isOwnProfile && (
589 <button
590 className={`profile-tab ${activeTab === "apikeys" ? "active" : ""}`}
591 onClick={() => setActiveTab("apikeys")}
592 >
593 <KeyIcon size={14} /> API Keys
594 </button>
595 )}
596 </div>
597
598 {loading && (
599 <div className="feed-container">
600 <div className="feed">
601 {[1, 2, 3].map((i) => (
602 <div key={i} className="card">
603 <div
604 className="skeleton skeleton-text"
605 style={{ width: "40%" }}
606 />
607 <div className="skeleton skeleton-text" />
608 <div
609 className="skeleton skeleton-text"
610 style={{ width: "60%" }}
611 />
612 </div>
613 ))}
614 </div>
615 </div>
616 )}
617
618 {error && (
619 <div className="empty-state">
620 <div className="empty-state-icon">⚠️</div>
621 <h3 className="empty-state-title">Error loading profile</h3>
622 <p className="empty-state-text">{error}</p>
623 </div>
624 )}
625
626 {!loading && !error && (
627 <div className="feed-container">
628 <div className="feed">{renderContent()}</div>
629 </div>
630 )}
631 </div>
632 );
633}
634
635function AppleIcon({ size = 16 }) {
636 return (
637 <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
638 <path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
639 </svg>
640 );
641}