Explore the margin.at codebase, lexicons, and more! margin.at

Compare changes

Choose any two refs to compare.

Changed files
+612 -47
backend
cmd
server
internal
api
web
+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
··· 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
··· 736 736 > 737 737 <HighlightIcon size={14} /> Highlight 738 738 </span> 739 + <ShareMenu uri={data.uri} text={highlightedText} /> 739 740 <button 740 741 className="annotation-action" 741 742 onClick={() => {
+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} />;
+1 -1
web/src/pages/Notifications.jsx
··· 169 169 > 170 170 <div 171 171 className="notification-avatar-container" 172 - style={{ marginRight: 12 }} 172 + style={{ marginRight: 12, position: "relative" }} 173 173 > 174 174 {n.actor?.avatar ? ( 175 175 <img