+6
backend/cmd/server/main.go
+6
backend/cmd/server/main.go
···
97
97
r.Get("/og-image", ogHandler.HandleOGImage)
98
98
r.Get("/annotation/{did}/{rkey}", ogHandler.HandleAnnotationPage)
99
99
r.Get("/at/{did}/{rkey}", ogHandler.HandleAnnotationPage)
100
+
r.Get("/{handle}/annotation/{rkey}", ogHandler.HandleAnnotationPage)
101
+
r.Get("/{handle}/highlight/{rkey}", ogHandler.HandleAnnotationPage)
102
+
r.Get("/{handle}/bookmark/{rkey}", ogHandler.HandleAnnotationPage)
103
+
104
+
r.Get("/collection/{uri}", ogHandler.HandleCollectionPage)
105
+
r.Get("/{handle}/collection/{rkey}", ogHandler.HandleCollectionPage)
100
106
101
107
staticDir := getEnv("STATIC_DIR", "../web/dist")
102
108
serveStatic(r, staticDir)
+17
-3
backend/internal/api/annotations.go
+17
-3
backend/internal/api/annotations.go
···
47
47
return
48
48
}
49
49
50
-
if req.URL == "" || req.Text == "" {
51
-
http.Error(w, "URL and text are required", http.StatusBadRequest)
50
+
if req.URL == "" {
51
+
http.Error(w, "URL is required", http.StatusBadRequest)
52
+
return
53
+
}
54
+
55
+
if req.Text == "" && req.Selector == nil && len(req.Tags) == 0 {
56
+
http.Error(w, "Must provide text, selector, or tags", http.StatusBadRequest)
52
57
return
53
58
}
54
59
···
498
503
Title string `json:"title,omitempty"`
499
504
Selector interface{} `json:"selector"`
500
505
Color string `json:"color,omitempty"`
506
+
Tags []string `json:"tags,omitempty"`
501
507
}
502
508
503
509
func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) {
···
519
525
}
520
526
521
527
urlHash := db.HashURL(req.URL)
522
-
record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color)
528
+
record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color, req.Tags)
523
529
524
530
var result *xrpc.CreateRecordOutput
525
531
err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
···
549
555
colorPtr = &req.Color
550
556
}
551
557
558
+
var tagsJSONPtr *string
559
+
if len(req.Tags) > 0 {
560
+
tagsBytes, _ := json.Marshal(req.Tags)
561
+
tagsStr := string(tagsBytes)
562
+
tagsJSONPtr = &tagsStr
563
+
}
564
+
552
565
cid := result.CID
553
566
highlight := &db.Highlight{
554
567
URI: result.URI,
···
558
571
TargetTitle: titlePtr,
559
572
SelectorJSON: selectorJSONPtr,
560
573
Color: colorPtr,
574
+
TagsJSON: tagsJSONPtr,
561
575
CreatedAt: time.Now(),
562
576
IndexedAt: time.Now(),
563
577
CID: &cid,
+26
-2
backend/internal/api/collections.go
+26
-2
backend/internal/api/collections.go
···
213
213
return
214
214
}
215
215
216
+
profiles := fetchProfilesForDIDs([]string{authorDID})
217
+
creator := profiles[authorDID]
218
+
219
+
apiCollections := make([]APICollection, len(collections))
220
+
for i, c := range collections {
221
+
icon := ""
222
+
if c.Icon != nil {
223
+
icon = *c.Icon
224
+
}
225
+
desc := ""
226
+
if c.Description != nil {
227
+
desc = *c.Description
228
+
}
229
+
apiCollections[i] = APICollection{
230
+
URI: c.URI,
231
+
Name: c.Name,
232
+
Description: desc,
233
+
Icon: icon,
234
+
Creator: creator,
235
+
CreatedAt: c.CreatedAt,
236
+
IndexedAt: c.IndexedAt,
237
+
}
238
+
}
239
+
216
240
w.Header().Set("Content-Type", "application/json")
217
241
json.NewEncoder(w).Encode(map[string]interface{}{
218
242
"@context": "http://www.w3.org/ns/anno.jsonld",
219
243
"type": "Collection",
220
-
"items": collections,
221
-
"totalItems": len(collections),
244
+
"items": apiCollections,
245
+
"totalItems": len(apiCollections),
222
246
})
223
247
}
224
248
+90
-28
backend/internal/api/handler.go
+90
-28
backend/internal/api/handler.go
···
81
81
limit := parseIntParam(r, "limit", 50)
82
82
offset := parseIntParam(r, "offset", 0)
83
83
motivation := r.URL.Query().Get("motivation")
84
+
tag := r.URL.Query().Get("tag")
84
85
85
86
var annotations []db.Annotation
86
87
var err error
···
90
91
annotations, err = h.db.GetAnnotationsByTargetHash(urlHash, limit, offset)
91
92
} else if motivation != "" {
92
93
annotations, err = h.db.GetAnnotationsByMotivation(motivation, limit, offset)
94
+
} else if tag != "" {
95
+
annotations, err = h.db.GetAnnotationsByTag(tag, limit, offset)
93
96
} else {
94
97
annotations, err = h.db.GetRecentAnnotations(limit, offset)
95
98
}
···
112
115
113
116
func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) {
114
117
limit := parseIntParam(r, "limit", 50)
118
+
tag := r.URL.Query().Get("tag")
119
+
creator := r.URL.Query().Get("creator")
115
120
116
-
annotations, _ := h.db.GetRecentAnnotations(limit, 0)
117
-
highlights, _ := h.db.GetRecentHighlights(limit, 0)
118
-
bookmarks, _ := h.db.GetRecentBookmarks(limit, 0)
121
+
var annotations []db.Annotation
122
+
var highlights []db.Highlight
123
+
var bookmarks []db.Bookmark
124
+
var collectionItems []db.CollectionItem
125
+
var err error
126
+
127
+
if tag != "" {
128
+
if creator != "" {
129
+
annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, limit, 0)
130
+
highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, limit, 0)
131
+
bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, limit, 0)
132
+
collectionItems = []db.CollectionItem{}
133
+
} else {
134
+
annotations, _ = h.db.GetAnnotationsByTag(tag, limit, 0)
135
+
highlights, _ = h.db.GetHighlightsByTag(tag, limit, 0)
136
+
bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0)
137
+
collectionItems = []db.CollectionItem{}
138
+
}
139
+
} else {
140
+
annotations, _ = h.db.GetRecentAnnotations(limit, 0)
141
+
highlights, _ = h.db.GetRecentHighlights(limit, 0)
142
+
bookmarks, _ = h.db.GetRecentBookmarks(limit, 0)
143
+
collectionItems, err = h.db.GetRecentCollectionItems(limit, 0)
144
+
if err != nil {
145
+
log.Printf("Error fetching collection items: %v\n", err)
146
+
}
147
+
}
119
148
120
149
authAnnos, _ := hydrateAnnotations(annotations)
121
150
authHighs, _ := hydrateHighlights(highlights)
122
151
authBooks, _ := hydrateBookmarks(bookmarks)
123
152
124
-
collectionItems, err := h.db.GetRecentCollectionItems(limit, 0)
125
-
if err != nil {
126
-
log.Printf("Error fetching collection items: %v\n", err)
127
-
}
128
-
// log.Printf("Fetched %d collection items\n", len(collectionItems))
129
153
authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems)
130
-
// log.Printf("Hydrated %d collection items\n", len(authCollectionItems))
131
154
132
155
var feed []interface{}
133
156
for _, a := range authAnnos {
···
188
211
return
189
212
}
190
213
191
-
annotation, err := h.db.GetAnnotationByURI(uri)
192
-
if err != nil {
193
-
http.Error(w, "Annotation not found", http.StatusNotFound)
194
-
return
214
+
serveResponse := func(data interface{}, context string) {
215
+
w.Header().Set("Content-Type", "application/json")
216
+
response := map[string]interface{}{
217
+
"@context": context,
218
+
}
219
+
jsonData, _ := json.Marshal(data)
220
+
json.Unmarshal(jsonData, &response)
221
+
json.NewEncoder(w).Encode(response)
195
222
}
196
223
197
-
enriched, _ := hydrateAnnotations([]db.Annotation{*annotation})
198
-
if len(enriched) == 0 {
199
-
http.Error(w, "Annotation not found", http.StatusNotFound)
200
-
return
224
+
if annotation, err := h.db.GetAnnotationByURI(uri); err == nil {
225
+
if enriched, _ := hydrateAnnotations([]db.Annotation{*annotation}); len(enriched) > 0 {
226
+
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
227
+
return
228
+
}
201
229
}
202
230
203
-
w.Header().Set("Content-Type", "application/json")
204
-
response := map[string]interface{}{
205
-
"@context": "http://www.w3.org/ns/anno.jsonld",
231
+
if highlight, err := h.db.GetHighlightByURI(uri); err == nil {
232
+
if enriched, _ := hydrateHighlights([]db.Highlight{*highlight}); len(enriched) > 0 {
233
+
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
234
+
return
235
+
}
236
+
}
237
+
238
+
if strings.Contains(uri, "at.margin.annotation") {
239
+
highlightURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1)
240
+
if highlight, err := h.db.GetHighlightByURI(highlightURI); err == nil {
241
+
if enriched, _ := hydrateHighlights([]db.Highlight{*highlight}); len(enriched) > 0 {
242
+
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
243
+
return
244
+
}
245
+
}
206
246
}
207
-
annJSON, _ := json.Marshal(enriched[0])
208
-
json.Unmarshal(annJSON, &response)
209
247
210
-
json.NewEncoder(w).Encode(response)
248
+
if bookmark, err := h.db.GetBookmarkByURI(uri); err == nil {
249
+
if enriched, _ := hydrateBookmarks([]db.Bookmark{*bookmark}); len(enriched) > 0 {
250
+
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
251
+
return
252
+
}
253
+
}
254
+
255
+
if strings.Contains(uri, "at.margin.annotation") {
256
+
bookmarkURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1)
257
+
if bookmark, err := h.db.GetBookmarkByURI(bookmarkURI); err == nil {
258
+
if enriched, _ := hydrateBookmarks([]db.Bookmark{*bookmark}); len(enriched) > 0 {
259
+
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
260
+
return
261
+
}
262
+
}
263
+
}
264
+
265
+
http.Error(w, "Annotation, Highlight, or Bookmark not found", http.StatusNotFound)
266
+
211
267
}
212
268
213
269
func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) {
···
243
299
244
300
func (h *Handler) GetHighlights(w http.ResponseWriter, r *http.Request) {
245
301
did := r.URL.Query().Get("creator")
302
+
tag := r.URL.Query().Get("tag")
246
303
limit := parseIntParam(r, "limit", 50)
247
304
offset := parseIntParam(r, "offset", 0)
248
305
249
-
if did == "" {
250
-
http.Error(w, "creator parameter required", http.StatusBadRequest)
251
-
return
306
+
var highlights []db.Highlight
307
+
var err error
308
+
309
+
if did != "" {
310
+
highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset)
311
+
} else if tag != "" {
312
+
highlights, err = h.db.GetHighlightsByTag(tag, limit, offset)
313
+
} else {
314
+
highlights, err = h.db.GetRecentHighlights(limit, offset)
252
315
}
253
316
254
-
highlights, err := h.db.GetHighlightsByAuthor(did, limit, offset)
255
317
if err != nil {
256
318
http.Error(w, err.Error(), http.StatusInternalServerError)
257
319
return
···
515
577
return
516
578
}
517
579
518
-
enriched, err := hydrateNotifications(notifications)
580
+
enriched, err := hydrateNotifications(h.db, notifications)
519
581
if err != nil {
520
582
log.Printf("Failed to hydrate notifications: %v\n", err)
521
583
}
+58
-14
backend/internal/api/hydration.go
+58
-14
backend/internal/api/hydration.go
···
99
99
}
100
100
101
101
type APICollection struct {
102
-
URI string `json:"uri"`
103
-
Name string `json:"name"`
104
-
Icon string `json:"icon,omitempty"`
102
+
URI string `json:"uri"`
103
+
Name string `json:"name"`
104
+
Description string `json:"description,omitempty"`
105
+
Icon string `json:"icon,omitempty"`
106
+
Creator Author `json:"creator"`
107
+
CreatedAt time.Time `json:"createdAt"`
108
+
IndexedAt time.Time `json:"indexedAt"`
105
109
}
106
110
107
111
type APICollectionItem struct {
···
118
122
}
119
123
120
124
type APINotification struct {
121
-
ID int `json:"id"`
122
-
Recipient Author `json:"recipient"`
123
-
Actor Author `json:"actor"`
124
-
Type string `json:"type"`
125
-
SubjectURI string `json:"subjectUri"`
126
-
CreatedAt time.Time `json:"createdAt"`
127
-
ReadAt *time.Time `json:"readAt,omitempty"`
125
+
ID int `json:"id"`
126
+
Recipient Author `json:"recipient"`
127
+
Actor Author `json:"actor"`
128
+
Type string `json:"type"`
129
+
SubjectURI string `json:"subjectUri"`
130
+
Subject interface{} `json:"subject,omitempty"`
131
+
CreatedAt time.Time `json:"createdAt"`
132
+
ReadAt *time.Time `json:"readAt,omitempty"`
128
133
}
129
134
130
135
func hydrateAnnotations(annotations []db.Annotation) ([]APIAnnotation, error) {
···
457
462
if coll.Icon != nil {
458
463
icon = *coll.Icon
459
464
}
465
+
desc := ""
466
+
if coll.Description != nil {
467
+
desc = *coll.Description
468
+
}
460
469
apiItem.Collection = &APICollection{
461
-
URI: coll.URI,
462
-
Name: coll.Name,
463
-
Icon: icon,
470
+
URI: coll.URI,
471
+
Name: coll.Name,
472
+
Description: desc,
473
+
Icon: icon,
474
+
Creator: profiles[coll.AuthorDID],
475
+
CreatedAt: coll.CreatedAt,
476
+
IndexedAt: coll.IndexedAt,
464
477
}
465
478
}
466
479
···
498
511
return result, nil
499
512
}
500
513
501
-
func hydrateNotifications(notifications []db.Notification) ([]APINotification, error) {
514
+
func hydrateNotifications(database *db.DB, notifications []db.Notification) ([]APINotification, error) {
502
515
if len(notifications) == 0 {
503
516
return []APINotification{}, nil
504
517
}
···
518
531
519
532
profiles := fetchProfilesForDIDs(dids)
520
533
534
+
replyURIs := make([]string, 0)
535
+
for _, n := range notifications {
536
+
if n.Type == "reply" {
537
+
replyURIs = append(replyURIs, n.SubjectURI)
538
+
}
539
+
}
540
+
541
+
replyMap := make(map[string]APIReply)
542
+
if len(replyURIs) > 0 {
543
+
var replies []db.Reply
544
+
for _, uri := range replyURIs {
545
+
r, err := database.GetReplyByURI(uri)
546
+
if err == nil {
547
+
replies = append(replies, *r)
548
+
}
549
+
}
550
+
551
+
hydratedReplies, _ := hydrateReplies(replies)
552
+
for _, r := range hydratedReplies {
553
+
replyMap[r.ID] = r
554
+
}
555
+
}
556
+
521
557
result := make([]APINotification, len(notifications))
522
558
for i, n := range notifications {
559
+
var subject interface{}
560
+
if n.Type == "reply" {
561
+
if val, ok := replyMap[n.SubjectURI]; ok {
562
+
subject = val
563
+
}
564
+
}
565
+
523
566
result[i] = APINotification{
524
567
ID: n.ID,
525
568
Recipient: profiles[n.RecipientDID],
526
569
Actor: profiles[n.ActorDID],
527
570
Type: n.Type,
528
571
SubjectURI: n.SubjectURI,
572
+
Subject: subject,
529
573
CreatedAt: n.CreatedAt,
530
574
ReadAt: n.ReadAt,
531
575
}
+691
-60
backend/internal/api/og.go
+691
-60
backend/internal/api/og.go
···
15
15
"net/http"
16
16
"net/url"
17
17
"os"
18
-
"regexp"
19
18
"strings"
20
19
21
20
"golang.org/x/image/font"
···
101
100
"Bluesky",
102
101
}
103
102
103
+
var lucideToEmoji = map[string]string{
104
+
"folder": "๐",
105
+
"star": "โญ",
106
+
"heart": "โค๏ธ",
107
+
"bookmark": "๐",
108
+
"lightbulb": "๐ก",
109
+
"zap": "โก",
110
+
"coffee": "โ",
111
+
"music": "๐ต",
112
+
"camera": "๐ท",
113
+
"code": "๐ป",
114
+
"globe": "๐",
115
+
"flag": "๐ฉ",
116
+
"tag": "๐ท๏ธ",
117
+
"box": "๐ฆ",
118
+
"archive": "๐๏ธ",
119
+
"file": "๐",
120
+
"image": "๐ผ๏ธ",
121
+
"video": "๐ฌ",
122
+
"mail": "โ๏ธ",
123
+
"pin": "๐",
124
+
"calendar": "๐
",
125
+
"clock": "๐",
126
+
"search": "๐",
127
+
"settings": "โ๏ธ",
128
+
"user": "๐ค",
129
+
"users": "๐ฅ",
130
+
"home": "๐ ",
131
+
"briefcase": "๐ผ",
132
+
"gift": "๐",
133
+
"award": "๐",
134
+
"target": "๐ฏ",
135
+
"trending": "๐",
136
+
"activity": "๐",
137
+
"cpu": "๐ฒ",
138
+
"database": "๐๏ธ",
139
+
"cloud": "โ๏ธ",
140
+
"sun": "โ๏ธ",
141
+
"moon": "๐",
142
+
"flame": "๐ฅ",
143
+
"leaf": "๐",
144
+
}
145
+
146
+
func iconToEmoji(icon string) string {
147
+
if strings.HasPrefix(icon, "icon:") {
148
+
name := strings.TrimPrefix(icon, "icon:")
149
+
if emoji, ok := lucideToEmoji[name]; ok {
150
+
return emoji
151
+
}
152
+
return "๐"
153
+
}
154
+
return icon
155
+
}
156
+
104
157
func isCrawler(userAgent string) bool {
105
158
ua := strings.ToLower(userAgent)
106
159
for _, bot := range crawlerUserAgents {
···
111
164
return false
112
165
}
113
166
167
+
func (h *OGHandler) resolveHandle(handle string) (string, error) {
168
+
resp, err := http.Get(fmt.Sprintf("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=%s", url.QueryEscape(handle)))
169
+
if err == nil && resp.StatusCode == http.StatusOK {
170
+
var result struct {
171
+
Did string `json:"did"`
172
+
}
173
+
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil && result.Did != "" {
174
+
return result.Did, nil
175
+
}
176
+
}
177
+
defer resp.Body.Close()
178
+
179
+
return "", fmt.Errorf("failed to resolve handle")
180
+
}
181
+
114
182
func (h *OGHandler) HandleAnnotationPage(w http.ResponseWriter, r *http.Request) {
115
183
path := r.URL.Path
184
+
var did, rkey, collectionType string
116
185
117
-
var annotationMatch = regexp.MustCompile(`^/at/([^/]+)/([^/]+)$`)
118
-
matches := annotationMatch.FindStringSubmatch(path)
186
+
parts := strings.Split(strings.Trim(path, "/"), "/")
187
+
if len(parts) >= 2 {
188
+
firstPart, _ := url.QueryUnescape(parts[0])
189
+
190
+
if firstPart == "at" || firstPart == "annotation" {
191
+
if len(parts) >= 3 {
192
+
did, _ = url.QueryUnescape(parts[1])
193
+
rkey = parts[2]
194
+
}
195
+
} else {
196
+
if len(parts) >= 3 {
197
+
var err error
198
+
did, err = h.resolveHandle(firstPart)
199
+
if err != nil {
200
+
h.serveIndexHTML(w, r)
201
+
return
202
+
}
119
203
120
-
if len(matches) != 3 {
204
+
switch parts[1] {
205
+
case "highlight":
206
+
collectionType = "at.margin.highlight"
207
+
case "bookmark":
208
+
collectionType = "at.margin.bookmark"
209
+
case "annotation":
210
+
collectionType = "at.margin.annotation"
211
+
}
212
+
rkey = parts[2]
213
+
}
214
+
}
215
+
}
216
+
217
+
if did == "" || rkey == "" {
121
218
h.serveIndexHTML(w, r)
122
219
return
123
220
}
124
221
125
-
did, _ := url.QueryUnescape(matches[1])
126
-
rkey := matches[2]
127
-
128
222
if !isCrawler(r.UserAgent()) {
129
223
h.serveIndexHTML(w, r)
130
224
return
131
225
}
132
226
133
-
uri := fmt.Sprintf("at://%s/at.margin.annotation/%s", did, rkey)
134
-
annotation, err := h.db.GetAnnotationByURI(uri)
135
-
if err == nil && annotation != nil {
136
-
h.serveAnnotationOG(w, annotation)
137
-
return
227
+
if collectionType != "" {
228
+
uri := fmt.Sprintf("at://%s/%s/%s", did, collectionType, rkey)
229
+
if h.tryServeType(w, uri, collectionType) {
230
+
return
231
+
}
232
+
} else {
233
+
types := []string{
234
+
"at.margin.annotation",
235
+
"at.margin.bookmark",
236
+
"at.margin.highlight",
237
+
}
238
+
for _, t := range types {
239
+
uri := fmt.Sprintf("at://%s/%s/%s", did, t, rkey)
240
+
if h.tryServeType(w, uri, t) {
241
+
return
242
+
}
243
+
}
244
+
245
+
colURI := fmt.Sprintf("at://%s/at.margin.collection/%s", did, rkey)
246
+
if h.tryServeType(w, colURI, "at.margin.collection") {
247
+
return
248
+
}
138
249
}
139
250
140
-
bookmarkURI := fmt.Sprintf("at://%s/at.margin.bookmark/%s", did, rkey)
141
-
bookmark, err := h.db.GetBookmarkByURI(bookmarkURI)
142
-
if err == nil && bookmark != nil {
143
-
h.serveBookmarkOG(w, bookmark)
251
+
h.serveIndexHTML(w, r)
252
+
}
253
+
254
+
func (h *OGHandler) tryServeType(w http.ResponseWriter, uri, colType string) bool {
255
+
switch colType {
256
+
case "at.margin.annotation":
257
+
if item, err := h.db.GetAnnotationByURI(uri); err == nil && item != nil {
258
+
h.serveAnnotationOG(w, item)
259
+
return true
260
+
}
261
+
case "at.margin.highlight":
262
+
if item, err := h.db.GetHighlightByURI(uri); err == nil && item != nil {
263
+
h.serveHighlightOG(w, item)
264
+
return true
265
+
}
266
+
case "at.margin.bookmark":
267
+
if item, err := h.db.GetBookmarkByURI(uri); err == nil && item != nil {
268
+
h.serveBookmarkOG(w, item)
269
+
return true
270
+
}
271
+
case "at.margin.collection":
272
+
if item, err := h.db.GetCollectionByURI(uri); err == nil && item != nil {
273
+
h.serveCollectionOG(w, item)
274
+
return true
275
+
}
276
+
}
277
+
return false
278
+
}
279
+
280
+
func (h *OGHandler) HandleCollectionPage(w http.ResponseWriter, r *http.Request) {
281
+
path := r.URL.Path
282
+
var did, rkey string
283
+
284
+
if strings.Contains(path, "/collection/") {
285
+
parts := strings.Split(strings.Trim(path, "/"), "/")
286
+
if len(parts) == 3 && parts[1] == "collection" {
287
+
handle, _ := url.QueryUnescape(parts[0])
288
+
rkey = parts[2]
289
+
var err error
290
+
did, err = h.resolveHandle(handle)
291
+
if err != nil {
292
+
h.serveIndexHTML(w, r)
293
+
return
294
+
}
295
+
} else if strings.HasPrefix(path, "/collection/") {
296
+
uriParam := strings.TrimPrefix(path, "/collection/")
297
+
if uriParam != "" {
298
+
uri, err := url.QueryUnescape(uriParam)
299
+
if err == nil {
300
+
parts := strings.Split(uri, "/")
301
+
if len(parts) >= 3 && strings.HasPrefix(uri, "at://") {
302
+
did = parts[2]
303
+
rkey = parts[len(parts)-1]
304
+
}
305
+
}
306
+
}
307
+
}
308
+
}
309
+
310
+
if did == "" && rkey == "" {
311
+
h.serveIndexHTML(w, r)
144
312
return
313
+
} else if did != "" && rkey != "" {
314
+
uri := fmt.Sprintf("at://%s/at.margin.collection/%s", did, rkey)
315
+
316
+
if !isCrawler(r.UserAgent()) {
317
+
h.serveIndexHTML(w, r)
318
+
return
319
+
}
320
+
321
+
collection, err := h.db.GetCollectionByURI(uri)
322
+
if err == nil && collection != nil {
323
+
h.serveCollectionOG(w, collection)
324
+
return
325
+
}
145
326
}
146
327
147
328
h.serveIndexHTML(w, r)
···
232
413
w.Write([]byte(htmlContent))
233
414
}
234
415
416
+
func (h *OGHandler) serveHighlightOG(w http.ResponseWriter, highlight *db.Highlight) {
417
+
title := "Highlight on Margin"
418
+
description := ""
419
+
420
+
if highlight.SelectorJSON != nil && *highlight.SelectorJSON != "" {
421
+
var selector struct {
422
+
Exact string `json:"exact"`
423
+
}
424
+
if err := json.Unmarshal([]byte(*highlight.SelectorJSON), &selector); err == nil && selector.Exact != "" {
425
+
description = fmt.Sprintf("\"%s\"", selector.Exact)
426
+
if len(description) > 200 {
427
+
description = description[:197] + "...\""
428
+
}
429
+
}
430
+
}
431
+
432
+
if highlight.TargetTitle != nil && *highlight.TargetTitle != "" {
433
+
title = fmt.Sprintf("Highlight on: %s", *highlight.TargetTitle)
434
+
if len(title) > 60 {
435
+
title = title[:57] + "..."
436
+
}
437
+
}
438
+
439
+
sourceDomain := ""
440
+
if highlight.TargetSource != "" {
441
+
if parsed, err := url.Parse(highlight.TargetSource); err == nil {
442
+
sourceDomain = parsed.Host
443
+
}
444
+
}
445
+
446
+
authorHandle := highlight.AuthorDID
447
+
profiles := fetchProfilesForDIDs([]string{highlight.AuthorDID})
448
+
if profile, ok := profiles[highlight.AuthorDID]; ok && profile.Handle != "" {
449
+
authorHandle = "@" + profile.Handle
450
+
}
451
+
452
+
if description == "" {
453
+
description = fmt.Sprintf("A highlight by %s", authorHandle)
454
+
if sourceDomain != "" {
455
+
description += fmt.Sprintf(" on %s", sourceDomain)
456
+
}
457
+
}
458
+
459
+
pageURL := fmt.Sprintf("%s/at/%s", h.baseURL, url.PathEscape(highlight.URI[5:]))
460
+
ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(highlight.URI))
461
+
462
+
htmlContent := fmt.Sprintf(`<!DOCTYPE html>
463
+
<html lang="en">
464
+
<head>
465
+
<meta charset="UTF-8">
466
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
467
+
<title>%s - Margin</title>
468
+
<meta name="description" content="%s">
469
+
470
+
<!-- Open Graph -->
471
+
<meta property="og:type" content="article">
472
+
<meta property="og:title" content="%s">
473
+
<meta property="og:description" content="%s">
474
+
<meta property="og:url" content="%s">
475
+
<meta property="og:image" content="%s">
476
+
<meta property="og:image:width" content="1200">
477
+
<meta property="og:image:height" content="630">
478
+
<meta property="og:site_name" content="Margin">
479
+
480
+
<!-- Twitter Card -->
481
+
<meta name="twitter:card" content="summary_large_image">
482
+
<meta name="twitter:title" content="%s">
483
+
<meta name="twitter:description" content="%s">
484
+
<meta name="twitter:image" content="%s">
485
+
486
+
<!-- Author -->
487
+
<meta property="article:author" content="%s">
488
+
489
+
<meta http-equiv="refresh" content="0; url=%s">
490
+
</head>
491
+
<body>
492
+
<p>Redirecting to <a href="%s">%s</a>...</p>
493
+
</body>
494
+
</html>`,
495
+
html.EscapeString(title),
496
+
html.EscapeString(description),
497
+
html.EscapeString(title),
498
+
html.EscapeString(description),
499
+
html.EscapeString(pageURL),
500
+
html.EscapeString(ogImageURL),
501
+
html.EscapeString(title),
502
+
html.EscapeString(description),
503
+
html.EscapeString(ogImageURL),
504
+
html.EscapeString(authorHandle),
505
+
html.EscapeString(pageURL),
506
+
html.EscapeString(pageURL),
507
+
html.EscapeString(title),
508
+
)
509
+
510
+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
511
+
w.Write([]byte(htmlContent))
512
+
}
513
+
514
+
func (h *OGHandler) serveCollectionOG(w http.ResponseWriter, collection *db.Collection) {
515
+
icon := "๐"
516
+
if collection.Icon != nil && *collection.Icon != "" {
517
+
icon = iconToEmoji(*collection.Icon)
518
+
}
519
+
520
+
title := fmt.Sprintf("%s %s", icon, collection.Name)
521
+
description := ""
522
+
if collection.Description != nil && *collection.Description != "" {
523
+
description = *collection.Description
524
+
if len(description) > 200 {
525
+
description = description[:197] + "..."
526
+
}
527
+
}
528
+
529
+
authorHandle := collection.AuthorDID
530
+
var avatarURL string
531
+
profiles := fetchProfilesForDIDs([]string{collection.AuthorDID})
532
+
if profile, ok := profiles[collection.AuthorDID]; ok {
533
+
if profile.Handle != "" {
534
+
authorHandle = "@" + profile.Handle
535
+
}
536
+
if profile.Avatar != "" {
537
+
avatarURL = profile.Avatar
538
+
}
539
+
}
540
+
541
+
if description == "" {
542
+
description = fmt.Sprintf("A collection by %s", authorHandle)
543
+
} else {
544
+
description = fmt.Sprintf("By %s โข %s", authorHandle, description)
545
+
}
546
+
547
+
pageURL := fmt.Sprintf("%s/collection/%s", h.baseURL, url.PathEscape(collection.URI))
548
+
ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(collection.URI))
549
+
550
+
_ = avatarURL
551
+
552
+
htmlContent := fmt.Sprintf(`<!DOCTYPE html>
553
+
<html lang="en">
554
+
<head>
555
+
<meta charset="UTF-8">
556
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
557
+
<title>%s - Margin</title>
558
+
<meta name="description" content="%s">
559
+
560
+
<!-- Open Graph -->
561
+
<meta property="og:type" content="article">
562
+
<meta property="og:title" content="%s">
563
+
<meta property="og:description" content="%s">
564
+
<meta property="og:url" content="%s">
565
+
<meta property="og:image" content="%s">
566
+
<meta property="og:image:width" content="1200">
567
+
<meta property="og:image:height" content="630">
568
+
<meta property="og:site_name" content="Margin">
569
+
570
+
<!-- Twitter Card -->
571
+
<meta name="twitter:card" content="summary_large_image">
572
+
<meta name="twitter:title" content="%s">
573
+
<meta name="twitter:description" content="%s">
574
+
<meta name="twitter:image" content="%s">
575
+
576
+
<!-- Author -->
577
+
<meta property="article:author" content="%s">
578
+
579
+
<meta http-equiv="refresh" content="0; url=%s">
580
+
</head>
581
+
<body>
582
+
<p>Redirecting to <a href="%s">%s</a>...</p>
583
+
</body>
584
+
</html>`,
585
+
html.EscapeString(title),
586
+
html.EscapeString(description),
587
+
html.EscapeString(title),
588
+
html.EscapeString(description),
589
+
html.EscapeString(pageURL),
590
+
html.EscapeString(ogImageURL),
591
+
html.EscapeString(title),
592
+
html.EscapeString(description),
593
+
html.EscapeString(ogImageURL),
594
+
html.EscapeString(authorHandle),
595
+
html.EscapeString(pageURL),
596
+
html.EscapeString(pageURL),
597
+
html.EscapeString(title),
598
+
)
599
+
600
+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
601
+
w.Write([]byte(htmlContent))
602
+
}
603
+
235
604
func (h *OGHandler) serveAnnotationOG(w http.ResponseWriter, annotation *db.Annotation) {
236
605
title := "Annotation on Margin"
237
606
description := ""
···
417
786
}
418
787
}
419
788
} else {
420
-
http.Error(w, "Record not found", http.StatusNotFound)
421
-
return
789
+
highlight, err := h.db.GetHighlightByURI(uri)
790
+
if err == nil && highlight != nil {
791
+
authorHandle = highlight.AuthorDID
792
+
profiles := fetchProfilesForDIDs([]string{highlight.AuthorDID})
793
+
if profile, ok := profiles[highlight.AuthorDID]; ok {
794
+
if profile.Handle != "" {
795
+
authorHandle = "@" + profile.Handle
796
+
}
797
+
if profile.Avatar != "" {
798
+
avatarURL = profile.Avatar
799
+
}
800
+
}
801
+
802
+
targetTitle := ""
803
+
if highlight.TargetTitle != nil {
804
+
targetTitle = *highlight.TargetTitle
805
+
}
806
+
807
+
if highlight.SelectorJSON != nil && *highlight.SelectorJSON != "" {
808
+
var selector struct {
809
+
Exact string `json:"exact"`
810
+
}
811
+
if err := json.Unmarshal([]byte(*highlight.SelectorJSON), &selector); err == nil && selector.Exact != "" {
812
+
quote = selector.Exact
813
+
}
814
+
}
815
+
816
+
if highlight.TargetSource != "" {
817
+
if parsed, err := url.Parse(highlight.TargetSource); err == nil {
818
+
sourceDomain = parsed.Host
819
+
}
820
+
}
821
+
822
+
img := generateHighlightOGImagePNG(authorHandle, targetTitle, quote, sourceDomain, avatarURL)
823
+
824
+
w.Header().Set("Content-Type", "image/png")
825
+
w.Header().Set("Cache-Control", "public, max-age=86400")
826
+
png.Encode(w, img)
827
+
return
828
+
} else {
829
+
collection, err := h.db.GetCollectionByURI(uri)
830
+
if err == nil && collection != nil {
831
+
authorHandle = collection.AuthorDID
832
+
profiles := fetchProfilesForDIDs([]string{collection.AuthorDID})
833
+
if profile, ok := profiles[collection.AuthorDID]; ok {
834
+
if profile.Handle != "" {
835
+
authorHandle = "@" + profile.Handle
836
+
}
837
+
if profile.Avatar != "" {
838
+
avatarURL = profile.Avatar
839
+
}
840
+
}
841
+
842
+
icon := "๐"
843
+
if collection.Icon != nil && *collection.Icon != "" {
844
+
icon = iconToEmoji(*collection.Icon)
845
+
}
846
+
847
+
description := ""
848
+
if collection.Description != nil && *collection.Description != "" {
849
+
description = *collection.Description
850
+
}
851
+
852
+
img := generateCollectionOGImagePNG(authorHandle, collection.Name, description, icon, avatarURL)
853
+
854
+
w.Header().Set("Content-Type", "image/png")
855
+
w.Header().Set("Cache-Control", "public, max-age=86400")
856
+
png.Encode(w, img)
857
+
return
858
+
} else {
859
+
http.Error(w, "Record not found", http.StatusNotFound)
860
+
return
861
+
}
862
+
}
422
863
}
423
864
}
424
865
···
432
873
func generateOGImagePNG(author, text, quote, source, avatarURL string) image.Image {
433
874
width := 1200
434
875
height := 630
435
-
padding := 120
876
+
padding := 100
436
877
437
878
bgPrimary := color.RGBA{12, 10, 20, 255}
438
879
accent := color.RGBA{168, 85, 247, 255}
439
880
textPrimary := color.RGBA{244, 240, 255, 255}
440
881
textSecondary := color.RGBA{168, 158, 200, 255}
441
-
textTertiary := color.RGBA{107, 95, 138, 255}
442
882
border := color.RGBA{45, 38, 64, 255}
443
883
444
884
img := image.NewRGBA(image.Rect(0, 0, width, height))
445
885
446
886
draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src)
447
-
draw.Draw(img, image.Rect(0, 0, width, 6), &image.Uniform{accent}, image.Point{}, draw.Src)
448
-
449
-
if logoImage != nil {
450
-
logoHeight := 50
451
-
logoWidth := int(float64(logoImage.Bounds().Dx()) * (float64(logoHeight) / float64(logoImage.Bounds().Dy())))
452
-
drawScaledImage(img, logoImage, padding, 80, logoWidth, logoHeight)
453
-
} else {
454
-
drawText(img, "Margin", padding, 120, accent, 36, true)
455
-
}
887
+
draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src)
456
888
457
-
avatarSize := 80
889
+
avatarSize := 64
458
890
avatarX := padding
459
-
avatarY := 180
891
+
avatarY := padding
892
+
460
893
avatarImg := fetchAvatarImage(avatarURL)
461
894
if avatarImg != nil {
462
895
drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize)
463
896
} else {
464
897
drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent)
465
898
}
466
-
467
-
handleX := avatarX + avatarSize + 24
468
-
drawText(img, author, handleX, avatarY+50, textSecondary, 24, false)
469
-
470
-
yPos := 280
471
-
draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src)
472
-
yPos += 40
899
+
drawText(img, author, avatarX+avatarSize+24, avatarY+42, textSecondary, 28, false)
473
900
474
901
contentWidth := width - (padding * 2)
902
+
yPos := 220
475
903
476
-
if quote != "" {
477
-
if len(quote) > 100 {
478
-
quote = quote[:97] + "..."
479
-
}
904
+
if text != "" {
905
+
textLen := len(text)
906
+
textSize := 32.0
907
+
textLineHeight := 42
908
+
maxTextLines := 5
480
909
481
-
lines := wrapTextToWidth(quote, contentWidth-30, 24)
482
-
numLines := min(len(lines), 2)
483
-
barHeight := numLines*32 + 10
910
+
if textLen > 200 {
911
+
textSize = 28.0
912
+
textLineHeight = 36
913
+
maxTextLines = 6
914
+
}
484
915
485
-
draw.Draw(img, image.Rect(padding, yPos, padding+6, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src)
916
+
lines := wrapTextToWidth(text, contentWidth, int(textSize))
917
+
numLines := min(len(lines), maxTextLines)
486
918
487
-
for i, line := range lines {
488
-
if i >= 2 {
489
-
break
919
+
for i := 0; i < numLines; i++ {
920
+
line := lines[i]
921
+
if i == numLines-1 && len(lines) > numLines {
922
+
line += "..."
490
923
}
491
-
drawText(img, "\""+line+"\"", padding+24, yPos+28+(i*32), textTertiary, 24, true)
924
+
drawText(img, line, padding, yPos+(i*textLineHeight), textPrimary, textSize, false)
492
925
}
493
-
yPos += 30 + (numLines * 32) + 30
926
+
yPos += (numLines * textLineHeight) + 40
494
927
}
495
928
496
-
if text != "" {
497
-
if len(text) > 300 {
498
-
text = text[:297] + "..."
929
+
if quote != "" {
930
+
quoteLen := len(quote)
931
+
quoteSize := 24.0
932
+
quoteLineHeight := 32
933
+
maxQuoteLines := 3
934
+
935
+
if quoteLen > 150 {
936
+
quoteSize = 20.0
937
+
quoteLineHeight = 28
938
+
maxQuoteLines = 4
499
939
}
500
-
lines := wrapTextToWidth(text, contentWidth, 32)
501
-
for i, line := range lines {
502
-
if i >= 6 {
503
-
break
940
+
941
+
lines := wrapTextToWidth(quote, contentWidth-30, int(quoteSize))
942
+
numLines := min(len(lines), maxQuoteLines)
943
+
barHeight := numLines * quoteLineHeight
944
+
945
+
draw.Draw(img, image.Rect(padding, yPos, padding+6, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src)
946
+
947
+
for i := 0; i < numLines; i++ {
948
+
line := lines[i]
949
+
if i == numLines-1 && len(lines) > numLines {
950
+
line += "..."
504
951
}
505
-
drawText(img, line, padding, yPos+(i*42), textPrimary, 32, false)
952
+
drawText(img, line, padding+24, yPos+24+(i*quoteLineHeight), textSecondary, quoteSize, true)
506
953
}
954
+
yPos += barHeight + 40
507
955
}
508
956
509
-
drawText(img, source, padding, 580, textTertiary, 20, false)
957
+
draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src)
958
+
yPos += 40
959
+
drawText(img, source, padding, yPos+32, textSecondary, 24, false)
510
960
511
961
return img
512
962
}
···
662
1112
}
663
1113
return lines
664
1114
}
1115
+
1116
+
func generateCollectionOGImagePNG(author, collectionName, description, icon, avatarURL string) image.Image {
1117
+
width := 1200
1118
+
height := 630
1119
+
padding := 120
1120
+
1121
+
bgPrimary := color.RGBA{12, 10, 20, 255}
1122
+
accent := color.RGBA{168, 85, 247, 255}
1123
+
textPrimary := color.RGBA{244, 240, 255, 255}
1124
+
textSecondary := color.RGBA{168, 158, 200, 255}
1125
+
textTertiary := color.RGBA{107, 95, 138, 255}
1126
+
border := color.RGBA{45, 38, 64, 255}
1127
+
1128
+
img := image.NewRGBA(image.Rect(0, 0, width, height))
1129
+
1130
+
draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src)
1131
+
draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src)
1132
+
1133
+
iconY := 120
1134
+
var iconWidth int
1135
+
if icon != "" {
1136
+
emojiImg := fetchTwemojiImage(icon)
1137
+
if emojiImg != nil {
1138
+
iconSize := 96
1139
+
drawScaledImage(img, emojiImg, padding, iconY, iconSize, iconSize)
1140
+
iconWidth = iconSize + 32
1141
+
} else {
1142
+
drawText(img, icon, padding, iconY+70, textPrimary, 80, true)
1143
+
iconWidth = 100
1144
+
}
1145
+
}
1146
+
1147
+
drawText(img, collectionName, padding+iconWidth, iconY+65, textPrimary, 64, true)
1148
+
1149
+
yPos := 280
1150
+
contentWidth := width - (padding * 2)
1151
+
1152
+
if description != "" {
1153
+
if len(description) > 200 {
1154
+
description = description[:197] + "..."
1155
+
}
1156
+
lines := wrapTextToWidth(description, contentWidth, 32)
1157
+
for i, line := range lines {
1158
+
if i >= 4 {
1159
+
break
1160
+
}
1161
+
drawText(img, line, padding, yPos+(i*42), textSecondary, 32, false)
1162
+
}
1163
+
} else {
1164
+
drawText(img, "A collection on Margin", padding, yPos, textTertiary, 32, false)
1165
+
}
1166
+
1167
+
yPos = 480
1168
+
draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src)
1169
+
1170
+
avatarSize := 64
1171
+
avatarX := padding
1172
+
avatarY := yPos + 40
1173
+
1174
+
avatarImg := fetchAvatarImage(avatarURL)
1175
+
if avatarImg != nil {
1176
+
drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize)
1177
+
} else {
1178
+
drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent)
1179
+
}
1180
+
1181
+
handleX := avatarX + avatarSize + 24
1182
+
drawText(img, author, handleX, avatarY+42, textTertiary, 28, false)
1183
+
1184
+
return img
1185
+
}
1186
+
1187
+
func fetchTwemojiImage(emoji string) image.Image {
1188
+
var codes []string
1189
+
for _, r := range emoji {
1190
+
codes = append(codes, fmt.Sprintf("%x", r))
1191
+
}
1192
+
hexCode := strings.Join(codes, "-")
1193
+
1194
+
url := fmt.Sprintf("https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/%s.png", hexCode)
1195
+
1196
+
resp, err := http.Get(url)
1197
+
if err != nil || resp.StatusCode != 200 {
1198
+
if strings.Contains(hexCode, "-fe0f") {
1199
+
simpleHex := strings.ReplaceAll(hexCode, "-fe0f", "")
1200
+
url = fmt.Sprintf("https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/%s.png", simpleHex)
1201
+
resp, err = http.Get(url)
1202
+
if err != nil || resp.StatusCode != 200 {
1203
+
return nil
1204
+
}
1205
+
} else {
1206
+
return nil
1207
+
}
1208
+
}
1209
+
defer resp.Body.Close()
1210
+
1211
+
img, _, err := image.Decode(resp.Body)
1212
+
if err != nil {
1213
+
return nil
1214
+
}
1215
+
return img
1216
+
}
1217
+
1218
+
func generateHighlightOGImagePNG(author, pageTitle, quote, source, avatarURL string) image.Image {
1219
+
width := 1200
1220
+
height := 630
1221
+
padding := 100
1222
+
1223
+
bgPrimary := color.RGBA{12, 10, 20, 255}
1224
+
accent := color.RGBA{250, 204, 21, 255}
1225
+
textPrimary := color.RGBA{244, 240, 255, 255}
1226
+
textSecondary := color.RGBA{168, 158, 200, 255}
1227
+
border := color.RGBA{45, 38, 64, 255}
1228
+
1229
+
img := image.NewRGBA(image.Rect(0, 0, width, height))
1230
+
1231
+
draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src)
1232
+
draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src)
1233
+
1234
+
avatarSize := 64
1235
+
avatarX := padding
1236
+
avatarY := padding
1237
+
1238
+
avatarImg := fetchAvatarImage(avatarURL)
1239
+
if avatarImg != nil {
1240
+
drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize)
1241
+
} else {
1242
+
drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent)
1243
+
}
1244
+
drawText(img, author, avatarX+avatarSize+24, avatarY+42, textSecondary, 28, false)
1245
+
1246
+
contentWidth := width - (padding * 2)
1247
+
yPos := 220
1248
+
if quote != "" {
1249
+
quoteLen := len(quote)
1250
+
fontSize := 42.0
1251
+
lineHeight := 56
1252
+
maxLines := 4
1253
+
1254
+
if quoteLen > 200 {
1255
+
fontSize = 32.0
1256
+
lineHeight = 44
1257
+
maxLines = 6
1258
+
} else if quoteLen > 100 {
1259
+
fontSize = 36.0
1260
+
lineHeight = 48
1261
+
maxLines = 5
1262
+
}
1263
+
1264
+
lines := wrapTextToWidth(quote, contentWidth-40, int(fontSize))
1265
+
numLines := min(len(lines), maxLines)
1266
+
barHeight := numLines * lineHeight
1267
+
1268
+
draw.Draw(img, image.Rect(padding, yPos, padding+8, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src)
1269
+
1270
+
for i := 0; i < numLines; i++ {
1271
+
line := lines[i]
1272
+
if i == numLines-1 && len(lines) > numLines {
1273
+
line += "..."
1274
+
}
1275
+
drawText(img, line, padding+40, yPos+42+(i*lineHeight), textPrimary, fontSize, false)
1276
+
}
1277
+
yPos += barHeight + 40
1278
+
}
1279
+
1280
+
draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src)
1281
+
yPos += 40
1282
+
1283
+
if pageTitle != "" {
1284
+
if len(pageTitle) > 60 {
1285
+
pageTitle = pageTitle[:57] + "..."
1286
+
}
1287
+
drawText(img, pageTitle, padding, yPos+32, textSecondary, 32, true)
1288
+
}
1289
+
1290
+
if source != "" {
1291
+
drawText(img, source, padding, yPos+80, textSecondary, 24, false)
1292
+
}
1293
+
1294
+
return img
1295
+
}
+134
backend/internal/db/queries.go
+134
backend/internal/db/queries.go
···
104
104
return scanAnnotations(rows)
105
105
}
106
106
107
+
func (db *DB) GetAnnotationsByTag(tag string, limit, offset int) ([]Annotation, error) {
108
+
pattern := "%\"" + tag + "\"%"
109
+
rows, err := db.Query(db.Rebind(`
110
+
SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid
111
+
FROM annotations
112
+
WHERE tags_json LIKE ?
113
+
ORDER BY created_at DESC
114
+
LIMIT ? OFFSET ?
115
+
`), pattern, limit, offset)
116
+
if err != nil {
117
+
return nil, err
118
+
}
119
+
defer rows.Close()
120
+
121
+
return scanAnnotations(rows)
122
+
}
123
+
107
124
func (db *DB) DeleteAnnotation(uri string) error {
108
125
_, err := db.Exec(db.Rebind(`DELETE FROM annotations WHERE uri = ?`), uri)
109
126
return err
···
242
259
return highlights, nil
243
260
}
244
261
262
+
func (db *DB) GetHighlightsByTag(tag string, limit, offset int) ([]Highlight, error) {
263
+
pattern := "%\"" + tag + "\"%"
264
+
rows, err := db.Query(db.Rebind(`
265
+
SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid
266
+
FROM highlights
267
+
WHERE tags_json LIKE ?
268
+
ORDER BY created_at DESC
269
+
LIMIT ? OFFSET ?
270
+
`), pattern, limit, offset)
271
+
if err != nil {
272
+
return nil, err
273
+
}
274
+
defer rows.Close()
275
+
276
+
var highlights []Highlight
277
+
for rows.Next() {
278
+
var h Highlight
279
+
if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil {
280
+
return nil, err
281
+
}
282
+
highlights = append(highlights, h)
283
+
}
284
+
return highlights, nil
285
+
}
286
+
245
287
func (db *DB) GetRecentBookmarks(limit, offset int) ([]Bookmark, error) {
246
288
rows, err := db.Query(db.Rebind(`
247
289
SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid
···
249
291
ORDER BY created_at DESC
250
292
LIMIT ? OFFSET ?
251
293
`), limit, offset)
294
+
if err != nil {
295
+
return nil, err
296
+
}
297
+
defer rows.Close()
298
+
299
+
var bookmarks []Bookmark
300
+
for rows.Next() {
301
+
var b Bookmark
302
+
if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil {
303
+
return nil, err
304
+
}
305
+
bookmarks = append(bookmarks, b)
306
+
}
307
+
return bookmarks, nil
308
+
}
309
+
310
+
func (db *DB) GetBookmarksByTag(tag string, limit, offset int) ([]Bookmark, error) {
311
+
pattern := "%\"" + tag + "\"%"
312
+
rows, err := db.Query(db.Rebind(`
313
+
SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid
314
+
FROM bookmarks
315
+
WHERE tags_json LIKE ?
316
+
ORDER BY created_at DESC
317
+
LIMIT ? OFFSET ?
318
+
`), pattern, limit, offset)
319
+
if err != nil {
320
+
return nil, err
321
+
}
322
+
defer rows.Close()
323
+
324
+
var bookmarks []Bookmark
325
+
for rows.Next() {
326
+
var b Bookmark
327
+
if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil {
328
+
return nil, err
329
+
}
330
+
bookmarks = append(bookmarks, b)
331
+
}
332
+
return bookmarks, nil
333
+
}
334
+
335
+
func (db *DB) GetAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Annotation, error) {
336
+
pattern := "%\"" + tag + "\"%"
337
+
rows, err := db.Query(db.Rebind(`
338
+
SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid
339
+
FROM annotations
340
+
WHERE author_did = ? AND tags_json LIKE ?
341
+
ORDER BY created_at DESC
342
+
LIMIT ? OFFSET ?
343
+
`), authorDID, pattern, limit, offset)
344
+
if err != nil {
345
+
return nil, err
346
+
}
347
+
defer rows.Close()
348
+
349
+
return scanAnnotations(rows)
350
+
}
351
+
352
+
func (db *DB) GetHighlightsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Highlight, error) {
353
+
pattern := "%\"" + tag + "\"%"
354
+
rows, err := db.Query(db.Rebind(`
355
+
SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid
356
+
FROM highlights
357
+
WHERE author_did = ? AND tags_json LIKE ?
358
+
ORDER BY created_at DESC
359
+
LIMIT ? OFFSET ?
360
+
`), authorDID, pattern, limit, offset)
361
+
if err != nil {
362
+
return nil, err
363
+
}
364
+
defer rows.Close()
365
+
366
+
var highlights []Highlight
367
+
for rows.Next() {
368
+
var h Highlight
369
+
if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil {
370
+
return nil, err
371
+
}
372
+
highlights = append(highlights, h)
373
+
}
374
+
return highlights, nil
375
+
}
376
+
377
+
func (db *DB) GetBookmarksByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Bookmark, error) {
378
+
pattern := "%\"" + tag + "\"%"
379
+
rows, err := db.Query(db.Rebind(`
380
+
SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid
381
+
FROM bookmarks
382
+
WHERE author_did = ? AND tags_json LIKE ?
383
+
ORDER BY created_at DESC
384
+
LIMIT ? OFFSET ?
385
+
`), authorDID, pattern, limit, offset)
252
386
if err != nil {
253
387
return nil, err
254
388
}
+2
-2
backend/internal/oauth/client.go
+2
-2
backend/internal/oauth/client.go
···
205
205
"jti": base64.RawURLEncoding.EncodeToString(jti),
206
206
"htm": method,
207
207
"htu": uri,
208
-
"iat": now.Unix(),
208
+
"iat": now.Add(-30 * time.Second).Unix(),
209
209
"exp": now.Add(5 * time.Minute).Unix(),
210
210
}
211
211
if nonce != "" {
···
243
243
Issuer: c.ClientID,
244
244
Subject: c.ClientID,
245
245
Audience: jwt.Audience{issuer},
246
-
IssuedAt: jwt.NewNumericDate(now),
246
+
IssuedAt: jwt.NewNumericDate(now.Add(-30 * time.Second)),
247
247
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
248
248
ID: base64.RawURLEncoding.EncodeToString(jti),
249
249
}
+1
backend/internal/oauth/handler.go
+1
backend/internal/oauth/handler.go
···
244
244
245
245
parResp, state, dpopNonce, err := client.SendPAR(meta, req.Handle, scope, dpopKey, pkceChallenge)
246
246
if err != nil {
247
+
log.Printf("PAR request failed: %v", err)
247
248
w.Header().Set("Content-Type", "application/json")
248
249
w.WriteHeader(http.StatusInternalServerError)
249
250
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to initiate authentication"})
+2
-1
backend/internal/xrpc/records.go
+2
-1
backend/internal/xrpc/records.go
···
78
78
CreatedAt string `json:"createdAt"`
79
79
}
80
80
81
-
func NewHighlightRecord(url, urlHash string, selector interface{}, color string) *HighlightRecord {
81
+
func NewHighlightRecord(url, urlHash string, selector interface{}, color string, tags []string) *HighlightRecord {
82
82
return &HighlightRecord{
83
83
Type: CollectionHighlight,
84
84
Target: AnnotationTarget{
···
87
87
Selector: selector,
88
88
},
89
89
Color: color,
90
+
Tags: tags,
90
91
CreatedAt: time.Now().UTC().Format(time.RFC3339),
91
92
}
92
93
}
+18
web/src/App.jsx
+18
web/src/App.jsx
···
34
34
<Route path="/annotation/:uri" element={<AnnotationDetail />} />
35
35
<Route path="/collections" element={<Collections />} />
36
36
<Route path="/collections/:rkey" element={<CollectionDetail />} />
37
+
<Route
38
+
path="/:handle/collection/:rkey"
39
+
element={<CollectionDetail />}
40
+
/>
41
+
42
+
<Route
43
+
path="/:handle/annotation/:rkey"
44
+
element={<AnnotationDetail />}
45
+
/>
46
+
<Route
47
+
path="/:handle/highlight/:rkey"
48
+
element={<AnnotationDetail />}
49
+
/>
50
+
<Route
51
+
path="/:handle/bookmark/:rkey"
52
+
element={<AnnotationDetail />}
53
+
/>
54
+
37
55
<Route path="/collection/*" element={<CollectionDetail />} />
38
56
<Route path="/privacy" element={<Privacy />} />
39
57
</Routes>
+65
-33
web/src/api/client.js
+65
-33
web/src/api/client.js
···
23
23
return request(`${API_BASE}/url-metadata?url=${encodeURIComponent(url)}`);
24
24
}
25
25
26
-
export async function getAnnotationFeed(limit = 50, offset = 0) {
27
-
return request(
28
-
`${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`,
29
-
);
26
+
export async function getAnnotationFeed(
27
+
limit = 50,
28
+
offset = 0,
29
+
tag = "",
30
+
creator = "",
31
+
) {
32
+
let url = `${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`;
33
+
if (tag) url += `&tag=${encodeURIComponent(tag)}`;
34
+
if (creator) url += `&creator=${encodeURIComponent(creator)}`;
35
+
return request(url);
30
36
}
31
37
32
38
export async function getAnnotations({
···
210
216
});
211
217
}
212
218
213
-
export async function createAnnotation({ url, text, quote, title, selector }) {
219
+
export async function createHighlight({ url, title, selector, color, tags }) {
220
+
return request(`${API_BASE}/highlights`, {
221
+
method: "POST",
222
+
body: JSON.stringify({ url, title, selector, color, tags }),
223
+
});
224
+
}
225
+
226
+
export async function createAnnotation({
227
+
url,
228
+
text,
229
+
quote,
230
+
title,
231
+
selector,
232
+
tags,
233
+
}) {
214
234
return request(`${API_BASE}/annotations`, {
215
235
method: "POST",
216
-
body: JSON.stringify({ url, text, quote, title, selector }),
236
+
body: JSON.stringify({ url, text, quote, title, selector, tags }),
217
237
});
218
238
}
219
239
···
283
303
284
304
if (item.type === "Annotation") {
285
305
return {
286
-
uri: item.id,
287
-
author: item.creator,
288
-
url: item.target?.source,
289
-
title: item.target?.title,
290
-
text: item.body?.value,
291
-
selector: item.target?.selector,
306
+
type: item.type,
307
+
uri: item.uri || item.id,
308
+
author: item.author || item.creator,
309
+
url: item.url || item.target?.source,
310
+
title: item.title || item.target?.title,
311
+
text: item.text || item.body?.value,
312
+
selector: item.selector || item.target?.selector,
292
313
motivation: item.motivation,
293
314
tags: item.tags || [],
294
-
createdAt: item.created,
315
+
createdAt: item.createdAt || item.created,
295
316
cid: item.cid || item.CID,
296
317
};
297
318
}
298
319
299
320
if (item.type === "Bookmark") {
300
321
return {
301
-
uri: item.id,
302
-
author: item.creator,
303
-
url: item.source,
322
+
type: item.type,
323
+
uri: item.uri || item.id,
324
+
author: item.author || item.creator,
325
+
url: item.url || item.source,
304
326
title: item.title,
305
327
description: item.description,
306
328
tags: item.tags || [],
307
-
createdAt: item.created,
329
+
createdAt: item.createdAt || item.created,
308
330
cid: item.cid || item.CID,
309
331
};
310
332
}
311
333
312
334
if (item.type === "Highlight") {
313
335
return {
314
-
uri: item.id,
315
-
author: item.creator,
316
-
url: item.target?.source,
317
-
title: item.target?.title,
318
-
selector: item.target?.selector,
336
+
type: item.type,
337
+
uri: item.uri || item.id,
338
+
author: item.author || item.creator,
339
+
url: item.url || item.target?.source,
340
+
title: item.title || item.target?.title,
341
+
selector: item.selector || item.target?.selector,
319
342
color: item.color,
320
343
tags: item.tags || [],
321
-
createdAt: item.created,
344
+
createdAt: item.createdAt || item.created,
322
345
cid: item.cid || item.CID,
323
346
};
324
347
}
···
340
363
341
364
export function normalizeHighlight(highlight) {
342
365
return {
343
-
uri: highlight.id,
344
-
author: highlight.creator,
345
-
url: highlight.target?.source,
346
-
title: highlight.target?.title,
347
-
selector: highlight.target?.selector,
366
+
uri: highlight.uri || highlight.id,
367
+
author: highlight.author || highlight.creator,
368
+
url: highlight.url || highlight.target?.source,
369
+
title: highlight.title || highlight.target?.title,
370
+
selector: highlight.selector || highlight.target?.selector,
348
371
color: highlight.color,
349
372
tags: highlight.tags || [],
350
-
createdAt: highlight.created,
373
+
createdAt: highlight.createdAt || highlight.created,
351
374
};
352
375
}
353
376
354
377
export function normalizeBookmark(bookmark) {
355
378
return {
356
-
uri: bookmark.id,
357
-
author: bookmark.creator,
358
-
url: bookmark.source,
379
+
uri: bookmark.uri || bookmark.id,
380
+
author: bookmark.author || bookmark.creator,
381
+
url: bookmark.url || bookmark.source,
359
382
title: bookmark.title,
360
383
description: bookmark.description,
361
384
tags: bookmark.tags || [],
362
-
createdAt: bookmark.created,
385
+
createdAt: bookmark.createdAt || bookmark.created,
363
386
};
364
387
}
365
388
···
369
392
);
370
393
if (!res.ok) throw new Error("Search failed");
371
394
return res.json();
395
+
}
396
+
397
+
export async function resolveHandle(handle) {
398
+
const res = await fetch(
399
+
`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
400
+
);
401
+
if (!res.ok) throw new Error("Failed to resolve handle");
402
+
const data = await res.json();
403
+
return data.did;
372
404
}
373
405
374
406
export async function startLogin(handle, inviteCode) {
+6
-2
web/src/components/AddToCollectionModal.jsx
+6
-2
web/src/components/AddToCollectionModal.jsx
···
23
23
24
24
useEffect(() => {
25
25
if (isOpen && user) {
26
+
if (!annotationUri) {
27
+
setLoading(false);
28
+
return;
29
+
}
26
30
loadCollections();
27
31
setError(null);
28
32
}
29
-
}, [isOpen, user]);
33
+
}, [isOpen, user, annotationUri]);
30
34
31
35
const loadCollections = async () => {
32
36
try {
···
71
75
className="modal-container"
72
76
style={{
73
77
maxWidth: "380px",
74
-
maxHeight: "80vh",
78
+
maxHeight: "80dvh",
75
79
display: "flex",
76
80
flexDirection: "column",
77
81
}}
+387
-265
web/src/components/AnnotationCard.jsx
+387
-265
web/src/components/AnnotationCard.jsx
···
5
5
import {
6
6
normalizeAnnotation,
7
7
normalizeHighlight,
8
+
normalizeBookmark,
8
9
deleteAnnotation,
9
10
likeAnnotation,
10
11
unlikeAnnotation,
···
26
27
BookmarkIcon,
27
28
} from "./Icons";
28
29
import { Folder, Edit2, Save, X, Clock } from "lucide-react";
29
-
import AddToCollectionModal from "./AddToCollectionModal";
30
30
import ShareMenu from "./ShareMenu";
31
31
32
32
function buildTextFragmentUrl(baseUrl, selector) {
···
59
59
}
60
60
};
61
61
62
-
export default function AnnotationCard({ annotation, onDelete }) {
62
+
export default function AnnotationCard({
63
+
annotation,
64
+
onDelete,
65
+
onAddToCollection,
66
+
}) {
63
67
const { user, login } = useAuth();
64
68
const data = normalizeAnnotation(annotation);
65
69
66
70
const [likeCount, setLikeCount] = useState(0);
67
71
const [isLiked, setIsLiked] = useState(false);
68
72
const [deleting, setDeleting] = useState(false);
69
-
const [showAddToCollection, setShowAddToCollection] = useState(false);
70
73
const [isEditing, setIsEditing] = useState(false);
71
74
const [editText, setEditText] = useState(data.text || "");
75
+
const [editTags, setEditTags] = useState(data.tags?.join(", ") || "");
72
76
const [saving, setSaving] = useState(false);
73
77
74
78
const [showHistory, setShowHistory] = useState(false);
···
181
185
const handleSaveEdit = async () => {
182
186
try {
183
187
setSaving(true);
184
-
await updateAnnotation(data.uri, editText, data.tags);
188
+
const tagList = editTags
189
+
.split(",")
190
+
.map((t) => t.trim())
191
+
.filter(Boolean);
192
+
await updateAnnotation(data.uri, editText, tagList);
185
193
setIsEditing(false);
186
194
if (annotation.body) annotation.body.value = editText;
187
195
else if (annotation.text) annotation.text = editText;
196
+
if (annotation.tags) annotation.tags = tagList;
197
+
data.tags = tagList;
188
198
} catch (err) {
189
199
alert("Failed to update: " + err.message);
190
200
} finally {
···
287
297
return (
288
298
<article className="card annotation-card">
289
299
<header className="annotation-header">
290
-
<Link to={marginProfileUrl || "#"} className="annotation-avatar-link">
291
-
<div className="annotation-avatar">
292
-
{authorAvatar ? (
293
-
<img src={authorAvatar} alt={authorDisplayName} />
294
-
) : (
295
-
<span>
296
-
{(authorDisplayName || authorHandle || "??")
297
-
?.substring(0, 2)
298
-
.toUpperCase()}
299
-
</span>
300
-
)}
300
+
<div className="annotation-header-left">
301
+
<Link to={marginProfileUrl || "#"} className="annotation-avatar-link">
302
+
<div className="annotation-avatar">
303
+
{authorAvatar ? (
304
+
<img src={authorAvatar} alt={authorDisplayName} />
305
+
) : (
306
+
<span>
307
+
{(authorDisplayName || authorHandle || "??")
308
+
?.substring(0, 2)
309
+
.toUpperCase()}
310
+
</span>
311
+
)}
312
+
</div>
313
+
</Link>
314
+
<div className="annotation-meta">
315
+
<div className="annotation-author-row">
316
+
<Link
317
+
to={marginProfileUrl || "#"}
318
+
className="annotation-author-link"
319
+
>
320
+
<span className="annotation-author">{authorDisplayName}</span>
321
+
</Link>
322
+
{authorHandle && (
323
+
<a
324
+
href={`https://bsky.app/profile/${authorHandle}`}
325
+
target="_blank"
326
+
rel="noopener noreferrer"
327
+
className="annotation-handle"
328
+
>
329
+
@{authorHandle}
330
+
</a>
331
+
)}
332
+
</div>
333
+
<div className="annotation-time">{formatDate(data.createdAt)}</div>
301
334
</div>
302
-
</Link>
303
-
<div className="annotation-meta">
304
-
<div className="annotation-author-row">
305
-
<Link
306
-
to={marginProfileUrl || "#"}
307
-
className="annotation-author-link"
308
-
>
309
-
<span className="annotation-author">{authorDisplayName}</span>
310
-
</Link>
311
-
{authorHandle && (
312
-
<a
313
-
href={`https://bsky.app/profile/${authorHandle}`}
314
-
target="_blank"
315
-
rel="noopener noreferrer"
316
-
className="annotation-handle"
335
+
</div>
336
+
<div className="annotation-header-right">
337
+
<div style={{ display: "flex", gap: "4px" }}>
338
+
{hasEditHistory && !data.color && !data.description && (
339
+
<button
340
+
className="annotation-action action-icon-only"
341
+
onClick={fetchHistory}
342
+
title="View Edit History"
317
343
>
318
-
@{authorHandle} <ExternalLinkIcon size={12} />
319
-
</a>
344
+
<Clock size={16} />
345
+
</button>
320
346
)}
321
-
</div>
322
-
<div className="annotation-time">{formatDate(data.createdAt)}</div>
323
-
</div>
324
-
<div className="action-buttons">
325
-
{}
326
-
{hasEditHistory && !data.color && !data.description && (
327
-
<button
328
-
className="annotation-edit-btn"
329
-
onClick={fetchHistory}
330
-
title="View Edit History"
331
-
>
332
-
<Clock size={16} />
333
-
</button>
334
-
)}
335
-
{}
336
-
{isOwner && (
337
-
<>
338
-
{!data.color && !data.description && (
347
+
348
+
{isOwner && (
349
+
<>
350
+
{!data.color && !data.description && (
351
+
<button
352
+
className="annotation-action action-icon-only"
353
+
onClick={() => setIsEditing(!isEditing)}
354
+
title="Edit"
355
+
>
356
+
<Edit2 size={16} />
357
+
</button>
358
+
)}
339
359
<button
340
-
className="annotation-edit-btn"
341
-
onClick={() => setIsEditing(!isEditing)}
342
-
title="Edit"
360
+
className="annotation-action action-icon-only"
361
+
onClick={handleDelete}
362
+
disabled={deleting}
363
+
title="Delete"
343
364
>
344
-
<Edit2 size={16} />
365
+
<TrashIcon size={16} />
345
366
</button>
346
-
)}
347
-
<button
348
-
className="annotation-delete"
349
-
onClick={handleDelete}
350
-
disabled={deleting}
351
-
title="Delete"
352
-
>
353
-
<TrashIcon size={16} />
354
-
</button>
355
-
</>
356
-
)}
367
+
</>
368
+
)}
369
+
</div>
357
370
</div>
358
371
</header>
359
372
360
-
{}
361
-
{}
362
373
{showHistory && (
363
374
<div className="history-panel">
364
375
<div className="history-header">
···
390
401
</div>
391
402
)}
392
403
393
-
<a
394
-
href={data.url}
395
-
target="_blank"
396
-
rel="noopener noreferrer"
397
-
className="annotation-source"
398
-
>
399
-
{truncateUrl(data.url)}
400
-
{data.title && (
401
-
<span className="annotation-source-title"> โข {data.title}</span>
402
-
)}
403
-
</a>
404
-
405
-
{highlightedText && (
404
+
<div className="annotation-content">
406
405
<a
407
-
href={fragmentUrl}
406
+
href={data.url}
408
407
target="_blank"
409
408
rel="noopener noreferrer"
410
-
className="annotation-highlight"
409
+
className="annotation-source"
411
410
>
412
-
<mark>"{highlightedText}"</mark>
411
+
{truncateUrl(data.url)}
412
+
{data.title && (
413
+
<span className="annotation-source-title"> โข {data.title}</span>
414
+
)}
413
415
</a>
414
-
)}
415
416
416
-
{isEditing ? (
417
-
<div className="mt-3">
418
-
<textarea
419
-
value={editText}
420
-
onChange={(e) => setEditText(e.target.value)}
421
-
className="reply-input"
422
-
rows={3}
423
-
style={{ marginBottom: "8px" }}
424
-
/>
425
-
<div className="action-buttons-end">
426
-
<button
427
-
onClick={() => setIsEditing(false)}
428
-
className="btn btn-ghost"
429
-
>
430
-
Cancel
431
-
</button>
432
-
<button
433
-
onClick={handleSaveEdit}
434
-
disabled={saving}
435
-
className="btn btn-primary btn-sm"
436
-
>
437
-
{saving ? (
438
-
"Saving..."
439
-
) : (
440
-
<>
441
-
<Save size={14} /> Save
442
-
</>
443
-
)}
444
-
</button>
417
+
{highlightedText && (
418
+
<a
419
+
href={fragmentUrl}
420
+
target="_blank"
421
+
rel="noopener noreferrer"
422
+
className="annotation-highlight"
423
+
style={{
424
+
borderLeftColor: isEditing ? editColor : data.color || "#f59e0b",
425
+
}}
426
+
>
427
+
<mark>"{highlightedText}"</mark>
428
+
</a>
429
+
)}
430
+
431
+
{isEditing ? (
432
+
<div className="mt-3">
433
+
<textarea
434
+
value={editText}
435
+
onChange={(e) => setEditText(e.target.value)}
436
+
className="reply-input"
437
+
rows={3}
438
+
style={{ marginBottom: "8px" }}
439
+
/>
440
+
<input
441
+
type="text"
442
+
className="reply-input"
443
+
placeholder="Tags (comma separated)..."
444
+
value={editTags}
445
+
onChange={(e) => setEditTags(e.target.value)}
446
+
style={{ marginBottom: "8px" }}
447
+
/>
448
+
<div className="action-buttons-end">
449
+
<button
450
+
onClick={() => setIsEditing(false)}
451
+
className="btn btn-ghost"
452
+
>
453
+
Cancel
454
+
</button>
455
+
<button
456
+
onClick={handleSaveEdit}
457
+
disabled={saving}
458
+
className="btn btn-primary btn-sm"
459
+
>
460
+
{saving ? (
461
+
"Saving..."
462
+
) : (
463
+
<>
464
+
<Save size={14} /> Save
465
+
</>
466
+
)}
467
+
</button>
468
+
</div>
445
469
</div>
446
-
</div>
447
-
) : (
448
-
data.text && <p className="annotation-text">{data.text}</p>
449
-
)}
470
+
) : (
471
+
data.text && <p className="annotation-text">{data.text}</p>
472
+
)}
450
473
451
-
{data.tags?.length > 0 && (
452
-
<div className="annotation-tags">
453
-
{data.tags.map((tag, i) => (
454
-
<span key={i} className="annotation-tag">
455
-
#{tag}
456
-
</span>
457
-
))}
458
-
</div>
459
-
)}
474
+
{data.tags?.length > 0 && (
475
+
<div className="annotation-tags">
476
+
{data.tags.map((tag, i) => (
477
+
<Link
478
+
key={i}
479
+
to={`/?tag=${encodeURIComponent(tag)}`}
480
+
className="annotation-tag"
481
+
>
482
+
#{tag}
483
+
</Link>
484
+
))}
485
+
</div>
486
+
)}
487
+
</div>
460
488
461
489
<footer className="annotation-actions">
462
-
<button
463
-
className={`annotation-action ${isLiked ? "liked" : ""}`}
464
-
onClick={handleLike}
465
-
>
466
-
<HeartIcon filled={isLiked} size={16} />
467
-
{likeCount > 0 && <span>{likeCount}</span>}
468
-
</button>
469
-
<button
470
-
className={`annotation-action ${showReplies ? "active" : ""}`}
471
-
onClick={() => setShowReplies(!showReplies)}
472
-
>
473
-
<MessageIcon size={16} />
474
-
<span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span>
475
-
</button>
476
-
<ShareMenu uri={data.uri} text={data.text} />
477
-
<button
478
-
className="annotation-action"
479
-
onClick={() => {
480
-
if (!user) {
481
-
login();
482
-
return;
483
-
}
484
-
setShowAddToCollection(true);
485
-
}}
486
-
>
487
-
<Folder size={16} />
488
-
<span>Collect</span>
489
-
</button>
490
+
<div className="annotation-actions-left">
491
+
<button
492
+
className={`annotation-action ${isLiked ? "liked" : ""}`}
493
+
onClick={handleLike}
494
+
>
495
+
<HeartIcon filled={isLiked} size={16} />
496
+
{likeCount > 0 && <span>{likeCount}</span>}
497
+
</button>
498
+
<button
499
+
className={`annotation-action ${showReplies ? "active" : ""}`}
500
+
onClick={() => setShowReplies(!showReplies)}
501
+
>
502
+
<MessageIcon size={16} />
503
+
<span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span>
504
+
</button>
505
+
<ShareMenu
506
+
uri={data.uri}
507
+
text={data.title || data.url}
508
+
handle={data.author?.handle}
509
+
type="Annotation"
510
+
/>
511
+
<button
512
+
className="annotation-action"
513
+
onClick={() => {
514
+
if (!user) {
515
+
login();
516
+
return;
517
+
}
518
+
if (onAddToCollection) onAddToCollection();
519
+
}}
520
+
>
521
+
<Folder size={16} />
522
+
<span>Collect</span>
523
+
</button>
524
+
</div>
490
525
</footer>
491
526
492
527
{showReplies && (
···
578
613
</div>
579
614
</div>
580
615
)}
581
-
582
-
<AddToCollectionModal
583
-
isOpen={showAddToCollection}
584
-
onClose={() => setShowAddToCollection(false)}
585
-
annotationUri={data.uri}
586
-
/>
587
616
</article>
588
617
);
589
618
}
590
619
591
-
export function HighlightCard({ highlight, onDelete }) {
620
+
export function HighlightCard({ highlight, onDelete, onAddToCollection }) {
592
621
const { user, login } = useAuth();
593
622
const data = normalizeHighlight(highlight);
594
623
const highlightedText =
595
624
data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null;
596
625
const fragmentUrl = buildTextFragmentUrl(data.url, data.selector);
597
626
const isOwner = user?.did && data.author?.did === user.did;
598
-
const [showAddToCollection, setShowAddToCollection] = useState(false);
599
627
const [isEditing, setIsEditing] = useState(false);
600
628
const [editColor, setEditColor] = useState(data.color || "#f59e0b");
629
+
const [editTags, setEditTags] = useState(data.tags?.join(", ") || "");
601
630
602
631
const handleSaveEdit = async () => {
603
632
try {
604
-
await updateHighlight(data.uri, editColor, []);
633
+
const tagList = editTags
634
+
.split(",")
635
+
.map((t) => t.trim())
636
+
.filter(Boolean);
637
+
638
+
await updateHighlight(data.uri, editColor, tagList);
605
639
setIsEditing(false);
606
640
607
641
if (highlight.color) highlight.color = editColor;
642
+
if (highlight.tags) highlight.tags = tagList;
643
+
else highlight.value = { ...highlight.value, tags: tagList };
608
644
} catch (err) {
609
645
alert("Failed to update: " + err.message);
610
646
}
···
633
669
return (
634
670
<article className="card annotation-card">
635
671
<header className="annotation-header">
636
-
<Link
637
-
to={data.author?.did ? `/profile/${data.author.did}` : "#"}
638
-
className="annotation-avatar-link"
639
-
>
640
-
<div className="annotation-avatar">
641
-
{data.author?.avatar ? (
642
-
<img src={data.author.avatar} alt="avatar" />
643
-
) : (
644
-
<span>??</span>
672
+
<div className="annotation-header-left">
673
+
<Link
674
+
to={data.author?.did ? `/profile/${data.author.did}` : "#"}
675
+
className="annotation-avatar-link"
676
+
>
677
+
<div className="annotation-avatar">
678
+
{data.author?.avatar ? (
679
+
<img src={data.author.avatar} alt="avatar" />
680
+
) : (
681
+
<span>??</span>
682
+
)}
683
+
</div>
684
+
</Link>
685
+
<div className="annotation-meta">
686
+
<Link to="#" className="annotation-author-link">
687
+
<span className="annotation-author">
688
+
{data.author?.displayName || "Unknown"}
689
+
</span>
690
+
</Link>
691
+
<div className="annotation-time">{formatDate(data.createdAt)}</div>
692
+
{data.author?.handle && (
693
+
<a
694
+
href={`https://bsky.app/profile/${data.author.handle}`}
695
+
target="_blank"
696
+
rel="noopener noreferrer"
697
+
className="annotation-handle"
698
+
>
699
+
@{data.author.handle}
700
+
</a>
645
701
)}
646
702
</div>
647
-
</Link>
648
-
<div className="annotation-meta">
649
-
<Link to="#" className="annotation-author-link">
650
-
<span className="annotation-author">
651
-
{data.author?.displayName || "Unknown"}
652
-
</span>
653
-
</Link>
654
-
<div className="annotation-time">{formatDate(data.createdAt)}</div>
655
703
</div>
656
-
<div className="action-buttons">
657
-
{isOwner && (
658
-
<>
659
-
<button
660
-
className="annotation-edit-btn"
661
-
onClick={() => setIsEditing(!isEditing)}
662
-
title="Edit Color"
663
-
>
664
-
<Edit2 size={16} />
665
-
</button>
666
-
<button
667
-
className="annotation-delete"
668
-
onClick={(e) => {
669
-
e.preventDefault();
670
-
onDelete && onDelete(highlight.id || highlight.uri);
671
-
}}
672
-
>
673
-
<TrashIcon size={16} />
674
-
</button>
675
-
</>
676
-
)}
704
+
705
+
<div className="annotation-header-right">
706
+
<div style={{ display: "flex", gap: "4px" }}>
707
+
{isOwner && (
708
+
<>
709
+
<button
710
+
className="annotation-action action-icon-only"
711
+
onClick={() => setIsEditing(!isEditing)}
712
+
title="Edit Color"
713
+
>
714
+
<Edit2 size={16} />
715
+
</button>
716
+
<button
717
+
className="annotation-action action-icon-only"
718
+
onClick={(e) => {
719
+
e.preventDefault();
720
+
onDelete && onDelete(highlight.id || highlight.uri);
721
+
}}
722
+
>
723
+
<TrashIcon size={16} />
724
+
</button>
725
+
</>
726
+
)}
727
+
</div>
677
728
</div>
678
729
</header>
679
730
680
-
<a
681
-
href={data.url}
682
-
target="_blank"
683
-
rel="noopener noreferrer"
684
-
className="annotation-source"
685
-
>
686
-
{truncateUrl(data.url)}
687
-
</a>
688
-
689
-
{highlightedText && (
731
+
<div className="annotation-content">
690
732
<a
691
-
href={fragmentUrl}
733
+
href={data.url}
692
734
target="_blank"
693
735
rel="noopener noreferrer"
694
-
className="annotation-highlight"
695
-
style={{
696
-
borderLeftColor: isEditing ? editColor : data.color || "#f59e0b",
697
-
}}
736
+
className="annotation-source"
698
737
>
699
-
<mark>"{highlightedText}"</mark>
738
+
{truncateUrl(data.url)}
700
739
</a>
701
-
)}
702
740
703
-
{isEditing && (
704
-
<div
705
-
className="mt-3"
706
-
style={{ display: "flex", alignItems: "center", gap: "8px" }}
707
-
>
708
-
<span style={{ fontSize: "0.9rem" }}>Color:</span>
709
-
<input
710
-
type="color"
711
-
value={editColor}
712
-
onChange={(e) => setEditColor(e.target.value)}
741
+
{highlightedText && (
742
+
<a
743
+
href={fragmentUrl}
744
+
target="_blank"
745
+
rel="noopener noreferrer"
746
+
className="annotation-highlight"
713
747
style={{
714
-
height: "32px",
715
-
width: "64px",
716
-
padding: 0,
717
-
border: "none",
718
-
borderRadius: "var(--radius-sm)",
719
-
overflow: "hidden",
748
+
borderLeftColor: isEditing ? editColor : data.color || "#f59e0b",
749
+
}}
750
+
>
751
+
<mark>"{highlightedText}"</mark>
752
+
</a>
753
+
)}
754
+
755
+
{isEditing && (
756
+
<div
757
+
className="mt-3"
758
+
style={{
759
+
display: "flex",
760
+
gap: "8px",
761
+
alignItems: "center",
762
+
padding: "8px",
763
+
background: "var(--bg-secondary)",
764
+
borderRadius: "var(--radius-md)",
765
+
border: "1px solid var(--border)",
766
+
}}
767
+
>
768
+
<div
769
+
className="color-picker-compact"
770
+
style={{
771
+
position: "relative",
772
+
width: "28px",
773
+
height: "28px",
774
+
flexShrink: 0,
775
+
}}
776
+
>
777
+
<div
778
+
style={{
779
+
backgroundColor: editColor,
780
+
width: "100%",
781
+
height: "100%",
782
+
borderRadius: "50%",
783
+
border: "2px solid var(--bg-card)",
784
+
boxShadow: "0 0 0 1px var(--border)",
785
+
}}
786
+
/>
787
+
<input
788
+
type="color"
789
+
value={editColor}
790
+
onChange={(e) => setEditColor(e.target.value)}
791
+
style={{
792
+
position: "absolute",
793
+
top: 0,
794
+
left: 0,
795
+
width: "100%",
796
+
height: "100%",
797
+
opacity: 0,
798
+
cursor: "pointer",
799
+
}}
800
+
title="Change Color"
801
+
/>
802
+
</div>
803
+
804
+
<input
805
+
type="text"
806
+
className="reply-input"
807
+
placeholder="e.g. tag1, tag2"
808
+
value={editTags}
809
+
onChange={(e) => setEditTags(e.target.value)}
810
+
style={{
811
+
margin: 0,
812
+
flex: 1,
813
+
fontSize: "0.9rem",
814
+
padding: "6px 10px",
815
+
height: "32px",
816
+
border: "none",
817
+
background: "transparent",
818
+
}}
819
+
/>
820
+
821
+
<button
822
+
onClick={handleSaveEdit}
823
+
className="btn btn-primary btn-sm"
824
+
style={{ padding: "0 10px", height: "32px", minWidth: "auto" }}
825
+
title="Save"
826
+
>
827
+
<Save size={16} />
828
+
</button>
829
+
</div>
830
+
)}
831
+
832
+
{data.tags?.length > 0 && (
833
+
<div className="annotation-tags">
834
+
{data.tags.map((tag, i) => (
835
+
<Link
836
+
key={i}
837
+
to={`/?tag=${encodeURIComponent(tag)}`}
838
+
className="annotation-tag"
839
+
>
840
+
#{tag}
841
+
</Link>
842
+
))}
843
+
</div>
844
+
)}
845
+
</div>
846
+
847
+
<footer className="annotation-actions">
848
+
<div className="annotation-actions-left">
849
+
<span
850
+
className="annotation-action"
851
+
style={{
852
+
color: data.color || "#f59e0b",
853
+
background: "none",
854
+
paddingLeft: 0,
720
855
}}
856
+
>
857
+
<HighlightIcon size={14} /> Highlight
858
+
</span>
859
+
<ShareMenu
860
+
uri={data.uri}
861
+
text={data.title || data.description}
862
+
handle={data.author?.handle}
863
+
type="Highlight"
721
864
/>
722
865
<button
723
-
onClick={handleSaveEdit}
724
-
className="btn btn-primary btn-sm"
725
-
style={{ marginLeft: "auto" }}
866
+
className="annotation-action"
867
+
onClick={() => {
868
+
if (!user) {
869
+
login();
870
+
return;
871
+
}
872
+
if (onAddToCollection) onAddToCollection();
873
+
}}
726
874
>
727
-
Save
875
+
<Folder size={16} />
876
+
<span>Collect</span>
728
877
</button>
729
878
</div>
730
-
)}
731
-
732
-
<footer className="annotation-actions">
733
-
<span
734
-
className="annotation-action annotation-type-badge"
735
-
style={{ color: data.color || "#f59e0b" }}
736
-
>
737
-
<HighlightIcon size={14} /> Highlight
738
-
</span>
739
-
<button
740
-
className="annotation-action"
741
-
onClick={() => {
742
-
if (!user) {
743
-
login();
744
-
return;
745
-
}
746
-
setShowAddToCollection(true);
747
-
}}
748
-
>
749
-
<Folder size={16} />
750
-
<span>Collect</span>
751
-
</button>
752
879
</footer>
753
-
<AddToCollectionModal
754
-
isOpen={showAddToCollection}
755
-
onClose={() => setShowAddToCollection(false)}
756
-
annotationUri={data.uri}
757
-
/>
758
880
</article>
759
881
);
760
882
}
+106
-125
web/src/components/BookmarkCard.jsx
+106
-125
web/src/components/BookmarkCard.jsx
···
3
3
import { Link } from "react-router-dom";
4
4
import {
5
5
normalizeAnnotation,
6
+
normalizeBookmark,
6
7
likeAnnotation,
7
8
unlikeAnnotation,
8
9
getLikeCount,
···
10
11
} from "../api/client";
11
12
import { HeartIcon, TrashIcon, ExternalLinkIcon, BookmarkIcon } from "./Icons";
12
13
import { Folder } from "lucide-react";
13
-
import AddToCollectionModal from "./AddToCollectionModal";
14
14
import ShareMenu from "./ShareMenu";
15
15
16
-
export default function BookmarkCard({ bookmark, annotation, onDelete }) {
16
+
export default function BookmarkCard({ bookmark, onAddToCollection }) {
17
17
const { user, login } = useAuth();
18
-
const data = normalizeAnnotation(bookmark || annotation);
18
+
const raw = bookmark;
19
+
const data =
20
+
raw.type === "Bookmark" ? normalizeBookmark(raw) : normalizeAnnotation(raw);
19
21
20
22
const [likeCount, setLikeCount] = useState(0);
21
23
const [isLiked, setIsLiked] = useState(false);
22
24
const [deleting, setDeleting] = useState(false);
23
-
const [showAddToCollection, setShowAddToCollection] = useState(false);
24
25
25
26
const isOwner = user?.did && data.author?.did === user.did;
26
27
···
81
82
}
82
83
};
83
84
84
-
const handleShare = async () => {
85
-
const uriParts = data.uri.split("/");
86
-
const did = uriParts[2];
87
-
const rkey = uriParts[uriParts.length - 1];
88
-
const shareUrl = `${window.location.origin}/at/${did}/${rkey}`;
89
-
if (navigator.share) {
90
-
try {
91
-
await navigator.share({ title: "Bookmark", url: shareUrl });
92
-
} catch {}
93
-
} else {
94
-
try {
95
-
await navigator.clipboard.writeText(shareUrl);
96
-
alert("Link copied!");
97
-
} catch {
98
-
prompt("Copy:", shareUrl);
99
-
}
100
-
}
101
-
};
102
-
103
85
const formatDate = (dateString) => {
104
86
if (!dateString) return "";
105
87
const date = new Date(dateString);
···
128
110
129
111
return (
130
112
<article className="card bookmark-card">
131
-
{}
132
113
<header className="annotation-header">
133
-
<Link to={marginProfileUrl || "#"} className="annotation-avatar-link">
134
-
<div className="annotation-avatar">
135
-
{authorAvatar ? (
136
-
<img src={authorAvatar} alt={authorDisplayName} />
137
-
) : (
138
-
<span>
139
-
{(authorDisplayName || authorHandle || "??")
140
-
?.substring(0, 2)
141
-
.toUpperCase()}
142
-
</span>
143
-
)}
114
+
<div className="annotation-header-left">
115
+
<Link to={marginProfileUrl || "#"} className="annotation-avatar-link">
116
+
<div className="annotation-avatar">
117
+
{authorAvatar ? (
118
+
<img src={authorAvatar} alt={authorDisplayName} />
119
+
) : (
120
+
<span>
121
+
{(authorDisplayName || authorHandle || "??")
122
+
?.substring(0, 2)
123
+
.toUpperCase()}
124
+
</span>
125
+
)}
126
+
</div>
127
+
</Link>
128
+
<div className="annotation-meta">
129
+
<div className="annotation-author-row">
130
+
<Link
131
+
to={marginProfileUrl || "#"}
132
+
className="annotation-author-link"
133
+
>
134
+
<span className="annotation-author">{authorDisplayName}</span>
135
+
</Link>
136
+
{authorHandle && (
137
+
<a
138
+
href={`https://bsky.app/profile/${authorHandle}`}
139
+
target="_blank"
140
+
rel="noopener noreferrer"
141
+
className="annotation-handle"
142
+
>
143
+
@{authorHandle}
144
+
</a>
145
+
)}
146
+
</div>
147
+
<div className="annotation-time">{formatDate(data.createdAt)}</div>
144
148
</div>
145
-
</Link>
146
-
<div className="annotation-meta">
147
-
<div className="annotation-author-row">
148
-
<Link
149
-
to={marginProfileUrl || "#"}
150
-
className="annotation-author-link"
151
-
>
152
-
<span className="annotation-author">{authorDisplayName}</span>
153
-
</Link>
154
-
{authorHandle && (
155
-
<a
156
-
href={`https://bsky.app/profile/${authorHandle}`}
157
-
target="_blank"
158
-
rel="noopener noreferrer"
159
-
className="annotation-handle"
149
+
</div>
150
+
151
+
<div className="annotation-header-right">
152
+
<div style={{ display: "flex", gap: "4px" }}>
153
+
{isOwner && (
154
+
<button
155
+
className="annotation-action action-icon-only"
156
+
onClick={handleDelete}
157
+
disabled={deleting}
158
+
title="Delete"
160
159
>
161
-
@{authorHandle} <ExternalLinkIcon size={12} />
162
-
</a>
160
+
<TrashIcon size={16} />
161
+
</button>
163
162
)}
164
163
</div>
165
-
<div className="annotation-time">{formatDate(data.createdAt)}</div>
166
-
</div>
167
-
<div className="action-buttons">
168
-
{isOwner && (
169
-
<button
170
-
className="annotation-delete"
171
-
onClick={handleDelete}
172
-
disabled={deleting}
173
-
title="Delete"
174
-
>
175
-
<TrashIcon size={16} />
176
-
</button>
177
-
)}
178
164
</div>
179
165
</header>
180
166
181
-
{}
182
-
<a
183
-
href={data.url}
184
-
target="_blank"
185
-
rel="noopener noreferrer"
186
-
className="bookmark-preview"
187
-
>
188
-
<div className="bookmark-preview-content">
189
-
<div className="bookmark-preview-site">
190
-
<BookmarkIcon size={14} />
191
-
<span>{domain}</span>
167
+
<div className="annotation-content">
168
+
<a
169
+
href={data.url}
170
+
target="_blank"
171
+
rel="noopener noreferrer"
172
+
className="bookmark-preview"
173
+
>
174
+
<div className="bookmark-preview-content">
175
+
<div className="bookmark-preview-site">
176
+
<BookmarkIcon size={14} />
177
+
<span>{domain}</span>
178
+
</div>
179
+
<h3 className="bookmark-preview-title">{data.title || data.url}</h3>
180
+
{data.description && (
181
+
<p className="bookmark-preview-desc">{data.description}</p>
182
+
)}
192
183
</div>
193
-
<h3 className="bookmark-preview-title">{data.title || data.url}</h3>
194
-
{data.description && (
195
-
<p className="bookmark-preview-desc">{data.description}</p>
196
-
)}
197
-
</div>
198
-
<div className="bookmark-preview-arrow">
199
-
<ExternalLinkIcon size={18} />
200
-
</div>
201
-
</a>
184
+
</a>
202
185
203
-
{}
204
-
{data.tags?.length > 0 && (
205
-
<div className="annotation-tags">
206
-
{data.tags.map((tag, i) => (
207
-
<span key={i} className="annotation-tag">
208
-
#{tag}
209
-
</span>
210
-
))}
211
-
</div>
212
-
)}
186
+
{data.tags?.length > 0 && (
187
+
<div className="annotation-tags">
188
+
{data.tags.map((tag, i) => (
189
+
<span key={i} className="annotation-tag">
190
+
#{tag}
191
+
</span>
192
+
))}
193
+
</div>
194
+
)}
195
+
</div>
213
196
214
-
{}
215
197
<footer className="annotation-actions">
216
-
<button
217
-
className={`annotation-action ${isLiked ? "liked" : ""}`}
218
-
onClick={handleLike}
219
-
>
220
-
<HeartIcon filled={isLiked} size={16} />
221
-
{likeCount > 0 && <span>{likeCount}</span>}
222
-
</button>
223
-
<ShareMenu uri={data.uri} text={data.title || data.description} />
224
-
<button
225
-
className="annotation-action"
226
-
onClick={() => {
227
-
if (!user) {
228
-
login();
229
-
return;
230
-
}
231
-
setShowAddToCollection(true);
232
-
}}
233
-
>
234
-
<Folder size={16} />
235
-
<span>Collect</span>
236
-
</button>
198
+
<div className="annotation-actions-left">
199
+
<button
200
+
className={`annotation-action ${isLiked ? "liked" : ""}`}
201
+
onClick={handleLike}
202
+
>
203
+
<HeartIcon filled={isLiked} size={16} />
204
+
{likeCount > 0 && <span>{likeCount}</span>}
205
+
</button>
206
+
<ShareMenu
207
+
uri={data.uri}
208
+
text={data.title || data.description}
209
+
handle={data.author?.handle}
210
+
type="Bookmark"
211
+
/>
212
+
<button
213
+
className="annotation-action"
214
+
onClick={() => {
215
+
if (!user) {
216
+
login();
217
+
return;
218
+
}
219
+
if (onAddToCollection) onAddToCollection();
220
+
}}
221
+
>
222
+
<Folder size={16} />
223
+
<span>Collect</span>
224
+
</button>
225
+
</div>
237
226
</footer>
238
-
239
-
{showAddToCollection && (
240
-
<AddToCollectionModal
241
-
isOpen={showAddToCollection}
242
-
annotationUri={data.uri}
243
-
onClose={() => setShowAddToCollection(false)}
244
-
/>
245
-
)}
246
227
</article>
247
228
);
248
229
}
+4
-2
web/src/components/CollectionItemCard.jsx
+4
-2
web/src/components/CollectionItemCard.jsx
···
54
54
</span>{" "}
55
55
added to{" "}
56
56
<Link
57
-
to={`/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(author.did)}`}
57
+
to={`/${author.handle}/collection/${collection.uri.split("/").pop()}`}
58
58
style={{
59
59
display: "inline-flex",
60
60
alignItems: "center",
···
70
70
</span>
71
71
<div style={{ marginLeft: "auto" }}>
72
72
<ShareMenu
73
-
customUrl={`${window.location.origin}/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(author.did)}`}
73
+
uri={collection.uri}
74
+
handle={author.handle}
75
+
type="Collection"
74
76
text={`Check out this collection by ${author.displayName}: ${collection.name}`}
75
77
/>
76
78
</div>
+5
-3
web/src/components/CollectionRow.jsx
+5
-3
web/src/components/CollectionRow.jsx
···
6
6
return (
7
7
<div className="collection-row">
8
8
<Link
9
-
to={`/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(
10
-
collection.authorDid || collection.author?.did,
11
-
)}`}
9
+
to={
10
+
collection.creator?.handle
11
+
? `/${collection.creator.handle}/collection/${collection.uri.split("/").pop()}`
12
+
: `/collection/${encodeURIComponent(collection.uri)}`
13
+
}
12
14
className="collection-row-content"
13
15
>
14
16
<div className="collection-row-icon">
+37
-9
web/src/components/Composer.jsx
+37
-9
web/src/components/Composer.jsx
···
1
1
import { useState } from "react";
2
-
import { createAnnotation } from "../api/client";
2
+
import { createAnnotation, createHighlight } from "../api/client";
3
3
4
4
export default function Composer({
5
5
url,
···
9
9
}) {
10
10
const [text, setText] = useState("");
11
11
const [quoteText, setQuoteText] = useState("");
12
+
const [tags, setTags] = useState("");
12
13
const [selector, setSelector] = useState(initialSelector);
13
14
const [loading, setLoading] = useState(false);
14
15
const [error, setError] = useState(null);
···
19
20
20
21
const handleSubmit = async (e) => {
21
22
e.preventDefault();
22
-
if (!text.trim()) return;
23
+
if (!text.trim() && !highlightedText && !quoteText.trim()) return;
23
24
24
25
try {
25
26
setLoading(true);
···
33
34
};
34
35
}
35
36
36
-
await createAnnotation({
37
-
url,
38
-
text,
39
-
selector: finalSelector || undefined,
40
-
});
37
+
const tagList = tags
38
+
.split(",")
39
+
.map((t) => t.trim())
40
+
.filter(Boolean);
41
+
42
+
if (!text.trim()) {
43
+
await createHighlight({
44
+
url,
45
+
selector: finalSelector,
46
+
color: "yellow",
47
+
tags: tagList,
48
+
});
49
+
} else {
50
+
await createAnnotation({
51
+
url,
52
+
text,
53
+
selector: finalSelector || undefined,
54
+
tags: tagList,
55
+
});
56
+
}
41
57
42
58
setText("");
43
59
setQuoteText("");
···
123
139
className="composer-input"
124
140
rows={4}
125
141
maxLength={3000}
126
-
required
127
142
disabled={loading}
128
143
/>
144
+
145
+
<div className="composer-tags">
146
+
<input
147
+
type="text"
148
+
value={tags}
149
+
onChange={(e) => setTags(e.target.value)}
150
+
placeholder="Add tags (comma separated)..."
151
+
className="composer-tags-input"
152
+
disabled={loading}
153
+
/>
154
+
</div>
129
155
130
156
<div className="composer-footer">
131
157
<span className="composer-count">{text.length}/3000</span>
···
143
169
<button
144
170
type="submit"
145
171
className="btn btn-primary"
146
-
disabled={loading || !text.trim()}
172
+
disabled={
173
+
loading || (!text.trim() && !highlightedText && !quoteText)
174
+
}
147
175
>
148
176
{loading ? "Posting..." : "Post"}
149
177
</button>
+299
-65
web/src/index.css
+299
-65
web/src/index.css
···
140
140
background: var(--bg-card);
141
141
border: 1px solid var(--border);
142
142
border-radius: var(--radius-lg);
143
-
padding: 20px;
143
+
padding: 24px;
144
144
transition: all 0.2s ease;
145
+
position: relative;
145
146
}
146
147
147
148
.card:hover {
148
149
border-color: var(--border-hover);
149
-
box-shadow: var(--shadow-sm);
150
+
box-shadow: var(--shadow-md);
151
+
transform: translateY(-1px);
150
152
}
151
153
152
154
.annotation-card {
153
155
display: flex;
154
156
flex-direction: column;
155
-
gap: 12px;
157
+
gap: 16px;
156
158
}
157
159
158
160
.annotation-header {
159
161
display: flex;
162
+
justify-content: space-between;
163
+
align-items: flex-start;
164
+
gap: 12px;
165
+
}
166
+
167
+
.annotation-header-left {
168
+
display: flex;
160
169
align-items: center;
161
170
gap: 12px;
171
+
flex: 1;
172
+
min-width: 0;
162
173
}
163
174
164
175
.annotation-avatar {
165
-
width: 42px;
166
-
height: 42px;
167
-
min-width: 42px;
176
+
width: 40px;
177
+
height: 40px;
178
+
min-width: 40px;
168
179
border-radius: var(--radius-full);
169
180
background: linear-gradient(135deg, var(--accent), #a855f7);
170
181
display: flex;
171
182
align-items: center;
172
183
justify-content: center;
173
184
font-weight: 600;
174
-
font-size: 1rem;
185
+
font-size: 0.95rem;
175
186
color: white;
176
187
overflow: hidden;
188
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
177
189
}
178
190
179
191
.annotation-avatar img {
···
183
195
}
184
196
185
197
.annotation-meta {
186
-
flex: 1;
187
-
min-width: 0;
198
+
display: flex;
199
+
flex-direction: column;
200
+
justify-content: center;
201
+
line-height: 1.3;
188
202
}
189
203
190
204
.annotation-avatar-link {
191
205
text-decoration: none;
206
+
border-radius: var(--radius-full);
207
+
transition: transform 0.15s ease;
208
+
}
209
+
210
+
.annotation-avatar-link:hover {
211
+
transform: scale(1.05);
192
212
}
193
213
194
214
.annotation-author-row {
···
201
221
.annotation-author {
202
222
font-weight: 600;
203
223
color: var(--text-primary);
224
+
font-size: 0.95rem;
204
225
}
205
226
206
227
.annotation-handle {
207
-
font-size: 0.9rem;
228
+
font-size: 0.85rem;
208
229
color: var(--text-tertiary);
209
230
text-decoration: none;
231
+
display: flex;
232
+
align-items: center;
233
+
gap: 3px;
210
234
}
211
235
212
236
.annotation-handle:hover {
213
237
color: var(--accent);
214
-
text-decoration: underline;
215
238
}
216
239
217
240
.annotation-time {
218
-
font-size: 0.85rem;
241
+
font-size: 0.8rem;
219
242
color: var(--text-tertiary);
243
+
}
244
+
245
+
.annotation-content {
246
+
display: flex;
247
+
flex-direction: column;
248
+
gap: 12px;
220
249
}
221
250
222
251
.annotation-source {
223
-
display: block;
224
-
font-size: 0.85rem;
252
+
display: inline-flex;
253
+
align-items: center;
254
+
gap: 6px;
255
+
font-size: 0.8rem;
225
256
color: var(--text-tertiary);
226
257
text-decoration: none;
227
-
margin-bottom: 8px;
258
+
padding: 4px 10px;
259
+
background: var(--bg-tertiary);
260
+
border-radius: var(--radius-full);
261
+
width: fit-content;
262
+
transition: all 0.15s ease;
263
+
max-width: 100%;
264
+
overflow: hidden;
265
+
text-overflow: ellipsis;
266
+
white-space: nowrap;
228
267
}
229
268
230
269
.annotation-source:hover {
231
-
color: var(--accent);
270
+
color: var(--text-primary);
271
+
background: var(--bg-hover);
232
272
}
233
273
234
274
.annotation-source-title {
235
275
color: var(--text-secondary);
276
+
opacity: 0.8;
236
277
}
237
278
238
279
.annotation-highlight {
239
280
display: block;
240
-
padding: 12px 16px;
281
+
position: relative;
282
+
padding: 16px 20px;
241
283
background: linear-gradient(
242
284
135deg,
243
-
rgba(79, 70, 229, 0.05),
244
-
rgba(168, 85, 247, 0.05)
285
+
rgba(79, 70, 229, 0.03),
286
+
rgba(168, 85, 247, 0.03)
245
287
);
246
288
border-left: 3px solid var(--accent);
247
-
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
289
+
border-radius: 4px var(--radius-md) var(--radius-md) 4px;
248
290
text-decoration: none;
249
-
transition: all 0.15s ease;
250
-
margin-bottom: 12px;
291
+
transition: all 0.2s ease;
292
+
margin: 4px 0;
251
293
}
252
294
253
295
.annotation-highlight:hover {
254
296
background: linear-gradient(
255
297
135deg,
256
-
rgba(79, 70, 229, 0.1),
257
-
rgba(168, 85, 247, 0.1)
298
+
rgba(79, 70, 229, 0.08),
299
+
rgba(168, 85, 247, 0.08)
258
300
);
301
+
transform: translateX(2px);
259
302
}
260
303
261
304
.annotation-highlight mark {
262
305
background: transparent;
263
306
color: var(--text-primary);
264
307
font-style: italic;
265
-
font-size: 0.95rem;
308
+
font-size: 1.05rem;
309
+
line-height: 1.6;
310
+
font-weight: 400;
311
+
display: inline;
266
312
}
267
313
268
314
.annotation-text {
269
315
font-size: 1rem;
270
316
line-height: 1.65;
271
317
color: var(--text-primary);
318
+
white-space: pre-wrap;
272
319
}
273
320
274
321
.annotation-actions {
275
322
display: flex;
276
323
align-items: center;
277
-
gap: 16px;
278
-
padding-top: 8px;
324
+
justify-content: space-between;
325
+
padding-top: 16px;
326
+
margin-top: 8px;
327
+
border-top: 1px solid rgba(255, 255, 255, 0.03);
328
+
}
329
+
330
+
.annotation-actions-left {
331
+
display: flex;
332
+
align-items: center;
333
+
gap: 8px;
279
334
}
280
335
281
336
.annotation-action {
···
284
339
gap: 6px;
285
340
color: var(--text-tertiary);
286
341
font-size: 0.85rem;
342
+
font-weight: 500;
287
343
padding: 6px 10px;
288
-
border-radius: var(--radius-sm);
289
-
transition: all 0.15s ease;
344
+
border-radius: var(--radius-md);
345
+
transition: all 0.2s ease;
346
+
background: transparent;
347
+
cursor: pointer;
290
348
}
291
349
292
350
.annotation-action:hover {
293
351
color: var(--text-secondary);
294
-
background: var(--bg-tertiary);
352
+
background: var(--bg-elevated);
295
353
}
296
354
297
355
.annotation-action.liked {
298
356
color: #ef4444;
357
+
background: rgba(239, 68, 68, 0.05);
358
+
}
359
+
360
+
.annotation-action.liked:hover {
361
+
background: rgba(239, 68, 68, 0.1);
362
+
}
363
+
364
+
.annotation-action.active {
365
+
color: var(--accent);
366
+
background: var(--accent-subtle);
367
+
}
368
+
369
+
.action-icon-only {
370
+
padding: 8px;
299
371
}
300
372
301
373
.annotation-delete {
302
374
background: none;
303
375
border: none;
304
376
cursor: pointer;
305
-
padding: 6px 8px;
377
+
padding: 8px;
306
378
font-size: 1rem;
307
379
color: var(--text-tertiary);
308
-
transition: all 0.15s ease;
309
-
border-radius: var(--radius-sm);
380
+
transition: all 0.2s ease;
381
+
border-radius: var(--radius-md);
382
+
opacity: 0.6;
310
383
}
311
384
312
385
.annotation-delete:hover {
313
386
color: var(--error);
314
387
background: rgba(239, 68, 68, 0.1);
388
+
opacity: 1;
315
389
}
316
390
317
391
.annotation-delete:disabled {
···
1043
1117
border-bottom-color: var(--accent);
1044
1118
}
1045
1119
1046
-
.bookmark-card {
1047
-
padding: 16px 20px;
1048
-
}
1049
-
1050
-
.bookmark-header {
1051
-
display: flex;
1052
-
align-items: flex-start;
1053
-
justify-content: space-between;
1054
-
gap: 12px;
1055
-
}
1056
-
1057
-
.bookmark-link {
1058
-
text-decoration: none;
1059
-
flex: 1;
1060
-
}
1061
-
1062
-
.bookmark-title {
1063
-
font-size: 1rem;
1064
-
font-weight: 600;
1065
-
color: var(--text-primary);
1066
-
margin: 0 0 4px 0;
1067
-
line-height: 1.4;
1068
-
}
1069
-
1070
-
.bookmark-title:hover {
1071
-
color: var(--accent);
1072
-
}
1073
-
1074
1120
.bookmark-description {
1075
1121
font-size: 0.9rem;
1076
1122
color: var(--text-secondary);
···
1368
1414
color: var(--text-tertiary);
1369
1415
}
1370
1416
1417
+
.composer-tags {
1418
+
margin-top: 12px;
1419
+
}
1420
+
1421
+
.composer-tags-input {
1422
+
width: 100%;
1423
+
padding: 12px 16px;
1424
+
background: var(--bg-secondary);
1425
+
border: 1px solid var(--border);
1426
+
border-radius: var(--radius-md);
1427
+
color: var(--text-primary);
1428
+
font-size: 0.95rem;
1429
+
transition: all 0.15s ease;
1430
+
}
1431
+
1432
+
.composer-tags-input:focus {
1433
+
outline: none;
1434
+
border-color: var(--accent);
1435
+
box-shadow: 0 0 0 3px var(--accent-subtle);
1436
+
}
1437
+
1438
+
.composer-tags-input::placeholder {
1439
+
color: var(--text-tertiary);
1440
+
}
1441
+
1371
1442
.composer-footer {
1372
1443
display: flex;
1373
1444
justify-content: space-between;
···
1393
1464
border-radius: var(--radius-md);
1394
1465
color: var(--error);
1395
1466
font-size: 0.9rem;
1467
+
}
1468
+
1469
+
.annotation-tags {
1470
+
display: flex;
1471
+
flex-wrap: wrap;
1472
+
gap: 6px;
1473
+
margin-top: 12px;
1474
+
margin-bottom: 8px;
1475
+
}
1476
+
1477
+
.annotation-tag {
1478
+
display: inline-flex;
1479
+
align-items: center;
1480
+
padding: 4px 10px;
1481
+
background: var(--bg-tertiary);
1482
+
color: var(--text-secondary);
1483
+
font-size: 0.8rem;
1484
+
font-weight: 500;
1485
+
border-radius: var(--radius-full);
1486
+
transition: all 0.15s ease;
1487
+
border: 1px solid transparent;
1488
+
text-decoration: none;
1489
+
}
1490
+
1491
+
.annotation-tag:hover {
1492
+
background: var(--bg-hover);
1493
+
color: var(--text-primary);
1494
+
border-color: var(--border);
1495
+
transform: translateY(-1px);
1496
+
}
1497
+
1498
+
.url-input-wrapper {
1499
+
margin-bottom: 24px;
1500
+
}
1501
+
1502
+
.url-input {
1503
+
width: 100%;
1504
+
padding: 16px;
1505
+
background: var(--bg-secondary);
1506
+
border: 1px solid var(--border);
1507
+
border-radius: var(--radius-md);
1508
+
color: var(--text-primary);
1509
+
font-size: 1.1rem;
1510
+
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
1511
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
1512
+
}
1513
+
1514
+
.url-input:focus {
1515
+
outline: none;
1516
+
border-color: var(--accent);
1517
+
box-shadow: 0 0 0 4px var(--accent-subtle);
1518
+
background: var(--bg-primary);
1519
+
}
1520
+
1521
+
.url-input::placeholder {
1522
+
color: var(--text-tertiary);
1396
1523
}
1397
1524
1398
1525
.annotation-detail-page {
···
2929
3056
padding: 1rem;
2930
3057
}
2931
3058
3059
+
.form-label {
3060
+
display: block;
3061
+
font-size: 0.85rem;
3062
+
font-weight: 600;
3063
+
color: var(--text-secondary);
3064
+
margin-bottom: 6px;
3065
+
}
3066
+
3067
+
.color-input-container {
3068
+
display: flex;
3069
+
align-items: center;
3070
+
gap: 12px;
3071
+
background: var(--bg-tertiary);
3072
+
padding: 8px 12px;
3073
+
border-radius: var(--radius-md);
3074
+
border: 1px solid var(--border);
3075
+
width: fit-content;
3076
+
}
3077
+
3078
+
.color-input-wrapper {
3079
+
position: relative;
3080
+
width: 32px;
3081
+
height: 32px;
3082
+
border-radius: var(--radius-full);
3083
+
overflow: hidden;
3084
+
border: 2px solid var(--border);
3085
+
cursor: pointer;
3086
+
transition: transform 0.1s;
3087
+
}
3088
+
3089
+
.color-input-wrapper:hover {
3090
+
transform: scale(1.1);
3091
+
border-color: var(--accent);
3092
+
}
3093
+
3094
+
.color-input-wrapper input[type="color"] {
3095
+
position: absolute;
3096
+
top: -50%;
3097
+
left: -50%;
3098
+
width: 200%;
3099
+
height: 200%;
3100
+
padding: 0;
3101
+
margin: 0;
3102
+
border: none;
3103
+
cursor: pointer;
3104
+
opacity: 0;
3105
+
}
3106
+
2932
3107
.bookmark-card {
2933
3108
display: flex;
2934
3109
flex-direction: column;
2935
-
gap: 12px;
3110
+
gap: 16px;
2936
3111
}
2937
3112
2938
3113
.bookmark-preview {
2939
3114
display: flex;
2940
-
align-items: stretch;
2941
-
gap: 16px;
2942
-
padding: 14px 16px;
3115
+
flex-direction: column;
2943
3116
background: var(--bg-secondary);
2944
3117
border: 1px solid var(--border);
2945
3118
border-radius: var(--radius-md);
3119
+
overflow: hidden;
2946
3120
text-decoration: none;
2947
3121
transition: all 0.2s ease;
3122
+
position: relative;
3123
+
}
3124
+
3125
+
.bookmark-preview:hover {
3126
+
border-color: var(--accent);
3127
+
box-shadow: var(--shadow-sm);
3128
+
transform: translateY(-1px);
3129
+
}
3130
+
3131
+
.bookmark-preview::before {
3132
+
content: "";
3133
+
position: absolute;
3134
+
left: 0;
3135
+
top: 0;
3136
+
bottom: 0;
3137
+
width: 4px;
3138
+
background: var(--accent);
3139
+
opacity: 0.7;
3140
+
}
3141
+
3142
+
.bookmark-preview-content {
3143
+
padding: 16px 20px;
3144
+
display: flex;
3145
+
flex-direction: column;
3146
+
gap: 8px;
3147
+
}
3148
+
3149
+
.bookmark-preview-header {
3150
+
display: flex;
3151
+
align-items: center;
3152
+
gap: 8px;
3153
+
margin-bottom: 4px;
3154
+
}
3155
+
3156
+
.bookmark-preview-site {
3157
+
font-size: 0.75rem;
3158
+
color: var(--accent);
3159
+
text-transform: uppercase;
3160
+
letter-spacing: 0.05em;
3161
+
font-weight: 700;
3162
+
display: flex;
3163
+
align-items: center;
3164
+
gap: 6px;
3165
+
}
3166
+
3167
+
.bookmark-preview-title {
3168
+
font-size: 1.15rem;
3169
+
font-weight: 700;
3170
+
color: var(--text-primary);
3171
+
line-height: 1.4;
3172
+
}
3173
+
3174
+
.bookmark-preview-desc {
3175
+
font-size: 0.95rem;
3176
+
color: var(--text-secondary);
3177
+
line-height: 1.6;
3178
+
}
3179
+
3180
+
.bookmark-preview-arrow {
3181
+
display: none;
2948
3182
}
2949
3183
2950
3184
.bookmark-preview:hover {
+121
-62
web/src/pages/AnnotationDetail.jsx
+121
-62
web/src/pages/AnnotationDetail.jsx
···
1
1
import { useState, useEffect } from "react";
2
-
import { useParams, Link } from "react-router-dom";
3
-
import AnnotationCard from "../components/AnnotationCard";
2
+
import { useParams, Link, useLocation } from "react-router-dom";
3
+
import AnnotationCard, { HighlightCard } from "../components/AnnotationCard";
4
+
import BookmarkCard from "../components/BookmarkCard";
4
5
import ReplyList from "../components/ReplyList";
5
6
import {
6
7
getAnnotation,
7
8
getReplies,
8
9
createReply,
9
10
deleteReply,
11
+
resolveHandle,
12
+
normalizeAnnotation,
10
13
} from "../api/client";
11
14
import { useAuth } from "../context/AuthContext";
12
15
import { MessageSquare } from "lucide-react";
13
16
14
17
export default function AnnotationDetail() {
15
-
const { uri, did, rkey } = useParams();
18
+
const { uri, did, rkey, handle, type } = useParams();
19
+
const location = useLocation();
16
20
const { isAuthenticated, user } = useAuth();
17
21
const [annotation, setAnnotation] = useState(null);
18
22
const [replies, setReplies] = useState([]);
···
23
27
const [posting, setPosting] = useState(false);
24
28
const [replyingTo, setReplyingTo] = useState(null);
25
29
26
-
const annotationUri = uri || `at://${did}/at.margin.annotation/${rkey}`;
30
+
const [targetUri, setTargetUri] = useState(uri);
31
+
32
+
useEffect(() => {
33
+
async function resolve() {
34
+
if (uri) {
35
+
setTargetUri(uri);
36
+
return;
37
+
}
38
+
39
+
if (handle && rkey) {
40
+
let collection = "at.margin.annotation";
41
+
if (type === "highlight") collection = "at.margin.highlight";
42
+
if (type === "bookmark") collection = "at.margin.bookmark";
43
+
44
+
try {
45
+
const resolvedDid = await resolveHandle(handle);
46
+
if (resolvedDid) {
47
+
setTargetUri(`at://${resolvedDid}/${collection}/${rkey}`);
48
+
}
49
+
} catch (e) {
50
+
console.error("Failed to resolve handle:", e);
51
+
}
52
+
} else if (did && rkey) {
53
+
setTargetUri(`at://${did}/at.margin.annotation/${rkey}`);
54
+
} else {
55
+
const pathParts = location.pathname.split("/");
56
+
const atIndex = pathParts.indexOf("at");
57
+
if (
58
+
atIndex !== -1 &&
59
+
pathParts[atIndex + 1] &&
60
+
pathParts[atIndex + 2]
61
+
) {
62
+
setTargetUri(
63
+
`at://${pathParts[atIndex + 1]}/at.margin.annotation/${pathParts[atIndex + 2]}`,
64
+
);
65
+
}
66
+
}
67
+
}
68
+
resolve();
69
+
}, [uri, did, rkey, handle, type, location.pathname]);
27
70
28
71
const refreshReplies = async () => {
29
-
const repliesData = await getReplies(annotationUri);
72
+
if (!targetUri) return;
73
+
const repliesData = await getReplies(targetUri);
30
74
setReplies(repliesData.items || []);
31
75
};
32
76
33
77
useEffect(() => {
34
78
async function fetchData() {
79
+
if (!targetUri) return;
80
+
35
81
try {
36
82
setLoading(true);
37
83
const [annData, repliesData] = await Promise.all([
38
-
getAnnotation(annotationUri),
39
-
getReplies(annotationUri).catch(() => ({ items: [] })),
84
+
getAnnotation(targetUri),
85
+
getReplies(targetUri).catch(() => ({ items: [] })),
40
86
]);
41
-
setAnnotation(annData);
87
+
setAnnotation(normalizeAnnotation(annData));
42
88
setReplies(repliesData.items || []);
43
89
} catch (err) {
44
90
setError(err.message);
···
47
93
}
48
94
}
49
95
fetchData();
50
-
}, [annotationUri]);
96
+
}, [targetUri]);
51
97
52
98
const handleReply = async (e) => {
53
99
if (e) e.preventDefault();
···
57
103
setPosting(true);
58
104
const parentUri = replyingTo
59
105
? replyingTo.id || replyingTo.uri
60
-
: annotationUri;
106
+
: targetUri;
61
107
const parentCid = replyingTo
62
108
? replyingTo.cid || ""
63
109
: annotation?.cid || "";
···
65
111
await createReply({
66
112
parentUri,
67
113
parentCid,
68
-
rootUri: annotationUri,
114
+
rootUri: targetUri,
69
115
rootCid: annotation?.cid || "",
70
116
text: replyText,
71
117
});
···
130
176
</Link>
131
177
</div>
132
178
133
-
<AnnotationCard annotation={annotation} />
179
+
{annotation.type === "Highlight" ? (
180
+
<HighlightCard
181
+
highlight={annotation}
182
+
onDelete={() => (window.location.href = "/")}
183
+
/>
184
+
) : annotation.type === "Bookmark" ? (
185
+
<BookmarkCard
186
+
bookmark={annotation}
187
+
onDelete={() => (window.location.href = "/")}
188
+
/>
189
+
) : (
190
+
<AnnotationCard annotation={annotation} />
191
+
)}
134
192
135
-
{}
136
-
<div className="replies-section">
137
-
<h3 className="replies-title">
138
-
<MessageSquare size={18} />
139
-
Replies ({replies.length})
140
-
</h3>
193
+
{annotation.type !== "Bookmark" && annotation.type !== "Highlight" && (
194
+
<div className="replies-section">
195
+
<h3 className="replies-title">
196
+
<MessageSquare size={18} />
197
+
Replies ({replies.length})
198
+
</h3>
141
199
142
-
{isAuthenticated && (
143
-
<div className="reply-form card">
144
-
{replyingTo && (
145
-
<div className="replying-to-banner">
146
-
<span>
147
-
Replying to @
148
-
{(replyingTo.creator || replyingTo.author)?.handle ||
149
-
"unknown"}
150
-
</span>
200
+
{isAuthenticated && (
201
+
<div className="reply-form card">
202
+
{replyingTo && (
203
+
<div className="replying-to-banner">
204
+
<span>
205
+
Replying to @
206
+
{(replyingTo.creator || replyingTo.author)?.handle ||
207
+
"unknown"}
208
+
</span>
209
+
<button
210
+
onClick={() => setReplyingTo(null)}
211
+
className="cancel-reply"
212
+
>
213
+
ร
214
+
</button>
215
+
</div>
216
+
)}
217
+
<textarea
218
+
value={replyText}
219
+
onChange={(e) => setReplyText(e.target.value)}
220
+
placeholder={
221
+
replyingTo
222
+
? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...`
223
+
: "Write a reply..."
224
+
}
225
+
className="reply-input"
226
+
rows={3}
227
+
disabled={posting}
228
+
/>
229
+
<div className="reply-form-actions">
151
230
<button
152
-
onClick={() => setReplyingTo(null)}
153
-
className="cancel-reply"
231
+
className="btn btn-primary"
232
+
disabled={posting || !replyText.trim()}
233
+
onClick={() => handleReply()}
154
234
>
155
-
ร
235
+
{posting ? "Posting..." : "Reply"}
156
236
</button>
157
237
</div>
158
-
)}
159
-
<textarea
160
-
value={replyText}
161
-
onChange={(e) => setReplyText(e.target.value)}
162
-
placeholder={
163
-
replyingTo
164
-
? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...`
165
-
: "Write a reply..."
166
-
}
167
-
className="reply-input"
168
-
rows={3}
169
-
disabled={posting}
170
-
/>
171
-
<div className="reply-form-actions">
172
-
<button
173
-
className="btn btn-primary"
174
-
disabled={posting || !replyText.trim()}
175
-
onClick={() => handleReply()}
176
-
>
177
-
{posting ? "Posting..." : "Reply"}
178
-
</button>
179
238
</div>
180
-
</div>
181
-
)}
239
+
)}
182
240
183
-
<ReplyList
184
-
replies={replies}
185
-
rootUri={annotationUri}
186
-
user={user}
187
-
onReply={(reply) => setReplyingTo(reply)}
188
-
onDelete={handleDeleteReply}
189
-
isInline={false}
190
-
/>
191
-
</div>
241
+
<ReplyList
242
+
replies={replies}
243
+
rootUri={targetUri}
244
+
user={user}
245
+
onReply={(reply) => setReplyingTo(reply)}
246
+
onDelete={handleDeleteReply}
247
+
isInline={false}
248
+
/>
249
+
</div>
250
+
)}
192
251
</div>
193
252
);
194
253
}
+52
-39
web/src/pages/CollectionDetail.jsx
+52
-39
web/src/pages/CollectionDetail.jsx
···
6
6
getCollectionItems,
7
7
removeItemFromCollection,
8
8
deleteCollection,
9
+
resolveHandle,
9
10
} from "../api/client";
10
11
import { useAuth } from "../context/AuthContext";
11
12
import CollectionModal from "../components/CollectionModal";
···
15
16
import ShareMenu from "../components/ShareMenu";
16
17
17
18
export default function CollectionDetail() {
18
-
const { rkey, "*": wildcardPath } = useParams();
19
+
const { rkey, handle, "*": wildcardPath } = useParams();
19
20
const location = useLocation();
20
21
const navigate = useNavigate();
21
22
const { user } = useAuth();
···
27
28
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
28
29
29
30
const searchParams = new URLSearchParams(location.search);
30
-
const authorDid = searchParams.get("author") || user?.did;
31
+
const paramAuthorDid = searchParams.get("author");
31
32
32
-
const getCollectionUri = () => {
33
-
if (wildcardPath) {
34
-
return decodeURIComponent(wildcardPath);
35
-
}
36
-
if (rkey && authorDid) {
37
-
return `at://${authorDid}/at.margin.collection/${rkey}`;
38
-
}
39
-
return null;
40
-
};
41
-
42
-
const collectionUri = getCollectionUri();
43
-
const isOwner = user?.did && authorDid === user.did;
33
+
const isOwner =
34
+
user?.did &&
35
+
(collection?.creator?.did === user.did || paramAuthorDid === user.did);
44
36
45
37
const fetchContext = async () => {
46
-
if (!collectionUri || !authorDid) {
47
-
setError("Invalid collection URL");
48
-
setLoading(false);
49
-
return;
50
-
}
51
-
52
38
try {
53
39
setLoading(true);
40
+
41
+
let targetUri = null;
42
+
let targetDid = paramAuthorDid || user?.did;
43
+
44
+
if (handle && rkey) {
45
+
try {
46
+
targetDid = await resolveHandle(handle);
47
+
targetUri = `at://${targetDid}/at.margin.collection/${rkey}`;
48
+
} catch (e) {
49
+
console.error("Failed to resolve handle", e);
50
+
}
51
+
} else if (wildcardPath) {
52
+
targetUri = decodeURIComponent(wildcardPath);
53
+
} else if (rkey && targetDid) {
54
+
targetUri = `at://${targetDid}/at.margin.collection/${rkey}`;
55
+
}
56
+
57
+
if (!targetUri) {
58
+
if (!user && !handle && !paramAuthorDid) {
59
+
setError("Please log in to view your collections");
60
+
return;
61
+
}
62
+
setError("Invalid collection URL");
63
+
return;
64
+
}
65
+
66
+
if (!targetDid && targetUri.startsWith("at://")) {
67
+
const parts = targetUri.split("/");
68
+
if (parts.length > 2) targetDid = parts[2];
69
+
}
70
+
71
+
if (!targetDid) {
72
+
setError("Could not determine collection owner");
73
+
return;
74
+
}
75
+
54
76
const [cols, itemsData] = await Promise.all([
55
-
getCollections(authorDid),
56
-
getCollectionItems(collectionUri),
77
+
getCollections(targetDid),
78
+
getCollectionItems(targetUri),
57
79
]);
58
80
59
81
const found =
60
-
cols.items?.find((c) => c.uri === collectionUri) ||
82
+
cols.items?.find((c) => c.uri === targetUri) ||
61
83
cols.items?.find(
62
-
(c) =>
63
-
collectionUri && c.uri.endsWith(collectionUri.split("/").pop()),
84
+
(c) => targetUri && c.uri.endsWith(targetUri.split("/").pop()),
64
85
);
86
+
65
87
if (!found) {
66
-
console.error(
67
-
"Collection not found. Looking for:",
68
-
collectionUri,
69
-
"Available:",
70
-
cols.items?.map((c) => c.uri),
71
-
);
72
88
setError("Collection not found");
73
89
return;
74
90
}
···
83
99
};
84
100
85
101
useEffect(() => {
86
-
if (collectionUri && authorDid) {
87
-
fetchContext();
88
-
} else if (!user && !searchParams.get("author")) {
89
-
setLoading(false);
90
-
setError("Please log in to view your collections");
91
-
}
92
-
}, [rkey, wildcardPath, authorDid, user]);
102
+
fetchContext();
103
+
}, [rkey, wildcardPath, handle, paramAuthorDid, user?.did]);
93
104
94
105
const handleEditSuccess = () => {
95
106
fetchContext();
···
171
182
</div>
172
183
<div className="collection-detail-actions">
173
184
<ShareMenu
174
-
customUrl={`${window.location.origin}/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(authorDid)}`}
185
+
uri={collection.uri}
186
+
handle={collection.creator?.handle}
187
+
type="Collection"
175
188
text={`Check out this collection: ${collection.name}`}
176
189
/>
177
190
{isOwner && (
+108
-7
web/src/pages/Feed.jsx
+108
-7
web/src/pages/Feed.jsx
···
1
1
import { useState, useEffect } from "react";
2
+
import { useSearchParams } from "react-router-dom";
2
3
import AnnotationCard, { HighlightCard } from "../components/AnnotationCard";
3
4
import BookmarkCard from "../components/BookmarkCard";
4
5
import CollectionItemCard from "../components/CollectionItemCard";
5
-
import { getAnnotationFeed } from "../api/client";
6
+
import { getAnnotationFeed, deleteHighlight } from "../api/client";
6
7
import { AlertIcon, InboxIcon } from "../components/Icons";
8
+
import { useAuth } from "../context/AuthContext";
9
+
10
+
import AddToCollectionModal from "../components/AddToCollectionModal";
7
11
8
12
export default function Feed() {
13
+
const [searchParams, setSearchParams] = useSearchParams();
14
+
const tagFilter = searchParams.get("tag");
9
15
const [annotations, setAnnotations] = useState([]);
10
16
const [loading, setLoading] = useState(true);
11
17
const [error, setError] = useState(null);
12
18
const [filter, setFilter] = useState("all");
19
+
const [collectionModalState, setCollectionModalState] = useState({
20
+
isOpen: false,
21
+
uri: null,
22
+
});
23
+
24
+
const { user } = useAuth();
13
25
14
26
useEffect(() => {
15
27
async function fetchFeed() {
16
28
try {
17
29
setLoading(true);
18
-
const data = await getAnnotationFeed();
30
+
let creatorDid = "";
31
+
if (filter === "my-tags" && user?.did) {
32
+
creatorDid = user.did;
33
+
}
34
+
35
+
const data = await getAnnotationFeed(
36
+
50,
37
+
0,
38
+
tagFilter || "",
39
+
creatorDid,
40
+
);
19
41
setAnnotations(data.items || []);
20
42
} catch (err) {
21
43
setError(err.message);
···
24
46
}
25
47
}
26
48
fetchFeed();
27
-
}, []);
49
+
}, [tagFilter, filter, user]);
28
50
29
51
const filteredAnnotations =
30
-
filter === "all"
52
+
filter === "all" || filter === "my-tags"
31
53
? annotations
32
54
: annotations.filter((a) => {
33
55
if (filter === "commenting")
···
46
68
<p className="page-description">
47
69
See what people are annotating, highlighting, and bookmarking
48
70
</p>
71
+
{tagFilter && (
72
+
<div
73
+
style={{
74
+
marginTop: "16px",
75
+
display: "flex",
76
+
alignItems: "center",
77
+
gap: "8px",
78
+
}}
79
+
>
80
+
<span
81
+
style={{ fontSize: "0.9rem", color: "var(--text-secondary)" }}
82
+
>
83
+
Filtering by tag: <strong>#{tagFilter}</strong>
84
+
</span>
85
+
<button
86
+
onClick={() => setSearchParams({})}
87
+
className="btn btn-sm"
88
+
style={{ padding: "2px 8px", fontSize: "0.8rem" }}
89
+
>
90
+
Clear
91
+
</button>
92
+
</div>
93
+
)}
49
94
</div>
50
95
51
96
{}
···
56
101
>
57
102
All
58
103
</button>
104
+
{user && (
105
+
<button
106
+
className={`filter-tab ${filter === "my-tags" ? "active" : ""}`}
107
+
onClick={() => setFilter("my-tags")}
108
+
>
109
+
My Feed
110
+
</button>
111
+
)}
59
112
<button
60
113
className={`filter-tab ${filter === "commenting" ? "active" : ""}`}
61
114
onClick={() => setFilter("commenting")}
···
129
182
item.type === "Highlight" ||
130
183
item.motivation === "highlighting"
131
184
) {
132
-
return <HighlightCard key={item.id} highlight={item} />;
185
+
return (
186
+
<HighlightCard
187
+
key={item.id}
188
+
highlight={item}
189
+
onDelete={async (uri) => {
190
+
const rkey = uri.split("/").pop();
191
+
await deleteHighlight(rkey);
192
+
setAnnotations((prev) =>
193
+
prev.filter((a) => a.id !== item.id),
194
+
);
195
+
}}
196
+
onAddToCollection={() =>
197
+
setCollectionModalState({
198
+
isOpen: true,
199
+
uri: item.uri || item.id,
200
+
})
201
+
}
202
+
/>
203
+
);
133
204
}
134
205
if (item.type === "Bookmark" || item.motivation === "bookmarking") {
135
-
return <BookmarkCard key={item.id} bookmark={item} />;
206
+
return (
207
+
<BookmarkCard
208
+
key={item.id}
209
+
bookmark={item}
210
+
onAddToCollection={() =>
211
+
setCollectionModalState({
212
+
isOpen: true,
213
+
uri: item.uri || item.id,
214
+
})
215
+
}
216
+
/>
217
+
);
136
218
}
137
-
return <AnnotationCard key={item.id} annotation={item} />;
219
+
return (
220
+
<AnnotationCard
221
+
key={item.id}
222
+
annotation={item}
223
+
onAddToCollection={() =>
224
+
setCollectionModalState({
225
+
isOpen: true,
226
+
uri: item.uri || item.id,
227
+
})
228
+
}
229
+
/>
230
+
);
138
231
})}
139
232
</div>
233
+
)}
234
+
235
+
{collectionModalState.isOpen && (
236
+
<AddToCollectionModal
237
+
isOpen={collectionModalState.isOpen}
238
+
onClose={() => setCollectionModalState({ isOpen: false, uri: null })}
239
+
annotationUri={collectionModalState.uri}
240
+
/>
140
241
)}
141
242
</div>
142
243
);
+10
-7
web/src/pages/Notifications.jsx
+10
-7
web/src/pages/Notifications.jsx
···
4
4
import { getNotifications, markNotificationsRead } from "../api/client";
5
5
import { BellIcon, HeartIcon, ReplyIcon } from "../components/Icons";
6
6
7
-
function getContentRoute(subjectUri) {
8
-
if (!subjectUri) return "/";
9
-
if (subjectUri.includes("at.margin.bookmark")) {
7
+
function getNotificationRoute(n) {
8
+
if (n.type === "reply" && n.subject?.inReplyTo) {
9
+
return `/annotation/${encodeURIComponent(n.subject.inReplyTo)}`;
10
+
}
11
+
if (!n.subjectUri) return "/";
12
+
if (n.subjectUri.includes("at.margin.bookmark")) {
10
13
return `/bookmarks`;
11
14
}
12
-
if (subjectUri.includes("at.margin.highlight")) {
15
+
if (n.subjectUri.includes("at.margin.highlight")) {
13
16
return `/highlights`;
14
17
}
15
-
return `/annotation/${encodeURIComponent(subjectUri)}`;
18
+
return `/annotation/${encodeURIComponent(n.subjectUri)}`;
16
19
}
17
20
18
21
export default function Notifications() {
···
163
166
{notifications.map((n, i) => (
164
167
<Link
165
168
key={n.id || i}
166
-
to={getContentRoute(n.subjectUri)}
169
+
to={getNotificationRoute(n)}
167
170
className="notification-item card"
168
171
style={{ alignItems: "center" }}
169
172
>
170
173
<div
171
174
className="notification-avatar-container"
172
-
style={{ marginRight: 12 }}
175
+
style={{ marginRight: 12, position: "relative" }}
173
176
>
174
177
{n.actor?.avatar ? (
175
178
<img