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>
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user