+6
backend/cmd/server/main.go
+6
backend/cmd/server/main.go
···
97
r.Get("/og-image", ogHandler.HandleOGImage)
98
r.Get("/annotation/{did}/{rkey}", ogHandler.HandleAnnotationPage)
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)
106
107
staticDir := getEnv("STATIC_DIR", "../web/dist")
108
serveStatic(r, staticDir)
+45
-24
backend/internal/api/annotations.go
+45
-24
backend/internal/api/annotations.go
···
47
return
48
}
49
50
-
if req.URL == "" || req.Text == "" {
51
-
http.Error(w, "URL and text are required", http.StatusBadRequest)
52
return
53
}
54
···
67
}
68
69
record := xrpc.NewAnnotationRecordWithMotivation(req.URL, urlHash, req.Text, req.Selector, req.Title, motivation)
70
71
var result *xrpc.CreateRecordOutput
72
err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
···
93
selectorJSONPtr = &selectorStr
94
}
95
96
cid := result.CID
97
did := session.DID
98
annotation := &db.Annotation{
···
105
TargetHash: urlHash,
106
TargetTitle: targetTitlePtr,
107
SelectorJSON: selectorJSONPtr,
108
CreatedAt: time.Now(),
109
IndexedAt: time.Now(),
110
}
···
203
}
204
rkey := parts[2]
205
206
-
var selector interface{} = nil
207
-
if annotation.SelectorJSON != nil && *annotation.SelectorJSON != "" {
208
-
json.Unmarshal([]byte(*annotation.SelectorJSON), &selector)
209
-
}
210
-
211
tagsJSON := ""
212
if len(req.Tags) > 0 {
213
tagsBytes, _ := json.Marshal(req.Tags)
214
tagsJSON = string(tagsBytes)
215
}
216
217
-
record := map[string]interface{}{
218
-
"$type": xrpc.CollectionAnnotation,
219
-
"text": req.Text,
220
-
"url": annotation.TargetSource,
221
-
"createdAt": annotation.CreatedAt.Format(time.RFC3339),
222
-
}
223
-
if selector != nil {
224
-
record["selector"] = selector
225
-
}
226
-
if len(req.Tags) > 0 {
227
-
record["tags"] = req.Tags
228
-
}
229
-
if annotation.TargetTitle != nil {
230
-
record["title"] = *annotation.TargetTitle
231
-
}
232
-
233
if annotation.BodyValue != nil {
234
previousContent := *annotation.BodyValue
235
s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID)
···
237
238
var result *xrpc.PutRecordOutput
239
err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
240
var updateErr error
241
result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record)
242
if updateErr != nil {
···
498
Title string `json:"title,omitempty"`
499
Selector interface{} `json:"selector"`
500
Color string `json:"color,omitempty"`
501
}
502
503
func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) {
···
519
}
520
521
urlHash := db.HashURL(req.URL)
522
-
record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color)
523
524
var result *xrpc.CreateRecordOutput
525
err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
···
549
colorPtr = &req.Color
550
}
551
552
cid := result.CID
553
highlight := &db.Highlight{
554
URI: result.URI,
···
558
TargetTitle: titlePtr,
559
SelectorJSON: selectorJSONPtr,
560
Color: colorPtr,
561
CreatedAt: time.Now(),
562
IndexedAt: time.Now(),
563
CID: &cid,
···
47
return
48
}
49
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)
57
return
58
}
59
···
72
}
73
74
record := xrpc.NewAnnotationRecordWithMotivation(req.URL, urlHash, req.Text, req.Selector, req.Title, motivation)
75
+
if len(req.Tags) > 0 {
76
+
record.Tags = req.Tags
77
+
}
78
79
var result *xrpc.CreateRecordOutput
80
err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
···
101
selectorJSONPtr = &selectorStr
102
}
103
104
+
var tagsJSONPtr *string
105
+
if len(req.Tags) > 0 {
106
+
tagsBytes, _ := json.Marshal(req.Tags)
107
+
tagsStr := string(tagsBytes)
108
+
tagsJSONPtr = &tagsStr
109
+
}
110
+
111
cid := result.CID
112
did := session.DID
113
annotation := &db.Annotation{
···
120
TargetHash: urlHash,
121
TargetTitle: targetTitlePtr,
122
SelectorJSON: selectorJSONPtr,
123
+
TagsJSON: tagsJSONPtr,
124
CreatedAt: time.Now(),
125
IndexedAt: time.Now(),
126
}
···
219
}
220
rkey := parts[2]
221
222
tagsJSON := ""
223
if len(req.Tags) > 0 {
224
tagsBytes, _ := json.Marshal(req.Tags)
225
tagsJSON = string(tagsBytes)
226
}
227
228
if annotation.BodyValue != nil {
229
previousContent := *annotation.BodyValue
230
s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID)
···
232
233
var result *xrpc.PutRecordOutput
234
err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
235
+
existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey)
236
+
if getErr != nil {
237
+
return fmt.Errorf("failed to fetch existing record: %w", getErr)
238
+
}
239
+
240
+
var record map[string]interface{}
241
+
if err := json.Unmarshal(existing.Value, &record); err != nil {
242
+
return fmt.Errorf("failed to parse existing record: %w", err)
243
+
}
244
+
245
+
record["text"] = req.Text
246
+
if req.Tags != nil {
247
+
record["tags"] = req.Tags
248
+
} else {
249
+
delete(record, "tags")
250
+
}
251
+
252
var updateErr error
253
result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record)
254
if updateErr != nil {
···
510
Title string `json:"title,omitempty"`
511
Selector interface{} `json:"selector"`
512
Color string `json:"color,omitempty"`
513
+
Tags []string `json:"tags,omitempty"`
514
}
515
516
func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) {
···
532
}
533
534
urlHash := db.HashURL(req.URL)
535
+
record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color, req.Tags)
536
537
var result *xrpc.CreateRecordOutput
538
err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
···
562
colorPtr = &req.Color
563
}
564
565
+
var tagsJSONPtr *string
566
+
if len(req.Tags) > 0 {
567
+
tagsBytes, _ := json.Marshal(req.Tags)
568
+
tagsStr := string(tagsBytes)
569
+
tagsJSONPtr = &tagsStr
570
+
}
571
+
572
cid := result.CID
573
highlight := &db.Highlight{
574
URI: result.URI,
···
578
TargetTitle: titlePtr,
579
SelectorJSON: selectorJSONPtr,
580
Color: colorPtr,
581
+
TagsJSON: tagsJSONPtr,
582
CreatedAt: time.Now(),
583
IndexedAt: time.Now(),
584
CID: &cid,
+35
-5
backend/internal/api/collections.go
+35
-5
backend/internal/api/collections.go
···
213
return
214
}
215
216
w.Header().Set("Content-Type", "application/json")
217
json.NewEncoder(w).Encode(map[string]interface{}{
218
"@context": "http://www.w3.org/ns/anno.jsonld",
219
"type": "Collection",
220
-
"items": collections,
221
-
"totalItems": len(collections),
222
})
223
}
224
···
254
255
enrichedItems := make([]EnrichedCollectionItem, 0, len(items))
256
257
for _, item := range items {
258
enriched := EnrichedCollectionItem{
259
URI: item.URI,
···
266
if strings.Contains(item.AnnotationURI, "at.margin.annotation") {
267
enriched.Type = "annotation"
268
if a, err := s.db.GetAnnotationByURI(item.AnnotationURI); err == nil {
269
-
hydrated, _ := hydrateAnnotations([]db.Annotation{*a})
270
if len(hydrated) > 0 {
271
enriched.Annotation = &hydrated[0]
272
}
···
274
} else if strings.Contains(item.AnnotationURI, "at.margin.highlight") {
275
enriched.Type = "highlight"
276
if h, err := s.db.GetHighlightByURI(item.AnnotationURI); err == nil {
277
-
hydrated, _ := hydrateHighlights([]db.Highlight{*h})
278
if len(hydrated) > 0 {
279
enriched.Highlight = &hydrated[0]
280
}
···
282
} else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") {
283
enriched.Type = "bookmark"
284
if b, err := s.db.GetBookmarkByURI(item.AnnotationURI); err == nil {
285
-
hydrated, _ := hydrateBookmarks([]db.Bookmark{*b})
286
if len(hydrated) > 0 {
287
enriched.Bookmark = &hydrated[0]
288
}
···
213
return
214
}
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
+
240
w.Header().Set("Content-Type", "application/json")
241
json.NewEncoder(w).Encode(map[string]interface{}{
242
"@context": "http://www.w3.org/ns/anno.jsonld",
243
"type": "Collection",
244
+
"items": apiCollections,
245
+
"totalItems": len(apiCollections),
246
})
247
}
248
···
278
279
enrichedItems := make([]EnrichedCollectionItem, 0, len(items))
280
281
+
session, err := s.refresher.GetSessionWithAutoRefresh(r)
282
+
viewerDID := ""
283
+
if err == nil {
284
+
viewerDID = session.DID
285
+
}
286
+
287
for _, item := range items {
288
enriched := EnrichedCollectionItem{
289
URI: item.URI,
···
296
if strings.Contains(item.AnnotationURI, "at.margin.annotation") {
297
enriched.Type = "annotation"
298
if a, err := s.db.GetAnnotationByURI(item.AnnotationURI); err == nil {
299
+
hydrated, _ := hydrateAnnotations(s.db, []db.Annotation{*a}, viewerDID)
300
if len(hydrated) > 0 {
301
enriched.Annotation = &hydrated[0]
302
}
···
304
} else if strings.Contains(item.AnnotationURI, "at.margin.highlight") {
305
enriched.Type = "highlight"
306
if h, err := s.db.GetHighlightByURI(item.AnnotationURI); err == nil {
307
+
hydrated, _ := hydrateHighlights(s.db, []db.Highlight{*h}, viewerDID)
308
if len(hydrated) > 0 {
309
enriched.Highlight = &hydrated[0]
310
}
···
312
} else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") {
313
enriched.Type = "bookmark"
314
if b, err := s.db.GetBookmarkByURI(item.AnnotationURI); err == nil {
315
+
hydrated, _ := hydrateBookmarks(s.db, []db.Bookmark{*b}, viewerDID)
316
if len(hydrated) > 0 {
317
enriched.Bookmark = &hydrated[0]
318
}
+119
-40
backend/internal/api/handler.go
+119
-40
backend/internal/api/handler.go
···
81
limit := parseIntParam(r, "limit", 50)
82
offset := parseIntParam(r, "offset", 0)
83
motivation := r.URL.Query().Get("motivation")
84
85
var annotations []db.Annotation
86
var err error
···
90
annotations, err = h.db.GetAnnotationsByTargetHash(urlHash, limit, offset)
91
} else if motivation != "" {
92
annotations, err = h.db.GetAnnotationsByMotivation(motivation, limit, offset)
93
} else {
94
annotations, err = h.db.GetRecentAnnotations(limit, offset)
95
}
···
99
return
100
}
101
102
-
enriched, _ := hydrateAnnotations(annotations)
103
104
w.Header().Set("Content-Type", "application/json")
105
json.NewEncoder(w).Encode(map[string]interface{}{
···
112
113
func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) {
114
limit := parseIntParam(r, "limit", 50)
115
-
116
-
annotations, _ := h.db.GetRecentAnnotations(limit, 0)
117
-
highlights, _ := h.db.GetRecentHighlights(limit, 0)
118
-
bookmarks, _ := h.db.GetRecentBookmarks(limit, 0)
119
120
-
authAnnos, _ := hydrateAnnotations(annotations)
121
-
authHighs, _ := hydrateHighlights(highlights)
122
-
authBooks, _ := hydrateBookmarks(bookmarks)
123
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
-
authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems)
130
-
// log.Printf("Hydrated %d collection items\n", len(authCollectionItems))
131
132
var feed []interface{}
133
for _, a := range authAnnos {
···
188
return
189
}
190
191
-
annotation, err := h.db.GetAnnotationByURI(uri)
192
-
if err != nil {
193
-
http.Error(w, "Annotation not found", http.StatusNotFound)
194
-
return
195
}
196
197
-
enriched, _ := hydrateAnnotations([]db.Annotation{*annotation})
198
-
if len(enriched) == 0 {
199
-
http.Error(w, "Annotation not found", http.StatusNotFound)
200
-
return
201
}
202
203
-
w.Header().Set("Content-Type", "application/json")
204
-
response := map[string]interface{}{
205
-
"@context": "http://www.w3.org/ns/anno.jsonld",
206
}
207
-
annJSON, _ := json.Marshal(enriched[0])
208
-
json.Unmarshal(annJSON, &response)
209
210
-
json.NewEncoder(w).Encode(response)
211
}
212
213
func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) {
···
228
annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset)
229
highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset)
230
231
-
enrichedAnnotations, _ := hydrateAnnotations(annotations)
232
-
enrichedHighlights, _ := hydrateHighlights(highlights)
233
234
w.Header().Set("Content-Type", "application/json")
235
json.NewEncoder(w).Encode(map[string]interface{}{
···
243
244
func (h *Handler) GetHighlights(w http.ResponseWriter, r *http.Request) {
245
did := r.URL.Query().Get("creator")
246
limit := parseIntParam(r, "limit", 50)
247
offset := parseIntParam(r, "offset", 0)
248
249
-
if did == "" {
250
-
http.Error(w, "creator parameter required", http.StatusBadRequest)
251
-
return
252
}
253
254
-
highlights, err := h.db.GetHighlightsByAuthor(did, limit, offset)
255
if err != nil {
256
http.Error(w, err.Error(), http.StatusInternalServerError)
257
return
258
}
259
260
-
enriched, _ := hydrateHighlights(highlights)
261
262
w.Header().Set("Content-Type", "application/json")
263
json.NewEncoder(w).Encode(map[string]interface{}{
···
284
return
285
}
286
287
-
enriched, _ := hydrateBookmarks(bookmarks)
288
289
w.Header().Set("Content-Type", "application/json")
290
json.NewEncoder(w).Encode(map[string]interface{}{
···
309
return
310
}
311
312
-
enriched, _ := hydrateAnnotations(annotations)
313
314
w.Header().Set("Content-Type", "application/json")
315
json.NewEncoder(w).Encode(map[string]interface{}{
···
335
return
336
}
337
338
-
enriched, _ := hydrateHighlights(highlights)
339
340
w.Header().Set("Content-Type", "application/json")
341
json.NewEncoder(w).Encode(map[string]interface{}{
···
361
return
362
}
363
364
-
enriched, _ := hydrateBookmarks(bookmarks)
365
366
w.Header().Set("Content-Type", "application/json")
367
json.NewEncoder(w).Encode(map[string]interface{}{
···
515
return
516
}
517
518
-
enriched, err := hydrateNotifications(notifications)
519
if err != nil {
520
log.Printf("Failed to hydrate notifications: %v\n", err)
521
}
···
560
w.Header().Set("Content-Type", "application/json")
561
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
562
}
···
81
limit := parseIntParam(r, "limit", 50)
82
offset := parseIntParam(r, "offset", 0)
83
motivation := r.URL.Query().Get("motivation")
84
+
tag := r.URL.Query().Get("tag")
85
86
var annotations []db.Annotation
87
var err error
···
91
annotations, err = h.db.GetAnnotationsByTargetHash(urlHash, limit, offset)
92
} else if motivation != "" {
93
annotations, err = h.db.GetAnnotationsByMotivation(motivation, limit, offset)
94
+
} else if tag != "" {
95
+
annotations, err = h.db.GetAnnotationsByTag(tag, limit, offset)
96
} else {
97
annotations, err = h.db.GetRecentAnnotations(limit, offset)
98
}
···
102
return
103
}
104
105
+
enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r))
106
107
w.Header().Set("Content-Type", "application/json")
108
json.NewEncoder(w).Encode(map[string]interface{}{
···
115
116
func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) {
117
limit := parseIntParam(r, "limit", 50)
118
+
tag := r.URL.Query().Get("tag")
119
+
creator := r.URL.Query().Get("creator")
120
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 if creator != "" {
140
+
annotations, _ = h.db.GetAnnotationsByAuthor(creator, limit, 0)
141
+
highlights, _ = h.db.GetHighlightsByAuthor(creator, limit, 0)
142
+
bookmarks, _ = h.db.GetBookmarksByAuthor(creator, limit, 0)
143
+
collectionItems = []db.CollectionItem{}
144
+
} else {
145
+
annotations, _ = h.db.GetRecentAnnotations(limit, 0)
146
+
highlights, _ = h.db.GetRecentHighlights(limit, 0)
147
+
bookmarks, _ = h.db.GetRecentBookmarks(limit, 0)
148
+
collectionItems, err = h.db.GetRecentCollectionItems(limit, 0)
149
+
if err != nil {
150
+
log.Printf("Error fetching collection items: %v\n", err)
151
+
}
152
}
153
+
154
+
viewerDID := h.getViewerDID(r)
155
+
authAnnos, _ := hydrateAnnotations(h.db, annotations, viewerDID)
156
+
authHighs, _ := hydrateHighlights(h.db, highlights, viewerDID)
157
+
authBooks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID)
158
+
159
+
authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, viewerDID)
160
161
var feed []interface{}
162
for _, a := range authAnnos {
···
217
return
218
}
219
220
+
serveResponse := func(data interface{}, context string) {
221
+
w.Header().Set("Content-Type", "application/json")
222
+
response := map[string]interface{}{
223
+
"@context": context,
224
+
}
225
+
jsonData, _ := json.Marshal(data)
226
+
json.Unmarshal(jsonData, &response)
227
+
json.NewEncoder(w).Encode(response)
228
+
}
229
+
230
+
if annotation, err := h.db.GetAnnotationByURI(uri); err == nil {
231
+
if enriched, _ := hydrateAnnotations(h.db, []db.Annotation{*annotation}, h.getViewerDID(r)); len(enriched) > 0 {
232
+
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
233
+
return
234
+
}
235
+
}
236
+
237
+
if highlight, err := h.db.GetHighlightByURI(uri); err == nil {
238
+
if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 {
239
+
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
240
+
return
241
+
}
242
+
}
243
+
244
+
if strings.Contains(uri, "at.margin.annotation") {
245
+
highlightURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1)
246
+
if highlight, err := h.db.GetHighlightByURI(highlightURI); err == nil {
247
+
if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 {
248
+
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
249
+
return
250
+
}
251
+
}
252
}
253
254
+
if bookmark, err := h.db.GetBookmarkByURI(uri); err == nil {
255
+
if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 {
256
+
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
257
+
return
258
+
}
259
}
260
261
+
if strings.Contains(uri, "at.margin.annotation") {
262
+
bookmarkURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1)
263
+
if bookmark, err := h.db.GetBookmarkByURI(bookmarkURI); err == nil {
264
+
if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 {
265
+
serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld")
266
+
return
267
+
}
268
+
}
269
}
270
+
271
+
http.Error(w, "Annotation, Highlight, or Bookmark not found", http.StatusNotFound)
272
273
}
274
275
func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) {
···
290
annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset)
291
highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset)
292
293
+
enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r))
294
+
enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r))
295
296
w.Header().Set("Content-Type", "application/json")
297
json.NewEncoder(w).Encode(map[string]interface{}{
···
305
306
func (h *Handler) GetHighlights(w http.ResponseWriter, r *http.Request) {
307
did := r.URL.Query().Get("creator")
308
+
tag := r.URL.Query().Get("tag")
309
limit := parseIntParam(r, "limit", 50)
310
offset := parseIntParam(r, "offset", 0)
311
312
+
var highlights []db.Highlight
313
+
var err error
314
+
315
+
if did != "" {
316
+
highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset)
317
+
} else if tag != "" {
318
+
highlights, err = h.db.GetHighlightsByTag(tag, limit, offset)
319
+
} else {
320
+
highlights, err = h.db.GetRecentHighlights(limit, offset)
321
}
322
323
if err != nil {
324
http.Error(w, err.Error(), http.StatusInternalServerError)
325
return
326
}
327
328
+
enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r))
329
330
w.Header().Set("Content-Type", "application/json")
331
json.NewEncoder(w).Encode(map[string]interface{}{
···
352
return
353
}
354
355
+
enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r))
356
357
w.Header().Set("Content-Type", "application/json")
358
json.NewEncoder(w).Encode(map[string]interface{}{
···
377
return
378
}
379
380
+
enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r))
381
382
w.Header().Set("Content-Type", "application/json")
383
json.NewEncoder(w).Encode(map[string]interface{}{
···
403
return
404
}
405
406
+
enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r))
407
408
w.Header().Set("Content-Type", "application/json")
409
json.NewEncoder(w).Encode(map[string]interface{}{
···
429
return
430
}
431
432
+
enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r))
433
434
w.Header().Set("Content-Type", "application/json")
435
json.NewEncoder(w).Encode(map[string]interface{}{
···
583
return
584
}
585
586
+
enriched, err := hydrateNotifications(h.db, notifications)
587
if err != nil {
588
log.Printf("Failed to hydrate notifications: %v\n", err)
589
}
···
628
w.Header().Set("Content-Type", "application/json")
629
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
630
}
631
+
func (h *Handler) getViewerDID(r *http.Request) string {
632
+
cookie, err := r.Cookie("margin_session")
633
+
if err != nil {
634
+
return ""
635
+
}
636
+
did, _, _, _, _, err := h.db.GetSession(cookie.Value)
637
+
if err != nil {
638
+
return ""
639
+
}
640
+
return did
641
+
}
+131
-50
backend/internal/api/hydration.go
+131
-50
backend/internal/api/hydration.go
···
50
}
51
52
type APIAnnotation struct {
53
-
ID string `json:"id"`
54
-
CID string `json:"cid"`
55
-
Type string `json:"type"`
56
-
Motivation string `json:"motivation,omitempty"`
57
-
Author Author `json:"creator"`
58
-
Body *APIBody `json:"body,omitempty"`
59
-
Target APITarget `json:"target"`
60
-
Tags []string `json:"tags,omitempty"`
61
-
Generator *APIGenerator `json:"generator,omitempty"`
62
-
CreatedAt time.Time `json:"created"`
63
-
IndexedAt time.Time `json:"indexed"`
64
}
65
66
type APIHighlight struct {
67
-
ID string `json:"id"`
68
-
Type string `json:"type"`
69
-
Author Author `json:"creator"`
70
-
Target APITarget `json:"target"`
71
-
Color string `json:"color,omitempty"`
72
-
Tags []string `json:"tags,omitempty"`
73
-
CreatedAt time.Time `json:"created"`
74
-
CID string `json:"cid,omitempty"`
75
}
76
77
type APIBookmark struct {
78
-
ID string `json:"id"`
79
-
Type string `json:"type"`
80
-
Author Author `json:"creator"`
81
-
Source string `json:"source"`
82
-
Title string `json:"title,omitempty"`
83
-
Description string `json:"description,omitempty"`
84
-
Tags []string `json:"tags,omitempty"`
85
-
CreatedAt time.Time `json:"created"`
86
-
CID string `json:"cid,omitempty"`
87
}
88
89
type APIReply struct {
···
99
}
100
101
type APICollection struct {
102
-
URI string `json:"uri"`
103
-
Name string `json:"name"`
104
-
Icon string `json:"icon,omitempty"`
105
}
106
107
type APICollectionItem struct {
···
118
}
119
120
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"`
128
}
129
130
-
func hydrateAnnotations(annotations []db.Annotation) ([]APIAnnotation, error) {
131
if len(annotations) == 0 {
132
return []APIAnnotation{}, nil
133
}
···
192
CreatedAt: a.CreatedAt,
193
IndexedAt: a.IndexedAt,
194
}
195
}
196
197
return result, nil
198
}
199
200
-
func hydrateHighlights(highlights []db.Highlight) ([]APIHighlight, error) {
201
if len(highlights) == 0 {
202
return []APIHighlight{}, nil
203
}
···
245
Tags: tags,
246
CreatedAt: h.CreatedAt,
247
CID: cid,
248
}
249
}
250
251
return result, nil
252
}
253
254
-
func hydrateBookmarks(bookmarks []db.Bookmark) ([]APIBookmark, error) {
255
if len(bookmarks) == 0 {
256
return []APIBookmark{}, nil
257
}
···
290
Tags: tags,
291
CreatedAt: b.CreatedAt,
292
CID: cid,
293
}
294
}
295
···
434
return result, nil
435
}
436
437
-
func hydrateCollectionItems(database *db.DB, items []db.CollectionItem) ([]APICollectionItem, error) {
438
if len(items) == 0 {
439
return []APICollectionItem{}, nil
440
}
···
457
if coll.Icon != nil {
458
icon = *coll.Icon
459
}
460
apiItem.Collection = &APICollection{
461
-
URI: coll.URI,
462
-
Name: coll.Name,
463
-
Icon: icon,
464
}
465
}
466
467
if strings.Contains(item.AnnotationURI, "at.margin.annotation") {
468
if a, err := database.GetAnnotationByURI(item.AnnotationURI); err == nil {
469
-
hydrated, _ := hydrateAnnotations([]db.Annotation{*a})
470
if len(hydrated) > 0 {
471
apiItem.Annotation = &hydrated[0]
472
}
473
}
474
} else if strings.Contains(item.AnnotationURI, "at.margin.highlight") {
475
if h, err := database.GetHighlightByURI(item.AnnotationURI); err == nil {
476
-
hydrated, _ := hydrateHighlights([]db.Highlight{*h})
477
if len(hydrated) > 0 {
478
apiItem.Highlight = &hydrated[0]
479
}
480
}
481
} else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") {
482
if b, err := database.GetBookmarkByURI(item.AnnotationURI); err == nil {
483
-
hydrated, _ := hydrateBookmarks([]db.Bookmark{*b})
484
if len(hydrated) > 0 {
485
apiItem.Bookmark = &hydrated[0]
486
} else {
487
log.Printf("Failed to hydrate bookmark %s: empty hydration result\n", item.AnnotationURI)
488
}
489
} else {
490
-
log.Printf("GetBookmarkByURI failed for %s: %v\n", item.AnnotationURI, err)
491
}
492
} else {
493
log.Printf("Unknown item type for URI: %s\n", item.AnnotationURI)
···
498
return result, nil
499
}
500
501
-
func hydrateNotifications(notifications []db.Notification) ([]APINotification, error) {
502
if len(notifications) == 0 {
503
return []APINotification{}, nil
504
}
···
518
519
profiles := fetchProfilesForDIDs(dids)
520
521
result := make([]APINotification, len(notifications))
522
for i, n := range notifications {
523
result[i] = APINotification{
524
ID: n.ID,
525
Recipient: profiles[n.RecipientDID],
526
Actor: profiles[n.ActorDID],
527
Type: n.Type,
528
SubjectURI: n.SubjectURI,
529
CreatedAt: n.CreatedAt,
530
ReadAt: n.ReadAt,
531
}
···
50
}
51
52
type APIAnnotation struct {
53
+
ID string `json:"id"`
54
+
CID string `json:"cid"`
55
+
Type string `json:"type"`
56
+
Motivation string `json:"motivation,omitempty"`
57
+
Author Author `json:"creator"`
58
+
Body *APIBody `json:"body,omitempty"`
59
+
Target APITarget `json:"target"`
60
+
Tags []string `json:"tags,omitempty"`
61
+
Generator *APIGenerator `json:"generator,omitempty"`
62
+
CreatedAt time.Time `json:"created"`
63
+
IndexedAt time.Time `json:"indexed"`
64
+
LikeCount int `json:"likeCount"`
65
+
ReplyCount int `json:"replyCount"`
66
+
ViewerHasLiked bool `json:"viewerHasLiked"`
67
}
68
69
type APIHighlight struct {
70
+
ID string `json:"id"`
71
+
Type string `json:"type"`
72
+
Author Author `json:"creator"`
73
+
Target APITarget `json:"target"`
74
+
Color string `json:"color,omitempty"`
75
+
Tags []string `json:"tags,omitempty"`
76
+
CreatedAt time.Time `json:"created"`
77
+
CID string `json:"cid,omitempty"`
78
+
LikeCount int `json:"likeCount"`
79
+
ReplyCount int `json:"replyCount"`
80
+
ViewerHasLiked bool `json:"viewerHasLiked"`
81
}
82
83
type APIBookmark struct {
84
+
ID string `json:"id"`
85
+
Type string `json:"type"`
86
+
Author Author `json:"creator"`
87
+
Source string `json:"source"`
88
+
Title string `json:"title,omitempty"`
89
+
Description string `json:"description,omitempty"`
90
+
Tags []string `json:"tags,omitempty"`
91
+
CreatedAt time.Time `json:"created"`
92
+
CID string `json:"cid,omitempty"`
93
+
LikeCount int `json:"likeCount"`
94
+
ReplyCount int `json:"replyCount"`
95
+
ViewerHasLiked bool `json:"viewerHasLiked"`
96
}
97
98
type APIReply struct {
···
108
}
109
110
type APICollection struct {
111
+
URI string `json:"uri"`
112
+
Name string `json:"name"`
113
+
Description string `json:"description,omitempty"`
114
+
Icon string `json:"icon,omitempty"`
115
+
Creator Author `json:"creator"`
116
+
CreatedAt time.Time `json:"createdAt"`
117
+
IndexedAt time.Time `json:"indexedAt"`
118
}
119
120
type APICollectionItem struct {
···
131
}
132
133
type APINotification struct {
134
+
ID int `json:"id"`
135
+
Recipient Author `json:"recipient"`
136
+
Actor Author `json:"actor"`
137
+
Type string `json:"type"`
138
+
SubjectURI string `json:"subjectUri"`
139
+
Subject interface{} `json:"subject,omitempty"`
140
+
CreatedAt time.Time `json:"createdAt"`
141
+
ReadAt *time.Time `json:"readAt,omitempty"`
142
}
143
144
+
func hydrateAnnotations(database *db.DB, annotations []db.Annotation, viewerDID string) ([]APIAnnotation, error) {
145
if len(annotations) == 0 {
146
return []APIAnnotation{}, nil
147
}
···
206
CreatedAt: a.CreatedAt,
207
IndexedAt: a.IndexedAt,
208
}
209
+
210
+
if database != nil {
211
+
result[i].LikeCount, _ = database.GetLikeCount(a.URI)
212
+
result[i].ReplyCount, _ = database.GetReplyCount(a.URI)
213
+
if viewerDID != "" {
214
+
if _, err := database.GetLikeByUserAndSubject(viewerDID, a.URI); err == nil {
215
+
result[i].ViewerHasLiked = true
216
+
}
217
+
}
218
+
}
219
}
220
221
return result, nil
222
}
223
224
+
func hydrateHighlights(database *db.DB, highlights []db.Highlight, viewerDID string) ([]APIHighlight, error) {
225
if len(highlights) == 0 {
226
return []APIHighlight{}, nil
227
}
···
269
Tags: tags,
270
CreatedAt: h.CreatedAt,
271
CID: cid,
272
+
}
273
+
274
+
if database != nil {
275
+
result[i].LikeCount, _ = database.GetLikeCount(h.URI)
276
+
result[i].ReplyCount, _ = database.GetReplyCount(h.URI)
277
+
if viewerDID != "" {
278
+
if _, err := database.GetLikeByUserAndSubject(viewerDID, h.URI); err == nil {
279
+
result[i].ViewerHasLiked = true
280
+
}
281
+
}
282
}
283
}
284
285
return result, nil
286
}
287
288
+
func hydrateBookmarks(database *db.DB, bookmarks []db.Bookmark, viewerDID string) ([]APIBookmark, error) {
289
if len(bookmarks) == 0 {
290
return []APIBookmark{}, nil
291
}
···
324
Tags: tags,
325
CreatedAt: b.CreatedAt,
326
CID: cid,
327
+
}
328
+
if database != nil {
329
+
result[i].LikeCount, _ = database.GetLikeCount(b.URI)
330
+
result[i].ReplyCount, _ = database.GetReplyCount(b.URI)
331
+
if viewerDID != "" {
332
+
if _, err := database.GetLikeByUserAndSubject(viewerDID, b.URI); err == nil {
333
+
result[i].ViewerHasLiked = true
334
+
}
335
+
}
336
}
337
}
338
···
477
return result, nil
478
}
479
480
+
func hydrateCollectionItems(database *db.DB, items []db.CollectionItem, viewerDID string) ([]APICollectionItem, error) {
481
if len(items) == 0 {
482
return []APICollectionItem{}, nil
483
}
···
500
if coll.Icon != nil {
501
icon = *coll.Icon
502
}
503
+
desc := ""
504
+
if coll.Description != nil {
505
+
desc = *coll.Description
506
+
}
507
apiItem.Collection = &APICollection{
508
+
URI: coll.URI,
509
+
Name: coll.Name,
510
+
Description: desc,
511
+
Icon: icon,
512
+
Creator: profiles[coll.AuthorDID],
513
+
CreatedAt: coll.CreatedAt,
514
+
IndexedAt: coll.IndexedAt,
515
}
516
}
517
518
if strings.Contains(item.AnnotationURI, "at.margin.annotation") {
519
if a, err := database.GetAnnotationByURI(item.AnnotationURI); err == nil {
520
+
hydrated, _ := hydrateAnnotations(database, []db.Annotation{*a}, viewerDID)
521
if len(hydrated) > 0 {
522
apiItem.Annotation = &hydrated[0]
523
}
524
}
525
} else if strings.Contains(item.AnnotationURI, "at.margin.highlight") {
526
if h, err := database.GetHighlightByURI(item.AnnotationURI); err == nil {
527
+
hydrated, _ := hydrateHighlights(database, []db.Highlight{*h}, viewerDID)
528
if len(hydrated) > 0 {
529
apiItem.Highlight = &hydrated[0]
530
}
531
}
532
} else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") {
533
if b, err := database.GetBookmarkByURI(item.AnnotationURI); err == nil {
534
+
hydrated, _ := hydrateBookmarks(database, []db.Bookmark{*b}, viewerDID)
535
if len(hydrated) > 0 {
536
apiItem.Bookmark = &hydrated[0]
537
} else {
538
log.Printf("Failed to hydrate bookmark %s: empty hydration result\n", item.AnnotationURI)
539
}
540
} else {
541
}
542
} else {
543
log.Printf("Unknown item type for URI: %s\n", item.AnnotationURI)
···
548
return result, nil
549
}
550
551
+
func hydrateNotifications(database *db.DB, notifications []db.Notification) ([]APINotification, error) {
552
if len(notifications) == 0 {
553
return []APINotification{}, nil
554
}
···
568
569
profiles := fetchProfilesForDIDs(dids)
570
571
+
replyURIs := make([]string, 0)
572
+
for _, n := range notifications {
573
+
if n.Type == "reply" {
574
+
replyURIs = append(replyURIs, n.SubjectURI)
575
+
}
576
+
}
577
+
578
+
replyMap := make(map[string]APIReply)
579
+
if len(replyURIs) > 0 {
580
+
var replies []db.Reply
581
+
for _, uri := range replyURIs {
582
+
r, err := database.GetReplyByURI(uri)
583
+
if err == nil {
584
+
replies = append(replies, *r)
585
+
}
586
+
}
587
+
588
+
hydratedReplies, _ := hydrateReplies(replies)
589
+
for _, r := range hydratedReplies {
590
+
replyMap[r.ID] = r
591
+
}
592
+
}
593
+
594
result := make([]APINotification, len(notifications))
595
for i, n := range notifications {
596
+
var subject interface{}
597
+
if n.Type == "reply" {
598
+
if val, ok := replyMap[n.SubjectURI]; ok {
599
+
subject = val
600
+
}
601
+
}
602
+
603
result[i] = APINotification{
604
ID: n.ID,
605
Recipient: profiles[n.RecipientDID],
606
Actor: profiles[n.ActorDID],
607
Type: n.Type,
608
SubjectURI: n.SubjectURI,
609
+
Subject: subject,
610
CreatedAt: n.CreatedAt,
611
ReadAt: n.ReadAt,
612
}
+691
-60
backend/internal/api/og.go
+691
-60
backend/internal/api/og.go
···
15
"net/http"
16
"net/url"
17
"os"
18
-
"regexp"
19
"strings"
20
21
"golang.org/x/image/font"
···
101
"Bluesky",
102
}
103
104
func isCrawler(userAgent string) bool {
105
ua := strings.ToLower(userAgent)
106
for _, bot := range crawlerUserAgents {
···
111
return false
112
}
113
114
func (h *OGHandler) HandleAnnotationPage(w http.ResponseWriter, r *http.Request) {
115
path := r.URL.Path
116
117
-
var annotationMatch = regexp.MustCompile(`^/at/([^/]+)/([^/]+)$`)
118
-
matches := annotationMatch.FindStringSubmatch(path)
119
120
-
if len(matches) != 3 {
121
h.serveIndexHTML(w, r)
122
return
123
}
124
125
-
did, _ := url.QueryUnescape(matches[1])
126
-
rkey := matches[2]
127
-
128
if !isCrawler(r.UserAgent()) {
129
h.serveIndexHTML(w, r)
130
return
131
}
132
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
138
}
139
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)
144
return
145
}
146
147
h.serveIndexHTML(w, r)
···
232
w.Write([]byte(htmlContent))
233
}
234
235
func (h *OGHandler) serveAnnotationOG(w http.ResponseWriter, annotation *db.Annotation) {
236
title := "Annotation on Margin"
237
description := ""
···
417
}
418
}
419
} else {
420
-
http.Error(w, "Record not found", http.StatusNotFound)
421
-
return
422
}
423
}
424
···
432
func generateOGImagePNG(author, text, quote, source, avatarURL string) image.Image {
433
width := 1200
434
height := 630
435
-
padding := 120
436
437
bgPrimary := color.RGBA{12, 10, 20, 255}
438
accent := color.RGBA{168, 85, 247, 255}
439
textPrimary := color.RGBA{244, 240, 255, 255}
440
textSecondary := color.RGBA{168, 158, 200, 255}
441
-
textTertiary := color.RGBA{107, 95, 138, 255}
442
border := color.RGBA{45, 38, 64, 255}
443
444
img := image.NewRGBA(image.Rect(0, 0, width, height))
445
446
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
-
}
456
457
-
avatarSize := 80
458
avatarX := padding
459
-
avatarY := 180
460
avatarImg := fetchAvatarImage(avatarURL)
461
if avatarImg != nil {
462
drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize)
463
} else {
464
drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent)
465
}
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
473
474
contentWidth := width - (padding * 2)
475
476
-
if quote != "" {
477
-
if len(quote) > 100 {
478
-
quote = quote[:97] + "..."
479
-
}
480
481
-
lines := wrapTextToWidth(quote, contentWidth-30, 24)
482
-
numLines := min(len(lines), 2)
483
-
barHeight := numLines*32 + 10
484
485
-
draw.Draw(img, image.Rect(padding, yPos, padding+6, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src)
486
487
-
for i, line := range lines {
488
-
if i >= 2 {
489
-
break
490
}
491
-
drawText(img, "\""+line+"\"", padding+24, yPos+28+(i*32), textTertiary, 24, true)
492
}
493
-
yPos += 30 + (numLines * 32) + 30
494
}
495
496
-
if text != "" {
497
-
if len(text) > 300 {
498
-
text = text[:297] + "..."
499
}
500
-
lines := wrapTextToWidth(text, contentWidth, 32)
501
-
for i, line := range lines {
502
-
if i >= 6 {
503
-
break
504
}
505
-
drawText(img, line, padding, yPos+(i*42), textPrimary, 32, false)
506
}
507
}
508
509
-
drawText(img, source, padding, 580, textTertiary, 20, false)
510
511
return img
512
}
···
662
}
663
return lines
664
}
···
15
"net/http"
16
"net/url"
17
"os"
18
"strings"
19
20
"golang.org/x/image/font"
···
100
"Bluesky",
101
}
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
+
157
func isCrawler(userAgent string) bool {
158
ua := strings.ToLower(userAgent)
159
for _, bot := range crawlerUserAgents {
···
164
return false
165
}
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
+
182
func (h *OGHandler) HandleAnnotationPage(w http.ResponseWriter, r *http.Request) {
183
path := r.URL.Path
184
+
var did, rkey, collectionType string
185
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
+
}
203
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 == "" {
218
h.serveIndexHTML(w, r)
219
return
220
}
221
222
if !isCrawler(r.UserAgent()) {
223
h.serveIndexHTML(w, r)
224
return
225
}
226
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
+
}
249
}
250
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)
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
+
}
326
}
327
328
h.serveIndexHTML(w, r)
···
413
w.Write([]byte(htmlContent))
414
}
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
+
604
func (h *OGHandler) serveAnnotationOG(w http.ResponseWriter, annotation *db.Annotation) {
605
title := "Annotation on Margin"
606
description := ""
···
786
}
787
}
788
} else {
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
+
}
863
}
864
}
865
···
873
func generateOGImagePNG(author, text, quote, source, avatarURL string) image.Image {
874
width := 1200
875
height := 630
876
+
padding := 100
877
878
bgPrimary := color.RGBA{12, 10, 20, 255}
879
accent := color.RGBA{168, 85, 247, 255}
880
textPrimary := color.RGBA{244, 240, 255, 255}
881
textSecondary := color.RGBA{168, 158, 200, 255}
882
border := color.RGBA{45, 38, 64, 255}
883
884
img := image.NewRGBA(image.Rect(0, 0, width, height))
885
886
draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src)
887
+
draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src)
888
889
+
avatarSize := 64
890
avatarX := padding
891
+
avatarY := padding
892
+
893
avatarImg := fetchAvatarImage(avatarURL)
894
if avatarImg != nil {
895
drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize)
896
} else {
897
drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent)
898
}
899
+
drawText(img, author, avatarX+avatarSize+24, avatarY+42, textSecondary, 28, false)
900
901
contentWidth := width - (padding * 2)
902
+
yPos := 220
903
904
+
if text != "" {
905
+
textLen := len(text)
906
+
textSize := 32.0
907
+
textLineHeight := 42
908
+
maxTextLines := 5
909
910
+
if textLen > 200 {
911
+
textSize = 28.0
912
+
textLineHeight = 36
913
+
maxTextLines = 6
914
+
}
915
916
+
lines := wrapTextToWidth(text, contentWidth, int(textSize))
917
+
numLines := min(len(lines), maxTextLines)
918
919
+
for i := 0; i < numLines; i++ {
920
+
line := lines[i]
921
+
if i == numLines-1 && len(lines) > numLines {
922
+
line += "..."
923
}
924
+
drawText(img, line, padding, yPos+(i*textLineHeight), textPrimary, textSize, false)
925
}
926
+
yPos += (numLines * textLineHeight) + 40
927
}
928
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
939
}
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 += "..."
951
}
952
+
drawText(img, line, padding+24, yPos+24+(i*quoteLineHeight), textSecondary, quoteSize, true)
953
}
954
+
yPos += barHeight + 40
955
}
956
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)
960
961
return img
962
}
···
1112
}
1113
return lines
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
+
}
+140
backend/internal/db/queries.go
+140
backend/internal/db/queries.go
···
104
return scanAnnotations(rows)
105
}
106
107
func (db *DB) DeleteAnnotation(uri string) error {
108
_, err := db.Exec(db.Rebind(`DELETE FROM annotations WHERE uri = ?`), uri)
109
return err
···
242
return highlights, nil
243
}
244
245
func (db *DB) GetRecentBookmarks(limit, offset int) ([]Bookmark, error) {
246
rows, err := db.Query(db.Rebind(`
247
SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid
···
265
return bookmarks, nil
266
}
267
268
func (db *DB) GetHighlightsByTargetHash(targetHash string, limit, offset int) ([]Highlight, error) {
269
rows, err := db.Query(db.Rebind(`
270
SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid
···
497
func (db *DB) GetLikeCount(subjectURI string) (int, error) {
498
var count int
499
err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM likes WHERE subject_uri = ?`), subjectURI).Scan(&count)
500
return count, err
501
}
502
···
104
return scanAnnotations(rows)
105
}
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
+
124
func (db *DB) DeleteAnnotation(uri string) error {
125
_, err := db.Exec(db.Rebind(`DELETE FROM annotations WHERE uri = ?`), uri)
126
return err
···
259
return highlights, nil
260
}
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
+
287
func (db *DB) GetRecentBookmarks(limit, offset int) ([]Bookmark, error) {
288
rows, err := db.Query(db.Rebind(`
289
SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid
···
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)
386
+
if err != nil {
387
+
return nil, err
388
+
}
389
+
defer rows.Close()
390
+
391
+
var bookmarks []Bookmark
392
+
for rows.Next() {
393
+
var b Bookmark
394
+
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 {
395
+
return nil, err
396
+
}
397
+
bookmarks = append(bookmarks, b)
398
+
}
399
+
return bookmarks, nil
400
+
}
401
+
402
func (db *DB) GetHighlightsByTargetHash(targetHash string, limit, offset int) ([]Highlight, error) {
403
rows, err := db.Query(db.Rebind(`
404
SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid
···
631
func (db *DB) GetLikeCount(subjectURI string) (int, error) {
632
var count int
633
err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM likes WHERE subject_uri = ?`), subjectURI).Scan(&count)
634
+
return count, err
635
+
}
636
+
637
+
func (db *DB) GetReplyCount(rootURI string) (int, error) {
638
+
var count int
639
+
err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM replies WHERE root_uri = ?`), rootURI).Scan(&count)
640
return count, err
641
}
642
+2
-2
backend/internal/oauth/client.go
+2
-2
backend/internal/oauth/client.go
···
205
"jti": base64.RawURLEncoding.EncodeToString(jti),
206
"htm": method,
207
"htu": uri,
208
-
"iat": now.Unix(),
209
"exp": now.Add(5 * time.Minute).Unix(),
210
}
211
if nonce != "" {
···
243
Issuer: c.ClientID,
244
Subject: c.ClientID,
245
Audience: jwt.Audience{issuer},
246
-
IssuedAt: jwt.NewNumericDate(now),
247
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
248
ID: base64.RawURLEncoding.EncodeToString(jti),
249
}
···
205
"jti": base64.RawURLEncoding.EncodeToString(jti),
206
"htm": method,
207
"htu": uri,
208
+
"iat": now.Add(-30 * time.Second).Unix(),
209
"exp": now.Add(5 * time.Minute).Unix(),
210
}
211
if nonce != "" {
···
243
Issuer: c.ClientID,
244
Subject: c.ClientID,
245
Audience: jwt.Audience{issuer},
246
+
IssuedAt: jwt.NewNumericDate(now.Add(-30 * time.Second)),
247
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
248
ID: base64.RawURLEncoding.EncodeToString(jti),
249
}
+1
backend/internal/oauth/handler.go
+1
backend/internal/oauth/handler.go
···
244
245
parResp, state, dpopNonce, err := client.SendPAR(meta, req.Handle, scope, dpopKey, pkceChallenge)
246
if err != nil {
247
w.Header().Set("Content-Type", "application/json")
248
w.WriteHeader(http.StatusInternalServerError)
249
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to initiate authentication"})
···
244
245
parResp, state, dpopNonce, err := client.SendPAR(meta, req.Handle, scope, dpopKey, pkceChallenge)
246
if err != nil {
247
+
log.Printf("PAR request failed: %v", err)
248
w.Header().Set("Content-Type", "application/json")
249
w.WriteHeader(http.StatusInternalServerError)
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
CreatedAt string `json:"createdAt"`
79
}
80
81
-
func NewHighlightRecord(url, urlHash string, selector interface{}, color string) *HighlightRecord {
82
return &HighlightRecord{
83
Type: CollectionHighlight,
84
Target: AnnotationTarget{
···
87
Selector: selector,
88
},
89
Color: color,
90
CreatedAt: time.Now().UTC().Format(time.RFC3339),
91
}
92
}
···
78
CreatedAt string `json:"createdAt"`
79
}
80
81
+
func NewHighlightRecord(url, urlHash string, selector interface{}, color string, tags []string) *HighlightRecord {
82
return &HighlightRecord{
83
Type: CollectionHighlight,
84
Target: AnnotationTarget{
···
87
Selector: selector,
88
},
89
Color: color,
90
+
Tags: tags,
91
CreatedAt: time.Now().UTC().Format(time.RFC3339),
92
}
93
}
+18
web/src/App.jsx
+18
web/src/App.jsx
···
34
<Route path="/annotation/:uri" element={<AnnotationDetail />} />
35
<Route path="/collections" element={<Collections />} />
36
<Route path="/collections/:rkey" element={<CollectionDetail />} />
37
<Route path="/collection/*" element={<CollectionDetail />} />
38
<Route path="/privacy" element={<Privacy />} />
39
</Routes>
···
34
<Route path="/annotation/:uri" element={<AnnotationDetail />} />
35
<Route path="/collections" element={<Collections />} />
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
+
55
<Route path="/collection/*" element={<CollectionDetail />} />
56
<Route path="/privacy" element={<Privacy />} />
57
</Routes>
+83
-33
web/src/api/client.js
+83
-33
web/src/api/client.js
···
23
return request(`${API_BASE}/url-metadata?url=${encodeURIComponent(url)}`);
24
}
25
26
-
export async function getAnnotationFeed(limit = 50, offset = 0) {
27
-
return request(
28
-
`${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`,
29
-
);
30
}
31
32
export async function getAnnotations({
···
210
});
211
}
212
213
-
export async function createAnnotation({ url, text, quote, title, selector }) {
214
return request(`${API_BASE}/annotations`, {
215
method: "POST",
216
-
body: JSON.stringify({ url, text, quote, title, selector }),
217
});
218
}
219
···
283
284
if (item.type === "Annotation") {
285
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,
292
motivation: item.motivation,
293
tags: item.tags || [],
294
-
createdAt: item.created,
295
cid: item.cid || item.CID,
296
};
297
}
298
299
if (item.type === "Bookmark") {
300
return {
301
-
uri: item.id,
302
-
author: item.creator,
303
-
url: item.source,
304
title: item.title,
305
description: item.description,
306
tags: item.tags || [],
307
-
createdAt: item.created,
308
cid: item.cid || item.CID,
309
};
310
}
311
312
if (item.type === "Highlight") {
313
return {
314
-
uri: item.id,
315
-
author: item.creator,
316
-
url: item.target?.source,
317
-
title: item.target?.title,
318
-
selector: item.target?.selector,
319
color: item.color,
320
tags: item.tags || [],
321
-
createdAt: item.created,
322
cid: item.cid || item.CID,
323
};
324
}
325
···
335
tags: item.tags || [],
336
createdAt: item.createdAt || item.created,
337
cid: item.cid || item.CID,
338
};
339
}
340
341
export function normalizeHighlight(highlight) {
342
return {
343
-
uri: highlight.id,
344
-
author: highlight.creator,
345
-
url: highlight.target?.source,
346
-
title: highlight.target?.title,
347
-
selector: highlight.target?.selector,
348
color: highlight.color,
349
tags: highlight.tags || [],
350
-
createdAt: highlight.created,
351
};
352
}
353
354
export function normalizeBookmark(bookmark) {
355
return {
356
-
uri: bookmark.id,
357
-
author: bookmark.creator,
358
-
url: bookmark.source,
359
title: bookmark.title,
360
description: bookmark.description,
361
tags: bookmark.tags || [],
362
-
createdAt: bookmark.created,
363
};
364
}
365
···
369
);
370
if (!res.ok) throw new Error("Search failed");
371
return res.json();
372
}
373
374
export async function startLogin(handle, inviteCode) {
···
23
return request(`${API_BASE}/url-metadata?url=${encodeURIComponent(url)}`);
24
}
25
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);
36
}
37
38
export async function getAnnotations({
···
216
});
217
}
218
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
+
}) {
234
return request(`${API_BASE}/annotations`, {
235
method: "POST",
236
+
body: JSON.stringify({ url, text, quote, title, selector, tags }),
237
});
238
}
239
···
303
304
if (item.type === "Annotation") {
305
return {
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,
313
motivation: item.motivation,
314
tags: item.tags || [],
315
+
createdAt: item.createdAt || item.created,
316
cid: item.cid || item.CID,
317
+
likeCount: item.likeCount || 0,
318
+
replyCount: item.replyCount || 0,
319
+
viewerHasLiked: item.viewerHasLiked || false,
320
};
321
}
322
323
if (item.type === "Bookmark") {
324
return {
325
+
type: item.type,
326
+
uri: item.uri || item.id,
327
+
author: item.author || item.creator,
328
+
url: item.url || item.source,
329
title: item.title,
330
description: item.description,
331
tags: item.tags || [],
332
+
createdAt: item.createdAt || item.created,
333
cid: item.cid || item.CID,
334
+
likeCount: item.likeCount || 0,
335
+
replyCount: item.replyCount || 0,
336
+
viewerHasLiked: item.viewerHasLiked || false,
337
};
338
}
339
340
if (item.type === "Highlight") {
341
return {
342
+
type: item.type,
343
+
uri: item.uri || item.id,
344
+
author: item.author || item.creator,
345
+
url: item.url || item.target?.source,
346
+
title: item.title || item.target?.title,
347
+
selector: item.selector || item.target?.selector,
348
color: item.color,
349
tags: item.tags || [],
350
+
createdAt: item.createdAt || item.created,
351
cid: item.cid || item.CID,
352
+
likeCount: item.likeCount || 0,
353
+
replyCount: item.replyCount || 0,
354
+
viewerHasLiked: item.viewerHasLiked || false,
355
};
356
}
357
···
367
tags: item.tags || [],
368
createdAt: item.createdAt || item.created,
369
cid: item.cid || item.CID,
370
+
likeCount: item.likeCount || 0,
371
+
replyCount: item.replyCount || 0,
372
+
viewerHasLiked: item.viewerHasLiked || false,
373
};
374
}
375
376
export function normalizeHighlight(highlight) {
377
return {
378
+
uri: highlight.uri || highlight.id,
379
+
author: highlight.author || highlight.creator,
380
+
url: highlight.url || highlight.target?.source,
381
+
title: highlight.title || highlight.target?.title,
382
+
selector: highlight.selector || highlight.target?.selector,
383
color: highlight.color,
384
tags: highlight.tags || [],
385
+
createdAt: highlight.createdAt || highlight.created,
386
+
likeCount: highlight.likeCount || 0,
387
+
replyCount: highlight.replyCount || 0,
388
+
viewerHasLiked: highlight.viewerHasLiked || false,
389
};
390
}
391
392
export function normalizeBookmark(bookmark) {
393
return {
394
+
uri: bookmark.uri || bookmark.id,
395
+
author: bookmark.author || bookmark.creator,
396
+
url: bookmark.url || bookmark.source,
397
title: bookmark.title,
398
description: bookmark.description,
399
tags: bookmark.tags || [],
400
+
createdAt: bookmark.createdAt || bookmark.created,
401
+
likeCount: bookmark.likeCount || 0,
402
+
replyCount: bookmark.replyCount || 0,
403
+
viewerHasLiked: bookmark.viewerHasLiked || false,
404
};
405
}
406
···
410
);
411
if (!res.ok) throw new Error("Search failed");
412
return res.json();
413
+
}
414
+
415
+
export async function resolveHandle(handle) {
416
+
const res = await fetch(
417
+
`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
418
+
);
419
+
if (!res.ok) throw new Error("Failed to resolve handle");
420
+
const data = await res.json();
421
+
return data.did;
422
}
423
424
export async function startLogin(handle, inviteCode) {
+6
-2
web/src/components/AddToCollectionModal.jsx
+6
-2
web/src/components/AddToCollectionModal.jsx
···
23
24
useEffect(() => {
25
if (isOpen && user) {
26
loadCollections();
27
setError(null);
28
}
29
-
}, [isOpen, user]);
30
31
const loadCollections = async () => {
32
try {
···
71
className="modal-container"
72
style={{
73
maxWidth: "380px",
74
-
maxHeight: "80vh",
75
display: "flex",
76
flexDirection: "column",
77
}}
···
23
24
useEffect(() => {
25
if (isOpen && user) {
26
+
if (!annotationUri) {
27
+
setLoading(false);
28
+
return;
29
+
}
30
loadCollections();
31
setError(null);
32
}
33
+
}, [isOpen, user, annotationUri]);
34
35
const loadCollections = async () => {
36
try {
···
75
className="modal-container"
76
style={{
77
maxWidth: "380px",
78
+
maxHeight: "80dvh",
79
display: "flex",
80
flexDirection: "column",
81
}}
+401
-307
web/src/components/AnnotationCard.jsx
+401
-307
web/src/components/AnnotationCard.jsx
···
5
import {
6
normalizeAnnotation,
7
normalizeHighlight,
8
deleteAnnotation,
9
likeAnnotation,
10
unlikeAnnotation,
···
26
BookmarkIcon,
27
} from "./Icons";
28
import { Folder, Edit2, Save, X, Clock } from "lucide-react";
29
-
import AddToCollectionModal from "./AddToCollectionModal";
30
import ShareMenu from "./ShareMenu";
31
32
function buildTextFragmentUrl(baseUrl, selector) {
···
59
}
60
};
61
62
-
export default function AnnotationCard({ annotation, onDelete }) {
63
const { user, login } = useAuth();
64
const data = normalizeAnnotation(annotation);
65
66
-
const [likeCount, setLikeCount] = useState(0);
67
-
const [isLiked, setIsLiked] = useState(false);
68
const [deleting, setDeleting] = useState(false);
69
-
const [showAddToCollection, setShowAddToCollection] = useState(false);
70
const [isEditing, setIsEditing] = useState(false);
71
const [editText, setEditText] = useState(data.text || "");
72
const [saving, setSaving] = useState(false);
73
74
const [showHistory, setShowHistory] = useState(false);
···
76
const [loadingHistory, setLoadingHistory] = useState(false);
77
78
const [replies, setReplies] = useState([]);
79
-
const [replyCount, setReplyCount] = useState(0);
80
const [showReplies, setShowReplies] = useState(false);
81
const [replyingTo, setReplyingTo] = useState(null);
82
const [replyText, setReplyText] = useState("");
···
86
87
const [hasEditHistory, setHasEditHistory] = useState(false);
88
89
-
useEffect(() => {
90
-
let mounted = true;
91
-
async function fetchData() {
92
-
try {
93
-
const repliesRes = await getReplies(data.uri);
94
-
if (mounted && repliesRes.items) {
95
-
setReplies(repliesRes.items);
96
-
setReplyCount(repliesRes.items.length);
97
-
}
98
-
99
-
const likeRes = await getLikeCount(data.uri);
100
-
if (mounted) {
101
-
if (likeRes.count !== undefined) {
102
-
setLikeCount(likeRes.count);
103
-
}
104
-
if (likeRes.liked !== undefined) {
105
-
setIsLiked(likeRes.liked);
106
-
}
107
-
}
108
-
109
-
if (!data.color && !data.description) {
110
-
try {
111
-
const history = await getEditHistory(data.uri);
112
-
if (mounted && history && history.length > 0) {
113
-
setHasEditHistory(true);
114
-
}
115
-
} catch {}
116
-
}
117
-
} catch (err) {
118
-
console.error("Failed to fetch data:", err);
119
-
}
120
-
}
121
-
if (data.uri) {
122
-
fetchData();
123
-
}
124
-
return () => {
125
-
mounted = false;
126
-
};
127
-
}, [data.uri]);
128
129
const fetchHistory = async () => {
130
if (showHistory) {
···
181
const handleSaveEdit = async () => {
182
try {
183
setSaving(true);
184
-
await updateAnnotation(data.uri, editText, data.tags);
185
setIsEditing(false);
186
if (annotation.body) annotation.body.value = editText;
187
else if (annotation.text) annotation.text = editText;
188
} catch (err) {
189
alert("Failed to update: " + err.message);
190
} finally {
···
287
return (
288
<article className="card annotation-card">
289
<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
-
)}
301
-
</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"
317
>
318
-
@{authorHandle} <ExternalLinkIcon size={12} />
319
-
</a>
320
-
)}
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 && (
339
-
<button
340
-
className="annotation-edit-btn"
341
-
onClick={() => setIsEditing(!isEditing)}
342
-
title="Edit"
343
>
344
-
<Edit2 size={16} />
345
-
</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
-
)}
357
</div>
358
</header>
359
360
-
{}
361
-
{}
362
{showHistory && (
363
<div className="history-panel">
364
<div className="history-header">
···
390
</div>
391
)}
392
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 && (
406
<a
407
-
href={fragmentUrl}
408
target="_blank"
409
rel="noopener noreferrer"
410
-
className="annotation-highlight"
411
>
412
-
<mark>"{highlightedText}"</mark>
413
</a>
414
-
)}
415
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>
445
</div>
446
-
</div>
447
-
) : (
448
-
data.text && <p className="annotation-text">{data.text}</p>
449
-
)}
450
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
-
)}
460
461
<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
</footer>
491
492
{showReplies && (
···
578
</div>
579
</div>
580
)}
581
-
582
-
<AddToCollectionModal
583
-
isOpen={showAddToCollection}
584
-
onClose={() => setShowAddToCollection(false)}
585
-
annotationUri={data.uri}
586
-
/>
587
</article>
588
);
589
}
590
591
-
export function HighlightCard({ highlight, onDelete }) {
592
const { user, login } = useAuth();
593
const data = normalizeHighlight(highlight);
594
const highlightedText =
595
data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null;
596
const fragmentUrl = buildTextFragmentUrl(data.url, data.selector);
597
const isOwner = user?.did && data.author?.did === user.did;
598
-
const [showAddToCollection, setShowAddToCollection] = useState(false);
599
const [isEditing, setIsEditing] = useState(false);
600
const [editColor, setEditColor] = useState(data.color || "#f59e0b");
601
602
const handleSaveEdit = async () => {
603
try {
604
-
await updateHighlight(data.uri, editColor, []);
605
setIsEditing(false);
606
607
if (highlight.color) highlight.color = editColor;
608
} catch (err) {
609
alert("Failed to update: " + err.message);
610
}
···
633
return (
634
<article className="card annotation-card">
635
<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>
645
)}
646
</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
</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
-
)}
677
</div>
678
</header>
679
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 && (
690
<a
691
-
href={fragmentUrl}
692
target="_blank"
693
rel="noopener noreferrer"
694
-
className="annotation-highlight"
695
-
style={{
696
-
borderLeftColor: isEditing ? editColor : data.color || "#f59e0b",
697
-
}}
698
>
699
-
<mark>"{highlightedText}"</mark>
700
</a>
701
-
)}
702
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)}
713
style={{
714
-
height: "32px",
715
-
width: "64px",
716
-
padding: 0,
717
-
border: "none",
718
-
borderRadius: "var(--radius-sm)",
719
-
overflow: "hidden",
720
}}
721
/>
722
<button
723
-
onClick={handleSaveEdit}
724
-
className="btn btn-primary btn-sm"
725
-
style={{ marginLeft: "auto" }}
726
>
727
-
Save
728
</button>
729
</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
</footer>
753
-
<AddToCollectionModal
754
-
isOpen={showAddToCollection}
755
-
onClose={() => setShowAddToCollection(false)}
756
-
annotationUri={data.uri}
757
-
/>
758
</article>
759
);
760
}
···
5
import {
6
normalizeAnnotation,
7
normalizeHighlight,
8
+
normalizeBookmark,
9
deleteAnnotation,
10
likeAnnotation,
11
unlikeAnnotation,
···
27
BookmarkIcon,
28
} from "./Icons";
29
import { Folder, Edit2, Save, X, Clock } from "lucide-react";
30
import ShareMenu from "./ShareMenu";
31
32
function buildTextFragmentUrl(baseUrl, selector) {
···
59
}
60
};
61
62
+
export default function AnnotationCard({
63
+
annotation,
64
+
onDelete,
65
+
onAddToCollection,
66
+
}) {
67
const { user, login } = useAuth();
68
const data = normalizeAnnotation(annotation);
69
70
+
const [likeCount, setLikeCount] = useState(data.likeCount || 0);
71
+
const [isLiked, setIsLiked] = useState(data.viewerHasLiked || false);
72
const [deleting, setDeleting] = useState(false);
73
const [isEditing, setIsEditing] = useState(false);
74
const [editText, setEditText] = useState(data.text || "");
75
+
const [editTags, setEditTags] = useState(data.tags?.join(", ") || "");
76
const [saving, setSaving] = useState(false);
77
78
const [showHistory, setShowHistory] = useState(false);
···
80
const [loadingHistory, setLoadingHistory] = useState(false);
81
82
const [replies, setReplies] = useState([]);
83
+
const [replyCount, setReplyCount] = useState(data.replyCount || 0);
84
const [showReplies, setShowReplies] = useState(false);
85
const [replyingTo, setReplyingTo] = useState(null);
86
const [replyText, setReplyText] = useState("");
···
90
91
const [hasEditHistory, setHasEditHistory] = useState(false);
92
93
+
useEffect(() => {}, []);
94
95
const fetchHistory = async () => {
96
if (showHistory) {
···
147
const handleSaveEdit = async () => {
148
try {
149
setSaving(true);
150
+
const tagList = editTags
151
+
.split(",")
152
+
.map((t) => t.trim())
153
+
.filter(Boolean);
154
+
await updateAnnotation(data.uri, editText, tagList);
155
setIsEditing(false);
156
if (annotation.body) annotation.body.value = editText;
157
else if (annotation.text) annotation.text = editText;
158
+
if (annotation.tags) annotation.tags = tagList;
159
+
data.tags = tagList;
160
} catch (err) {
161
alert("Failed to update: " + err.message);
162
} finally {
···
259
return (
260
<article className="card annotation-card">
261
<header className="annotation-header">
262
+
<div className="annotation-header-left">
263
+
<Link to={marginProfileUrl || "#"} className="annotation-avatar-link">
264
+
<div className="annotation-avatar">
265
+
{authorAvatar ? (
266
+
<img src={authorAvatar} alt={authorDisplayName} />
267
+
) : (
268
+
<span>
269
+
{(authorDisplayName || authorHandle || "??")
270
+
?.substring(0, 2)
271
+
.toUpperCase()}
272
+
</span>
273
+
)}
274
+
</div>
275
+
</Link>
276
+
<div className="annotation-meta">
277
+
<div className="annotation-author-row">
278
+
<Link
279
+
to={marginProfileUrl || "#"}
280
+
className="annotation-author-link"
281
>
282
+
<span className="annotation-author">{authorDisplayName}</span>
283
+
</Link>
284
+
{authorHandle && (
285
+
<a
286
+
href={`https://bsky.app/profile/${authorHandle}`}
287
+
target="_blank"
288
+
rel="noopener noreferrer"
289
+
className="annotation-handle"
290
>
291
+
@{authorHandle}
292
+
</a>
293
)}
294
+
</div>
295
+
<div className="annotation-time">{formatDate(data.createdAt)}</div>
296
+
</div>
297
+
</div>
298
+
<div className="annotation-header-right">
299
+
<div style={{ display: "flex", gap: "4px" }}>
300
+
{hasEditHistory && !data.color && !data.description && (
301
<button
302
+
className="annotation-action action-icon-only"
303
+
onClick={fetchHistory}
304
+
title="View Edit History"
305
>
306
+
<Clock size={16} />
307
</button>
308
+
)}
309
+
310
+
{isOwner && (
311
+
<>
312
+
{!data.color && !data.description && (
313
+
<button
314
+
className="annotation-action action-icon-only"
315
+
onClick={() => setIsEditing(!isEditing)}
316
+
title="Edit"
317
+
>
318
+
<Edit2 size={16} />
319
+
</button>
320
+
)}
321
+
<button
322
+
className="annotation-action action-icon-only"
323
+
onClick={handleDelete}
324
+
disabled={deleting}
325
+
title="Delete"
326
+
>
327
+
<TrashIcon size={16} />
328
+
</button>
329
+
</>
330
+
)}
331
+
</div>
332
</div>
333
</header>
334
335
{showHistory && (
336
<div className="history-panel">
337
<div className="history-header">
···
363
</div>
364
)}
365
366
+
<div className="annotation-content">
367
<a
368
+
href={data.url}
369
target="_blank"
370
rel="noopener noreferrer"
371
+
className="annotation-source"
372
>
373
+
{truncateUrl(data.url)}
374
+
{data.title && (
375
+
<span className="annotation-source-title"> โข {data.title}</span>
376
+
)}
377
</a>
378
+
379
+
{highlightedText && (
380
+
<a
381
+
href={fragmentUrl}
382
+
target="_blank"
383
+
rel="noopener noreferrer"
384
+
className="annotation-highlight"
385
+
style={{
386
+
borderLeftColor: data.color || "var(--accent)",
387
+
}}
388
+
>
389
+
<mark>"{highlightedText}"</mark>
390
+
</a>
391
+
)}
392
393
+
{isEditing ? (
394
+
<div className="mt-3">
395
+
<textarea
396
+
value={editText}
397
+
onChange={(e) => setEditText(e.target.value)}
398
+
className="reply-input"
399
+
rows={3}
400
+
style={{ marginBottom: "8px" }}
401
+
/>
402
+
<input
403
+
type="text"
404
+
className="reply-input"
405
+
placeholder="Tags (comma separated)..."
406
+
value={editTags}
407
+
onChange={(e) => setEditTags(e.target.value)}
408
+
style={{ marginBottom: "8px" }}
409
+
/>
410
+
<div className="action-buttons-end">
411
+
<button
412
+
onClick={() => setIsEditing(false)}
413
+
className="btn btn-ghost"
414
+
>
415
+
Cancel
416
+
</button>
417
+
<button
418
+
onClick={handleSaveEdit}
419
+
disabled={saving}
420
+
className="btn btn-primary btn-sm"
421
+
>
422
+
{saving ? (
423
+
"Saving..."
424
+
) : (
425
+
<>
426
+
<Save size={14} /> Save
427
+
</>
428
+
)}
429
+
</button>
430
+
</div>
431
</div>
432
+
) : (
433
+
data.text && <p className="annotation-text">{data.text}</p>
434
+
)}
435
436
+
{data.tags?.length > 0 && (
437
+
<div className="annotation-tags">
438
+
{data.tags.map((tag, i) => (
439
+
<Link
440
+
key={i}
441
+
to={`/?tag=${encodeURIComponent(tag)}`}
442
+
className="annotation-tag"
443
+
>
444
+
#{tag}
445
+
</Link>
446
+
))}
447
+
</div>
448
+
)}
449
+
</div>
450
451
<footer className="annotation-actions">
452
+
<div className="annotation-actions-left">
453
+
<button
454
+
className={`annotation-action ${isLiked ? "liked" : ""}`}
455
+
onClick={handleLike}
456
+
>
457
+
<HeartIcon filled={isLiked} size={16} />
458
+
{likeCount > 0 && <span>{likeCount}</span>}
459
+
</button>
460
+
<button
461
+
className={`annotation-action ${showReplies ? "active" : ""}`}
462
+
onClick={async () => {
463
+
if (!showReplies && replies.length === 0) {
464
+
try {
465
+
const res = await getReplies(data.uri);
466
+
if (res.items) setReplies(res.items);
467
+
} catch (err) {
468
+
console.error("Failed to load replies:", err);
469
+
}
470
+
}
471
+
setShowReplies(!showReplies);
472
+
}}
473
+
>
474
+
<MessageIcon size={16} />
475
+
<span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span>
476
+
</button>
477
+
<ShareMenu
478
+
uri={data.uri}
479
+
text={data.title || data.url}
480
+
handle={data.author?.handle}
481
+
type="Annotation"
482
+
/>
483
+
<button
484
+
className="annotation-action"
485
+
onClick={() => {
486
+
if (!user) {
487
+
login();
488
+
return;
489
+
}
490
+
if (onAddToCollection) onAddToCollection();
491
+
}}
492
+
>
493
+
<Folder size={16} />
494
+
<span>Collect</span>
495
+
</button>
496
+
</div>
497
</footer>
498
499
{showReplies && (
···
585
</div>
586
</div>
587
)}
588
</article>
589
);
590
}
591
592
+
export function HighlightCard({ highlight, onDelete, onAddToCollection }) {
593
const { user, login } = useAuth();
594
const data = normalizeHighlight(highlight);
595
const highlightedText =
596
data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null;
597
const fragmentUrl = buildTextFragmentUrl(data.url, data.selector);
598
const isOwner = user?.did && data.author?.did === user.did;
599
const [isEditing, setIsEditing] = useState(false);
600
const [editColor, setEditColor] = useState(data.color || "#f59e0b");
601
+
const [editTags, setEditTags] = useState(data.tags?.join(", ") || "");
602
603
const handleSaveEdit = async () => {
604
try {
605
+
const tagList = editTags
606
+
.split(",")
607
+
.map((t) => t.trim())
608
+
.filter(Boolean);
609
+
610
+
await updateHighlight(data.uri, editColor, tagList);
611
setIsEditing(false);
612
613
if (highlight.color) highlight.color = editColor;
614
+
if (highlight.tags) highlight.tags = tagList;
615
+
else highlight.value = { ...highlight.value, tags: tagList };
616
} catch (err) {
617
alert("Failed to update: " + err.message);
618
}
···
641
return (
642
<article className="card annotation-card">
643
<header className="annotation-header">
644
+
<div className="annotation-header-left">
645
+
<Link
646
+
to={data.author?.did ? `/profile/${data.author.did}` : "#"}
647
+
className="annotation-avatar-link"
648
+
>
649
+
<div className="annotation-avatar">
650
+
{data.author?.avatar ? (
651
+
<img src={data.author.avatar} alt="avatar" />
652
+
) : (
653
+
<span>??</span>
654
+
)}
655
+
</div>
656
+
</Link>
657
+
<div className="annotation-meta">
658
+
<Link to="#" className="annotation-author-link">
659
+
<span className="annotation-author">
660
+
{data.author?.displayName || "Unknown"}
661
+
</span>
662
+
</Link>
663
+
<div className="annotation-time">{formatDate(data.createdAt)}</div>
664
+
{data.author?.handle && (
665
+
<a
666
+
href={`https://bsky.app/profile/${data.author.handle}`}
667
+
target="_blank"
668
+
rel="noopener noreferrer"
669
+
className="annotation-handle"
670
+
>
671
+
@{data.author.handle}
672
+
</a>
673
)}
674
</div>
675
</div>
676
+
677
+
<div className="annotation-header-right">
678
+
<div style={{ display: "flex", gap: "4px" }}>
679
+
{isOwner && (
680
+
<>
681
+
<button
682
+
className="annotation-action action-icon-only"
683
+
onClick={() => setIsEditing(!isEditing)}
684
+
title="Edit Color"
685
+
>
686
+
<Edit2 size={16} />
687
+
</button>
688
+
<button
689
+
className="annotation-action action-icon-only"
690
+
onClick={(e) => {
691
+
e.preventDefault();
692
+
onDelete && onDelete(highlight.id || highlight.uri);
693
+
}}
694
+
>
695
+
<TrashIcon size={16} />
696
+
</button>
697
+
</>
698
+
)}
699
+
</div>
700
</div>
701
</header>
702
703
+
<div className="annotation-content">
704
<a
705
+
href={data.url}
706
target="_blank"
707
rel="noopener noreferrer"
708
+
className="annotation-source"
709
>
710
+
{truncateUrl(data.url)}
711
</a>
712
713
+
{highlightedText && (
714
+
<a
715
+
href={fragmentUrl}
716
+
target="_blank"
717
+
rel="noopener noreferrer"
718
+
className="annotation-highlight"
719
+
style={{
720
+
borderLeftColor: isEditing ? editColor : data.color || "#f59e0b",
721
+
}}
722
+
>
723
+
<mark>"{highlightedText}"</mark>
724
+
</a>
725
+
)}
726
+
727
+
{isEditing && (
728
+
<div
729
+
className="mt-3"
730
+
style={{
731
+
display: "flex",
732
+
gap: "8px",
733
+
alignItems: "center",
734
+
padding: "8px",
735
+
background: "var(--bg-secondary)",
736
+
borderRadius: "var(--radius-md)",
737
+
border: "1px solid var(--border)",
738
+
}}
739
+
>
740
+
<div
741
+
className="color-picker-compact"
742
+
style={{
743
+
position: "relative",
744
+
width: "28px",
745
+
height: "28px",
746
+
flexShrink: 0,
747
+
}}
748
+
>
749
+
<div
750
+
style={{
751
+
backgroundColor: editColor,
752
+
width: "100%",
753
+
height: "100%",
754
+
borderRadius: "50%",
755
+
border: "2px solid var(--bg-card)",
756
+
boxShadow: "0 0 0 1px var(--border)",
757
+
}}
758
+
/>
759
+
<input
760
+
type="color"
761
+
value={editColor}
762
+
onChange={(e) => setEditColor(e.target.value)}
763
+
style={{
764
+
position: "absolute",
765
+
top: 0,
766
+
left: 0,
767
+
width: "100%",
768
+
height: "100%",
769
+
opacity: 0,
770
+
cursor: "pointer",
771
+
}}
772
+
title="Change Color"
773
+
/>
774
+
</div>
775
+
776
+
<input
777
+
type="text"
778
+
className="reply-input"
779
+
placeholder="e.g. tag1, tag2"
780
+
value={editTags}
781
+
onChange={(e) => setEditTags(e.target.value)}
782
+
style={{
783
+
margin: 0,
784
+
flex: 1,
785
+
fontSize: "0.9rem",
786
+
padding: "6px 10px",
787
+
height: "32px",
788
+
border: "none",
789
+
background: "transparent",
790
+
}}
791
+
/>
792
+
793
+
<button
794
+
onClick={handleSaveEdit}
795
+
className="btn btn-primary btn-sm"
796
+
style={{ padding: "0 10px", height: "32px", minWidth: "auto" }}
797
+
title="Save"
798
+
>
799
+
<Save size={16} />
800
+
</button>
801
+
</div>
802
+
)}
803
+
804
+
{data.tags?.length > 0 && (
805
+
<div className="annotation-tags">
806
+
{data.tags.map((tag, i) => (
807
+
<Link
808
+
key={i}
809
+
to={`/?tag=${encodeURIComponent(tag)}`}
810
+
className="annotation-tag"
811
+
>
812
+
#{tag}
813
+
</Link>
814
+
))}
815
+
</div>
816
+
)}
817
+
</div>
818
+
819
+
<footer className="annotation-actions">
820
+
<div className="annotation-actions-left">
821
+
<span
822
+
className="annotation-action"
823
style={{
824
+
color: data.color || "#f59e0b",
825
+
background: "none",
826
+
paddingLeft: 0,
827
}}
828
+
>
829
+
<HighlightIcon size={14} /> Highlight
830
+
</span>
831
+
<ShareMenu
832
+
uri={data.uri}
833
+
text={data.title || data.description}
834
+
handle={data.author?.handle}
835
+
type="Highlight"
836
/>
837
<button
838
+
className="annotation-action"
839
+
onClick={() => {
840
+
if (!user) {
841
+
login();
842
+
return;
843
+
}
844
+
if (onAddToCollection) onAddToCollection();
845
+
}}
846
>
847
+
<Folder size={16} />
848
+
<span>Collect</span>
849
</button>
850
</div>
851
</footer>
852
</article>
853
);
854
}
+106
-125
web/src/components/BookmarkCard.jsx
+106
-125
web/src/components/BookmarkCard.jsx
···
3
import { Link } from "react-router-dom";
4
import {
5
normalizeAnnotation,
6
likeAnnotation,
7
unlikeAnnotation,
8
getLikeCount,
···
10
} from "../api/client";
11
import { HeartIcon, TrashIcon, ExternalLinkIcon, BookmarkIcon } from "./Icons";
12
import { Folder } from "lucide-react";
13
-
import AddToCollectionModal from "./AddToCollectionModal";
14
import ShareMenu from "./ShareMenu";
15
16
-
export default function BookmarkCard({ bookmark, annotation, onDelete }) {
17
const { user, login } = useAuth();
18
-
const data = normalizeAnnotation(bookmark || annotation);
19
20
const [likeCount, setLikeCount] = useState(0);
21
const [isLiked, setIsLiked] = useState(false);
22
const [deleting, setDeleting] = useState(false);
23
-
const [showAddToCollection, setShowAddToCollection] = useState(false);
24
25
const isOwner = user?.did && data.author?.did === user.did;
26
···
81
}
82
};
83
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
const formatDate = (dateString) => {
104
if (!dateString) return "";
105
const date = new Date(dateString);
···
128
129
return (
130
<article className="card bookmark-card">
131
-
{}
132
<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
-
)}
144
</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"
160
>
161
-
@{authorHandle} <ExternalLinkIcon size={12} />
162
-
</a>
163
)}
164
</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
</div>
179
</header>
180
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>
192
</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>
202
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
-
)}
213
214
-
{}
215
<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>
237
</footer>
238
-
239
-
{showAddToCollection && (
240
-
<AddToCollectionModal
241
-
isOpen={showAddToCollection}
242
-
annotationUri={data.uri}
243
-
onClose={() => setShowAddToCollection(false)}
244
-
/>
245
-
)}
246
</article>
247
);
248
}
···
3
import { Link } from "react-router-dom";
4
import {
5
normalizeAnnotation,
6
+
normalizeBookmark,
7
likeAnnotation,
8
unlikeAnnotation,
9
getLikeCount,
···
11
} from "../api/client";
12
import { HeartIcon, TrashIcon, ExternalLinkIcon, BookmarkIcon } from "./Icons";
13
import { Folder } from "lucide-react";
14
import ShareMenu from "./ShareMenu";
15
16
+
export default function BookmarkCard({ bookmark, onAddToCollection }) {
17
const { user, login } = useAuth();
18
+
const raw = bookmark;
19
+
const data =
20
+
raw.type === "Bookmark" ? normalizeBookmark(raw) : normalizeAnnotation(raw);
21
22
const [likeCount, setLikeCount] = useState(0);
23
const [isLiked, setIsLiked] = useState(false);
24
const [deleting, setDeleting] = useState(false);
25
26
const isOwner = user?.did && data.author?.did === user.did;
27
···
82
}
83
};
84
85
const formatDate = (dateString) => {
86
if (!dateString) return "";
87
const date = new Date(dateString);
···
110
111
return (
112
<article className="card bookmark-card">
113
<header className="annotation-header">
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>
148
</div>
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"
159
>
160
+
<TrashIcon size={16} />
161
+
</button>
162
)}
163
</div>
164
</div>
165
</header>
166
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
+
)}
183
</div>
184
+
</a>
185
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>
196
197
<footer className="annotation-actions">
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>
226
</footer>
227
</article>
228
);
229
}
+4
-2
web/src/components/CollectionItemCard.jsx
+4
-2
web/src/components/CollectionItemCard.jsx
···
54
</span>{" "}
55
added to{" "}
56
<Link
57
-
to={`/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(author.did)}`}
58
style={{
59
display: "inline-flex",
60
alignItems: "center",
···
70
</span>
71
<div style={{ marginLeft: "auto" }}>
72
<ShareMenu
73
-
customUrl={`${window.location.origin}/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(author.did)}`}
74
text={`Check out this collection by ${author.displayName}: ${collection.name}`}
75
/>
76
</div>
···
54
</span>{" "}
55
added to{" "}
56
<Link
57
+
to={`/${author.handle}/collection/${collection.uri.split("/").pop()}`}
58
style={{
59
display: "inline-flex",
60
alignItems: "center",
···
70
</span>
71
<div style={{ marginLeft: "auto" }}>
72
<ShareMenu
73
+
uri={collection.uri}
74
+
handle={author.handle}
75
+
type="Collection"
76
text={`Check out this collection by ${author.displayName}: ${collection.name}`}
77
/>
78
</div>
+5
-3
web/src/components/CollectionRow.jsx
+5
-3
web/src/components/CollectionRow.jsx
···
6
return (
7
<div className="collection-row">
8
<Link
9
+
to={
10
+
collection.creator?.handle
11
+
? `/${collection.creator.handle}/collection/${collection.uri.split("/").pop()}`
12
+
: `/collection/${encodeURIComponent(collection.uri)}`
13
+
}
14
className="collection-row-content"
15
>
16
<div className="collection-row-icon">
+37
-9
web/src/components/Composer.jsx
+37
-9
web/src/components/Composer.jsx
···
1
import { useState } from "react";
2
-
import { createAnnotation } from "../api/client";
3
4
export default function Composer({
5
url,
···
9
}) {
10
const [text, setText] = useState("");
11
const [quoteText, setQuoteText] = useState("");
12
const [selector, setSelector] = useState(initialSelector);
13
const [loading, setLoading] = useState(false);
14
const [error, setError] = useState(null);
···
19
20
const handleSubmit = async (e) => {
21
e.preventDefault();
22
-
if (!text.trim()) return;
23
24
try {
25
setLoading(true);
···
33
};
34
}
35
36
-
await createAnnotation({
37
-
url,
38
-
text,
39
-
selector: finalSelector || undefined,
40
-
});
41
42
setText("");
43
setQuoteText("");
···
123
className="composer-input"
124
rows={4}
125
maxLength={3000}
126
-
required
127
disabled={loading}
128
/>
129
130
<div className="composer-footer">
131
<span className="composer-count">{text.length}/3000</span>
···
143
<button
144
type="submit"
145
className="btn btn-primary"
146
-
disabled={loading || !text.trim()}
147
>
148
{loading ? "Posting..." : "Post"}
149
</button>
···
1
import { useState } from "react";
2
+
import { createAnnotation, createHighlight } from "../api/client";
3
4
export default function Composer({
5
url,
···
9
}) {
10
const [text, setText] = useState("");
11
const [quoteText, setQuoteText] = useState("");
12
+
const [tags, setTags] = useState("");
13
const [selector, setSelector] = useState(initialSelector);
14
const [loading, setLoading] = useState(false);
15
const [error, setError] = useState(null);
···
20
21
const handleSubmit = async (e) => {
22
e.preventDefault();
23
+
if (!text.trim() && !highlightedText && !quoteText.trim()) return;
24
25
try {
26
setLoading(true);
···
34
};
35
}
36
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
+
}
57
58
setText("");
59
setQuoteText("");
···
139
className="composer-input"
140
rows={4}
141
maxLength={3000}
142
disabled={loading}
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>
155
156
<div className="composer-footer">
157
<span className="composer-count">{text.length}/3000</span>
···
169
<button
170
type="submit"
171
className="btn btn-primary"
172
+
disabled={
173
+
loading || (!text.trim() && !highlightedText && !quoteText)
174
+
}
175
>
176
{loading ? "Posting..." : "Post"}
177
</button>
+299
-65
web/src/index.css
+299
-65
web/src/index.css
···
140
background: var(--bg-card);
141
border: 1px solid var(--border);
142
border-radius: var(--radius-lg);
143
-
padding: 20px;
144
transition: all 0.2s ease;
145
}
146
147
.card:hover {
148
border-color: var(--border-hover);
149
-
box-shadow: var(--shadow-sm);
150
}
151
152
.annotation-card {
153
display: flex;
154
flex-direction: column;
155
-
gap: 12px;
156
}
157
158
.annotation-header {
159
display: flex;
160
align-items: center;
161
gap: 12px;
162
}
163
164
.annotation-avatar {
165
-
width: 42px;
166
-
height: 42px;
167
-
min-width: 42px;
168
border-radius: var(--radius-full);
169
background: linear-gradient(135deg, var(--accent), #a855f7);
170
display: flex;
171
align-items: center;
172
justify-content: center;
173
font-weight: 600;
174
-
font-size: 1rem;
175
color: white;
176
overflow: hidden;
177
}
178
179
.annotation-avatar img {
···
183
}
184
185
.annotation-meta {
186
-
flex: 1;
187
-
min-width: 0;
188
}
189
190
.annotation-avatar-link {
191
text-decoration: none;
192
}
193
194
.annotation-author-row {
···
201
.annotation-author {
202
font-weight: 600;
203
color: var(--text-primary);
204
}
205
206
.annotation-handle {
207
-
font-size: 0.9rem;
208
color: var(--text-tertiary);
209
text-decoration: none;
210
}
211
212
.annotation-handle:hover {
213
color: var(--accent);
214
-
text-decoration: underline;
215
}
216
217
.annotation-time {
218
-
font-size: 0.85rem;
219
color: var(--text-tertiary);
220
}
221
222
.annotation-source {
223
-
display: block;
224
-
font-size: 0.85rem;
225
color: var(--text-tertiary);
226
text-decoration: none;
227
-
margin-bottom: 8px;
228
}
229
230
.annotation-source:hover {
231
-
color: var(--accent);
232
}
233
234
.annotation-source-title {
235
color: var(--text-secondary);
236
}
237
238
.annotation-highlight {
239
display: block;
240
-
padding: 12px 16px;
241
background: linear-gradient(
242
135deg,
243
-
rgba(79, 70, 229, 0.05),
244
-
rgba(168, 85, 247, 0.05)
245
);
246
border-left: 3px solid var(--accent);
247
-
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
248
text-decoration: none;
249
-
transition: all 0.15s ease;
250
-
margin-bottom: 12px;
251
}
252
253
.annotation-highlight:hover {
254
background: linear-gradient(
255
135deg,
256
-
rgba(79, 70, 229, 0.1),
257
-
rgba(168, 85, 247, 0.1)
258
);
259
}
260
261
.annotation-highlight mark {
262
background: transparent;
263
color: var(--text-primary);
264
font-style: italic;
265
-
font-size: 0.95rem;
266
}
267
268
.annotation-text {
269
font-size: 1rem;
270
line-height: 1.65;
271
color: var(--text-primary);
272
}
273
274
.annotation-actions {
275
display: flex;
276
align-items: center;
277
-
gap: 16px;
278
-
padding-top: 8px;
279
}
280
281
.annotation-action {
···
284
gap: 6px;
285
color: var(--text-tertiary);
286
font-size: 0.85rem;
287
padding: 6px 10px;
288
-
border-radius: var(--radius-sm);
289
-
transition: all 0.15s ease;
290
}
291
292
.annotation-action:hover {
293
color: var(--text-secondary);
294
-
background: var(--bg-tertiary);
295
}
296
297
.annotation-action.liked {
298
color: #ef4444;
299
}
300
301
.annotation-delete {
302
background: none;
303
border: none;
304
cursor: pointer;
305
-
padding: 6px 8px;
306
font-size: 1rem;
307
color: var(--text-tertiary);
308
-
transition: all 0.15s ease;
309
-
border-radius: var(--radius-sm);
310
}
311
312
.annotation-delete:hover {
313
color: var(--error);
314
background: rgba(239, 68, 68, 0.1);
315
}
316
317
.annotation-delete:disabled {
···
1043
border-bottom-color: var(--accent);
1044
}
1045
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
.bookmark-description {
1075
font-size: 0.9rem;
1076
color: var(--text-secondary);
···
1368
color: var(--text-tertiary);
1369
}
1370
1371
.composer-footer {
1372
display: flex;
1373
justify-content: space-between;
···
1393
border-radius: var(--radius-md);
1394
color: var(--error);
1395
font-size: 0.9rem;
1396
}
1397
1398
.annotation-detail-page {
···
2929
padding: 1rem;
2930
}
2931
2932
.bookmark-card {
2933
display: flex;
2934
flex-direction: column;
2935
-
gap: 12px;
2936
}
2937
2938
.bookmark-preview {
2939
display: flex;
2940
-
align-items: stretch;
2941
-
gap: 16px;
2942
-
padding: 14px 16px;
2943
background: var(--bg-secondary);
2944
border: 1px solid var(--border);
2945
border-radius: var(--radius-md);
2946
text-decoration: none;
2947
transition: all 0.2s ease;
2948
}
2949
2950
.bookmark-preview:hover {
···
140
background: var(--bg-card);
141
border: 1px solid var(--border);
142
border-radius: var(--radius-lg);
143
+
padding: 24px;
144
transition: all 0.2s ease;
145
+
position: relative;
146
}
147
148
.card:hover {
149
border-color: var(--border-hover);
150
+
box-shadow: var(--shadow-md);
151
+
transform: translateY(-1px);
152
}
153
154
.annotation-card {
155
display: flex;
156
flex-direction: column;
157
+
gap: 16px;
158
}
159
160
.annotation-header {
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;
169
align-items: center;
170
gap: 12px;
171
+
flex: 1;
172
+
min-width: 0;
173
}
174
175
.annotation-avatar {
176
+
width: 40px;
177
+
height: 40px;
178
+
min-width: 40px;
179
border-radius: var(--radius-full);
180
background: linear-gradient(135deg, var(--accent), #a855f7);
181
display: flex;
182
align-items: center;
183
justify-content: center;
184
font-weight: 600;
185
+
font-size: 0.95rem;
186
color: white;
187
overflow: hidden;
188
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
189
}
190
191
.annotation-avatar img {
···
195
}
196
197
.annotation-meta {
198
+
display: flex;
199
+
flex-direction: column;
200
+
justify-content: center;
201
+
line-height: 1.3;
202
}
203
204
.annotation-avatar-link {
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);
212
}
213
214
.annotation-author-row {
···
221
.annotation-author {
222
font-weight: 600;
223
color: var(--text-primary);
224
+
font-size: 0.95rem;
225
}
226
227
.annotation-handle {
228
+
font-size: 0.85rem;
229
color: var(--text-tertiary);
230
text-decoration: none;
231
+
display: flex;
232
+
align-items: center;
233
+
gap: 3px;
234
}
235
236
.annotation-handle:hover {
237
color: var(--accent);
238
}
239
240
.annotation-time {
241
+
font-size: 0.8rem;
242
color: var(--text-tertiary);
243
+
}
244
+
245
+
.annotation-content {
246
+
display: flex;
247
+
flex-direction: column;
248
+
gap: 12px;
249
}
250
251
.annotation-source {
252
+
display: inline-flex;
253
+
align-items: center;
254
+
gap: 6px;
255
+
font-size: 0.8rem;
256
color: var(--text-tertiary);
257
text-decoration: none;
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;
267
}
268
269
.annotation-source:hover {
270
+
color: var(--text-primary);
271
+
background: var(--bg-hover);
272
}
273
274
.annotation-source-title {
275
color: var(--text-secondary);
276
+
opacity: 0.8;
277
}
278
279
.annotation-highlight {
280
display: block;
281
+
position: relative;
282
+
padding: 16px 20px;
283
background: linear-gradient(
284
135deg,
285
+
rgba(79, 70, 229, 0.03),
286
+
rgba(168, 85, 247, 0.03)
287
);
288
border-left: 3px solid var(--accent);
289
+
border-radius: 4px var(--radius-md) var(--radius-md) 4px;
290
text-decoration: none;
291
+
transition: all 0.2s ease;
292
+
margin: 4px 0;
293
}
294
295
.annotation-highlight:hover {
296
background: linear-gradient(
297
135deg,
298
+
rgba(79, 70, 229, 0.08),
299
+
rgba(168, 85, 247, 0.08)
300
);
301
+
transform: translateX(2px);
302
}
303
304
.annotation-highlight mark {
305
background: transparent;
306
color: var(--text-primary);
307
font-style: italic;
308
+
font-size: 1.05rem;
309
+
line-height: 1.6;
310
+
font-weight: 400;
311
+
display: inline;
312
}
313
314
.annotation-text {
315
font-size: 1rem;
316
line-height: 1.65;
317
color: var(--text-primary);
318
+
white-space: pre-wrap;
319
}
320
321
.annotation-actions {
322
display: flex;
323
align-items: center;
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;
334
}
335
336
.annotation-action {
···
339
gap: 6px;
340
color: var(--text-tertiary);
341
font-size: 0.85rem;
342
+
font-weight: 500;
343
padding: 6px 10px;
344
+
border-radius: var(--radius-md);
345
+
transition: all 0.2s ease;
346
+
background: transparent;
347
+
cursor: pointer;
348
}
349
350
.annotation-action:hover {
351
color: var(--text-secondary);
352
+
background: var(--bg-elevated);
353
}
354
355
.annotation-action.liked {
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;
371
}
372
373
.annotation-delete {
374
background: none;
375
border: none;
376
cursor: pointer;
377
+
padding: 8px;
378
font-size: 1rem;
379
color: var(--text-tertiary);
380
+
transition: all 0.2s ease;
381
+
border-radius: var(--radius-md);
382
+
opacity: 0.6;
383
}
384
385
.annotation-delete:hover {
386
color: var(--error);
387
background: rgba(239, 68, 68, 0.1);
388
+
opacity: 1;
389
}
390
391
.annotation-delete:disabled {
···
1117
border-bottom-color: var(--accent);
1118
}
1119
1120
.bookmark-description {
1121
font-size: 0.9rem;
1122
color: var(--text-secondary);
···
1414
color: var(--text-tertiary);
1415
}
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
+
1442
.composer-footer {
1443
display: flex;
1444
justify-content: space-between;
···
1464
border-radius: var(--radius-md);
1465
color: var(--error);
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);
1523
}
1524
1525
.annotation-detail-page {
···
3056
padding: 1rem;
3057
}
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
+
3107
.bookmark-card {
3108
display: flex;
3109
flex-direction: column;
3110
+
gap: 16px;
3111
}
3112
3113
.bookmark-preview {
3114
display: flex;
3115
+
flex-direction: column;
3116
background: var(--bg-secondary);
3117
border: 1px solid var(--border);
3118
border-radius: var(--radius-md);
3119
+
overflow: hidden;
3120
text-decoration: none;
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;
3182
}
3183
3184
.bookmark-preview:hover {
+121
-62
web/src/pages/AnnotationDetail.jsx
+121
-62
web/src/pages/AnnotationDetail.jsx
···
1
import { useState, useEffect } from "react";
2
-
import { useParams, Link } from "react-router-dom";
3
-
import AnnotationCard from "../components/AnnotationCard";
4
import ReplyList from "../components/ReplyList";
5
import {
6
getAnnotation,
7
getReplies,
8
createReply,
9
deleteReply,
10
} from "../api/client";
11
import { useAuth } from "../context/AuthContext";
12
import { MessageSquare } from "lucide-react";
13
14
export default function AnnotationDetail() {
15
-
const { uri, did, rkey } = useParams();
16
const { isAuthenticated, user } = useAuth();
17
const [annotation, setAnnotation] = useState(null);
18
const [replies, setReplies] = useState([]);
···
23
const [posting, setPosting] = useState(false);
24
const [replyingTo, setReplyingTo] = useState(null);
25
26
-
const annotationUri = uri || `at://${did}/at.margin.annotation/${rkey}`;
27
28
const refreshReplies = async () => {
29
-
const repliesData = await getReplies(annotationUri);
30
setReplies(repliesData.items || []);
31
};
32
33
useEffect(() => {
34
async function fetchData() {
35
try {
36
setLoading(true);
37
const [annData, repliesData] = await Promise.all([
38
-
getAnnotation(annotationUri),
39
-
getReplies(annotationUri).catch(() => ({ items: [] })),
40
]);
41
-
setAnnotation(annData);
42
setReplies(repliesData.items || []);
43
} catch (err) {
44
setError(err.message);
···
47
}
48
}
49
fetchData();
50
-
}, [annotationUri]);
51
52
const handleReply = async (e) => {
53
if (e) e.preventDefault();
···
57
setPosting(true);
58
const parentUri = replyingTo
59
? replyingTo.id || replyingTo.uri
60
-
: annotationUri;
61
const parentCid = replyingTo
62
? replyingTo.cid || ""
63
: annotation?.cid || "";
···
65
await createReply({
66
parentUri,
67
parentCid,
68
-
rootUri: annotationUri,
69
rootCid: annotation?.cid || "",
70
text: replyText,
71
});
···
130
</Link>
131
</div>
132
133
-
<AnnotationCard annotation={annotation} />
134
135
-
{}
136
-
<div className="replies-section">
137
-
<h3 className="replies-title">
138
-
<MessageSquare size={18} />
139
-
Replies ({replies.length})
140
-
</h3>
141
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>
151
<button
152
-
onClick={() => setReplyingTo(null)}
153
-
className="cancel-reply"
154
>
155
-
ร
156
</button>
157
</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
</div>
180
-
</div>
181
-
)}
182
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>
192
</div>
193
);
194
}
···
1
import { useState, useEffect } from "react";
2
+
import { useParams, Link, useLocation } from "react-router-dom";
3
+
import AnnotationCard, { HighlightCard } from "../components/AnnotationCard";
4
+
import BookmarkCard from "../components/BookmarkCard";
5
import ReplyList from "../components/ReplyList";
6
import {
7
getAnnotation,
8
getReplies,
9
createReply,
10
deleteReply,
11
+
resolveHandle,
12
+
normalizeAnnotation,
13
} from "../api/client";
14
import { useAuth } from "../context/AuthContext";
15
import { MessageSquare } from "lucide-react";
16
17
export default function AnnotationDetail() {
18
+
const { uri, did, rkey, handle, type } = useParams();
19
+
const location = useLocation();
20
const { isAuthenticated, user } = useAuth();
21
const [annotation, setAnnotation] = useState(null);
22
const [replies, setReplies] = useState([]);
···
27
const [posting, setPosting] = useState(false);
28
const [replyingTo, setReplyingTo] = useState(null);
29
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]);
70
71
const refreshReplies = async () => {
72
+
if (!targetUri) return;
73
+
const repliesData = await getReplies(targetUri);
74
setReplies(repliesData.items || []);
75
};
76
77
useEffect(() => {
78
async function fetchData() {
79
+
if (!targetUri) return;
80
+
81
try {
82
setLoading(true);
83
const [annData, repliesData] = await Promise.all([
84
+
getAnnotation(targetUri),
85
+
getReplies(targetUri).catch(() => ({ items: [] })),
86
]);
87
+
setAnnotation(normalizeAnnotation(annData));
88
setReplies(repliesData.items || []);
89
} catch (err) {
90
setError(err.message);
···
93
}
94
}
95
fetchData();
96
+
}, [targetUri]);
97
98
const handleReply = async (e) => {
99
if (e) e.preventDefault();
···
103
setPosting(true);
104
const parentUri = replyingTo
105
? replyingTo.id || replyingTo.uri
106
+
: targetUri;
107
const parentCid = replyingTo
108
? replyingTo.cid || ""
109
: annotation?.cid || "";
···
111
await createReply({
112
parentUri,
113
parentCid,
114
+
rootUri: targetUri,
115
rootCid: annotation?.cid || "",
116
text: replyText,
117
});
···
176
</Link>
177
</div>
178
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
+
)}
192
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>
199
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">
230
<button
231
+
className="btn btn-primary"
232
+
disabled={posting || !replyText.trim()}
233
+
onClick={() => handleReply()}
234
>
235
+
{posting ? "Posting..." : "Reply"}
236
</button>
237
</div>
238
</div>
239
+
)}
240
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
+
)}
251
</div>
252
);
253
}
+52
-39
web/src/pages/CollectionDetail.jsx
+52
-39
web/src/pages/CollectionDetail.jsx
···
6
getCollectionItems,
7
removeItemFromCollection,
8
deleteCollection,
9
} from "../api/client";
10
import { useAuth } from "../context/AuthContext";
11
import CollectionModal from "../components/CollectionModal";
···
15
import ShareMenu from "../components/ShareMenu";
16
17
export default function CollectionDetail() {
18
-
const { rkey, "*": wildcardPath } = useParams();
19
const location = useLocation();
20
const navigate = useNavigate();
21
const { user } = useAuth();
···
27
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
28
29
const searchParams = new URLSearchParams(location.search);
30
-
const authorDid = searchParams.get("author") || user?.did;
31
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;
44
45
const fetchContext = async () => {
46
-
if (!collectionUri || !authorDid) {
47
-
setError("Invalid collection URL");
48
-
setLoading(false);
49
-
return;
50
-
}
51
-
52
try {
53
setLoading(true);
54
const [cols, itemsData] = await Promise.all([
55
-
getCollections(authorDid),
56
-
getCollectionItems(collectionUri),
57
]);
58
59
const found =
60
-
cols.items?.find((c) => c.uri === collectionUri) ||
61
cols.items?.find(
62
-
(c) =>
63
-
collectionUri && c.uri.endsWith(collectionUri.split("/").pop()),
64
);
65
if (!found) {
66
-
console.error(
67
-
"Collection not found. Looking for:",
68
-
collectionUri,
69
-
"Available:",
70
-
cols.items?.map((c) => c.uri),
71
-
);
72
setError("Collection not found");
73
return;
74
}
···
83
};
84
85
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]);
93
94
const handleEditSuccess = () => {
95
fetchContext();
···
171
</div>
172
<div className="collection-detail-actions">
173
<ShareMenu
174
-
customUrl={`${window.location.origin}/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(authorDid)}`}
175
text={`Check out this collection: ${collection.name}`}
176
/>
177
{isOwner && (
···
6
getCollectionItems,
7
removeItemFromCollection,
8
deleteCollection,
9
+
resolveHandle,
10
} from "../api/client";
11
import { useAuth } from "../context/AuthContext";
12
import CollectionModal from "../components/CollectionModal";
···
16
import ShareMenu from "../components/ShareMenu";
17
18
export default function CollectionDetail() {
19
+
const { rkey, handle, "*": wildcardPath } = useParams();
20
const location = useLocation();
21
const navigate = useNavigate();
22
const { user } = useAuth();
···
28
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
29
30
const searchParams = new URLSearchParams(location.search);
31
+
const paramAuthorDid = searchParams.get("author");
32
33
+
const isOwner =
34
+
user?.did &&
35
+
(collection?.creator?.did === user.did || paramAuthorDid === user.did);
36
37
const fetchContext = async () => {
38
try {
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
+
76
const [cols, itemsData] = await Promise.all([
77
+
getCollections(targetDid),
78
+
getCollectionItems(targetUri),
79
]);
80
81
const found =
82
+
cols.items?.find((c) => c.uri === targetUri) ||
83
cols.items?.find(
84
+
(c) => targetUri && c.uri.endsWith(targetUri.split("/").pop()),
85
);
86
+
87
if (!found) {
88
setError("Collection not found");
89
return;
90
}
···
99
};
100
101
useEffect(() => {
102
+
fetchContext();
103
+
}, [rkey, wildcardPath, handle, paramAuthorDid, user?.did]);
104
105
const handleEditSuccess = () => {
106
fetchContext();
···
182
</div>
183
<div className="collection-detail-actions">
184
<ShareMenu
185
+
uri={collection.uri}
186
+
handle={collection.creator?.handle}
187
+
type="Collection"
188
text={`Check out this collection: ${collection.name}`}
189
/>
190
{isOwner && (
+131
-8
web/src/pages/Feed.jsx
+131
-8
web/src/pages/Feed.jsx
···
1
import { useState, useEffect } from "react";
2
import AnnotationCard, { HighlightCard } from "../components/AnnotationCard";
3
import BookmarkCard from "../components/BookmarkCard";
4
import CollectionItemCard from "../components/CollectionItemCard";
5
-
import { getAnnotationFeed } from "../api/client";
6
import { AlertIcon, InboxIcon } from "../components/Icons";
7
8
export default function Feed() {
9
const [annotations, setAnnotations] = useState([]);
10
const [loading, setLoading] = useState(true);
11
const [error, setError] = useState(null);
12
-
const [filter, setFilter] = useState("all");
13
14
useEffect(() => {
15
async function fetchFeed() {
16
try {
17
setLoading(true);
18
-
const data = await getAnnotationFeed();
19
setAnnotations(data.items || []);
20
} catch (err) {
21
setError(err.message);
···
24
}
25
}
26
fetchFeed();
27
-
}, []);
28
29
const filteredAnnotations =
30
-
filter === "all"
31
? annotations
32
: annotations.filter((a) => {
33
if (filter === "commenting")
···
46
<p className="page-description">
47
See what people are annotating, highlighting, and bookmarking
48
</p>
49
</div>
50
51
{}
···
56
>
57
All
58
</button>
59
<button
60
className={`filter-tab ${filter === "commenting" ? "active" : ""}`}
61
onClick={() => setFilter("commenting")}
···
129
item.type === "Highlight" ||
130
item.motivation === "highlighting"
131
) {
132
-
return <HighlightCard key={item.id} highlight={item} />;
133
}
134
if (item.type === "Bookmark" || item.motivation === "bookmarking") {
135
-
return <BookmarkCard key={item.id} bookmark={item} />;
136
}
137
-
return <AnnotationCard key={item.id} annotation={item} />;
138
})}
139
</div>
140
)}
141
</div>
142
);
···
1
import { useState, useEffect } from "react";
2
+
import { useSearchParams } from "react-router-dom";
3
import AnnotationCard, { HighlightCard } from "../components/AnnotationCard";
4
import BookmarkCard from "../components/BookmarkCard";
5
import CollectionItemCard from "../components/CollectionItemCard";
6
+
import { getAnnotationFeed, deleteHighlight } from "../api/client";
7
import { AlertIcon, InboxIcon } from "../components/Icons";
8
+
import { useAuth } from "../context/AuthContext";
9
+
10
+
import AddToCollectionModal from "../components/AddToCollectionModal";
11
12
export default function Feed() {
13
+
const [searchParams, setSearchParams] = useSearchParams();
14
+
const tagFilter = searchParams.get("tag");
15
+
16
+
const [filter, setFilter] = useState(() => {
17
+
return localStorage.getItem("feedFilter") || "all";
18
+
});
19
+
20
const [annotations, setAnnotations] = useState([]);
21
const [loading, setLoading] = useState(true);
22
const [error, setError] = useState(null);
23
+
24
+
useEffect(() => {
25
+
localStorage.setItem("feedFilter", filter);
26
+
}, [filter]);
27
+
28
+
const [collectionModalState, setCollectionModalState] = useState({
29
+
isOpen: false,
30
+
uri: null,
31
+
});
32
+
33
+
const { user } = useAuth();
34
35
useEffect(() => {
36
async function fetchFeed() {
37
try {
38
setLoading(true);
39
+
let creatorDid = "";
40
+
41
+
if (filter === "my-tags") {
42
+
if (user?.did) {
43
+
creatorDid = user.did;
44
+
} else {
45
+
setAnnotations([]);
46
+
setLoading(false);
47
+
return;
48
+
}
49
+
}
50
+
51
+
const data = await getAnnotationFeed(
52
+
50,
53
+
0,
54
+
tagFilter || "",
55
+
creatorDid,
56
+
);
57
setAnnotations(data.items || []);
58
} catch (err) {
59
setError(err.message);
···
62
}
63
}
64
fetchFeed();
65
+
}, [tagFilter, filter, user]);
66
67
const filteredAnnotations =
68
+
filter === "all" || filter === "my-tags"
69
? annotations
70
: annotations.filter((a) => {
71
if (filter === "commenting")
···
84
<p className="page-description">
85
See what people are annotating, highlighting, and bookmarking
86
</p>
87
+
{tagFilter && (
88
+
<div
89
+
style={{
90
+
marginTop: "16px",
91
+
display: "flex",
92
+
alignItems: "center",
93
+
gap: "8px",
94
+
}}
95
+
>
96
+
<span
97
+
style={{ fontSize: "0.9rem", color: "var(--text-secondary)" }}
98
+
>
99
+
Filtering by tag: <strong>#{tagFilter}</strong>
100
+
</span>
101
+
<button
102
+
onClick={() =>
103
+
setSearchParams((prev) => {
104
+
const next = new URLSearchParams(prev);
105
+
next.delete("tag");
106
+
return next;
107
+
})
108
+
}
109
+
className="btn btn-sm"
110
+
style={{ padding: "2px 8px", fontSize: "0.8rem" }}
111
+
>
112
+
Clear
113
+
</button>
114
+
</div>
115
+
)}
116
</div>
117
118
{}
···
123
>
124
All
125
</button>
126
+
{user && (
127
+
<button
128
+
className={`filter-tab ${filter === "my-tags" ? "active" : ""}`}
129
+
onClick={() => setFilter("my-tags")}
130
+
>
131
+
My Feed
132
+
</button>
133
+
)}
134
<button
135
className={`filter-tab ${filter === "commenting" ? "active" : ""}`}
136
onClick={() => setFilter("commenting")}
···
204
item.type === "Highlight" ||
205
item.motivation === "highlighting"
206
) {
207
+
return (
208
+
<HighlightCard
209
+
key={item.id}
210
+
highlight={item}
211
+
onDelete={async (uri) => {
212
+
const rkey = uri.split("/").pop();
213
+
await deleteHighlight(rkey);
214
+
setAnnotations((prev) =>
215
+
prev.filter((a) => a.id !== item.id),
216
+
);
217
+
}}
218
+
onAddToCollection={() =>
219
+
setCollectionModalState({
220
+
isOpen: true,
221
+
uri: item.uri || item.id,
222
+
})
223
+
}
224
+
/>
225
+
);
226
}
227
if (item.type === "Bookmark" || item.motivation === "bookmarking") {
228
+
return (
229
+
<BookmarkCard
230
+
key={item.id}
231
+
bookmark={item}
232
+
onAddToCollection={() =>
233
+
setCollectionModalState({
234
+
isOpen: true,
235
+
uri: item.uri || item.id,
236
+
})
237
+
}
238
+
/>
239
+
);
240
}
241
+
return (
242
+
<AnnotationCard
243
+
key={item.id}
244
+
annotation={item}
245
+
onAddToCollection={() =>
246
+
setCollectionModalState({
247
+
isOpen: true,
248
+
uri: item.uri || item.id,
249
+
})
250
+
}
251
+
/>
252
+
);
253
})}
254
</div>
255
+
)}
256
+
257
+
{collectionModalState.isOpen && (
258
+
<AddToCollectionModal
259
+
isOpen={collectionModalState.isOpen}
260
+
onClose={() => setCollectionModalState({ isOpen: false, uri: null })}
261
+
annotationUri={collectionModalState.uri}
262
+
/>
263
)}
264
</div>
265
);
+5
-1
web/src/pages/New.jsx
+5
-1
web/src/pages/New.jsx
+10
-7
web/src/pages/Notifications.jsx
+10
-7
web/src/pages/Notifications.jsx
···
4
import { getNotifications, markNotificationsRead } from "../api/client";
5
import { BellIcon, HeartIcon, ReplyIcon } from "../components/Icons";
6
7
-
function getContentRoute(subjectUri) {
8
-
if (!subjectUri) return "/";
9
-
if (subjectUri.includes("at.margin.bookmark")) {
10
return `/bookmarks`;
11
}
12
-
if (subjectUri.includes("at.margin.highlight")) {
13
return `/highlights`;
14
}
15
-
return `/annotation/${encodeURIComponent(subjectUri)}`;
16
}
17
18
export default function Notifications() {
···
163
{notifications.map((n, i) => (
164
<Link
165
key={n.id || i}
166
-
to={getContentRoute(n.subjectUri)}
167
className="notification-item card"
168
style={{ alignItems: "center" }}
169
>
170
<div
171
className="notification-avatar-container"
172
-
style={{ marginRight: 12 }}
173
>
174
{n.actor?.avatar ? (
175
<img
···
4
import { getNotifications, markNotificationsRead } from "../api/client";
5
import { BellIcon, HeartIcon, ReplyIcon } from "../components/Icons";
6
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")) {
13
return `/bookmarks`;
14
}
15
+
if (n.subjectUri.includes("at.margin.highlight")) {
16
return `/highlights`;
17
}
18
+
return `/annotation/${encodeURIComponent(n.subjectUri)}`;
19
}
20
21
export default function Notifications() {
···
166
{notifications.map((n, i) => (
167
<Link
168
key={n.id || i}
169
+
to={getNotificationRoute(n)}
170
className="notification-item card"
171
style={{ alignItems: "center" }}
172
>
173
<div
174
className="notification-avatar-container"
175
+
style={{ marginRight: 12, position: "relative" }}
176
>
177
{n.actor?.avatar ? (
178
<img
+1
-17
web/src/pages/Profile.jsx
+1
-17
web/src/pages/Profile.jsx
···
130
</div>
131
);
132
}
133
-
return bookmarks.map((b) => <BookmarkCard key={b.id} annotation={b} />);
134
-
}
135
-
if (activeTab === "bookmarks") {
136
-
if (bookmarks.length === 0) {
137
-
return (
138
-
<div className="empty-state">
139
-
<div className="empty-state-icon">
140
-
<BookmarkIcon size={32} />
141
-
</div>
142
-
<h3 className="empty-state-title">No bookmarks</h3>
143
-
<p className="empty-state-text">
144
-
This user hasn't bookmarked any pages.
145
-
</p>
146
-
</div>
147
-
);
148
-
}
149
-
return bookmarks.map((b) => <BookmarkCard key={b.id} annotation={b} />);
150
}
151
152
if (activeTab === "collections") {