+1
backend/cmd/server/main.go
+1
backend/cmd/server/main.go
···
97
97
r.Get("/og-image", ogHandler.HandleOGImage)
98
98
r.Get("/annotation/{did}/{rkey}", ogHandler.HandleAnnotationPage)
99
99
r.Get("/at/{did}/{rkey}", ogHandler.HandleAnnotationPage)
100
+
r.Get("/collection/{uri}", ogHandler.HandleCollectionPage)
100
101
101
102
staticDir := getEnv("STATIC_DIR", "../web/dist")
102
103
serveStatic(r, staticDir)
+595
-44
backend/internal/api/og.go
+595
-44
backend/internal/api/og.go
···
101
101
"Bluesky",
102
102
}
103
103
104
+
var lucideToEmoji = map[string]string{
105
+
"folder": "๐",
106
+
"star": "โญ",
107
+
"heart": "โค๏ธ",
108
+
"bookmark": "๐",
109
+
"lightbulb": "๐ก",
110
+
"zap": "โก",
111
+
"coffee": "โ",
112
+
"music": "๐ต",
113
+
"camera": "๐ท",
114
+
"code": "๐ป",
115
+
"globe": "๐",
116
+
"flag": "๐ฉ",
117
+
"tag": "๐ท๏ธ",
118
+
"box": "๐ฆ",
119
+
"archive": "๐๏ธ",
120
+
"file": "๐",
121
+
"image": "๐ผ๏ธ",
122
+
"video": "๐ฌ",
123
+
"mail": "โ๏ธ",
124
+
"pin": "๐",
125
+
"calendar": "๐
",
126
+
"clock": "๐",
127
+
"search": "๐",
128
+
"settings": "โ๏ธ",
129
+
"user": "๐ค",
130
+
"users": "๐ฅ",
131
+
"home": "๐ ",
132
+
"briefcase": "๐ผ",
133
+
"gift": "๐",
134
+
"award": "๐",
135
+
"target": "๐ฏ",
136
+
"trending": "๐",
137
+
"activity": "๐",
138
+
"cpu": "๐ฒ",
139
+
"database": "๐๏ธ",
140
+
"cloud": "โ๏ธ",
141
+
"sun": "โ๏ธ",
142
+
"moon": "๐",
143
+
"flame": "๐ฅ",
144
+
"leaf": "๐",
145
+
}
146
+
147
+
func iconToEmoji(icon string) string {
148
+
if strings.HasPrefix(icon, "icon:") {
149
+
name := strings.TrimPrefix(icon, "icon:")
150
+
if emoji, ok := lucideToEmoji[name]; ok {
151
+
return emoji
152
+
}
153
+
return "๐"
154
+
}
155
+
return icon
156
+
}
157
+
104
158
func isCrawler(userAgent string) bool {
105
159
ua := strings.ToLower(userAgent)
106
160
for _, bot := range crawlerUserAgents {
···
144
198
return
145
199
}
146
200
201
+
highlightURI := fmt.Sprintf("at://%s/at.margin.highlight/%s", did, rkey)
202
+
highlight, err := h.db.GetHighlightByURI(highlightURI)
203
+
if err == nil && highlight != nil {
204
+
h.serveHighlightOG(w, highlight)
205
+
return
206
+
}
207
+
208
+
collectionURI := fmt.Sprintf("at://%s/at.margin.collection/%s", did, rkey)
209
+
collection, err := h.db.GetCollectionByURI(collectionURI)
210
+
if err == nil && collection != nil {
211
+
h.serveCollectionOG(w, collection)
212
+
return
213
+
}
214
+
215
+
h.serveIndexHTML(w, r)
216
+
}
217
+
218
+
func (h *OGHandler) HandleCollectionPage(w http.ResponseWriter, r *http.Request) {
219
+
path := r.URL.Path
220
+
prefix := "/collection/"
221
+
if !strings.HasPrefix(path, prefix) {
222
+
h.serveIndexHTML(w, r)
223
+
return
224
+
}
225
+
226
+
uriParam := strings.TrimPrefix(path, prefix)
227
+
if uriParam == "" {
228
+
h.serveIndexHTML(w, r)
229
+
return
230
+
}
231
+
232
+
uri, err := url.QueryUnescape(uriParam)
233
+
if err != nil {
234
+
uri = uriParam
235
+
}
236
+
237
+
if !isCrawler(r.UserAgent()) {
238
+
h.serveIndexHTML(w, r)
239
+
return
240
+
}
241
+
242
+
collection, err := h.db.GetCollectionByURI(uri)
243
+
if err == nil && collection != nil {
244
+
h.serveCollectionOG(w, collection)
245
+
return
246
+
}
247
+
147
248
h.serveIndexHTML(w, r)
148
249
}
149
250
···
232
333
w.Write([]byte(htmlContent))
233
334
}
234
335
336
+
func (h *OGHandler) serveHighlightOG(w http.ResponseWriter, highlight *db.Highlight) {
337
+
title := "Highlight on Margin"
338
+
description := ""
339
+
340
+
if highlight.SelectorJSON != nil && *highlight.SelectorJSON != "" {
341
+
var selector struct {
342
+
Exact string `json:"exact"`
343
+
}
344
+
if err := json.Unmarshal([]byte(*highlight.SelectorJSON), &selector); err == nil && selector.Exact != "" {
345
+
description = fmt.Sprintf("\"%s\"", selector.Exact)
346
+
if len(description) > 200 {
347
+
description = description[:197] + "...\""
348
+
}
349
+
}
350
+
}
351
+
352
+
if highlight.TargetTitle != nil && *highlight.TargetTitle != "" {
353
+
title = fmt.Sprintf("Highlight on: %s", *highlight.TargetTitle)
354
+
if len(title) > 60 {
355
+
title = title[:57] + "..."
356
+
}
357
+
}
358
+
359
+
sourceDomain := ""
360
+
if highlight.TargetSource != "" {
361
+
if parsed, err := url.Parse(highlight.TargetSource); err == nil {
362
+
sourceDomain = parsed.Host
363
+
}
364
+
}
365
+
366
+
authorHandle := highlight.AuthorDID
367
+
profiles := fetchProfilesForDIDs([]string{highlight.AuthorDID})
368
+
if profile, ok := profiles[highlight.AuthorDID]; ok && profile.Handle != "" {
369
+
authorHandle = "@" + profile.Handle
370
+
}
371
+
372
+
if description == "" {
373
+
description = fmt.Sprintf("A highlight by %s", authorHandle)
374
+
if sourceDomain != "" {
375
+
description += fmt.Sprintf(" on %s", sourceDomain)
376
+
}
377
+
}
378
+
379
+
pageURL := fmt.Sprintf("%s/at/%s", h.baseURL, url.PathEscape(highlight.URI[5:]))
380
+
ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(highlight.URI))
381
+
382
+
htmlContent := fmt.Sprintf(`<!DOCTYPE html>
383
+
<html lang="en">
384
+
<head>
385
+
<meta charset="UTF-8">
386
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
387
+
<title>%s - Margin</title>
388
+
<meta name="description" content="%s">
389
+
390
+
<!-- Open Graph -->
391
+
<meta property="og:type" content="article">
392
+
<meta property="og:title" content="%s">
393
+
<meta property="og:description" content="%s">
394
+
<meta property="og:url" content="%s">
395
+
<meta property="og:image" content="%s">
396
+
<meta property="og:image:width" content="1200">
397
+
<meta property="og:image:height" content="630">
398
+
<meta property="og:site_name" content="Margin">
399
+
400
+
<!-- Twitter Card -->
401
+
<meta name="twitter:card" content="summary_large_image">
402
+
<meta name="twitter:title" content="%s">
403
+
<meta name="twitter:description" content="%s">
404
+
<meta name="twitter:image" content="%s">
405
+
406
+
<!-- Author -->
407
+
<meta property="article:author" content="%s">
408
+
409
+
<meta http-equiv="refresh" content="0; url=%s">
410
+
</head>
411
+
<body>
412
+
<p>Redirecting to <a href="%s">%s</a>...</p>
413
+
</body>
414
+
</html>`,
415
+
html.EscapeString(title),
416
+
html.EscapeString(description),
417
+
html.EscapeString(title),
418
+
html.EscapeString(description),
419
+
html.EscapeString(pageURL),
420
+
html.EscapeString(ogImageURL),
421
+
html.EscapeString(title),
422
+
html.EscapeString(description),
423
+
html.EscapeString(ogImageURL),
424
+
html.EscapeString(authorHandle),
425
+
html.EscapeString(pageURL),
426
+
html.EscapeString(pageURL),
427
+
html.EscapeString(title),
428
+
)
429
+
430
+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
431
+
w.Write([]byte(htmlContent))
432
+
}
433
+
434
+
func (h *OGHandler) serveCollectionOG(w http.ResponseWriter, collection *db.Collection) {
435
+
icon := "๐"
436
+
if collection.Icon != nil && *collection.Icon != "" {
437
+
icon = iconToEmoji(*collection.Icon)
438
+
}
439
+
440
+
title := fmt.Sprintf("%s %s", icon, collection.Name)
441
+
description := ""
442
+
if collection.Description != nil && *collection.Description != "" {
443
+
description = *collection.Description
444
+
if len(description) > 200 {
445
+
description = description[:197] + "..."
446
+
}
447
+
}
448
+
449
+
authorHandle := collection.AuthorDID
450
+
var avatarURL string
451
+
profiles := fetchProfilesForDIDs([]string{collection.AuthorDID})
452
+
if profile, ok := profiles[collection.AuthorDID]; ok {
453
+
if profile.Handle != "" {
454
+
authorHandle = "@" + profile.Handle
455
+
}
456
+
if profile.Avatar != "" {
457
+
avatarURL = profile.Avatar
458
+
}
459
+
}
460
+
461
+
if description == "" {
462
+
description = fmt.Sprintf("A collection by %s", authorHandle)
463
+
} else {
464
+
description = fmt.Sprintf("By %s โข %s", authorHandle, description)
465
+
}
466
+
467
+
pageURL := fmt.Sprintf("%s/collection/%s", h.baseURL, url.PathEscape(collection.URI))
468
+
ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(collection.URI))
469
+
470
+
_ = avatarURL
471
+
472
+
htmlContent := fmt.Sprintf(`<!DOCTYPE html>
473
+
<html lang="en">
474
+
<head>
475
+
<meta charset="UTF-8">
476
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
477
+
<title>%s - Margin</title>
478
+
<meta name="description" content="%s">
479
+
480
+
<!-- Open Graph -->
481
+
<meta property="og:type" content="article">
482
+
<meta property="og:title" content="%s">
483
+
<meta property="og:description" content="%s">
484
+
<meta property="og:url" content="%s">
485
+
<meta property="og:image" content="%s">
486
+
<meta property="og:image:width" content="1200">
487
+
<meta property="og:image:height" content="630">
488
+
<meta property="og:site_name" content="Margin">
489
+
490
+
<!-- Twitter Card -->
491
+
<meta name="twitter:card" content="summary_large_image">
492
+
<meta name="twitter:title" content="%s">
493
+
<meta name="twitter:description" content="%s">
494
+
<meta name="twitter:image" content="%s">
495
+
496
+
<!-- Author -->
497
+
<meta property="article:author" content="%s">
498
+
499
+
<meta http-equiv="refresh" content="0; url=%s">
500
+
</head>
501
+
<body>
502
+
<p>Redirecting to <a href="%s">%s</a>...</p>
503
+
</body>
504
+
</html>`,
505
+
html.EscapeString(title),
506
+
html.EscapeString(description),
507
+
html.EscapeString(title),
508
+
html.EscapeString(description),
509
+
html.EscapeString(pageURL),
510
+
html.EscapeString(ogImageURL),
511
+
html.EscapeString(title),
512
+
html.EscapeString(description),
513
+
html.EscapeString(ogImageURL),
514
+
html.EscapeString(authorHandle),
515
+
html.EscapeString(pageURL),
516
+
html.EscapeString(pageURL),
517
+
html.EscapeString(title),
518
+
)
519
+
520
+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
521
+
w.Write([]byte(htmlContent))
522
+
}
523
+
235
524
func (h *OGHandler) serveAnnotationOG(w http.ResponseWriter, annotation *db.Annotation) {
236
525
title := "Annotation on Margin"
237
526
description := ""
···
417
706
}
418
707
}
419
708
} else {
420
-
http.Error(w, "Record not found", http.StatusNotFound)
421
-
return
709
+
highlight, err := h.db.GetHighlightByURI(uri)
710
+
if err == nil && highlight != nil {
711
+
authorHandle = highlight.AuthorDID
712
+
profiles := fetchProfilesForDIDs([]string{highlight.AuthorDID})
713
+
if profile, ok := profiles[highlight.AuthorDID]; ok {
714
+
if profile.Handle != "" {
715
+
authorHandle = "@" + profile.Handle
716
+
}
717
+
if profile.Avatar != "" {
718
+
avatarURL = profile.Avatar
719
+
}
720
+
}
721
+
722
+
targetTitle := ""
723
+
if highlight.TargetTitle != nil {
724
+
targetTitle = *highlight.TargetTitle
725
+
}
726
+
727
+
if highlight.SelectorJSON != nil && *highlight.SelectorJSON != "" {
728
+
var selector struct {
729
+
Exact string `json:"exact"`
730
+
}
731
+
if err := json.Unmarshal([]byte(*highlight.SelectorJSON), &selector); err == nil && selector.Exact != "" {
732
+
quote = selector.Exact
733
+
}
734
+
}
735
+
736
+
if highlight.TargetSource != "" {
737
+
if parsed, err := url.Parse(highlight.TargetSource); err == nil {
738
+
sourceDomain = parsed.Host
739
+
}
740
+
}
741
+
742
+
img := generateHighlightOGImagePNG(authorHandle, targetTitle, quote, sourceDomain, avatarURL)
743
+
744
+
w.Header().Set("Content-Type", "image/png")
745
+
w.Header().Set("Cache-Control", "public, max-age=86400")
746
+
png.Encode(w, img)
747
+
return
748
+
} else {
749
+
collection, err := h.db.GetCollectionByURI(uri)
750
+
if err == nil && collection != nil {
751
+
authorHandle = collection.AuthorDID
752
+
profiles := fetchProfilesForDIDs([]string{collection.AuthorDID})
753
+
if profile, ok := profiles[collection.AuthorDID]; ok {
754
+
if profile.Handle != "" {
755
+
authorHandle = "@" + profile.Handle
756
+
}
757
+
if profile.Avatar != "" {
758
+
avatarURL = profile.Avatar
759
+
}
760
+
}
761
+
762
+
icon := "๐"
763
+
if collection.Icon != nil && *collection.Icon != "" {
764
+
icon = iconToEmoji(*collection.Icon)
765
+
}
766
+
767
+
description := ""
768
+
if collection.Description != nil && *collection.Description != "" {
769
+
description = *collection.Description
770
+
}
771
+
772
+
img := generateCollectionOGImagePNG(authorHandle, collection.Name, description, icon, avatarURL)
773
+
774
+
w.Header().Set("Content-Type", "image/png")
775
+
w.Header().Set("Cache-Control", "public, max-age=86400")
776
+
png.Encode(w, img)
777
+
return
778
+
} else {
779
+
http.Error(w, "Record not found", http.StatusNotFound)
780
+
return
781
+
}
782
+
}
422
783
}
423
784
}
424
785
···
432
793
func generateOGImagePNG(author, text, quote, source, avatarURL string) image.Image {
433
794
width := 1200
434
795
height := 630
435
-
padding := 120
796
+
padding := 100
436
797
437
798
bgPrimary := color.RGBA{12, 10, 20, 255}
438
799
accent := color.RGBA{168, 85, 247, 255}
439
800
textPrimary := color.RGBA{244, 240, 255, 255}
440
801
textSecondary := color.RGBA{168, 158, 200, 255}
441
-
textTertiary := color.RGBA{107, 95, 138, 255}
442
802
border := color.RGBA{45, 38, 64, 255}
443
803
444
804
img := image.NewRGBA(image.Rect(0, 0, width, height))
445
805
446
806
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
-
}
807
+
draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src)
456
808
457
-
avatarSize := 80
809
+
avatarSize := 64
458
810
avatarX := padding
459
-
avatarY := 180
811
+
avatarY := padding
812
+
460
813
avatarImg := fetchAvatarImage(avatarURL)
461
814
if avatarImg != nil {
462
815
drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize)
463
816
} else {
464
817
drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent)
465
818
}
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
819
+
drawText(img, author, avatarX+avatarSize+24, avatarY+42, textSecondary, 28, false)
473
820
474
821
contentWidth := width - (padding * 2)
822
+
yPos := 220
475
823
476
-
if quote != "" {
477
-
if len(quote) > 100 {
478
-
quote = quote[:97] + "..."
479
-
}
824
+
if text != "" {
825
+
textLen := len(text)
826
+
textSize := 32.0
827
+
textLineHeight := 42
828
+
maxTextLines := 5
480
829
481
-
lines := wrapTextToWidth(quote, contentWidth-30, 24)
482
-
numLines := min(len(lines), 2)
483
-
barHeight := numLines*32 + 10
830
+
if textLen > 200 {
831
+
textSize = 28.0
832
+
textLineHeight = 36
833
+
maxTextLines = 6
834
+
}
484
835
485
-
draw.Draw(img, image.Rect(padding, yPos, padding+6, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src)
836
+
lines := wrapTextToWidth(text, contentWidth, int(textSize))
837
+
numLines := min(len(lines), maxTextLines)
486
838
487
-
for i, line := range lines {
488
-
if i >= 2 {
489
-
break
839
+
for i := 0; i < numLines; i++ {
840
+
line := lines[i]
841
+
if i == numLines-1 && len(lines) > numLines {
842
+
line += "..."
490
843
}
491
-
drawText(img, "\""+line+"\"", padding+24, yPos+28+(i*32), textTertiary, 24, true)
844
+
drawText(img, line, padding, yPos+(i*textLineHeight), textPrimary, textSize, false)
492
845
}
493
-
yPos += 30 + (numLines * 32) + 30
846
+
yPos += (numLines * textLineHeight) + 40
494
847
}
495
848
496
-
if text != "" {
497
-
if len(text) > 300 {
498
-
text = text[:297] + "..."
849
+
if quote != "" {
850
+
quoteLen := len(quote)
851
+
quoteSize := 24.0
852
+
quoteLineHeight := 32
853
+
maxQuoteLines := 3
854
+
855
+
if quoteLen > 150 {
856
+
quoteSize = 20.0
857
+
quoteLineHeight = 28
858
+
maxQuoteLines = 4
499
859
}
500
-
lines := wrapTextToWidth(text, contentWidth, 32)
501
-
for i, line := range lines {
502
-
if i >= 6 {
503
-
break
860
+
861
+
lines := wrapTextToWidth(quote, contentWidth-30, int(quoteSize))
862
+
numLines := min(len(lines), maxQuoteLines)
863
+
barHeight := numLines * quoteLineHeight
864
+
865
+
draw.Draw(img, image.Rect(padding, yPos, padding+6, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src)
866
+
867
+
for i := 0; i < numLines; i++ {
868
+
line := lines[i]
869
+
if i == numLines-1 && len(lines) > numLines {
870
+
line += "..."
504
871
}
505
-
drawText(img, line, padding, yPos+(i*42), textPrimary, 32, false)
872
+
drawText(img, line, padding+24, yPos+24+(i*quoteLineHeight), textSecondary, quoteSize, true)
506
873
}
874
+
yPos += barHeight + 40
507
875
}
508
876
509
-
drawText(img, source, padding, 580, textTertiary, 20, false)
877
+
draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src)
878
+
yPos += 40
879
+
drawText(img, source, padding, yPos+32, textSecondary, 24, false)
510
880
511
881
return img
512
882
}
···
662
1032
}
663
1033
return lines
664
1034
}
1035
+
1036
+
func generateCollectionOGImagePNG(author, collectionName, description, icon, avatarURL string) image.Image {
1037
+
width := 1200
1038
+
height := 630
1039
+
padding := 120
1040
+
1041
+
bgPrimary := color.RGBA{12, 10, 20, 255}
1042
+
accent := color.RGBA{168, 85, 247, 255}
1043
+
textPrimary := color.RGBA{244, 240, 255, 255}
1044
+
textSecondary := color.RGBA{168, 158, 200, 255}
1045
+
textTertiary := color.RGBA{107, 95, 138, 255}
1046
+
border := color.RGBA{45, 38, 64, 255}
1047
+
1048
+
img := image.NewRGBA(image.Rect(0, 0, width, height))
1049
+
1050
+
draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src)
1051
+
draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src)
1052
+
1053
+
iconY := 120
1054
+
var iconWidth int
1055
+
if icon != "" {
1056
+
emojiImg := fetchTwemojiImage(icon)
1057
+
if emojiImg != nil {
1058
+
iconSize := 96
1059
+
drawScaledImage(img, emojiImg, padding, iconY, iconSize, iconSize)
1060
+
iconWidth = iconSize + 32
1061
+
} else {
1062
+
drawText(img, icon, padding, iconY+70, textPrimary, 80, true)
1063
+
iconWidth = 100
1064
+
}
1065
+
}
1066
+
1067
+
drawText(img, collectionName, padding+iconWidth, iconY+65, textPrimary, 64, true)
1068
+
1069
+
yPos := 280
1070
+
contentWidth := width - (padding * 2)
1071
+
1072
+
if description != "" {
1073
+
if len(description) > 200 {
1074
+
description = description[:197] + "..."
1075
+
}
1076
+
lines := wrapTextToWidth(description, contentWidth, 32)
1077
+
for i, line := range lines {
1078
+
if i >= 4 {
1079
+
break
1080
+
}
1081
+
drawText(img, line, padding, yPos+(i*42), textSecondary, 32, false)
1082
+
}
1083
+
} else {
1084
+
drawText(img, "A collection on Margin", padding, yPos, textTertiary, 32, false)
1085
+
}
1086
+
1087
+
yPos = 480
1088
+
draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src)
1089
+
1090
+
avatarSize := 64
1091
+
avatarX := padding
1092
+
avatarY := yPos + 40
1093
+
1094
+
avatarImg := fetchAvatarImage(avatarURL)
1095
+
if avatarImg != nil {
1096
+
drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize)
1097
+
} else {
1098
+
drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent)
1099
+
}
1100
+
1101
+
handleX := avatarX + avatarSize + 24
1102
+
drawText(img, author, handleX, avatarY+42, textTertiary, 28, false)
1103
+
1104
+
return img
1105
+
}
1106
+
1107
+
func fetchTwemojiImage(emoji string) image.Image {
1108
+
var codes []string
1109
+
for _, r := range emoji {
1110
+
codes = append(codes, fmt.Sprintf("%x", r))
1111
+
}
1112
+
hexCode := strings.Join(codes, "-")
1113
+
1114
+
url := fmt.Sprintf("https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/%s.png", hexCode)
1115
+
1116
+
resp, err := http.Get(url)
1117
+
if err != nil || resp.StatusCode != 200 {
1118
+
if strings.Contains(hexCode, "-fe0f") {
1119
+
simpleHex := strings.ReplaceAll(hexCode, "-fe0f", "")
1120
+
url = fmt.Sprintf("https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/%s.png", simpleHex)
1121
+
resp, err = http.Get(url)
1122
+
if err != nil || resp.StatusCode != 200 {
1123
+
return nil
1124
+
}
1125
+
} else {
1126
+
return nil
1127
+
}
1128
+
}
1129
+
defer resp.Body.Close()
1130
+
1131
+
img, _, err := image.Decode(resp.Body)
1132
+
if err != nil {
1133
+
return nil
1134
+
}
1135
+
return img
1136
+
}
1137
+
1138
+
func generateHighlightOGImagePNG(author, pageTitle, quote, source, avatarURL string) image.Image {
1139
+
width := 1200
1140
+
height := 630
1141
+
padding := 100
1142
+
1143
+
bgPrimary := color.RGBA{12, 10, 20, 255}
1144
+
accent := color.RGBA{250, 204, 21, 255}
1145
+
textPrimary := color.RGBA{244, 240, 255, 255}
1146
+
textSecondary := color.RGBA{168, 158, 200, 255}
1147
+
border := color.RGBA{45, 38, 64, 255}
1148
+
1149
+
img := image.NewRGBA(image.Rect(0, 0, width, height))
1150
+
1151
+
draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src)
1152
+
draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src)
1153
+
1154
+
avatarSize := 64
1155
+
avatarX := padding
1156
+
avatarY := padding
1157
+
1158
+
avatarImg := fetchAvatarImage(avatarURL)
1159
+
if avatarImg != nil {
1160
+
drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize)
1161
+
} else {
1162
+
drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent)
1163
+
}
1164
+
drawText(img, author, avatarX+avatarSize+24, avatarY+42, textSecondary, 28, false)
1165
+
1166
+
contentWidth := width - (padding * 2)
1167
+
yPos := 220
1168
+
if quote != "" {
1169
+
quoteLen := len(quote)
1170
+
fontSize := 42.0
1171
+
lineHeight := 56
1172
+
maxLines := 4
1173
+
1174
+
if quoteLen > 200 {
1175
+
fontSize = 32.0
1176
+
lineHeight = 44
1177
+
maxLines = 6
1178
+
} else if quoteLen > 100 {
1179
+
fontSize = 36.0
1180
+
lineHeight = 48
1181
+
maxLines = 5
1182
+
}
1183
+
1184
+
lines := wrapTextToWidth(quote, contentWidth-40, int(fontSize))
1185
+
numLines := min(len(lines), maxLines)
1186
+
barHeight := numLines * lineHeight
1187
+
1188
+
draw.Draw(img, image.Rect(padding, yPos, padding+8, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src)
1189
+
1190
+
for i := 0; i < numLines; i++ {
1191
+
line := lines[i]
1192
+
if i == numLines-1 && len(lines) > numLines {
1193
+
line += "..."
1194
+
}
1195
+
drawText(img, line, padding+40, yPos+42+(i*lineHeight), textPrimary, fontSize, false)
1196
+
}
1197
+
yPos += barHeight + 40
1198
+
}
1199
+
1200
+
draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src)
1201
+
yPos += 40
1202
+
1203
+
if pageTitle != "" {
1204
+
if len(pageTitle) > 60 {
1205
+
pageTitle = pageTitle[:57] + "..."
1206
+
}
1207
+
drawText(img, pageTitle, padding, yPos+32, textSecondary, 32, true)
1208
+
}
1209
+
1210
+
if source != "" {
1211
+
drawText(img, source, padding, yPos+80, textSecondary, 24, false)
1212
+
}
1213
+
1214
+
return img
1215
+
}
+1
web/src/components/AnnotationCard.jsx
+1
web/src/components/AnnotationCard.jsx
+14
-2
web/src/pages/Feed.jsx
+14
-2
web/src/pages/Feed.jsx
···
2
2
import AnnotationCard, { HighlightCard } from "../components/AnnotationCard";
3
3
import BookmarkCard from "../components/BookmarkCard";
4
4
import CollectionItemCard from "../components/CollectionItemCard";
5
-
import { getAnnotationFeed } from "../api/client";
5
+
import { getAnnotationFeed, deleteHighlight } from "../api/client";
6
6
import { AlertIcon, InboxIcon } from "../components/Icons";
7
7
8
8
export default function Feed() {
···
129
129
item.type === "Highlight" ||
130
130
item.motivation === "highlighting"
131
131
) {
132
-
return <HighlightCard key={item.id} highlight={item} />;
132
+
return (
133
+
<HighlightCard
134
+
key={item.id}
135
+
highlight={item}
136
+
onDelete={async (uri) => {
137
+
const rkey = uri.split("/").pop();
138
+
await deleteHighlight(rkey);
139
+
setAnnotations((prev) =>
140
+
prev.filter((a) => a.id !== item.id),
141
+
);
142
+
}}
143
+
/>
144
+
);
133
145
}
134
146
if (item.type === "Bookmark" || item.motivation === "bookmarking") {
135
147
return <BookmarkCard key={item.id} bookmark={item} />;