Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: opengraph tweaks

+155 -243
+5
cmd/ogtest/main.go
··· 155 155 BrewerType: "immersion", 156 156 }) 157 157 }}, 158 + 159 + // Site card 160 + {"/tmp/og-site.png", func() (*ogcard.Card, error) { 161 + return ogcard.DrawSiteCard() 162 + }}, 158 163 } 159 164 160 165 for _, tc := range cases {
+4 -41
internal/handlers/brew.go
··· 23 23 24 24 // populateBrewOGMetadata sets OpenGraph metadata on layoutData for a brew page. 25 25 // This enriches social media previews when brew links are shared. 26 - func (h *Handler) populateBrewOGMetadata(layoutData *components.LayoutData, brew *models.Brew, baseURL, shareURL string) { 26 + func (h *Handler) populateBrewOGMetadata(layoutData *components.LayoutData, brew *models.Brew, owner, baseURL, shareURL string) { 27 27 if brew == nil { 28 28 return 29 29 } 30 30 31 - // Build OG title from bean info 32 31 var ogTitle string 33 32 if brew.Bean != nil { 34 - if brew.Bean.Origin != "" { 35 - ogTitle = fmt.Sprintf("%s from %s", brew.Bean.Name, brew.Bean.Origin) 36 - } else { 37 - ogTitle = brew.Bean.Name 38 - } 33 + ogTitle = brew.Bean.Name 39 34 } else { 40 35 ogTitle = "Coffee Brew" 41 36 } 42 37 43 - // Build OG description with rating and tasting notes 44 - var descParts []string 45 - if brew.Rating > 0 { 46 - descParts = append(descParts, fmt.Sprintf("Rated %d/10", brew.Rating)) 47 - } 48 - if brew.TastingNotes != "" { 49 - // Truncate tasting notes if too long 50 - notes := brew.TastingNotes 51 - if len(notes) > 100 { 52 - notes = notes[:97] + "..." 53 - } 54 - descParts = append(descParts, notes) 55 - } 56 - if brew.Bean != nil && brew.Bean.Roaster != nil { 57 - descParts = append(descParts, fmt.Sprintf("Roasted by %s", brew.Bean.Roaster.Name)) 58 - } 59 - 60 - var ogDescription string 61 - if len(descParts) > 0 { 62 - ogDescription = strings.Join(descParts, " · ") 63 - } else { 64 - ogDescription = "A coffee brew tracked on Arabica" 65 - } 66 - 67 - layoutData.OGTitle = ogTitle 68 - layoutData.OGDescription = ogDescription 69 - layoutData.OGType = "article" 70 - 71 - if baseURL != "" && shareURL != "" { 72 - layoutData.OGUrl = baseURL + shareURL 73 - ogImageURL := strings.Replace(shareURL, "?", "/og-image?", 1) 74 - layoutData.OGImage = baseURL + ogImageURL 75 - } 38 + populateOGFields(layoutData, ogTitle, "brew", owner, baseURL, shareURL) 76 39 } 77 40 78 41 // HandleBrewOGImage generates a 1200x630 PNG preview card for a brew. ··· 337 300 338 301 // Create layout data with OpenGraph metadata 339 302 layoutData := h.buildLayoutData(r, "Brew Details", isAuthenticated, didStr, userProfile) 340 - h.populateBrewOGMetadata(layoutData, brew, h.publicBaseURL(r), shareURL) 303 + h.populateBrewOGMetadata(layoutData, brew, owner, h.publicBaseURL(r), shareURL) 341 304 342 305 // Get like data 343 306 var isLiked bool
+18 -125
internal/handlers/entity_views.go
··· 206 206 } 207 207 208 208 layoutData := h.buildLayoutData(r, beanViewProps.Bean.Name, isAuthenticated, didStr, userProfile) 209 - h.populateBeanOGMetadata(layoutData, beanViewProps.Bean, h.publicBaseURL(r), shareURL) 209 + h.populateBeanOGMetadata(layoutData, beanViewProps.Bean, owner, h.publicBaseURL(r), shareURL) 210 210 211 211 sd := h.fetchSocialData(r.Context(), subjectURI, didStr, isAuthenticated) 212 212 ··· 338 338 } 339 339 340 340 layoutData := h.buildLayoutData(r, props.Roaster.Name, isAuthenticated, didStr, userProfile) 341 - h.populateRoasterOGMetadata(layoutData, props.Roaster, h.publicBaseURL(r), shareURL) 341 + h.populateRoasterOGMetadata(layoutData, props.Roaster, owner, h.publicBaseURL(r), shareURL) 342 342 343 343 sd := h.fetchSocialData(r.Context(), subjectURI, didStr, isAuthenticated) 344 344 ··· 470 470 } 471 471 472 472 layoutData := h.buildLayoutData(r, props.Grinder.Name, isAuthenticated, didStr, userProfile) 473 - h.populateGrinderOGMetadata(layoutData, props.Grinder, h.publicBaseURL(r), shareURL) 473 + h.populateGrinderOGMetadata(layoutData, props.Grinder, owner, h.publicBaseURL(r), shareURL) 474 474 475 475 sd := h.fetchSocialData(r.Context(), subjectURI, didStr, isAuthenticated) 476 476 ··· 602 602 } 603 603 604 604 layoutData := h.buildLayoutData(r, props.Brewer.Name, isAuthenticated, didStr, userProfile) 605 - h.populateBrewerOGMetadata(layoutData, props.Brewer, h.publicBaseURL(r), shareURL) 605 + h.populateBrewerOGMetadata(layoutData, props.Brewer, owner, h.publicBaseURL(r), shareURL) 606 606 607 607 sd := h.fetchSocialData(r.Context(), subjectURI, didStr, isAuthenticated) 608 608 ··· 769 769 } 770 770 771 771 layoutData := h.buildLayoutData(r, props.Recipe.Name, isAuthenticated, didStr, userProfile) 772 - h.populateRecipeOGMetadata(layoutData, props.Recipe, h.publicBaseURL(r), shareURL) 772 + h.populateRecipeOGMetadata(layoutData, props.Recipe, owner, h.publicBaseURL(r), shareURL) 773 773 774 774 sd := h.fetchSocialData(r.Context(), subjectURI, didStr, isAuthenticated) 775 775 ··· 1143 1143 1144 1144 // OG metadata helpers for entity types 1145 1145 1146 - func (h *Handler) populateBeanOGMetadata(layoutData *components.LayoutData, bean *models.Bean, baseURL, shareURL string) { 1146 + func (h *Handler) populateBeanOGMetadata(layoutData *components.LayoutData, bean *models.Bean, owner, baseURL, shareURL string) { 1147 1147 if bean == nil { 1148 1148 return 1149 1149 } 1150 - 1151 - ogTitle := bean.Name 1152 - if ogTitle == "" { 1153 - ogTitle = bean.Origin 1154 - } 1155 - 1156 - var descParts []string 1157 - if bean.Origin != "" { 1158 - descParts = append(descParts, "Origin: "+bean.Origin) 1159 - } 1160 - if bean.RoastLevel != "" { 1161 - descParts = append(descParts, "Roast: "+bean.RoastLevel) 1162 - } 1163 - if bean.Roaster != nil { 1164 - descParts = append(descParts, "by "+bean.Roaster.Name) 1165 - } 1166 - 1167 - var ogDescription string 1168 - if len(descParts) > 0 { 1169 - ogDescription = strings.Join(descParts, " · ") 1170 - } else { 1171 - ogDescription = "coffee bean" 1172 - } 1173 - 1174 - layoutData.OGTitle = ogTitle 1175 - layoutData.OGDescription = ogDescription 1176 - layoutData.OGType = "article" 1177 - if baseURL != "" && shareURL != "" { 1178 - layoutData.OGUrl = baseURL + shareURL 1179 - layoutData.OGImage = baseURL + strings.Replace(shareURL, "?", "/og-image?", 1) 1150 + title := bean.Name 1151 + if title == "" { 1152 + title = bean.Origin 1180 1153 } 1154 + populateOGFields(layoutData, title, "bean", owner, baseURL, shareURL) 1181 1155 } 1182 1156 1183 - func (h *Handler) populateRoasterOGMetadata(layoutData *components.LayoutData, roaster *models.Roaster, baseURL, shareURL string) { 1157 + func (h *Handler) populateRoasterOGMetadata(layoutData *components.LayoutData, roaster *models.Roaster, owner, baseURL, shareURL string) { 1184 1158 if roaster == nil { 1185 1159 return 1186 1160 } 1187 - 1188 - var descParts []string 1189 - if roaster.Location != "" { 1190 - descParts = append(descParts, roaster.Location) 1191 - } 1192 - 1193 - var ogDescription string 1194 - if len(descParts) > 0 { 1195 - ogDescription = strings.Join(descParts, " · ") 1196 - } else { 1197 - ogDescription = "roaster" 1198 - } 1199 - 1200 - layoutData.OGTitle = roaster.Name 1201 - layoutData.OGDescription = ogDescription 1202 - layoutData.OGType = "article" 1203 - if baseURL != "" && shareURL != "" { 1204 - layoutData.OGUrl = baseURL + shareURL 1205 - layoutData.OGImage = baseURL + strings.Replace(shareURL, "?", "/og-image?", 1) 1206 - } 1161 + populateOGFields(layoutData, roaster.Name, "roaster", owner, baseURL, shareURL) 1207 1162 } 1208 1163 1209 - func (h *Handler) populateGrinderOGMetadata(layoutData *components.LayoutData, grinder *models.Grinder, baseURL, shareURL string) { 1164 + func (h *Handler) populateGrinderOGMetadata(layoutData *components.LayoutData, grinder *models.Grinder, owner, baseURL, shareURL string) { 1210 1165 if grinder == nil { 1211 1166 return 1212 1167 } 1213 - 1214 - var descParts []string 1215 - if grinder.GrinderType != "" { 1216 - descParts = append(descParts, grinder.GrinderType) 1217 - } 1218 - if grinder.BurrType != "" { 1219 - descParts = append(descParts, grinder.BurrType+" burrs") 1220 - } 1221 - 1222 - var ogDescription string 1223 - if len(descParts) > 0 { 1224 - ogDescription = strings.Join(descParts, " · ") 1225 - } else { 1226 - ogDescription = "grinder" 1227 - } 1228 - 1229 - layoutData.OGTitle = grinder.Name 1230 - layoutData.OGDescription = ogDescription 1231 - layoutData.OGType = "article" 1232 - if baseURL != "" && shareURL != "" { 1233 - layoutData.OGUrl = baseURL + shareURL 1234 - layoutData.OGImage = baseURL + strings.Replace(shareURL, "?", "/og-image?", 1) 1235 - } 1168 + populateOGFields(layoutData, grinder.Name, "grinder", owner, baseURL, shareURL) 1236 1169 } 1237 1170 1238 - func (h *Handler) populateBrewerOGMetadata(layoutData *components.LayoutData, brewer *models.Brewer, baseURL, shareURL string) { 1171 + func (h *Handler) populateBrewerOGMetadata(layoutData *components.LayoutData, brewer *models.Brewer, owner, baseURL, shareURL string) { 1239 1172 if brewer == nil { 1240 1173 return 1241 1174 } 1242 - 1243 - var descParts []string 1244 - if brewer.BrewerType != "" { 1245 - descParts = append(descParts, brewer.BrewerType) 1246 - } 1247 - 1248 - var ogDescription string 1249 - if len(descParts) > 0 { 1250 - ogDescription = strings.Join(descParts, " · ") 1251 - } else { 1252 - ogDescription = "brewer" 1253 - } 1254 - 1255 - layoutData.OGTitle = brewer.Name 1256 - layoutData.OGDescription = ogDescription 1257 - layoutData.OGType = "article" 1258 - if baseURL != "" && shareURL != "" { 1259 - layoutData.OGUrl = baseURL + shareURL 1260 - layoutData.OGImage = baseURL + strings.Replace(shareURL, "?", "/og-image?", 1) 1261 - } 1175 + populateOGFields(layoutData, brewer.Name, "brewer", owner, baseURL, shareURL) 1262 1176 } 1263 1177 1264 - func (h *Handler) populateRecipeOGMetadata(layoutData *components.LayoutData, recipe *models.Recipe, baseURL, shareURL string) { 1178 + func (h *Handler) populateRecipeOGMetadata(layoutData *components.LayoutData, recipe *models.Recipe, owner, baseURL, shareURL string) { 1265 1179 if recipe == nil { 1266 1180 return 1267 1181 } 1268 - 1269 - var descParts []string 1270 - if recipe.CoffeeAmount > 0 { 1271 - descParts = append(descParts, fmt.Sprintf("%.0fg coffee", recipe.CoffeeAmount)) 1272 - } 1273 - if recipe.WaterAmount > 0 { 1274 - descParts = append(descParts, fmt.Sprintf("%.0fg water", recipe.WaterAmount)) 1275 - } 1276 - var ogDescription string 1277 - if len(descParts) > 0 { 1278 - ogDescription = strings.Join(descParts, " · ") 1279 - } else { 1280 - ogDescription = "coffee recipe" 1281 - } 1282 - 1283 - layoutData.OGTitle = recipe.Name 1284 - layoutData.OGDescription = ogDescription 1285 - layoutData.OGType = "article" 1286 - if baseURL != "" && shareURL != "" { 1287 - layoutData.OGUrl = baseURL + shareURL 1288 - layoutData.OGImage = baseURL + strings.Replace(shareURL, "?", "/og-image?", 1) 1289 - } 1182 + populateOGFields(layoutData, recipe.Name, "recipe", owner, baseURL, shareURL) 1290 1183 }
+26
internal/handlers/feed.go
··· 10 10 "arabica/internal/metrics" 11 11 "arabica/internal/models" 12 12 "arabica/internal/moderation" 13 + "arabica/internal/ogcard" 13 14 "arabica/internal/web/components" 14 15 "arabica/internal/web/pages" 15 16 ··· 58 59 func (h *Handler) HandleHome(w http.ResponseWriter, r *http.Request) { 59 60 layoutData, didStr, isAuthenticated := h.layoutDataFromRequest(r, "Home") 60 61 62 + // Set OG metadata for the home page 63 + layoutData.OGTitle = "Arabica" 64 + layoutData.OGDescription = "Coffee brew tracking on the AT Protocol. Your data, your PDS, your coffee." 65 + baseURL := h.publicBaseURL(r) 66 + if baseURL != "" { 67 + layoutData.OGImage = baseURL + "/og-image" 68 + layoutData.OGUrl = baseURL + "/" 69 + } 70 + 61 71 // Create home props 62 72 homeProps := pages.HomeProps{ 63 73 IsAuthenticated: isAuthenticated, ··· 68 78 if err := pages.Home(layoutData, homeProps).Render(r.Context(), w); err != nil { 69 79 http.Error(w, "Failed to render page", http.StatusInternalServerError) 70 80 log.Error().Err(err).Msg("Failed to render home page") 81 + } 82 + } 83 + 84 + // HandleSiteOGImage generates a 1200x630 PNG preview card for the site. 85 + func (h *Handler) HandleSiteOGImage(w http.ResponseWriter, r *http.Request) { 86 + card, err := ogcard.DrawSiteCard() 87 + if err != nil { 88 + log.Error().Err(err).Msg("Failed to generate site OG image") 89 + http.Error(w, "Failed to generate image", http.StatusInternalServerError) 90 + return 91 + } 92 + 93 + w.Header().Set("Content-Type", "image/png") 94 + w.Header().Set("Cache-Control", "public, max-age=86400") 95 + if err := card.EncodePNG(w); err != nil { 96 + log.Error().Err(err).Msg("Failed to encode site OG image") 71 97 } 72 98 } 73 99
+22
internal/handlers/handlers.go
··· 342 342 w.WriteHeader(http.StatusOK) 343 343 } 344 344 345 + // populateOGFields sets the standard OG metadata fields for an entity page. 346 + // The description follows the pattern "{type} from {owner} on arabica.social". 347 + func populateOGFields(layoutData *components.LayoutData, title, recordType, owner, baseURL, shareURL string) { 348 + layoutData.OGTitle = title 349 + layoutData.OGType = "article" 350 + 351 + if owner != "" { 352 + layoutData.OGDescription = fmt.Sprintf("%s from %s on arabica.social", recordType, owner) 353 + } else { 354 + layoutData.OGDescription = fmt.Sprintf("%s on arabica.social", recordType) 355 + } 356 + 357 + if baseURL != "" && shareURL != "" { 358 + layoutData.OGUrl = baseURL + shareURL 359 + if idx := strings.Index(shareURL, "?"); idx >= 0 { 360 + layoutData.OGImage = baseURL + shareURL[:idx] + "/og-image" + shareURL[idx:] 361 + } else { 362 + layoutData.OGImage = baseURL + shareURL + "/og-image" 363 + } 364 + } 365 + } 366 + 345 367 // publicBaseURL returns the public-facing base URL for constructing absolute URLs. 346 368 // It prefers the configured PublicURL, falling back to deriving it from the request. 347 369 func (h *Handler) publicBaseURL(r *http.Request) string {
+36 -77
internal/handlers/handlers_test.go
··· 450 450 tests := []struct { 451 451 name string 452 452 brew *models.Brew 453 + owner string 453 454 shareURL string 454 455 publicURL string 455 456 wantTitle string 456 457 wantDescription string 457 458 wantType string 458 459 wantURL string 460 + wantImage string 459 461 }{ 460 462 { 461 - name: "nil brew", 462 - brew: nil, 463 - shareURL: "/brews/123?owner=test", 464 - publicURL: "https://arabica.example.com", 465 - wantTitle: "", // unchanged 466 - wantDescription: "", // unchanged 467 - wantType: "", // unchanged 468 - wantURL: "", // unchanged 463 + name: "nil brew", 464 + brew: nil, 465 + owner: "alice.bsky.social", 466 + shareURL: "/brews/123?owner=alice.bsky.social", 467 + publicURL: "https://arabica.example.com", 469 468 }, 470 469 { 471 - name: "brew with bean and origin", 470 + name: "brew with bean", 472 471 brew: &models.Brew{ 473 - Rating: 8, 474 - TastingNotes: "Fruity and bright", 475 - Bean: &models.Bean{ 476 - Name: "Ethiopian Yirgacheffe", 477 - Origin: "Ethiopia", 478 - }, 472 + Bean: &models.Bean{Name: "Ethiopian Yirgacheffe"}, 479 473 }, 480 - shareURL: "/brews/123?owner=test", 474 + owner: "alice.bsky.social", 475 + shareURL: "/brews/123?owner=alice.bsky.social", 481 476 publicURL: "https://arabica.example.com", 482 - wantTitle: "Ethiopian Yirgacheffe from Ethiopia", 483 - wantDescription: "Rated 8/10 · Fruity and bright", 477 + wantTitle: "Ethiopian Yirgacheffe", 478 + wantDescription: "brew from alice.bsky.social on arabica.social", 484 479 wantType: "article", 485 - wantURL: "https://arabica.example.com/brews/123?owner=test", 480 + wantURL: "https://arabica.example.com/brews/123?owner=alice.bsky.social", 481 + wantImage: "https://arabica.example.com/brews/123/og-image?owner=alice.bsky.social", 486 482 }, 487 483 { 488 - name: "brew with bean without origin", 489 - brew: &models.Brew{ 490 - Rating: 7, 491 - Bean: &models.Bean{ 492 - Name: "House Blend", 493 - }, 494 - }, 495 - shareURL: "/brews/456", 496 - publicURL: "https://arabica.example.com", 497 - wantTitle: "House Blend", 498 - wantDescription: "Rated 7/10", 499 - wantType: "article", 500 - wantURL: "https://arabica.example.com/brews/456", 501 - }, 502 - { 503 - name: "brew without bean", 504 - brew: &models.Brew{ 505 - Rating: 5, 506 - }, 507 - shareURL: "/brews/789", 484 + name: "brew without bean", 485 + brew: &models.Brew{}, 486 + owner: "bob.bsky.social", 487 + shareURL: "/brews/789?owner=bob.bsky.social", 508 488 publicURL: "https://arabica.example.com", 509 489 wantTitle: "Coffee Brew", 510 - wantDescription: "Rated 5/10", 511 - wantType: "article", 512 - wantURL: "https://arabica.example.com/brews/789", 513 - }, 514 - { 515 - name: "brew with roaster", 516 - brew: &models.Brew{ 517 - TastingNotes: "Chocolatey", 518 - Bean: &models.Bean{ 519 - Name: "Dark Roast", 520 - Origin: "Brazil", 521 - Roaster: &models.Roaster{ 522 - Name: "Local Roasters", 523 - }, 524 - }, 525 - }, 526 - shareURL: "/brews/abc", 527 - publicURL: "https://arabica.example.com", 528 - wantTitle: "Dark Roast from Brazil", 529 - wantDescription: "Chocolatey · Roasted by Local Roasters", 490 + wantDescription: "brew from bob.bsky.social on arabica.social", 530 491 wantType: "article", 531 - wantURL: "https://arabica.example.com/brews/abc", 492 + wantURL: "https://arabica.example.com/brews/789?owner=bob.bsky.social", 493 + wantImage: "https://arabica.example.com/brews/789/og-image?owner=bob.bsky.social", 532 494 }, 533 495 { 534 - name: "no public URL configured", 496 + name: "no public URL", 535 497 brew: &models.Brew{ 536 - Rating: 9, 537 - Bean: &models.Bean{ 538 - Name: "Premium Blend", 539 - }, 498 + Bean: &models.Bean{Name: "Premium Blend"}, 540 499 }, 500 + owner: "alice.bsky.social", 541 501 shareURL: "/brews/xyz", 542 502 publicURL: "", 543 503 wantTitle: "Premium Blend", 544 - wantDescription: "Rated 9/10", 504 + wantDescription: "brew from alice.bsky.social on arabica.social", 545 505 wantType: "article", 546 - wantURL: "", // no absolute URL without public URL 547 506 }, 548 507 { 549 - name: "long tasting notes truncated", 508 + name: "no owner", 550 509 brew: &models.Brew{ 551 - TastingNotes: strings.Repeat("This is a very long tasting note that should be truncated. ", 5), 552 - Bean: &models.Bean{ 553 - Name: "Test Bean", 554 - }, 510 + Bean: &models.Bean{Name: "House Blend"}, 555 511 }, 556 - shareURL: "/brews/long", 512 + owner: "", 513 + shareURL: "/brews/456", 557 514 publicURL: "https://arabica.example.com", 558 - wantTitle: "Test Bean", 559 - wantDescription: "This is a very long tasting note that should be truncated. This is a very long tasting note that ...", 515 + wantTitle: "House Blend", 516 + wantDescription: "brew on arabica.social", 560 517 wantType: "article", 561 - wantURL: "https://arabica.example.com/brews/long", 518 + wantURL: "https://arabica.example.com/brews/456", 519 + wantImage: "https://arabica.example.com/brews/456/og-image", 562 520 }, 563 521 } 564 522 ··· 571 529 } 572 530 layoutData := &components.LayoutData{} 573 531 574 - h.populateBrewOGMetadata(layoutData, tt.brew, tt.publicURL, tt.shareURL) 532 + h.populateBrewOGMetadata(layoutData, tt.brew, tt.owner, tt.publicURL, tt.shareURL) 575 533 576 534 assert.Equal(t, tt.wantTitle, layoutData.OGTitle) 577 535 assert.Equal(t, tt.wantDescription, layoutData.OGDescription) 578 536 assert.Equal(t, tt.wantType, layoutData.OGType) 579 537 assert.Equal(t, tt.wantURL, layoutData.OGUrl) 538 + assert.Equal(t, tt.wantImage, layoutData.OGImage) 580 539 }) 581 540 } 582 541 }
+43
internal/ogcard/site.go
··· 1 + package ogcard 2 + 3 + // DrawSiteCard generates a 1200x630 OG image for the root site embed. 4 + // It shows the logo prominently with the site name and tagline. 5 + func DrawSiteCard() (*Card, error) { 6 + card, err := NewCard(cardWidth, cardHeight, ColorBg) 7 + if err != nil { 8 + return nil, err 9 + } 10 + 11 + // Left accent stripe (brand color) 12 + card.DrawRect(0, 0, stripeW, cardHeight, ColorBrand) 13 + 14 + // Bottom brand bar 15 + card.DrawRect(0, brandBarY, cardWidth, cardHeight, ColorBrand) 16 + card.DrawBoldText("arabica.social", leftPad, brandBarY+18, ColorWhite, 26) 17 + 18 + // Logo centered in right portion 19 + if logo := GetLogo(); logo != nil { 20 + logoSz := 220 21 + lx := 820 22 + ly := (brandBarY - logoSz) / 2 23 + card.DrawImageScaled(logo, lx, ly, logoSz, logoSz) 24 + } 25 + 26 + // Text content — vertically centered in left area 27 + contentH := 54 + 16 + 40 + 24 + 32 + 30 // title + gap + tagline + gap + divider + detail line 28 + y := (brandBarY - contentH) / 2 29 + 30 + card.DrawBoldText("arabica.social", leftPad, y, ColorDark, 48) 31 + y += 54 + 16 32 + 33 + card.DrawText("coffee brew tracking on the AT Protocol", leftPad, y, ColorBody, 28) 34 + y += 40 + 24 35 + 36 + // Divider 37 + card.DrawRect(leftPad, y, leftPad+120, y+2, ColorDivider) 38 + y += 32 39 + 40 + card.DrawText("your data, your PDS, your coffee", leftPad, y, ColorMuted, 22) 41 + 42 + return card, nil 43 + }
+1
internal/routing/routing.go
··· 70 70 71 71 // Page routes (must come before static files) 72 72 mux.HandleFunc("GET /{$}", h.HandleHome) // {$} means exact match 73 + mux.HandleFunc("GET /og-image", h.HandleSiteOGImage) 73 74 mux.HandleFunc("GET /about", h.HandleAbout) 74 75 mux.HandleFunc("GET /terms", h.HandleTerms) 75 76 mux.HandleFunc("GET /join", h.HandleJoin)