From 8b32d982676180a63528a245c932b6457e471b2f Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Thu, 14 May 2026 22:55:54 -0400 Subject: [PATCH] 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 --- .dockerignore | 3 + .gitignore | 4 + Dockerfile | 15 ++ compose.yaml | 11 ++ db/db.go | 31 ++++ db/models.go | 37 ++++ db/queries.sql | 53 ++++++ db/queries.sql.go | 350 +++++++++++++++++++++++++++++++++++++ go.mod | 8 + go.sum | 4 + handlers.go | 390 +++++++++++++++++++++++++++++++++++++++++ main.go | 174 ++++++++++++++++++ schema.sql | 30 ++++ sqlc.yaml | 9 + static/.keep | 0 templates/event.html | 81 +++++++++ templates/home.html | 68 ++++++++ templates/layout.html | 398 ++++++++++++++++++++++++++++++++++++++++++ templates/slots.html | 33 ++++ 19 files changed, 1699 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100644 db/db.go create mode 100644 db/models.go create mode 100644 db/queries.sql create mode 100644 db/queries.sql.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handlers.go create mode 100644 main.go create mode 100644 schema.sql create mode 100644 sqlc.yaml create mode 100644 static/.keep create mode 100644 templates/event.html create mode 100644 templates/home.html create mode 100644 templates/layout.html create mode 100644 templates/slots.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fbef9e2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +bbq +bbq.db +.git diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8973df8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bbq +bbq.db +bbq.db-shm +bbq.db-wal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9cd42e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.26-bookworm AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=1 go build -o /bbq . + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=build /bbq /usr/local/bin/bbq +VOLUME /data +ENV BBQ_DB=/data/bbq.db +ENV PORT=8080 +EXPOSE 8080 +CMD ["bbq"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..785989d --- /dev/null +++ b/compose.yaml @@ -0,0 +1,11 @@ +services: + bbq: + build: . + restart: unless-stopped + ports: + - "127.0.0.1:8090:8080" + volumes: + - bbq-data:/data + +volumes: + bbq-data: diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..f43598b --- /dev/null +++ b/db/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/db/models.go b/db/models.go new file mode 100644 index 0000000..4f976f1 --- /dev/null +++ b/db/models.go @@ -0,0 +1,37 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "time" +) + +type Claim struct { + ID int64 + SlotID int64 + Name string + Note string + CreatedAt time.Time +} + +type Event struct { + ID int64 + Slug string + Title string + Date string + Time string + Location string + AdminToken string + CreatedAt time.Time +} + +type Slot struct { + ID int64 + EventID int64 + Name string + Emoji string + MaxClaims int64 + SortOrder int64 +} diff --git a/db/queries.sql b/db/queries.sql new file mode 100644 index 0000000..be6df74 --- /dev/null +++ b/db/queries.sql @@ -0,0 +1,53 @@ +-- name: GetEventBySlug :one +SELECT * FROM events WHERE slug = ?; + +-- name: GetEventByAdminToken :one +SELECT * FROM events WHERE admin_token = ?; + +-- name: CreateEvent :one +INSERT INTO events (slug, title, date, time, location, admin_token) +VALUES (?, ?, ?, ?, ?, ?) +RETURNING *; + +-- name: UpdateEvent :exec +UPDATE events SET title = ?, date = ?, time = ?, location = ? WHERE id = ?; + +-- name: DeleteEvent :exec +DELETE FROM events WHERE id = ?; + +-- name: ListSlots :many +SELECT * FROM slots WHERE event_id = ? ORDER BY sort_order, id; + +-- name: GetSlot :one +SELECT * FROM slots WHERE id = ?; + +-- name: CreateSlot :one +INSERT INTO slots (event_id, name, emoji, max_claims, sort_order) +VALUES (?, ?, ?, ?, ?) +RETURNING *; + +-- name: UpdateSlot :exec +UPDATE slots SET name = ?, emoji = ?, max_claims = ? WHERE id = ?; + +-- name: DeleteSlot :exec +DELETE FROM slots WHERE id = ?; + +-- name: ListClaimsBySlot :many +SELECT * FROM claims WHERE slot_id = ? ORDER BY created_at; + +-- name: ListClaimsByEvent :many +SELECT c.* FROM claims c +JOIN slots s ON c.slot_id = s.id +WHERE s.event_id = ? +ORDER BY c.slot_id, c.created_at; + +-- name: CreateClaim :one +INSERT INTO claims (slot_id, name, note) +VALUES (?, ?, ?) +RETURNING *; + +-- name: DeleteClaim :exec +DELETE FROM claims WHERE id = ?; + +-- name: CountClaimsBySlot :one +SELECT COUNT(*) FROM claims WHERE slot_id = ?; diff --git a/db/queries.sql.go b/db/queries.sql.go new file mode 100644 index 0000000..4d97436 --- /dev/null +++ b/db/queries.sql.go @@ -0,0 +1,350 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: queries.sql + +package db + +import ( + "context" +) + +const countClaimsBySlot = `-- name: CountClaimsBySlot :one +SELECT COUNT(*) FROM claims WHERE slot_id = ? +` + +func (q *Queries) CountClaimsBySlot(ctx context.Context, slotID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countClaimsBySlot, slotID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createClaim = `-- name: CreateClaim :one +INSERT INTO claims (slot_id, name, note) +VALUES (?, ?, ?) +RETURNING id, slot_id, name, note, created_at +` + +type CreateClaimParams struct { + SlotID int64 + Name string + Note string +} + +func (q *Queries) CreateClaim(ctx context.Context, arg CreateClaimParams) (Claim, error) { + row := q.db.QueryRowContext(ctx, createClaim, arg.SlotID, arg.Name, arg.Note) + var i Claim + err := row.Scan( + &i.ID, + &i.SlotID, + &i.Name, + &i.Note, + &i.CreatedAt, + ) + return i, err +} + +const createEvent = `-- name: CreateEvent :one +INSERT INTO events (slug, title, date, time, location, admin_token) +VALUES (?, ?, ?, ?, ?, ?) +RETURNING id, slug, title, date, time, location, admin_token, created_at +` + +type CreateEventParams struct { + Slug string + Title string + Date string + Time string + Location string + AdminToken string +} + +func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) { + row := q.db.QueryRowContext(ctx, createEvent, + arg.Slug, + arg.Title, + arg.Date, + arg.Time, + arg.Location, + arg.AdminToken, + ) + var i Event + err := row.Scan( + &i.ID, + &i.Slug, + &i.Title, + &i.Date, + &i.Time, + &i.Location, + &i.AdminToken, + &i.CreatedAt, + ) + return i, err +} + +const createSlot = `-- name: CreateSlot :one +INSERT INTO slots (event_id, name, emoji, max_claims, sort_order) +VALUES (?, ?, ?, ?, ?) +RETURNING id, event_id, name, emoji, max_claims, sort_order +` + +type CreateSlotParams struct { + EventID int64 + Name string + Emoji string + MaxClaims int64 + SortOrder int64 +} + +func (q *Queries) CreateSlot(ctx context.Context, arg CreateSlotParams) (Slot, error) { + row := q.db.QueryRowContext(ctx, createSlot, + arg.EventID, + arg.Name, + arg.Emoji, + arg.MaxClaims, + arg.SortOrder, + ) + var i Slot + err := row.Scan( + &i.ID, + &i.EventID, + &i.Name, + &i.Emoji, + &i.MaxClaims, + &i.SortOrder, + ) + return i, err +} + +const deleteClaim = `-- name: DeleteClaim :exec +DELETE FROM claims WHERE id = ? +` + +func (q *Queries) DeleteClaim(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteClaim, id) + return err +} + +const deleteEvent = `-- name: DeleteEvent :exec +DELETE FROM events WHERE id = ? +` + +func (q *Queries) DeleteEvent(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteEvent, id) + return err +} + +const deleteSlot = `-- name: DeleteSlot :exec +DELETE FROM slots WHERE id = ? +` + +func (q *Queries) DeleteSlot(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteSlot, id) + return err +} + +const getEventByAdminToken = `-- name: GetEventByAdminToken :one +SELECT id, slug, title, date, time, location, admin_token, created_at FROM events WHERE admin_token = ? +` + +func (q *Queries) GetEventByAdminToken(ctx context.Context, adminToken string) (Event, error) { + row := q.db.QueryRowContext(ctx, getEventByAdminToken, adminToken) + var i Event + err := row.Scan( + &i.ID, + &i.Slug, + &i.Title, + &i.Date, + &i.Time, + &i.Location, + &i.AdminToken, + &i.CreatedAt, + ) + return i, err +} + +const getEventBySlug = `-- name: GetEventBySlug :one +SELECT id, slug, title, date, time, location, admin_token, created_at FROM events WHERE slug = ? +` + +func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error) { + row := q.db.QueryRowContext(ctx, getEventBySlug, slug) + var i Event + err := row.Scan( + &i.ID, + &i.Slug, + &i.Title, + &i.Date, + &i.Time, + &i.Location, + &i.AdminToken, + &i.CreatedAt, + ) + return i, err +} + +const getSlot = `-- name: GetSlot :one +SELECT id, event_id, name, emoji, max_claims, sort_order FROM slots WHERE id = ? +` + +func (q *Queries) GetSlot(ctx context.Context, id int64) (Slot, error) { + row := q.db.QueryRowContext(ctx, getSlot, id) + var i Slot + err := row.Scan( + &i.ID, + &i.EventID, + &i.Name, + &i.Emoji, + &i.MaxClaims, + &i.SortOrder, + ) + return i, err +} + +const listClaimsByEvent = `-- name: ListClaimsByEvent :many +SELECT c.id, c.slot_id, c.name, c.note, c.created_at FROM claims c +JOIN slots s ON c.slot_id = s.id +WHERE s.event_id = ? +ORDER BY c.slot_id, c.created_at +` + +func (q *Queries) ListClaimsByEvent(ctx context.Context, eventID int64) ([]Claim, error) { + rows, err := q.db.QueryContext(ctx, listClaimsByEvent, eventID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Claim + for rows.Next() { + var i Claim + if err := rows.Scan( + &i.ID, + &i.SlotID, + &i.Name, + &i.Note, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listClaimsBySlot = `-- name: ListClaimsBySlot :many +SELECT id, slot_id, name, note, created_at FROM claims WHERE slot_id = ? ORDER BY created_at +` + +func (q *Queries) ListClaimsBySlot(ctx context.Context, slotID int64) ([]Claim, error) { + rows, err := q.db.QueryContext(ctx, listClaimsBySlot, slotID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Claim + for rows.Next() { + var i Claim + if err := rows.Scan( + &i.ID, + &i.SlotID, + &i.Name, + &i.Note, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSlots = `-- name: ListSlots :many +SELECT id, event_id, name, emoji, max_claims, sort_order FROM slots WHERE event_id = ? ORDER BY sort_order, id +` + +func (q *Queries) ListSlots(ctx context.Context, eventID int64) ([]Slot, error) { + rows, err := q.db.QueryContext(ctx, listSlots, eventID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Slot + for rows.Next() { + var i Slot + if err := rows.Scan( + &i.ID, + &i.EventID, + &i.Name, + &i.Emoji, + &i.MaxClaims, + &i.SortOrder, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateEvent = `-- name: UpdateEvent :exec +UPDATE events SET title = ?, date = ?, time = ?, location = ? WHERE id = ? +` + +type UpdateEventParams struct { + Title string + Date string + Time string + Location string + ID int64 +} + +func (q *Queries) UpdateEvent(ctx context.Context, arg UpdateEventParams) error { + _, err := q.db.ExecContext(ctx, updateEvent, + arg.Title, + arg.Date, + arg.Time, + arg.Location, + arg.ID, + ) + return err +} + +const updateSlot = `-- name: UpdateSlot :exec +UPDATE slots SET name = ?, emoji = ?, max_claims = ? WHERE id = ? +` + +type UpdateSlotParams struct { + Name string + Emoji string + MaxClaims int64 + ID int64 +} + +func (q *Queries) UpdateSlot(ctx context.Context, arg UpdateSlotParams) error { + _, err := q.db.ExecContext(ctx, updateSlot, + arg.Name, + arg.Emoji, + arg.MaxClaims, + arg.ID, + ) + return err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6f2ce4a --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/ryanchen/bbq + +go 1.22 + +require ( + github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/mattn/go-sqlite3 v1.14.44 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b61ec51 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..dc5debd --- /dev/null +++ b/handlers.go @@ -0,0 +1,390 @@ +package main + +import ( + "bytes" + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "log" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/ryanchen/bbq/db" +) + +func randomToken() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} + +func randomSlug() string { + b := make([]byte, 4) + rand.Read(b) + return hex.EncodeToString(b) +} + +// --- Home / Create Event --- + +func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { + pageTmpl["home"].ExecuteTemplate(w, "layout", nil) +} + +func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + title := strings.TrimSpace(r.FormValue("title")) + date := strings.TrimSpace(r.FormValue("date")) + time_ := strings.TrimSpace(r.FormValue("time")) + location := strings.TrimSpace(r.FormValue("location")) + + if title == "" { + http.Error(w, "Title is required", http.StatusBadRequest) + return + } + + slug := randomSlug() + token := randomToken() + event, err := s.q.CreateEvent(r.Context(), db.CreateEventParams{ + Slug: slug, Title: title, Date: date, Time: time_, Location: location, AdminToken: token, + }) + if err != nil { + log.Printf("create event: %v", err) + http.Error(w, "Failed to create event", http.StatusInternalServerError) + return + } + + // Parse slot fields: slots like "drinks", "salad", etc. + slotNames := r.Form["slot_name"] + slotEmojis := r.Form["slot_emoji"] + slotMaxes := r.Form["slot_max"] + for i, name := range slotNames { + name = strings.TrimSpace(name) + if name == "" { + continue + } + emoji := "" + if i < len(slotEmojis) { + emoji = strings.TrimSpace(slotEmojis[i]) + } + maxClaims := int64(1) + if i < len(slotMaxes) { + if v, err := strconv.ParseInt(slotMaxes[i], 10, 64); err == nil && v > 0 { + maxClaims = v + } + } + s.q.CreateSlot(r.Context(), db.CreateSlotParams{ + EventID: event.ID, Name: name, Emoji: emoji, MaxClaims: maxClaims, SortOrder: int64(i), + }) + } + + http.Redirect(w, r, fmt.Sprintf("/e/%s/admin/%s", event.Slug, event.AdminToken), http.StatusSeeOther) +} + +// --- Guest Event View --- + +type SlotView struct { + Slot db.Slot + Claims []db.Claim + ClaimCount int64 + IsFull bool + Pct int64 +} + +type EventPageData struct { + Event db.Event + Slots []SlotView + TotalGoing int64 + IsAdmin bool +} + +func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) { + event, err := s.q.GetEventBySlug(r.Context(), slug) + if err != nil { + return nil, err + } + + slots, err := s.q.ListSlots(r.Context(), event.ID) + if err != nil { + return nil, err + } + + claims, err := s.q.ListClaimsByEvent(r.Context(), event.ID) + if err != nil { + return nil, err + } + + claimsBySlot := make(map[int64][]db.Claim) + for _, c := range claims { + claimsBySlot[c.SlotID] = append(claimsBySlot[c.SlotID], c) + } + + var slotViews []SlotView + var totalGoing int64 + for _, slot := range slots { + sc := claimsBySlot[slot.ID] + count := int64(len(sc)) + totalGoing += count + pct := int64(0) + if slot.MaxClaims > 0 { + pct = (count * 100) / slot.MaxClaims + if pct > 100 { + pct = 100 + } + } + slotViews = append(slotViews, SlotView{ + Slot: slot, + Claims: sc, + ClaimCount: count, + IsFull: count >= slot.MaxClaims, + Pct: pct, + }) + } + + return &EventPageData{ + Event: event, + Slots: slotViews, + TotalGoing: totalGoing, + IsAdmin: isAdmin, + }, nil +} + +func (s *Server) handleEvent(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + data, err := s.loadEventPage(r, slug, false) + if err != nil { + if err == sql.ErrNoRows { + http.NotFound(w, r) + return + } + log.Printf("load event: %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + pageTmpl["event"].ExecuteTemplate(w, "layout", data) +} + +func (s *Server) handleSlotsPartial(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + data, err := s.loadEventPage(r, slug, false) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) +} + +// --- Claim / Unclaim --- + +func (s *Server) handleClaim(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + r.ParseForm() + + slotID, err := strconv.ParseInt(r.FormValue("slot_id"), 10, 64) + if err != nil { + http.Error(w, "Invalid slot", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + http.Error(w, "Name is required", http.StatusBadRequest) + return + } + note := strings.TrimSpace(r.FormValue("note")) + + // Check slot exists and belongs to this event + slot, err := s.q.GetSlot(r.Context(), slotID) + if err != nil { + http.Error(w, "Slot not found", http.StatusNotFound) + return + } + + event, err := s.q.GetEventBySlug(r.Context(), slug) + if err != nil || slot.EventID != event.ID { + http.Error(w, "Invalid slot", http.StatusBadRequest) + return + } + + // Check not full + count, err := s.q.CountClaimsBySlot(r.Context(), slotID) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + if count >= slot.MaxClaims { + http.Error(w, "Slot is full", http.StatusConflict) + return + } + + _, err = s.q.CreateClaim(r.Context(), db.CreateClaimParams{ + SlotID: slotID, Name: name, Note: note, + }) + if err != nil { + log.Printf("create claim: %v", err) + http.Error(w, "Failed to claim", http.StatusInternalServerError) + return + } + + s.notify(slug) + + // Return updated slots partial + data, err := s.loadEventPage(r, slug, false) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) +} + +func (s *Server) handleUnclaim(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + claimID, err := strconv.ParseInt(chi.URLParam(r, "claimID"), 10, 64) + if err != nil { + http.Error(w, "Invalid claim", http.StatusBadRequest) + return + } + + err = s.q.DeleteClaim(r.Context(), claimID) + if err != nil { + http.Error(w, "Failed to remove", http.StatusInternalServerError) + return + } + + s.notify(slug) + + data, err := s.loadEventPage(r, slug, false) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) +} + +// --- SSE --- + +func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "SSE not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + ch := s.subscribe(slug) + defer s.unsubscribe(slug, ch) + + for { + select { + case <-ch: + data, err := s.loadEventPage(r, slug, false) + if err != nil { + return + } + var buf bytes.Buffer + pageTmpl["slots"].ExecuteTemplate(&buf, "slots-inner", data) + // SSE format: replace newlines for event stream + lines := strings.Split(buf.String(), "\n") + for _, line := range lines { + fmt.Fprintf(w, "data: %s\n", line) + } + fmt.Fprintf(w, "\n") + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} + +// --- Admin --- + +func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + token := chi.URLParam(r, "token") + + event, err := s.q.GetEventBySlug(r.Context(), slug) + if err != nil || event.AdminToken != token { + http.NotFound(w, r) + return + } + + data, err := s.loadEventPage(r, slug, true) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + pageTmpl["event"].ExecuteTemplate(w, "layout", data) +} + +func (s *Server) handleCreateSlot(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + token := chi.URLParam(r, "token") + + event, err := s.q.GetEventBySlug(r.Context(), slug) + if err != nil || event.AdminToken != token { + http.NotFound(w, r) + return + } + + r.ParseForm() + name := strings.TrimSpace(r.FormValue("name")) + emoji := strings.TrimSpace(r.FormValue("emoji")) + maxClaims := int64(1) + if v, err := strconv.ParseInt(r.FormValue("max_claims"), 10, 64); err == nil && v > 0 { + maxClaims = v + } + + if name == "" { + http.Error(w, "Name required", http.StatusBadRequest) + return + } + + _, err = s.q.CreateSlot(r.Context(), db.CreateSlotParams{ + EventID: event.ID, Name: name, Emoji: emoji, MaxClaims: maxClaims, SortOrder: 999, + }) + if err != nil { + http.Error(w, "Failed", http.StatusInternalServerError) + return + } + + s.notify(slug) + + data, err := s.loadEventPage(r, slug, true) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) +} + +func (s *Server) handleDeleteSlot(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + token := chi.URLParam(r, "token") + + event, err := s.q.GetEventBySlug(r.Context(), slug) + if err != nil || event.AdminToken != token { + http.NotFound(w, r) + return + } + + slotID, err := strconv.ParseInt(chi.URLParam(r, "slotID"), 10, 64) + if err != nil { + http.Error(w, "Invalid slot", http.StatusBadRequest) + return + } + + s.q.DeleteSlot(r.Context(), slotID) + s.notify(slug) + + data, err := s.loadEventPage(r, slug, true) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9e81f43 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..149e9b4 --- /dev/null +++ b/schema.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + date TEXT NOT NULL, -- e.g. "Saturday, June 14" + time TEXT NOT NULL, -- e.g. "2:00 PM" + location TEXT NOT NULL, + admin_token TEXT NOT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS slots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE, + name TEXT NOT NULL, + emoji TEXT NOT NULL DEFAULT '', + max_claims INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS claims ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slot_id INTEGER NOT NULL REFERENCES slots(id) ON DELETE CASCADE, + name TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_slots_event ON slots(event_id); +CREATE INDEX IF NOT EXISTS idx_claims_slot ON claims(slot_id); diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..3788140 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,9 @@ +version: "2" +sql: + - engine: "sqlite" + queries: "db/queries.sql" + schema: "schema.sql" + gen: + go: + package: "db" + out: "db" diff --git a/static/.keep b/static/.keep new file mode 100644 index 0000000..e69de29 diff --git a/templates/event.html b/templates/event.html new file mode 100644 index 0000000..d02529d --- /dev/null +++ b/templates/event.html @@ -0,0 +1,81 @@ +{{template "layout" .}} + +{{define "title"}}{{.Event.Title}} โ€” bbq{{end}} + +{{define "admin-bar"}}{{if .IsAdmin}}
ADMIN VIEW โ€” share the guest link: /e/{{.Event.Slug}}
{{end}}{{end}} + +{{define "content"}} +
+
Open ยท {{.TotalGoing}} going
+

{{.Event.Title}}

+
+ {{if .Event.Date}}📅 {{.Event.Date}}{{end}} + {{if .Event.Time}}🕒 {{.Event.Time}}{{end}} + {{if .Event.Location}}📍 {{.Event.Location}}{{end}} +
+
+ + + +
+ {{template "slots-inner" .}} +
+ + + +
+
I'll bring something →
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +{{if .IsAdmin}} + +
+
+
+ +
+ +
+
+
+ + +
+
+ + +
+ +
+
+{{end}} +{{end}} diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..5e1a980 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,68 @@ +{{template "layout" .}} + +{{define "title"}}bbq โ€” create a potluck{{end}} + +{{define "content"}} +
+
New event
+

Create a potluck

+

+ Set up what's needed and share the link. No sign-up required. +

+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+
+
+ +
+ + +
+
+ + + + +
+
+ + +{{end}} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..c70728c --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,398 @@ +{{define "layout"}} + + + + +{{block "title" .}}picnic{{end}} + + + + + + +{{block "admin-bar" .}}{{end}} +
+ +
+
+{{block "content" .}}{{end}} +
+ + +{{end}} diff --git a/templates/slots.html b/templates/slots.html new file mode 100644 index 0000000..ead1832 --- /dev/null +++ b/templates/slots.html @@ -0,0 +1,33 @@ +{{define "slots-inner"}} +{{range .Slots}} +
+
+
{{.Slot.Emoji}} {{.Slot.Name}}
+
+ {{if .Claims}} + {{range .Claims}} + + {{.Name}}{{if .Note}} ({{.Note}}){{end}} + + + {{end}} + {{else}} + nobody yet + {{end}} +
+
+
+
+
+
+ {{.ClaimCount}} / {{.Slot.MaxClaims}}{{if .IsFull}} ✓{{end}} +
+
+{{end}} +{{end}} + +{{define "slots.html"}}{{template "slots-inner" .}}{{end}}