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:
2026-05-14 22:55:54 -04:00
commit 8b32d98267
19 changed files with 1699 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
bbq
bbq.db
.git
+4
View File
@@ -0,0 +1,4 @@
bbq
bbq.db
bbq.db-shm
bbq.db-wal
+15
View File
@@ -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"]
+11
View File
@@ -0,0 +1,11 @@
services:
bbq:
build: .
restart: unless-stopped
ports:
- "127.0.0.1:8090:8080"
volumes:
- bbq-data:/data
volumes:
bbq-data:
+31
View File
@@ -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,
}
}
+37
View File
@@ -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
}
+53
View File
@@ -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 = ?;
+350
View File
@@ -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
}
+8
View File
@@ -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
)
+4
View File
@@ -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=
+390
View File
@@ -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)
}
+174
View File
@@ -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)
}
}
+30
View File
@@ -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);
+9
View File
@@ -0,0 +1,9 @@
version: "2"
sql:
- engine: "sqlite"
queries: "db/queries.sql"
schema: "schema.sql"
gen:
go:
package: "db"
out: "db"
View File
+81
View File
@@ -0,0 +1,81 @@
{{template "layout" .}}
{{define "title"}}{{.Event.Title}} — bbq{{end}}
{{define "admin-bar"}}{{if .IsAdmin}}<div class="admin-bar">ADMIN VIEW — share the guest link: /e/{{.Event.Slug}}</div>{{end}}{{end}}
{{define "content"}}
<div class="event-header">
<div class="event-tag">Open · {{.TotalGoing}} going</div>
<h1 class="event-title">{{.Event.Title}}</h1>
<div class="event-meta">
{{if .Event.Date}}<span>&#128197; {{.Event.Date}}</span>{{end}}
{{if .Event.Time}}<span>&#128338; {{.Event.Time}}</span>{{end}}
{{if .Event.Location}}<span>&#128205; {{.Event.Location}}</span>{{end}}
</div>
</div>
<div class="section-label">What's needed</div>
<div id="slots-container"
hx-ext="sse"
sse-connect="/e/{{.Event.Slug}}/sse"
sse-swap="message"
hx-swap="innerHTML settle:0.1s">
{{template "slots-inner" .}}
</div>
<div class="section-label">Add your name</div>
<div class="claim-form-wrapper">
<div class="form-title">I'll bring something &#8594;</div>
<form hx-post="/e/{{.Event.Slug}}/claim"
hx-target="#slots-container"
hx-swap="innerHTML settle:0.1s"
hx-on::after-request="if(event.detail.successful) this.reset()">
<div class="form-row">
<label>Your name</label>
<input type="text" name="name" placeholder="e.g. Sam" required>
</div>
<div class="form-row">
<label>Slot</label>
<select name="slot_id">
{{range .Slots}}{{if not .IsFull}}
<option value="{{.Slot.ID}}">{{.Slot.Emoji}} {{.Slot.Name}} ({{$left := sub .Slot.MaxClaims .ClaimCount}}{{$left}} spot{{if ne $left 1}}s{{end}} left)</option>
{{end}}{{end}}
</select>
</div>
<div class="form-row">
<label>Note (optional)</label>
<input type="text" name="note" placeholder="e.g. bringing sparkling water + lemonade">
</div>
<button class="btn-submit" type="submit">Count me in &#8599;</button>
</form>
</div>
{{if .IsAdmin}}
<div class="section-label" style="margin-top:40px">Admin: Add slot</div>
<div class="claim-form-wrapper">
<form hx-post="/e/{{.Event.Slug}}/admin/{{.Event.AdminToken}}/slot"
hx-target="#slots-container"
hx-swap="innerHTML settle:0.1s"
hx-on::after-request="if(event.detail.successful) this.reset()">
<div class="form-row">
<label>Emoji</label>
<div class="emoji-pick" style="width:100%">
<input type="text" name="emoji" placeholder="&#127829;" readonly style="border:var(--border-w) solid var(--border);background:var(--cream);padding:10px 14px;font-size:0.95rem;width:100%;">
</div>
</div>
<div class="form-row">
<label>Slot name</label>
<input type="text" name="name" placeholder="e.g. Dessert" required>
</div>
<div class="form-row">
<label>Max claims</label>
<input type="number" name="max_claims" value="2" min="1">
</div>
<button class="btn-submit" type="submit">Add slot</button>
</form>
</div>
{{end}}
{{end}}
+68
View File
@@ -0,0 +1,68 @@
{{template "layout" .}}
{{define "title"}}bbq — create a potluck{{end}}
{{define "content"}}
<div class="event-header">
<div class="event-tag">New event</div>
<h1 class="event-title">Create a potluck</h1>
<p style="font-family:'DM Mono',monospace;font-size:0.8rem;color:#555;margin-top:8px;">
Set up what's needed and share the link. No sign-up required.
</p>
</div>
<div class="claim-form-wrapper">
<form method="POST" action="/events">
<div class="form-row">
<label>Event name</label>
<input type="text" name="title" placeholder="e.g. Prospect Park Summer Picnic" required>
</div>
<div class="form-row">
<label>Date</label>
<input type="text" name="date" placeholder="e.g. Saturday, June 14">
</div>
<div class="form-row">
<label>Time</label>
<input type="text" name="time" placeholder="e.g. 2:00 PM">
</div>
<div class="form-row">
<label>Location</label>
<input type="text" name="location" placeholder="e.g. Long Meadow, entrance at 9th St">
</div>
<div class="section-label" style="margin:24px 0 16px">Slots</div>
<div id="slot-rows">
<div class="slot-row" style="display:flex;gap:8px;margin-bottom:12px;">
<div class="emoji-pick">
<input type="text" name="slot_emoji" placeholder="&#127834;" readonly style="border:var(--border-w) solid var(--border);background:var(--cream);padding:10px;font-size:0.95rem;">
</div>
<input type="text" name="slot_name" placeholder="Slot name" style="flex:1;border:var(--border-w) solid var(--border);background:var(--cream);padding:10px;font-family:'Bricolage Grotesque',sans-serif;font-size:0.95rem;">
<input type="number" name="slot_max" value="2" min="1" style="width:60px;border:var(--border-w) solid var(--border);background:var(--cream);padding:10px;font-size:0.95rem;text-align:center;">
</div>
</div>
<button type="button" onclick="addSlotRow()" class="btn-claim" style="margin-bottom:20px;">+ Add slot</button>
<button class="btn-submit" type="submit">Create event &#8599;</button>
</form>
</div>
<script>
function addSlotRow() {
const container = document.getElementById('slot-rows');
const row = document.createElement('div');
row.className = 'slot-row';
row.style.cssText = 'display:flex;gap:8px;margin-bottom:12px;';
row.innerHTML = `
<div class="emoji-pick">
<input type="text" name="slot_emoji" placeholder="\u{1F356}" readonly style="border:var(--border-w) solid var(--border);background:var(--cream);padding:10px;font-size:0.95rem;">
</div>
<input type="text" name="slot_name" placeholder="Slot name" style="flex:1;border:var(--border-w) solid var(--border);background:var(--cream);padding:10px;font-family:'Bricolage Grotesque',sans-serif;font-size:0.95rem;">
<input type="number" name="slot_max" value="2" min="1" style="width:60px;border:var(--border-w) solid var(--border);background:var(--cream);padding:10px;font-size:0.95rem;text-align:center;">
`;
container.appendChild(row);
initEmojiPickers();
}
</script>
{{end}}
+398
View File
@@ -0,0 +1,398 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}picnic{{end}}</title>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,700;12..96,800&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
<style>
:root {
--cream: #f5f0e8;
--ink: #1a1a1a;
--border: #1a1a1a;
--sage: #a8c5a0;
--peach: #f2a482;
--yellow: #f5e642;
--radius: 0px;
--border-w: 2.5px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--cream);
color: var(--ink);
font-family: 'Bricolage Grotesque', sans-serif;
min-height: 100vh;
padding: 0;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 100;
opacity: 0.4;
}
header {
border-bottom: var(--border-w) solid var(--border);
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--yellow);
}
.logo {
font-size: 1.1rem;
font-weight: 800;
letter-spacing: -0.03em;
text-decoration: none;
color: var(--ink);
}
.logo span {
display: inline-block;
background: var(--ink);
color: var(--yellow);
padding: 2px 8px;
margin-right: 4px;
}
main {
max-width: 680px;
margin: 0 auto;
padding: 40px 24px 80px;
}
.event-header {
border: var(--border-w) solid var(--border);
background: white;
padding: 28px;
margin-bottom: 32px;
position: relative;
box-shadow: 5px 5px 0 var(--ink);
}
.event-tag {
display: inline-block;
background: var(--sage);
border: var(--border-w) solid var(--border);
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
padding: 3px 10px;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.event-title {
font-size: 2.4rem;
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1.1;
margin-bottom: 12px;
}
.event-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-family: 'DM Mono', monospace;
font-size: 0.78rem;
color: #555;
}
.section-label {
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 10px;
}
.section-label::after {
content: '';
flex: 1;
height: var(--border-w);
background: var(--ink);
}
.slots-grid {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 40px;
}
.slot-card {
border: var(--border-w) solid var(--border);
background: white;
padding: 18px 20px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
transition: box-shadow 0.12s, transform 0.12s;
}
.slot-card:hover {
box-shadow: 4px 4px 0 var(--ink);
transform: translate(-2px, -2px);
}
.slot-card.full { background: #f0f0f0; }
.slot-info { flex: 1; }
.slot-name {
font-weight: 700;
font-size: 1.05rem;
letter-spacing: -0.02em;
margin-bottom: 6px;
}
.slot-claims {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.claim-chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--cream);
border: var(--border-w) solid var(--border);
font-family: 'DM Mono', monospace;
font-size: 0.72rem;
padding: 3px 10px;
}
.claim-chip button {
background: none;
border: none;
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
padding: 0;
color: #888;
transition: color 0.1s;
}
.claim-chip button:hover { color: var(--ink); }
.slot-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
}
.slot-count {
font-family: 'DM Mono', monospace;
font-size: 0.72rem;
color: #888;
white-space: nowrap;
}
.slot-count.warn { color: var(--peach); font-weight: 700; }
.btn-claim {
background: var(--yellow);
border: var(--border-w) solid var(--border);
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 700;
font-size: 0.8rem;
padding: 6px 14px;
cursor: pointer;
transition: box-shadow 0.1s, transform 0.1s;
white-space: nowrap;
}
.btn-claim:hover {
box-shadow: 3px 3px 0 var(--ink);
transform: translate(-1px, -1px);
}
.claim-form-wrapper {
border: var(--border-w) solid var(--border);
background: white;
padding: 24px;
box-shadow: 5px 5px 0 var(--ink);
}
.form-title {
font-size: 1.2rem;
font-weight: 800;
letter-spacing: -0.03em;
margin-bottom: 20px;
}
.form-row {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
}
.form-row label {
font-family: 'DM Mono', monospace;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.form-row input, .form-row select {
border: var(--border-w) solid var(--border);
background: var(--cream);
padding: 10px 14px;
font-family: 'Bricolage Grotesque', sans-serif;
font-size: 0.95rem;
outline: none;
transition: box-shadow 0.1s;
appearance: none;
}
.form-row input:focus, .form-row select:focus {
box-shadow: 3px 3px 0 var(--ink);
}
.btn-submit {
background: var(--ink);
color: var(--yellow);
border: var(--border-w) solid var(--border);
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 800;
font-size: 1rem;
padding: 12px 28px;
cursor: pointer;
transition: box-shadow 0.1s, transform 0.1s;
letter-spacing: -0.02em;
width: 100%;
margin-top: 4px;
}
.btn-submit:hover {
box-shadow: 4px 4px 0 var(--sage);
transform: translate(-2px, -2px);
}
.progress-bar {
height: 4px;
background: #e8e8e8;
border: 1px solid var(--border);
margin-top: 8px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--sage);
transition: width 0.3s;
}
.progress-fill.full { background: var(--peach); }
.nobody {
font-family: 'DM Mono', monospace;
font-size: 0.72rem;
color: #aaa;
padding: 4px 0 0;
}
.admin-bar {
background: var(--peach);
border-bottom: var(--border-w) solid var(--border);
padding: 8px 24px;
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
text-align: center;
}
/* Emoji picker */
.emoji-pick {
position: relative;
width: 60px;
flex-shrink: 0;
}
.emoji-pick input {
width: 100%;
text-align: center;
cursor: pointer;
caret-color: transparent;
}
.emoji-grid {
display: none;
position: absolute;
bottom: calc(100% + 6px);
left: 0;
background: white;
border: var(--border-w) solid var(--border);
box-shadow: 4px 4px 0 var(--ink);
padding: 8px;
display: none;
grid-template-columns: repeat(6, 1fr);
gap: 2px;
z-index: 50;
width: 240px;
}
.emoji-grid.open { display: grid; }
.emoji-grid button {
background: none;
border: 2px solid transparent;
font-size: 1.25rem;
padding: 4px;
cursor: pointer;
border-radius: 0;
line-height: 1;
transition: background 0.08s, border-color 0.08s;
}
.emoji-grid button:hover {
background: var(--yellow);
border-color: var(--border);
}
.btn-danger {
background: none;
border: none;
color: #c44;
cursor: pointer;
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
padding: 2px 6px;
text-decoration: underline;
}
.btn-danger:hover { color: #a00; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.event-header { animation: fadeUp 0.4s ease both; }
.slot-card { animation: fadeUp 0.4s ease both; }
.claim-form-wrapper { animation: fadeUp 0.4s 0.3s ease both; }
.htmx-settling .slot-card { opacity: 1; }
</style>
</head>
<body>
{{block "admin-bar" .}}{{end}}
<header>
<a class="logo" href="/"><span>&#127834;</span> bbq</a>
</header>
<main>
{{block "content" .}}{{end}}
</main>
<script>
const EMOJIS = [
"\u{1F354}","\u{1F355}","\u{1F32E}","\u{1F32F}","\u{1F957}","\u{1F956}",
"\u{1F969}","\u{1F953}","\u{1F35D}","\u{1F35C}","\u{1F372}","\u{1F37B}",
"\u{1F964}","\u{1F377}","\u{2615}","\u{1F375}","\u{1F95A}","\u{1F9C0}",
"\u{1F370}","\u{1F382}","\u{1F366}","\u{1F36A}","\u{1F349}","\u{1F353}",
"\u{1F34E}","\u{1F34A}","\u{1F34C}","\u{1F347}","\u{1F33D}","\u{1F952}",
"\u{1F345}","\u{1F955}","\u{1F968}","\u{1F9C1}","\u{1F37A}","\u{1F379}"
];
function initEmojiPickers() {
document.querySelectorAll('.emoji-pick').forEach(wrap => {
if (wrap.dataset.init) return;
wrap.dataset.init = '1';
const input = wrap.querySelector('input');
let grid = wrap.querySelector('.emoji-grid');
if (!grid) {
grid = document.createElement('div');
grid.className = 'emoji-grid';
EMOJIS.forEach(e => {
const b = document.createElement('button');
b.type = 'button';
b.textContent = e;
b.addEventListener('click', () => {
input.value = e;
grid.classList.remove('open');
});
grid.appendChild(b);
});
wrap.appendChild(grid);
}
input.addEventListener('click', (ev) => {
ev.preventDefault();
document.querySelectorAll('.emoji-grid.open').forEach(g => {
if (g !== grid) g.classList.remove('open');
});
grid.classList.toggle('open');
});
});
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.emoji-pick')) {
document.querySelectorAll('.emoji-grid.open').forEach(g => g.classList.remove('open'));
}
});
document.addEventListener('DOMContentLoaded', initEmojiPickers);
</script>
</body>
</html>{{end}}
+33
View File
@@ -0,0 +1,33 @@
{{define "slots-inner"}}
{{range .Slots}}
<div class="slot-card{{if .IsFull}} full{{end}}">
<div class="slot-info">
<div class="slot-name">{{.Slot.Emoji}} {{.Slot.Name}}</div>
<div class="slot-claims">
{{if .Claims}}
{{range .Claims}}
<span class="claim-chip">
{{.Name}}{{if .Note}} <small style="color:#888">({{.Note}})</small>{{end}}
<button hx-delete="/e/{{$.Event.Slug}}/claim/{{.ID}}"
hx-target="#slots-container"
hx-swap="innerHTML settle:0.1s"
hx-confirm="Remove {{.Name}}?"
title="Remove">&#215;</button>
</span>
{{end}}
{{else}}
<span class="nobody">nobody yet</span>
{{end}}
</div>
<div class="progress-bar">
<div class="progress-fill{{if .IsFull}} full{{end}}" style="width:{{.Pct}}%"></div>
</div>
</div>
<div class="slot-right">
<span class="slot-count{{if .IsFull}} warn{{end}}">{{.ClaimCount}} / {{.Slot.MaxClaims}}{{if .IsFull}} &#10003;{{end}}</span>
</div>
</div>
{{end}}
{{end}}
{{define "slots.html"}}{{template "slots-inner" .}}{{end}}