Files
bbq/main.go
T
ryan 8b32d98267 Initial commit: potluck signup app
Go + chi + SQLite + HTMX with SSE live updates.
Soft Brutalism design, emoji picker, Docker deploy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-14 22:55:54 -04:00

175 lines
3.7 KiB
Go

package main
import (
"context"
"database/sql"
"embed"
"fmt"
"html/template"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
_ "github.com/mattn/go-sqlite3"
"github.com/ryanchen/bbq/db"
)
//go:embed templates/*.html
var templateFS embed.FS
//go:embed static/*
var staticFS embed.FS
//go:embed schema.sql
var schemaSQL string
var pageTmpl map[string]*template.Template
type Server struct {
q *db.Queries
db *sql.DB
// SSE: map of event slug -> set of channels
mu sync.Mutex
clients map[string]map[chan struct{}]struct{}
}
func NewServer(database *sql.DB) *Server {
return &Server{
q: db.New(database),
db: database,
clients: make(map[string]map[chan struct{}]struct{}),
}
}
func (s *Server) subscribe(slug string) chan struct{} {
s.mu.Lock()
defer s.mu.Unlock()
ch := make(chan struct{}, 1)
if s.clients[slug] == nil {
s.clients[slug] = make(map[chan struct{}]struct{})
}
s.clients[slug][ch] = struct{}{}
return ch
}
func (s *Server) unsubscribe(slug string, ch chan struct{}) {
s.mu.Lock()
defer s.mu.Unlock()
if m, ok := s.clients[slug]; ok {
delete(m, ch)
if len(m) == 0 {
delete(s.clients, slug)
}
}
}
func (s *Server) notify(slug string) {
s.mu.Lock()
defer s.mu.Unlock()
for ch := range s.clients[slug] {
select {
case ch <- struct{}{}:
default:
}
}
}
func main() {
dbPath := "bbq.db"
if v := os.Getenv("BBQ_DB"); v != "" {
dbPath = v
}
port := "8080"
if v := os.Getenv("PORT"); v != "" {
port = v
}
database, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on")
if err != nil {
log.Fatal(err)
}
defer database.Close()
database.SetMaxOpenConns(1)
if _, err := database.Exec(schemaSQL); err != nil {
log.Fatal("schema init: ", err)
}
funcMap := template.FuncMap{
"pct": func(count, max int64) int64 {
if max == 0 {
return 0
}
return (count * 100) / max
},
"sub": func(a, b int64) int64 {
return a - b
},
}
// Parse each page with layout + shared partials
pageTmpl = make(map[string]*template.Template)
shared := []string{"templates/layout.html", "templates/slots.html"}
for _, page := range []string{"home", "event"} {
files := append([]string{"templates/" + page + ".html"}, shared...)
pageTmpl[page] = template.Must(
template.New("").Funcs(funcMap).ParseFS(templateFS, files...),
)
}
// slots-only partial (for HTMX responses)
pageTmpl["slots"] = template.Must(
template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/slots.html"),
)
srv := NewServer(database)
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Static files
r.Handle("/static/*", http.FileServer(http.FS(staticFS)))
// Home / create event
r.Get("/", srv.handleHome)
r.Post("/events", srv.handleCreateEvent)
// Guest event view
r.Get("/e/{slug}", srv.handleEvent)
r.Get("/e/{slug}/slots", srv.handleSlotsPartial)
r.Post("/e/{slug}/claim", srv.handleClaim)
r.Delete("/e/{slug}/claim/{claimID}", srv.handleUnclaim)
// SSE
r.Get("/e/{slug}/sse", srv.handleSSE)
// Admin
r.Get("/e/{slug}/admin/{token}", srv.handleAdmin)
r.Post("/e/{slug}/admin/{token}/slot", srv.handleCreateSlot)
r.Delete("/e/{slug}/admin/{token}/slot/{slotID}", srv.handleDeleteSlot)
server := &http.Server{Addr: ":" + port, Handler: r}
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
server.Shutdown(ctx)
}()
fmt.Printf("Listening on http://localhost:%s\n", port)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}