(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement profile editing, links, bio, and cool stuff

scanash00 3be7368d 7f13fc29

+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 + }