Add markdown event descriptions for hosts to provide context
Hosts can now add a free-form description (with markdown rendering via goldmark) when creating or editing events. Descriptions render safely with no raw HTML passthrough. Includes ALTER TABLE migration for existing databases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+9
-8
@@ -17,14 +17,15 @@ type Claim struct {
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID int64
|
||||
Slug string
|
||||
Title string
|
||||
Date string
|
||||
Time string
|
||||
Location string
|
||||
AdminToken string
|
||||
CreatedAt time.Time
|
||||
ID int64
|
||||
Slug string
|
||||
Title string
|
||||
Date string
|
||||
Time string
|
||||
Location string
|
||||
AdminToken string
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Rsvp struct {
|
||||
|
||||
+5
-2
@@ -5,10 +5,13 @@ SELECT * FROM events WHERE slug = ?;
|
||||
SELECT * FROM events WHERE admin_token = ?;
|
||||
|
||||
-- name: CreateEvent :one
|
||||
INSERT INTO events (slug, title, date, time, location, admin_token)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO events (slug, title, date, time, location, admin_token, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateEventDescription :exec
|
||||
UPDATE events SET description = ? WHERE id = ?;
|
||||
|
||||
-- name: UpdateEvent :exec
|
||||
UPDATE events SET title = ?, date = ?, time = ?, location = ? WHERE id = ?;
|
||||
|
||||
|
||||
+30
-11
@@ -57,18 +57,19 @@ func (q *Queries) CreateClaim(ctx context.Context, arg CreateClaimParams) (Claim
|
||||
}
|
||||
|
||||
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
|
||||
INSERT INTO events (slug, title, date, time, location, admin_token, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id, slug, title, date, time, location, admin_token, description, created_at
|
||||
`
|
||||
|
||||
type CreateEventParams struct {
|
||||
Slug string
|
||||
Title string
|
||||
Date string
|
||||
Time string
|
||||
Location string
|
||||
AdminToken string
|
||||
Slug string
|
||||
Title string
|
||||
Date string
|
||||
Time string
|
||||
Location string
|
||||
AdminToken string
|
||||
Description string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) {
|
||||
@@ -79,6 +80,7 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event
|
||||
arg.Time,
|
||||
arg.Location,
|
||||
arg.AdminToken,
|
||||
arg.Description,
|
||||
)
|
||||
var i Event
|
||||
err := row.Scan(
|
||||
@@ -89,6 +91,7 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event
|
||||
&i.Time,
|
||||
&i.Location,
|
||||
&i.AdminToken,
|
||||
&i.Description,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
@@ -190,7 +193,7 @@ func (q *Queries) DeleteSlot(ctx context.Context, id int64) error {
|
||||
}
|
||||
|
||||
const getEventByAdminToken = `-- name: GetEventByAdminToken :one
|
||||
SELECT id, slug, title, date, time, location, admin_token, created_at FROM events WHERE admin_token = ?
|
||||
SELECT id, slug, title, date, time, location, admin_token, description, created_at FROM events WHERE admin_token = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetEventByAdminToken(ctx context.Context, adminToken string) (Event, error) {
|
||||
@@ -204,13 +207,14 @@ func (q *Queries) GetEventByAdminToken(ctx context.Context, adminToken string) (
|
||||
&i.Time,
|
||||
&i.Location,
|
||||
&i.AdminToken,
|
||||
&i.Description,
|
||||
&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 = ?
|
||||
SELECT id, slug, title, date, time, location, admin_token, description, created_at FROM events WHERE slug = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error) {
|
||||
@@ -224,6 +228,7 @@ func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error
|
||||
&i.Time,
|
||||
&i.Location,
|
||||
&i.AdminToken,
|
||||
&i.Description,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
@@ -406,6 +411,20 @@ func (q *Queries) UpdateEvent(ctx context.Context, arg UpdateEventParams) error
|
||||
return err
|
||||
}
|
||||
|
||||
const updateEventDescription = `-- name: UpdateEventDescription :exec
|
||||
UPDATE events SET description = ? WHERE id = ?
|
||||
`
|
||||
|
||||
type UpdateEventDescriptionParams struct {
|
||||
Description string
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateEventDescription(ctx context.Context, arg UpdateEventDescriptionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateEventDescription, arg.Description, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateSlot = `-- name: UpdateSlot :exec
|
||||
UPDATE slots SET name = ?, emoji = ?, max_claims = ? WHERE id = ?
|
||||
`
|
||||
|
||||
@@ -5,4 +5,5 @@ go 1.22
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.44 // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
)
|
||||
|
||||
@@ -2,3 +2,5 @@ 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=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
|
||||
+45
-13
@@ -6,6 +6,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
|
||||
const (
|
||||
maxFieldLen = 200
|
||||
maxDescLen = 5000
|
||||
maxNoteLen = 500
|
||||
maxSlots = 20
|
||||
maxRsvps = 200
|
||||
@@ -57,6 +59,7 @@ func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
date := sanitize(r.FormValue("date"), maxFieldLen)
|
||||
time_ := sanitize(r.FormValue("time"), maxFieldLen)
|
||||
location := sanitize(r.FormValue("location"), maxFieldLen)
|
||||
description := sanitize(r.FormValue("description"), maxDescLen)
|
||||
|
||||
if title == "" {
|
||||
http.Error(w, "Title is required", http.StatusBadRequest)
|
||||
@@ -66,7 +69,7 @@ func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
Slug: slug, Title: title, Date: date, Time: time_, Location: location, AdminToken: token, Description: description,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("create event: %v", err)
|
||||
@@ -119,12 +122,13 @@ type SlotView struct {
|
||||
}
|
||||
|
||||
type EventPageData struct {
|
||||
Event db.Event
|
||||
Slots []SlotView
|
||||
Rsvps []db.Rsvp
|
||||
TotalGoing int64
|
||||
IsAdmin bool
|
||||
BaseURL string
|
||||
Event db.Event
|
||||
Slots []SlotView
|
||||
Rsvps []db.Rsvp
|
||||
TotalGoing int64
|
||||
IsAdmin bool
|
||||
BaseURL string
|
||||
DescriptionHTML template.HTML
|
||||
}
|
||||
|
||||
func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) {
|
||||
@@ -176,13 +180,19 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
|
||||
}
|
||||
totalGoing += int64(len(rsvps))
|
||||
|
||||
var descHTML template.HTML
|
||||
if event.Description != "" {
|
||||
descHTML = RenderMarkdown(event.Description)
|
||||
}
|
||||
|
||||
return &EventPageData{
|
||||
Event: event,
|
||||
Slots: slotViews,
|
||||
Rsvps: rsvps,
|
||||
TotalGoing: totalGoing,
|
||||
IsAdmin: isAdmin,
|
||||
BaseURL: s.baseURL,
|
||||
Event: event,
|
||||
Slots: slotViews,
|
||||
Rsvps: rsvps,
|
||||
TotalGoing: totalGoing,
|
||||
IsAdmin: isAdmin,
|
||||
BaseURL: s.baseURL,
|
||||
DescriptionHTML: descHTML,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -425,6 +435,28 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
pageTmpl["event"].ExecuteTemplate(w, "layout", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateDescription(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.Body = http.MaxBytesReader(w, r.Body, 32*1024)
|
||||
r.ParseForm()
|
||||
description := sanitize(r.FormValue("description"), maxDescLen)
|
||||
|
||||
s.q.UpdateEventDescription(r.Context(), db.UpdateEventDescriptionParams{
|
||||
Description: description,
|
||||
ID: event.ID,
|
||||
})
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/e/%s/admin/%s", slug, token), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateSlot(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
token := chi.URLParam(r, "token")
|
||||
|
||||
@@ -104,6 +104,7 @@ func main() {
|
||||
if _, err := database.Exec(schemaSQL); err != nil {
|
||||
log.Fatal("schema init: ", err)
|
||||
}
|
||||
runMigrations(database)
|
||||
|
||||
funcMap := template.FuncMap{
|
||||
"pct": func(count, max int64) int64 {
|
||||
@@ -160,6 +161,7 @@ func main() {
|
||||
|
||||
// Admin
|
||||
r.Get("/e/{slug}/admin/{token}", srv.handleAdmin)
|
||||
r.Post("/e/{slug}/admin/{token}/description", srv.handleUpdateDescription)
|
||||
r.Post("/e/{slug}/admin/{token}/slot", srv.handleCreateSlot)
|
||||
r.Delete("/e/{slug}/admin/{token}/slot/{slotID}", srv.handleDeleteSlot)
|
||||
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
)
|
||||
|
||||
var mdRenderer = goldmark.New(
|
||||
goldmark.WithRendererOptions(
|
||||
html.WithHardWraps(),
|
||||
),
|
||||
)
|
||||
|
||||
func RenderMarkdown(input string) template.HTML {
|
||||
var buf bytes.Buffer
|
||||
if err := mdRenderer.Convert([]byte(input), &buf); err != nil {
|
||||
return template.HTML(template.HTMLEscapeString(input))
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func runMigrations(database *sql.DB) {
|
||||
_, err := database.Exec(`ALTER TABLE events ADD COLUMN description TEXT DEFAULT ''`)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
return
|
||||
}
|
||||
log.Printf("migration warning: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS events (
|
||||
time TEXT NOT NULL, -- e.g. "2:00 PM"
|
||||
location TEXT NOT NULL,
|
||||
admin_token TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{{if .Event.Time}}<span>🕒 {{.Event.Time}}</span>{{end}}
|
||||
{{if .Event.Location}}<span>📍 {{.Event.Location}}</span>{{end}}
|
||||
</div>
|
||||
{{if .DescriptionHTML}}
|
||||
<div class="event-description">{{.DescriptionHTML}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div id="slots-container"
|
||||
@@ -87,6 +90,17 @@
|
||||
{{end}}
|
||||
|
||||
{{if .IsAdmin}}
|
||||
<div class="section-label" style="margin-top:40px">Admin: Description</div>
|
||||
<div class="claim-form-wrapper">
|
||||
<form method="POST" action="/e/{{.Event.Slug}}/admin/{{.Event.AdminToken}}/description">
|
||||
<div class="form-row">
|
||||
<label>Event description (markdown supported)</label>
|
||||
<textarea name="description" rows="5" style="border:var(--border-w) solid var(--border);background:var(--cream);padding:10px 14px;font-family:'Bricolage Grotesque',sans-serif;font-size:0.95rem;resize:vertical;outline:none;">{{.Event.Description}}</textarea>
|
||||
</div>
|
||||
<button class="btn-submit" type="submit">Save description</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
<label>Location</label>
|
||||
<input type="text" name="location" placeholder="e.g. Long Meadow, entrance at 9th St">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Description (optional, markdown supported)</label>
|
||||
<textarea name="description" rows="4" placeholder="e.g. Bring your own blanket! We'll have a grill set up near the big oak tree." style="border:var(--border-w) solid var(--border);background:var(--cream);padding:10px 14px;font-family:'Bricolage Grotesque',sans-serif;font-size:0.95rem;resize:vertical;outline:none;"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="section-label" style="margin:24px 0 16px">Slots</div>
|
||||
|
||||
|
||||
+36
-1
@@ -97,6 +97,41 @@
|
||||
font-size: 0.78rem;
|
||||
color: #555;
|
||||
}
|
||||
.event-description {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #ddd;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.event-description h1 { font-size: 1.3rem; margin: 16px 0 8px; }
|
||||
.event-description h2 { font-size: 1.15rem; margin: 14px 0 6px; }
|
||||
.event-description h3 { font-size: 1.05rem; margin: 12px 0 4px; }
|
||||
.event-description p { margin: 8px 0; }
|
||||
.event-description ul, .event-description ol { margin: 8px 0; padding-left: 24px; }
|
||||
.event-description li { margin: 4px 0; }
|
||||
.event-description a { color: var(--ink); text-decoration: underline; }
|
||||
.event-description code {
|
||||
font-family: 'DM Mono', monospace;
|
||||
background: var(--cream);
|
||||
padding: 2px 6px;
|
||||
font-size: 0.85em;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.event-description pre {
|
||||
background: var(--cream);
|
||||
border: 1px solid #ddd;
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.event-description pre code { background: none; border: none; padding: 0; }
|
||||
.event-description blockquote {
|
||||
border-left: 3px solid var(--ink);
|
||||
padding-left: 12px;
|
||||
margin: 8px 0;
|
||||
color: #555;
|
||||
}
|
||||
.section-label {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
@@ -230,7 +265,7 @@
|
||||
transition: box-shadow 0.1s;
|
||||
appearance: none;
|
||||
}
|
||||
.form-row input:focus, .form-row select:focus {
|
||||
.form-row input:focus, .form-row select:focus, .form-row textarea:focus {
|
||||
box-shadow: 3px 3px 0 var(--ink);
|
||||
}
|
||||
.btn-submit {
|
||||
|
||||
Reference in New Issue
Block a user