Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

Implement profile editing, links, bio, and cool stuff

+797 -19
+2
backend/cmd/server/main.go
··· 109 109 r.Get("/{handle}/bookmark/{rkey}", ogHandler.HandleAnnotationPage) 110 110 111 111 r.Get("/api/tags/trending", handler.HandleGetTrendingTags) 112 + r.Put("/api/profile", handler.UpdateProfile) 113 + r.Get("/api/profile/{did}", handler.GetProfile) 112 114 113 115 r.Get("/collection/{uri}", ogHandler.HandleCollectionPage) 114 116 r.Get("/{handle}/collection/{rkey}", ogHandler.HandleCollectionPage)
+150
backend/internal/api/profile.go
··· 1 + package api 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + "strings" 9 + "time" 10 + 11 + "github.com/go-chi/chi/v5" 12 + 13 + "margin.at/internal/db" 14 + "margin.at/internal/xrpc" 15 + ) 16 + 17 + type UpdateProfileRequest struct { 18 + Bio string `json:"bio"` 19 + Website string `json:"website"` 20 + Links []string `json:"links"` 21 + } 22 + 23 + func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { 24 + session, err := h.refresher.GetSessionWithAutoRefresh(r) 25 + if err != nil { 26 + http.Error(w, err.Error(), http.StatusUnauthorized) 27 + return 28 + } 29 + 30 + var req UpdateProfileRequest 31 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 32 + http.Error(w, "Invalid request body", http.StatusBadRequest) 33 + return 34 + } 35 + 36 + record := &xrpc.MarginProfileRecord{ 37 + Type: xrpc.CollectionProfile, 38 + Bio: req.Bio, 39 + Website: req.Website, 40 + Links: req.Links, 41 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 42 + } 43 + 44 + if err := record.Validate(); err != nil { 45 + http.Error(w, err.Error(), http.StatusBadRequest) 46 + return 47 + } 48 + 49 + err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 50 + _, err := client.PutRecord(r.Context(), did, xrpc.CollectionProfile, "self", record) 51 + return err 52 + }) 53 + 54 + if err != nil { 55 + http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError) 56 + return 57 + } 58 + 59 + linksJSON, _ := json.Marshal(req.Links) 60 + profile := &db.Profile{ 61 + URI: fmt.Sprintf("at://%s/%s/self", session.DID, xrpc.CollectionProfile), 62 + AuthorDID: session.DID, 63 + Bio: &req.Bio, 64 + Website: &req.Website, 65 + LinksJSON: stringPtr(string(linksJSON)), 66 + CreatedAt: time.Now(), 67 + IndexedAt: time.Now(), 68 + } 69 + h.db.UpsertProfile(profile) 70 + 71 + w.Header().Set("Content-Type", "application/json") 72 + w.WriteHeader(http.StatusOK) 73 + json.NewEncoder(w).Encode(req) 74 + } 75 + 76 + func stringPtr(s string) *string { 77 + return &s 78 + } 79 + 80 + func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) { 81 + did := chi.URLParam(r, "did") 82 + if decoded, err := url.QueryUnescape(did); err == nil { 83 + did = decoded 84 + } 85 + 86 + if did == "" { 87 + http.Error(w, "DID required", http.StatusBadRequest) 88 + return 89 + } 90 + 91 + if !strings.HasPrefix(did, "did:") { 92 + var resolvedDID string 93 + err := h.db.QueryRow("SELECT did FROM sessions WHERE handle = $1 LIMIT 1", did).Scan(&resolvedDID) 94 + if err == nil { 95 + did = resolvedDID 96 + } else { 97 + resolvedDID, err = xrpc.ResolveHandle(did) 98 + if err == nil { 99 + did = resolvedDID 100 + } 101 + } 102 + } 103 + 104 + profile, err := h.db.GetProfile(did) 105 + if err != nil { 106 + http.Error(w, "Failed to fetch profile", http.StatusInternalServerError) 107 + return 108 + } 109 + 110 + if profile == nil { 111 + w.Header().Set("Content-Type", "application/json") 112 + if did != "" && strings.HasPrefix(did, "did:") { 113 + json.NewEncoder(w).Encode(map[string]string{"did": did}) 114 + } else { 115 + w.Write([]byte("{}")) 116 + } 117 + return 118 + } 119 + 120 + resp := struct { 121 + URI string `json:"uri"` 122 + DID string `json:"did"` 123 + Bio string `json:"bio"` 124 + Website string `json:"website"` 125 + Links []string `json:"links"` 126 + CreatedAt string `json:"createdAt"` 127 + IndexedAt string `json:"indexedAt"` 128 + }{ 129 + URI: profile.URI, 130 + DID: profile.AuthorDID, 131 + CreatedAt: profile.CreatedAt.Format(time.RFC3339), 132 + IndexedAt: profile.IndexedAt.Format(time.RFC3339), 133 + } 134 + 135 + if profile.Bio != nil { 136 + resp.Bio = *profile.Bio 137 + } 138 + if profile.Website != nil { 139 + resp.Website = *profile.Website 140 + } 141 + if profile.LinksJSON != nil && *profile.LinksJSON != "" { 142 + _ = json.Unmarshal([]byte(*profile.LinksJSON), &resp.Links) 143 + } 144 + if resp.Links == nil { 145 + resp.Links = []string{} 146 + } 147 + 148 + w.Header().Set("Content-Type", "application/json") 149 + json.NewEncoder(w).Encode(resp) 150 + }
+58
backend/internal/db/db.go
··· 129 129 LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` 130 130 } 131 131 132 + type Profile struct { 133 + URI string `json:"uri"` 134 + AuthorDID string `json:"authorDid"` 135 + Bio *string `json:"bio,omitempty"` 136 + Website *string `json:"website,omitempty"` 137 + LinksJSON *string `json:"links,omitempty"` 138 + CreatedAt time.Time `json:"createdAt"` 139 + IndexedAt time.Time `json:"indexedAt"` 140 + CID *string `json:"cid,omitempty"` 141 + } 142 + 132 143 func New(dsn string) (*DB, error) { 133 144 driver := "sqlite3" 134 145 if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { ··· 328 339 db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner_did)`) 329 340 db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)`) 330 341 342 + db.Exec(`CREATE TABLE IF NOT EXISTS profiles ( 343 + uri TEXT PRIMARY KEY, 344 + author_did TEXT NOT NULL, 345 + bio TEXT, 346 + website TEXT, 347 + links_json TEXT, 348 + created_at ` + dateType + ` NOT NULL, 349 + indexed_at ` + dateType + ` NOT NULL, 350 + cid TEXT 351 + )`) 352 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_profiles_author_did ON profiles(author_did)`) 353 + 331 354 db.runMigrations() 332 355 333 356 db.Exec(`CREATE TABLE IF NOT EXISTS cursors ( ··· 365 388 return err 366 389 } 367 390 391 + func (db *DB) GetProfile(did string) (*Profile, error) { 392 + var p Profile 393 + err := db.QueryRow("SELECT uri, author_did, bio, website, links_json, created_at, indexed_at FROM profiles WHERE author_did = $1", did).Scan( 394 + &p.URI, &p.AuthorDID, &p.Bio, &p.Website, &p.LinksJSON, &p.CreatedAt, &p.IndexedAt, 395 + ) 396 + if err == sql.ErrNoRows { 397 + return nil, nil 398 + } 399 + if err != nil { 400 + return nil, err 401 + } 402 + return &p, nil 403 + } 404 + 405 + func (db *DB) UpsertProfile(p *Profile) error { 406 + query := ` 407 + INSERT INTO profiles (uri, author_did, bio, website, links_json, created_at, indexed_at) 408 + VALUES ($1, $2, $3, $4, $5, $6, $7) 409 + ON CONFLICT(uri) DO UPDATE SET 410 + bio = EXCLUDED.bio, 411 + website = EXCLUDED.website, 412 + links_json = EXCLUDED.links_json, 413 + indexed_at = EXCLUDED.indexed_at 414 + ` 415 + _, err := db.Exec(db.Rebind(query), p.URI, p.AuthorDID, p.Bio, p.Website, p.LinksJSON, p.CreatedAt, p.IndexedAt) 416 + return err 417 + } 418 + 419 + func (db *DB) DeleteProfile(uri string) error { 420 + _, err := db.Exec("DELETE FROM profiles WHERE uri = $1", uri) 421 + return err 422 + } 423 + 368 424 func (db *DB) runMigrations() { 369 425 370 426 db.Exec(`ALTER TABLE sessions ADD COLUMN dpop_key TEXT`) ··· 385 441 db.Exec(`UPDATE annotations SET body_value = text WHERE body_value IS NULL AND text IS NOT NULL`) 386 442 db.Exec(`UPDATE annotations SET target_title = title WHERE target_title IS NULL AND title IS NOT NULL`) 387 443 db.Exec(`UPDATE annotations SET motivation = 'commenting' WHERE motivation IS NULL`) 444 + 445 + db.Exec(`ALTER TABLE profiles ADD COLUMN website TEXT`) 388 446 389 447 if db.driver == "postgres" { 390 448 db.Exec(`ALTER TABLE cursors ALTER COLUMN last_cursor TYPE BIGINT`)
+57
backend/internal/firehose/ingester.go
··· 23 23 CollectionLike = "at.margin.like" 24 24 CollectionCollection = "at.margin.collection" 25 25 CollectionCollectionItem = "at.margin.collectionItem" 26 + CollectionProfile = "at.margin.profile" 26 27 ) 27 28 28 29 var RelayURL = "wss://jetstream2.us-east.bsky.network/subscribe" ··· 50 51 i.RegisterHandler(CollectionLike, i.handleLike) 51 52 i.RegisterHandler(CollectionCollection, i.handleCollection) 52 53 i.RegisterHandler(CollectionCollectionItem, i.handleCollectionItem) 54 + i.RegisterHandler(CollectionProfile, i.handleProfile) 53 55 54 56 return i 55 57 } ··· 231 233 i.db.DeleteCollection(uri) 232 234 case CollectionCollectionItem: 233 235 i.db.RemoveFromCollection(uri) 236 + case CollectionProfile: 237 + i.db.DeleteProfile(uri) 234 238 } 235 239 } 236 240 ··· 630 634 log.Printf("Indexed collection item from %s", event.Repo) 631 635 } 632 636 } 637 + 638 + func (i *Ingester) handleProfile(event *FirehoseEvent) { 639 + if event.Rkey != "self" { 640 + return 641 + } 642 + 643 + var record struct { 644 + Bio string `json:"bio"` 645 + Website string `json:"website"` 646 + Links []string `json:"links"` 647 + CreatedAt string `json:"createdAt"` 648 + } 649 + 650 + if err := json.Unmarshal(event.Record, &record); err != nil { 651 + return 652 + } 653 + 654 + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 655 + 656 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 657 + if err != nil { 658 + createdAt = time.Now() 659 + } 660 + 661 + var bioPtr, websitePtr, linksJSONPtr *string 662 + if record.Bio != "" { 663 + bioPtr = &record.Bio 664 + } 665 + if record.Website != "" { 666 + websitePtr = &record.Website 667 + } 668 + if len(record.Links) > 0 { 669 + linksBytes, _ := json.Marshal(record.Links) 670 + linksStr := string(linksBytes) 671 + linksJSONPtr = &linksStr 672 + } 673 + 674 + profile := &db.Profile{ 675 + URI: uri, 676 + AuthorDID: event.Repo, 677 + Bio: bioPtr, 678 + Website: websitePtr, 679 + LinksJSON: linksJSONPtr, 680 + CreatedAt: createdAt, 681 + IndexedAt: time.Now(), 682 + } 683 + 684 + if err := i.db.UpsertProfile(profile); err != nil { 685 + log.Printf("Failed to index profile: %v", err) 686 + } else { 687 + log.Printf("Indexed profile from %s", event.Repo) 688 + } 689 + }
+19
backend/internal/xrpc/records.go
··· 15 15 CollectionLike = "at.margin.like" 16 16 CollectionCollection = "at.margin.collection" 17 17 CollectionCollectionItem = "at.margin.collectionItem" 18 + CollectionProfile = "at.margin.profile" 18 19 ) 19 20 20 21 const ( ··· 362 363 CreatedAt: time.Now().UTC().Format(time.RFC3339), 363 364 } 364 365 } 366 + 367 + type MarginProfileRecord struct { 368 + Type string `json:"$type"` 369 + Bio string `json:"bio,omitempty"` 370 + Website string `json:"website,omitempty"` 371 + Links []string `json:"links,omitempty"` 372 + CreatedAt string `json:"createdAt"` 373 + } 374 + 375 + func (r *MarginProfileRecord) Validate() error { 376 + if len(r.Bio) > 5000 { 377 + return fmt.Errorf("bio too long") 378 + } 379 + if len(r.Links) > 20 { 380 + return fmt.Errorf("too many links") 381 + } 382 + return nil 383 + }
+27
backend/internal/xrpc/utils.go
··· 49 49 } 50 50 return "", nil 51 51 } 52 + func ResolveHandle(handle string) (string, error) { 53 + if strings.HasPrefix(handle, "did:") { 54 + return handle, nil 55 + } 56 + 57 + url := fmt.Sprintf("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=%s", handle) 58 + client := &http.Client{ 59 + Timeout: 5 * time.Second, 60 + } 61 + resp, err := client.Get(url) 62 + if err != nil { 63 + return "", err 64 + } 65 + defer resp.Body.Close() 66 + 67 + if resp.StatusCode != 200 { 68 + return "", fmt.Errorf("failed to resolve handle: %d", resp.StatusCode) 69 + } 70 + 71 + var result struct { 72 + DID string `json:"did"` 73 + } 74 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 75 + return "", err 76 + } 77 + return result.DID, nil 78 + }
+2 -1
lexicons/at.margin.authFull.json lexicons/at/margin/authFull.json
··· 20 20 "at.margin.reply", 21 21 "at.margin.like", 22 22 "at.margin.collection", 23 - "at.margin.collectionItem" 23 + "at.margin.collectionItem", 24 + "at.margin.profile" 24 25 ] 25 26 } 26 27 ]
+40
lexicons/at/margin/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.margin.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A profile for a user on the Margin network.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["createdAt"], 12 + "properties": { 13 + "bio": { 14 + "type": "string", 15 + "maxLength": 5000, 16 + "description": "User biography or description." 17 + }, 18 + "website": { 19 + "type": "string", 20 + "maxLength": 1000, 21 + "description": "User website URL." 22 + }, 23 + "links": { 24 + "type": "array", 25 + "description": "List of other relevant links (e.g. GitHub, Bluesky, etc).", 26 + "items": { 27 + "type": "string", 28 + "maxLength": 1000 29 + }, 30 + "maxLength": 20 31 + }, 32 + "createdAt": { 33 + "type": "string", 34 + "format": "datetime" 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+11
web/src/api/client.js
··· 57 57 return request(`${API_BASE}/annotation?uri=${encodeURIComponent(uri)}`); 58 58 } 59 59 60 + export async function getProfile(did) { 61 + return request(`${API_BASE}/profile/${encodeURIComponent(did)}`); 62 + } 63 + 60 64 export async function getUserAnnotations(did, limit = 50, offset = 0) { 61 65 return request( 62 66 `${API_BASE}/users/${encodeURIComponent(did)}/annotations?limit=${limit}&offset=${offset}`, ··· 161 165 return request(`${API_BASE}/collections?uri=${encodeURIComponent(uri)}`, { 162 166 method: "PUT", 163 167 body: JSON.stringify({ name, description, icon }), 168 + }); 169 + } 170 + 171 + export async function updateProfile({ bio, website, links }) { 172 + return request(`${API_BASE}/profile`, { 173 + method: "PUT", 174 + body: JSON.stringify({ bio, website, links }), 164 175 }); 165 176 } 166 177
+145
web/src/components/EditProfileModal.jsx
··· 1 + import { useState } from "react"; 2 + import { updateProfile } from "../api/client"; 3 + 4 + export default function EditProfileModal({ profile, onClose, onUpdate }) { 5 + const [bio, setBio] = useState(profile?.bio || ""); 6 + const [website, setWebsite] = useState(profile?.website || ""); 7 + const [links, setLinks] = useState(profile?.links || []); 8 + const [newLink, setNewLink] = useState(""); 9 + const [saving, setSaving] = useState(false); 10 + const [error, setError] = useState(null); 11 + 12 + const handleSubmit = async (e) => { 13 + e.preventDefault(); 14 + setSaving(true); 15 + setError(null); 16 + 17 + try { 18 + await updateProfile({ bio, website, links }); 19 + onUpdate(); 20 + onClose(); 21 + } catch (err) { 22 + setError(err.message); 23 + } finally { 24 + setSaving(false); 25 + } 26 + }; 27 + 28 + const addLink = () => { 29 + if (!newLink) return; 30 + 31 + if (!links.includes(newLink)) { 32 + setLinks([...links, newLink]); 33 + setNewLink(""); 34 + setError(null); 35 + } 36 + }; 37 + 38 + const removeLink = (index) => { 39 + setLinks(links.filter((_, i) => i !== index)); 40 + }; 41 + 42 + return ( 43 + <div className="modal-overlay" onClick={onClose}> 44 + <div className="modal-container" onClick={(e) => e.stopPropagation()}> 45 + <div className="modal-header"> 46 + <h2>Edit Profile</h2> 47 + <button className="modal-close-btn" onClick={onClose}> 48 + <svg 49 + width="20" 50 + height="20" 51 + viewBox="0 0 24 24" 52 + fill="none" 53 + stroke="currentColor" 54 + strokeWidth="2" 55 + strokeLinecap="round" 56 + strokeLinejoin="round" 57 + > 58 + <line x1="18" y1="6" x2="6" y2="18" /> 59 + <line x1="6" y1="6" x2="18" y2="18" /> 60 + </svg> 61 + </button> 62 + </div> 63 + <form onSubmit={handleSubmit} className="modal-body"> 64 + {error && <div className="error-message">{error}</div>} 65 + 66 + <div className="form-group"> 67 + <label>Bio</label> 68 + <textarea 69 + className="input" 70 + value={bio} 71 + onChange={(e) => setBio(e.target.value)} 72 + placeholder="Tell us about yourself..." 73 + rows={4} 74 + maxLength={5000} 75 + /> 76 + <div className="char-count">{bio.length}/5000</div> 77 + </div> 78 + 79 + <div className="form-group"> 80 + <label>Website</label> 81 + <input 82 + type="url" 83 + className="input" 84 + value={website} 85 + onChange={(e) => setWebsite(e.target.value)} 86 + placeholder="https://example.com" 87 + maxLength={1000} 88 + /> 89 + </div> 90 + 91 + <div className="form-group"> 92 + <label>Links</label> 93 + <div className="links-input-group"> 94 + <input 95 + type="url" 96 + className="input" 97 + value={newLink} 98 + onChange={(e) => setNewLink(e.target.value)} 99 + placeholder="Add a link (e.g. GitHub, LinkedIn)..." 100 + onKeyDown={(e) => 101 + e.key === "Enter" && (e.preventDefault(), addLink()) 102 + } 103 + /> 104 + <button 105 + type="button" 106 + className="btn btn-secondary" 107 + onClick={addLink} 108 + > 109 + Add 110 + </button> 111 + </div> 112 + <ul className="links-list"> 113 + {links.map((link, i) => ( 114 + <li key={i} className="link-item"> 115 + <span>{link}</span> 116 + <button 117 + type="button" 118 + className="btn-icon-sm" 119 + onClick={() => removeLink(i)} 120 + > 121 + × 122 + </button> 123 + </li> 124 + ))} 125 + </ul> 126 + </div> 127 + 128 + <div className="modal-actions"> 129 + <button 130 + type="button" 131 + className="btn btn-secondary" 132 + onClick={onClose} 133 + disabled={saving} 134 + > 135 + Cancel 136 + </button> 137 + <button type="submit" className="btn btn-primary" disabled={saving}> 138 + {saving ? "Saving..." : "Save Profile"} 139 + </button> 140 + </div> 141 + </form> 142 + </div> 143 + </div> 144 + ); 145 + }
+26
web/src/components/Icons.jsx
··· 1 + import tangledLogo from "../assets/tangled.svg"; 2 + import { FaGithub, FaLinkedin } from "react-icons/fa"; 3 + 1 4 export function HeartIcon({ filled = false, size = 18 }) { 2 5 return filled ? ( 3 6 <svg ··· 462 465 </svg> 463 466 ); 464 467 } 468 + 469 + export function GithubIcon({ size = 18 }) { 470 + return <FaGithub size={size} />; 471 + } 472 + 473 + export function LinkedinIcon({ size = 18 }) { 474 + return <FaLinkedin size={size} />; 475 + } 476 + 477 + export function TangledIcon({ size = 18 }) { 478 + return ( 479 + <div 480 + style={{ 481 + width: size, 482 + height: size, 483 + backgroundColor: "currentColor", 484 + WebkitMask: `url(${tangledLogo}) no-repeat center / contain`, 485 + mask: `url(${tangledLogo}) no-repeat center / contain`, 486 + display: "inline-block", 487 + }} 488 + /> 489 + ); 490 + }
+70
web/src/css/modals.css
··· 438 438 text-align: center; 439 439 margin-top: 8px; 440 440 } 441 + 442 + .modal-body { 443 + padding: 16px; 444 + display: flex; 445 + flex-direction: column; 446 + gap: 16px; 447 + } 448 + 449 + .links-input-group { 450 + display: flex; 451 + gap: 8px; 452 + margin-bottom: 8px; 453 + } 454 + 455 + .links-input-group input { 456 + flex: 1; 457 + } 458 + 459 + .links-list { 460 + list-style: none; 461 + padding: 0; 462 + margin: 0; 463 + display: flex; 464 + flex-direction: column; 465 + gap: 8px; 466 + } 467 + 468 + .link-item { 469 + display: flex; 470 + align-items: center; 471 + justify-content: map; 472 + gap: 8px; 473 + padding: 8px 12px; 474 + background: var(--bg-tertiary); 475 + border: 1px solid var(--border); 476 + border-radius: var(--radius-md); 477 + font-size: 0.9rem; 478 + color: var(--text-primary); 479 + word-break: break-all; 480 + } 481 + 482 + .link-item span { 483 + flex: 1; 484 + } 485 + 486 + .btn-icon-sm { 487 + background: none; 488 + border: none; 489 + color: var(--text-tertiary); 490 + cursor: pointer; 491 + padding: 4px; 492 + border-radius: 4px; 493 + display: flex; 494 + align-items: center; 495 + justify-content: center; 496 + font-size: 1.1rem; 497 + line-height: 1; 498 + } 499 + 500 + .btn-icon-sm:hover { 501 + background: var(--bg-hover); 502 + color: #ff4444; 503 + } 504 + 505 + .char-count { 506 + text-align: right; 507 + font-size: 0.75rem; 508 + color: var(--text-tertiary); 509 + margin-top: 4px; 510 + }
+55 -1
web/src/css/profile.css
··· 1 1 .profile-header { 2 2 display: flex; 3 - align-items: center; 3 + align-items: flex-start; 4 4 gap: 24px; 5 5 margin-bottom: 32px; 6 6 padding-bottom: 24px; ··· 255 255 gap: 16px; 256 256 } 257 257 } 258 + 259 + .profile-margin-details { 260 + margin-top: 16px; 261 + display: flex; 262 + flex-direction: column; 263 + gap: 12px; 264 + } 265 + 266 + .profile-bio { 267 + font-size: 0.95rem; 268 + color: var(--text-primary); 269 + line-height: 1.5; 270 + white-space: pre-wrap; 271 + max-width: 600px; 272 + } 273 + 274 + .profile-links { 275 + display: flex; 276 + flex-wrap: wrap; 277 + gap: 8px; 278 + align-items: center; 279 + } 280 + 281 + .profile-link-chip { 282 + display: inline-flex; 283 + align-items: center; 284 + gap: 6px; 285 + padding: 6px 12px; 286 + background: var(--bg-tertiary); 287 + border: 1px solid var(--border); 288 + border-radius: 8px; 289 + color: var(--text-secondary); 290 + text-decoration: none; 291 + font-size: 0.85rem; 292 + font-weight: 500; 293 + transition: all 0.2s ease; 294 + } 295 + 296 + .profile-link-chip:hover { 297 + background: var(--bg-hover); 298 + color: var(--text-primary); 299 + border-color: var(--text-tertiary); 300 + transform: translateY(-1px); 301 + } 302 + 303 + .profile-link-chip.main-website { 304 + background: rgba(var(--accent-rgb), 0.1); 305 + color: var(--accent); 306 + border-color: var(--accent); 307 + } 308 + 309 + .profile-link-chip.main-website:hover { 310 + background: rgba(var(--accent-rgb), 0.15); 311 + }
+112 -17
web/src/pages/Profile.jsx
··· 2 2 import { useParams } from "react-router-dom"; 3 3 import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 4 import BookmarkCard from "../components/BookmarkCard"; 5 + import { getLinkIconType, formatUrl } from "../utils/formatting"; 5 6 import { 6 7 getUserAnnotations, 7 8 getUserHighlights, 8 9 getUserBookmarks, 9 10 getCollections, 11 + getProfile, 10 12 getAPIKeys, 11 13 createAPIKey, 12 14 deleteAPIKey, 13 15 } from "../api/client"; 14 16 import { useAuth } from "../context/AuthContext"; 17 + import EditProfileModal from "../components/EditProfileModal"; 15 18 import CollectionIcon from "../components/CollectionIcon"; 16 19 import CollectionRow from "../components/CollectionRow"; 17 20 import { ··· 19 22 HighlightIcon, 20 23 BookmarkIcon, 21 24 BlueskyIcon, 25 + GithubIcon, 26 + LinkedinIcon, 27 + TangledIcon, 28 + LinkIcon, 22 29 } from "../components/Icons"; 23 30 31 + function 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 + 24 47 function KeyIcon({ size = 16 }) { 25 48 return ( 26 49 <svg ··· 53 76 const [keysLoading, setKeysLoading] = useState(false); 54 77 const [loading, setLoading] = useState(true); 55 78 const [error, setError] = useState(null); 79 + const [showEditModal, setShowEditModal] = useState(false); 56 80 57 81 const isOwnProfile = user && (user.did === handle || user.handle === handle); 58 82 ··· 61 85 try { 62 86 setLoading(true); 63 87 64 - const profileRes = await fetch( 88 + const bskyPromise = fetch( 65 89 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`, 66 - ); 67 - let did = handle; 68 - if (profileRes.ok) { 69 - const profileData = await profileRes.json(); 70 - setProfile(profileData); 71 - did = profileData.did; 90 + ).then((res) => (res.ok ? res.json() : null)); 91 + 92 + const marginPromise = getProfile(handle).catch(() => null); 93 + 94 + const marginData = await marginPromise; 95 + let did = handle.startsWith("did:") ? handle : marginData?.did; 96 + if (!did) { 97 + const bskyData = await bskyPromise; 98 + if (bskyData) { 99 + did = bskyData.did; 100 + setProfile(bskyData); 101 + } 102 + } else { 103 + if (marginData) { 104 + setProfile((prev) => ({ ...prev, ...marginData })); 105 + } 72 106 } 73 107 74 - const [annData, hlData, bmData, collData] = await Promise.all([ 75 - getUserAnnotations(did), 76 - getUserHighlights(did).catch(() => ({ items: [] })), 77 - getUserBookmarks(did).catch(() => ({ items: [] })), 78 - getCollections(did).catch(() => ({ items: [] })), 79 - ]); 80 - setAnnotations(annData.items || []); 81 - setHighlights(hlData.items || []); 82 - setBookmarks(bmData.items || []); 83 - setCollections(collData.items || []); 108 + if (did) { 109 + const [annData, hlData, bmData, collData] = await Promise.all([ 110 + getUserAnnotations(did), 111 + getUserHighlights(did).catch(() => ({ items: [] })), 112 + getUserBookmarks(did).catch(() => ({ items: [] })), 113 + getCollections(did).catch(() => ({ items: [] })), 114 + ]); 115 + setAnnotations(annData.items || []); 116 + setHighlights(hlData.items || []); 117 + setBookmarks(bmData.items || []); 118 + setCollections(collData.items || []); 119 + 120 + const bskyData = await bskyPromise; 121 + if (bskyData || marginData) { 122 + setProfile((prev) => ({ 123 + ...(bskyData || {}), 124 + ...prev, 125 + ...(marginData || {}), 126 + })); 127 + } 128 + } 84 129 } catch (err) { 130 + console.error(err); 85 131 setError(err.message); 86 132 } finally { 87 133 setLoading(false); ··· 432 478 <strong>{highlights.length}</strong> highlights 433 479 </span> 434 480 </div> 481 + 482 + {(profile?.bio || profile?.website || profile?.links?.length > 0) && ( 483 + <div className="profile-margin-details"> 484 + {profile.bio && <p className="profile-bio">{profile.bio}</p>} 485 + <div className="profile-links"> 486 + {profile.website && ( 487 + <a 488 + href={profile.website} 489 + target="_blank" 490 + rel="noopener noreferrer" 491 + className="profile-link-chip main-website" 492 + > 493 + <LinkIcon size={14} /> {formatUrl(profile.website)} 494 + </a> 495 + )} 496 + {profile.links?.map((link, i) => ( 497 + <a 498 + key={i} 499 + href={link} 500 + target="_blank" 501 + rel="noopener noreferrer" 502 + className="profile-link-chip" 503 + > 504 + <LinkIconComponent url={link} /> {formatUrl(link)} 505 + </a> 506 + ))} 507 + </div> 508 + </div> 509 + )} 510 + 511 + {isOwnProfile && ( 512 + <button 513 + className="btn btn-secondary btn-sm" 514 + style={{ marginTop: "1rem", alignSelf: "flex-start" }} 515 + onClick={() => setShowEditModal(true)} 516 + > 517 + Edit Profile 518 + </button> 519 + )} 435 520 </div> 436 521 </header> 522 + 523 + {showEditModal && ( 524 + <EditProfileModal 525 + profile={profile} 526 + onClose={() => setShowEditModal(false)} 527 + onUpdate={() => { 528 + window.location.reload(); 529 + }} 530 + /> 531 + )} 437 532 438 533 <div className="profile-tabs"> 439 534 <button
+23
web/src/utils/formatting.js
··· 1 + export function getLinkIconType(url) { 2 + if (!url) return "link"; 3 + try { 4 + const hostname = new URL(url).hostname; 5 + if (hostname.includes("github.com")) return "github"; 6 + if (hostname.includes("bsky.app")) return "bluesky"; 7 + if (hostname.includes("linkedin.com")) return "linkedin"; 8 + if (hostname.includes("tangled.org")) return "tangled"; 9 + if (hostname.includes("youtube.com")) return "youtube"; 10 + } catch { 11 + /* ignore */ 12 + } 13 + return "link"; 14 + } 15 + 16 + export function formatUrl(url) { 17 + try { 18 + return new URL(url).hostname; 19 + } catch { 20 + /* ignore */ 21 + return url; 22 + } 23 + }