diff --git a/handlers.go b/handlers.go index db9ac52..9b1094d 100644 --- a/handlers.go +++ b/handlers.go @@ -124,6 +124,7 @@ type EventPageData struct { Rsvps []db.Rsvp TotalGoing int64 IsAdmin bool + BaseURL string } 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, TotalGoing: totalGoing, IsAdmin: isAdmin, + BaseURL: s.baseURL, }, nil } diff --git a/main.go b/main.go index de7eea8..6a5b83c 100644 --- a/main.go +++ b/main.go @@ -32,18 +32,20 @@ var schemaSQL string var pageTmpl map[string]*template.Template type Server struct { - q *db.Queries - db *sql.DB + q *db.Queries + db *sql.DB + baseURL string // SSE: map of event slug -> set of channels mu sync.Mutex clients map[string]map[chan struct{}]struct{} } -func NewServer(database *sql.DB) *Server { +func NewServer(database *sql.DB, baseURL string) *Server { return &Server{ q: db.New(database), db: database, + baseURL: baseURL, clients: make(map[string]map[chan struct{}]struct{}), } } @@ -90,6 +92,7 @@ func main() { if v := os.Getenv("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") if err != nil { @@ -128,7 +131,7 @@ func main() { template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/slots.html"), ) - srv := NewServer(database) + srv := NewServer(database, baseURL) r := chi.NewRouter() r.Use(middleware.Logger) @@ -149,6 +152,9 @@ func main() { r.Post("/e/{slug}/rsvp", srv.handleRsvp) r.Delete("/e/{slug}/rsvp/{rsvpID}", srv.handleUnrsvp) + // OG image + r.Get("/e/{slug}/og.png", srv.handleOGImage) + // SSE r.Get("/e/{slug}/sse", srv.handleSSE) diff --git a/ogimage.go b/ogimage.go new file mode 100644 index 0000000..6efc58c --- /dev/null +++ b/ogimage.go @@ -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()) +} diff --git a/templates/event.html b/templates/event.html index 3cc2fe8..1d0e1d0 100644 --- a/templates/event.html +++ b/templates/event.html @@ -5,10 +5,14 @@ {{define "meta"}} + + + - + + {{end}} {{define "admin-bar"}}{{if .IsAdmin}}
{{end}}{{end}}