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,3 @@
|
|||||||
|
bbq
|
||||||
|
bbq.db
|
||||||
|
.git
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
bbq
|
||||||
|
bbq.db
|
||||||
|
bbq.db-shm
|
||||||
|
bbq.db-wal
|
||||||
+15
@@ -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"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
bbq:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8090:8080"
|
||||||
|
volumes:
|
||||||
|
- bbq-data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bbq-data:
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 = ?;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
version: "2"
|
||||||
|
sql:
|
||||||
|
- engine: "sqlite"
|
||||||
|
queries: "db/queries.sql"
|
||||||
|
schema: "schema.sql"
|
||||||
|
gen:
|
||||||
|
go:
|
||||||
|
package: "db"
|
||||||
|
out: "db"
|
||||||
@@ -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>📅 {{.Event.Date}}</span>{{end}}
|
||||||
|
{{if .Event.Time}}<span>🕒 {{.Event.Time}}</span>{{end}}
|
||||||
|
{{if .Event.Location}}<span>📍 {{.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 →</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 ↗</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="🍕" 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}}
|
||||||
@@ -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="🍚" 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 ↗</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}}
|
||||||
@@ -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>🍚</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}}
|
||||||
@@ -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">×</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}} ✓{{end}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "slots.html"}}{{template "slots-inner" .}}{{end}}
|
||||||
Reference in New Issue
Block a user