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