Add dynamic OG image generation for rich iMessage/social previews
Generates a branded 1200x630 PNG per event with title, date/time/location using Go's image package and a custom bitmap font. Supports BBQ_BASE_URL env var for absolute URLs required by link preview crawlers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -124,6 +124,7 @@ type EventPageData struct {
|
|||||||
Rsvps []db.Rsvp
|
Rsvps []db.Rsvp
|
||||||
TotalGoing int64
|
TotalGoing int64
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
|
BaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) {
|
func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) {
|
||||||
@@ -181,6 +182,7 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
|
|||||||
Rsvps: rsvps,
|
Rsvps: rsvps,
|
||||||
TotalGoing: totalGoing,
|
TotalGoing: totalGoing,
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
|
BaseURL: s.baseURL,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,16 +34,18 @@ var pageTmpl map[string]*template.Template
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
q *db.Queries
|
q *db.Queries
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
baseURL string
|
||||||
|
|
||||||
// SSE: map of event slug -> set of channels
|
// SSE: map of event slug -> set of channels
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
clients map[string]map[chan struct{}]struct{}
|
clients map[string]map[chan struct{}]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(database *sql.DB) *Server {
|
func NewServer(database *sql.DB, baseURL string) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
q: db.New(database),
|
q: db.New(database),
|
||||||
db: database,
|
db: database,
|
||||||
|
baseURL: baseURL,
|
||||||
clients: make(map[string]map[chan struct{}]struct{}),
|
clients: make(map[string]map[chan struct{}]struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +92,7 @@ func main() {
|
|||||||
if v := os.Getenv("PORT"); v != "" {
|
if v := os.Getenv("PORT"); v != "" {
|
||||||
port = v
|
port = v
|
||||||
}
|
}
|
||||||
|
baseURL := os.Getenv("BBQ_BASE_URL") // e.g. https://bbq.torrtle.co
|
||||||
|
|
||||||
database, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on")
|
database, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -128,7 +131,7 @@ func main() {
|
|||||||
template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/slots.html"),
|
template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/slots.html"),
|
||||||
)
|
)
|
||||||
|
|
||||||
srv := NewServer(database)
|
srv := NewServer(database, baseURL)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
@@ -149,6 +152,9 @@ func main() {
|
|||||||
r.Post("/e/{slug}/rsvp", srv.handleRsvp)
|
r.Post("/e/{slug}/rsvp", srv.handleRsvp)
|
||||||
r.Delete("/e/{slug}/rsvp/{rsvpID}", srv.handleUnrsvp)
|
r.Delete("/e/{slug}/rsvp/{rsvpID}", srv.handleUnrsvp)
|
||||||
|
|
||||||
|
// OG image
|
||||||
|
r.Get("/e/{slug}/og.png", srv.handleOGImage)
|
||||||
|
|
||||||
// SSE
|
// SSE
|
||||||
r.Get("/e/{slug}/sse", srv.handleSSE)
|
r.Get("/e/{slug}/sse", srv.handleSSE)
|
||||||
|
|
||||||
|
|||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
"image/png"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ogCream = color.RGBA{0xf5, 0xf0, 0xe8, 0xff}
|
||||||
|
ogInk = color.RGBA{0x1a, 0x1a, 0x1a, 0xff}
|
||||||
|
ogYellow = color.RGBA{0xf5, 0xe6, 0x42, 0xff}
|
||||||
|
ogSage = color.RGBA{0xa8, 0xc5, 0xa0, 0xff}
|
||||||
|
ogPeach = color.RGBA{0xf2, 0xa4, 0x82, 0xff}
|
||||||
|
ogWhite = color.RGBA{0xff, 0xff, 0xff, 0xff}
|
||||||
|
)
|
||||||
|
|
||||||
|
func fillRect(img *image.RGBA, x1, y1, x2, y2 int, c color.RGBA) {
|
||||||
|
draw.Draw(img, image.Rect(x1, y1, x2, y2), &image.Uniform{c}, image.Point{}, draw.Src)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5x7 pixel font for uppercase letters, digits, and a few symbols
|
||||||
|
var glyphs = map[rune][7]uint8{
|
||||||
|
'A': {0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001},
|
||||||
|
'B': {0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110},
|
||||||
|
'C': {0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110},
|
||||||
|
'D': {0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110},
|
||||||
|
'E': {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111},
|
||||||
|
'F': {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000},
|
||||||
|
'G': {0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110},
|
||||||
|
'H': {0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001},
|
||||||
|
'I': {0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111},
|
||||||
|
'J': {0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100},
|
||||||
|
'K': {0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001},
|
||||||
|
'L': {0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111},
|
||||||
|
'M': {0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001},
|
||||||
|
'N': {0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001},
|
||||||
|
'O': {0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110},
|
||||||
|
'P': {0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000},
|
||||||
|
'Q': {0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101},
|
||||||
|
'R': {0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001},
|
||||||
|
'S': {0b01110, 0b10001, 0b10000, 0b01110, 0b00001, 0b10001, 0b01110},
|
||||||
|
'T': {0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100},
|
||||||
|
'U': {0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110},
|
||||||
|
'V': {0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100},
|
||||||
|
'W': {0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001},
|
||||||
|
'X': {0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001},
|
||||||
|
'Y': {0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100},
|
||||||
|
'Z': {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111},
|
||||||
|
'0': {0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110},
|
||||||
|
'1': {0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111},
|
||||||
|
'2': {0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111},
|
||||||
|
'3': {0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110},
|
||||||
|
'4': {0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010},
|
||||||
|
'5': {0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110},
|
||||||
|
'6': {0b01110, 0b10000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110},
|
||||||
|
'7': {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000},
|
||||||
|
'8': {0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110},
|
||||||
|
'9': {0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00001, 0b01110},
|
||||||
|
' ': {0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000},
|
||||||
|
'\'': {0b00100, 0b00100, 0b01000, 0b00000, 0b00000, 0b00000, 0b00000},
|
||||||
|
'.': {0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100},
|
||||||
|
',': {0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100, 0b01000},
|
||||||
|
'!': {0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00000, 0b00100},
|
||||||
|
'?': {0b01110, 0b10001, 0b00001, 0b00110, 0b00100, 0b00000, 0b00100},
|
||||||
|
'-': {0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000},
|
||||||
|
'#': {0b01010, 0b01010, 0b11111, 0b01010, 0b11111, 0b01010, 0b01010},
|
||||||
|
'/': {0b00001, 0b00010, 0b00010, 0b00100, 0b01000, 0b01000, 0b10000},
|
||||||
|
':': {0b00000, 0b00100, 0b00000, 0b00000, 0b00000, 0b00100, 0b00000},
|
||||||
|
'@': {0b01110, 0b10001, 0b10111, 0b10101, 0b10110, 0b10000, 0b01110},
|
||||||
|
'+': {0b00000, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0b00000},
|
||||||
|
'&': {0b01100, 0b10010, 0b10100, 0b01000, 0b10101, 0b10010, 0b01101},
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawText(img *image.RGBA, text string, x, y, scale int, c color.RGBA) int {
|
||||||
|
cx := x
|
||||||
|
for _, ch := range text {
|
||||||
|
if ch >= 'a' && ch <= 'z' {
|
||||||
|
ch = ch - 'a' + 'A'
|
||||||
|
}
|
||||||
|
glyph, ok := glyphs[ch]
|
||||||
|
if !ok {
|
||||||
|
cx += 4 * scale // skip unknown chars
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for row := 0; row < 7; row++ {
|
||||||
|
for col := 0; col < 5; col++ {
|
||||||
|
if glyph[row]&(1<<(4-col)) != 0 {
|
||||||
|
fillRect(img, cx+col*scale, y+row*scale, cx+(col+1)*scale, y+(row+1)*scale, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cx += 6 * scale // 5 pixels + 1 spacing
|
||||||
|
}
|
||||||
|
return cx - x
|
||||||
|
}
|
||||||
|
|
||||||
|
func textWidth(text string, scale int) int {
|
||||||
|
n := 0
|
||||||
|
for range text {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n*6*scale - scale
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleOGImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
event, err := s.q.GetEventBySlug(r.Context(), slug)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imgW, imgH := 1200, 630
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, imgW, imgH))
|
||||||
|
|
||||||
|
// Background
|
||||||
|
fillRect(img, 0, 0, imgW, imgH, ogCream)
|
||||||
|
|
||||||
|
// Yellow header bar
|
||||||
|
fillRect(img, 0, 0, imgW, 80, ogYellow)
|
||||||
|
fillRect(img, 0, 78, imgW, 83, ogInk)
|
||||||
|
|
||||||
|
// Logo in header: dark box with emoji-colored inner
|
||||||
|
fillRect(img, 40, 16, 88, 64, ogInk)
|
||||||
|
fillRect(img, 44, 20, 84, 60, ogYellow)
|
||||||
|
drawText(img, "BBQ", 100, 26, 4, ogInk)
|
||||||
|
|
||||||
|
// Center card
|
||||||
|
cardX, cardY := 120, 140
|
||||||
|
cardW, cardH := imgW-240, 400
|
||||||
|
|
||||||
|
// Shadow
|
||||||
|
fillRect(img, cardX+8, cardY+8, cardX+cardW+8, cardY+cardH+8, ogInk)
|
||||||
|
// Border
|
||||||
|
fillRect(img, cardX-3, cardY-3, cardX+cardW+3, cardY+cardH+3, ogInk)
|
||||||
|
// Card
|
||||||
|
fillRect(img, cardX, cardY, cardX+cardW, cardY+cardH, ogWhite)
|
||||||
|
|
||||||
|
// Tag pill
|
||||||
|
tagText := "POTLUCK"
|
||||||
|
tagW := textWidth(tagText, 3) + 24
|
||||||
|
fillRect(img, cardX+30-2, cardY+28-2, cardX+30+tagW+2, cardY+28+28+2, ogInk)
|
||||||
|
fillRect(img, cardX+30, cardY+28, cardX+30+tagW, cardY+28+28, ogSage)
|
||||||
|
drawText(img, tagText, cardX+42, cardY+32, 3, ogInk)
|
||||||
|
|
||||||
|
// Event title - scale to fit
|
||||||
|
title := event.Title
|
||||||
|
scale := 7
|
||||||
|
for scale > 3 {
|
||||||
|
if textWidth(title, scale) <= cardW-80 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
scale--
|
||||||
|
}
|
||||||
|
// Truncate if still too wide
|
||||||
|
for len(title) > 0 && textWidth(title, scale) > cardW-80 {
|
||||||
|
title = title[:len(title)-1]
|
||||||
|
}
|
||||||
|
titleY := cardY + 90
|
||||||
|
drawText(img, title, cardX+38, titleY, scale, ogInk)
|
||||||
|
|
||||||
|
// Date/time/location line
|
||||||
|
meta := ""
|
||||||
|
if event.Date != "" {
|
||||||
|
meta = event.Date
|
||||||
|
}
|
||||||
|
if event.Time != "" {
|
||||||
|
if meta != "" {
|
||||||
|
meta += " "
|
||||||
|
}
|
||||||
|
meta += event.Time
|
||||||
|
}
|
||||||
|
if event.Location != "" {
|
||||||
|
if meta != "" {
|
||||||
|
meta += " - "
|
||||||
|
}
|
||||||
|
meta += event.Location
|
||||||
|
}
|
||||||
|
if meta != "" {
|
||||||
|
metaScale := 3
|
||||||
|
for len(meta) > 0 && textWidth(meta, metaScale) > cardW-80 {
|
||||||
|
meta = meta[:len(meta)-1]
|
||||||
|
}
|
||||||
|
metaY := titleY + 7*scale + 24
|
||||||
|
drawText(img, meta, cardX+38, metaY, metaScale, color.RGBA{0x66, 0x66, 0x66, 0xff})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decorative slot-like bars at the bottom of the card
|
||||||
|
barY := cardY + cardH - 100
|
||||||
|
bars := []struct {
|
||||||
|
w int
|
||||||
|
c color.RGBA
|
||||||
|
}{
|
||||||
|
{320, ogYellow},
|
||||||
|
{240, ogSage},
|
||||||
|
{280, ogPeach},
|
||||||
|
{200, ogYellow},
|
||||||
|
}
|
||||||
|
for i, b := range bars {
|
||||||
|
y := barY + i*22
|
||||||
|
fillRect(img, cardX+38, y, cardX+38+b.w, y+14, b.c)
|
||||||
|
fillRect(img, cardX+38, y, cardX+38+b.w, y+14, b.c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom accent stripe
|
||||||
|
fillRect(img, 0, imgH-14, imgW, imgH-10, ogInk)
|
||||||
|
fillRect(img, 0, imgH-10, imgW, imgH, ogYellow)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
png.Encode(&buf, img)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
w.Write(buf.Bytes())
|
||||||
|
}
|
||||||
@@ -5,10 +5,14 @@
|
|||||||
{{define "meta"}}
|
{{define "meta"}}
|
||||||
<meta property="og:title" content="{{.Event.Title}}">
|
<meta property="og:title" content="{{.Event.Title}}">
|
||||||
<meta property="og:description" content="{{if .Event.Date}}{{.Event.Date}}{{end}}{{if .Event.Time}} at {{.Event.Time}}{{end}}{{if .Event.Location}} — {{.Event.Location}}{{end}} · {{.TotalGoing}} going">
|
<meta property="og:description" content="{{if .Event.Date}}{{.Event.Date}}{{end}}{{if .Event.Time}} at {{.Event.Time}}{{end}}{{if .Event.Location}} — {{.Event.Location}}{{end}} · {{.TotalGoing}} going">
|
||||||
|
<meta property="og:image" content="{{.BaseURL}}/e/{{.Event.Slug}}/og.png">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="{{.Event.Title}}">
|
<meta name="twitter:title" content="{{.Event.Title}}">
|
||||||
<meta name="twitter:description" content="{{if .Event.Date}}{{.Event.Date}}{{end}}{{if .Event.Time}} at {{.Event.Time}}{{end}}{{if .Event.Location}} — {{.Event.Location}}{{end}} · {{.TotalGoing}} going">
|
<meta name="twitter:description" content="{{if .Event.Date}}{{.Event.Date}}{{end}}{{if .Event.Time}} at {{.Event.Time}}{{end}}{{if .Event.Location}} — {{.Event.Location}}{{end}} · {{.TotalGoing}} going">
|
||||||
|
<meta name="twitter:image" content="{{.BaseURL}}/e/{{.Event.Slug}}/og.png">
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "admin-bar"}}{{if .IsAdmin}}<div class="admin-bar">ADMIN VIEW — share the guest link: /e/{{.Event.Slug}}</div>{{end}}{{end}}
|
{{define "admin-bar"}}{{if .IsAdmin}}<div class="admin-bar">ADMIN VIEW — share the guest link: /e/{{.Event.Slug}}</div>{{end}}{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user