Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at ui-refactor 641 lines 20 kB view raw
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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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}