8b32d98267
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>
175 lines
3.7 KiB
Go
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)
|
|
}
|
|
}
|