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 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, baseURL string) *Server { return &Server{ q: db.New(database), db: database, baseURL: baseURL, 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 } 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 { log.Fatal(err) } defer database.Close() database.SetMaxOpenConns(1) if _, err := database.Exec(schemaSQL); err != nil { log.Fatal("schema init: ", err) } runMigrations(database) 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, baseURL) 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) 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) // Admin r.Get("/e/{slug}/admin/{token}", srv.handleAdmin) r.Post("/e/{slug}/admin/{token}/description", srv.handleUpdateDescription) 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) } }