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:
@@ -24,6 +24,7 @@ type Event struct {
|
|||||||
Time string
|
Time string
|
||||||
Location string
|
Location string
|
||||||
AdminToken string
|
AdminToken string
|
||||||
|
Description string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -5,10 +5,13 @@ SELECT * FROM events WHERE slug = ?;
|
|||||||
SELECT * FROM events WHERE admin_token = ?;
|
SELECT * FROM events WHERE admin_token = ?;
|
||||||
|
|
||||||
-- name: CreateEvent :one
|
-- name: CreateEvent :one
|
||||||
INSERT INTO events (slug, title, date, time, location, admin_token)
|
INSERT INTO events (slug, title, date, time, location, admin_token, description)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateEventDescription :exec
|
||||||
|
UPDATE events SET description = ? WHERE id = ?;
|
||||||
|
|
||||||
-- name: UpdateEvent :exec
|
-- name: UpdateEvent :exec
|
||||||
UPDATE events SET title = ?, date = ?, time = ?, location = ? WHERE id = ?;
|
UPDATE events SET title = ?, date = ?, time = ?, location = ? WHERE id = ?;
|
||||||
|
|
||||||
|
|||||||
+24
-5
@@ -57,9 +57,9 @@ func (q *Queries) CreateClaim(ctx context.Context, arg CreateClaimParams) (Claim
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createEvent = `-- name: CreateEvent :one
|
const createEvent = `-- name: CreateEvent :one
|
||||||
INSERT INTO events (slug, title, date, time, location, admin_token)
|
INSERT INTO events (slug, title, date, time, location, admin_token, description)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING id, slug, title, date, time, location, admin_token, created_at
|
RETURNING id, slug, title, date, time, location, admin_token, description, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateEventParams struct {
|
type CreateEventParams struct {
|
||||||
@@ -69,6 +69,7 @@ type CreateEventParams struct {
|
|||||||
Time string
|
Time string
|
||||||
Location string
|
Location string
|
||||||
AdminToken string
|
AdminToken string
|
||||||
|
Description string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) {
|
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.Time,
|
||||||
arg.Location,
|
arg.Location,
|
||||||
arg.AdminToken,
|
arg.AdminToken,
|
||||||
|
arg.Description,
|
||||||
)
|
)
|
||||||
var i Event
|
var i Event
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -89,6 +91,7 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event
|
|||||||
&i.Time,
|
&i.Time,
|
||||||
&i.Location,
|
&i.Location,
|
||||||
&i.AdminToken,
|
&i.AdminToken,
|
||||||
|
&i.Description,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
@@ -190,7 +193,7 @@ func (q *Queries) DeleteSlot(ctx context.Context, id int64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getEventByAdminToken = `-- name: GetEventByAdminToken :one
|
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) {
|
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.Time,
|
||||||
&i.Location,
|
&i.Location,
|
||||||
&i.AdminToken,
|
&i.AdminToken,
|
||||||
|
&i.Description,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEventBySlug = `-- name: GetEventBySlug :one
|
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) {
|
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.Time,
|
||||||
&i.Location,
|
&i.Location,
|
||||||
&i.AdminToken,
|
&i.AdminToken,
|
||||||
|
&i.Description,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
@@ -406,6 +411,20 @@ func (q *Queries) UpdateEvent(ctx context.Context, arg UpdateEventParams) error
|
|||||||
return err
|
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
|
const updateSlot = `-- name: UpdateSlot :exec
|
||||||
UPDATE slots SET name = ?, emoji = ?, max_claims = ? WHERE id = ?
|
UPDATE slots SET name = ?, emoji = ?, max_claims = ? WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ go 1.22
|
|||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.44 // 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/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 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
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=
|
||||||
|
|||||||
+33
-1
@@ -6,6 +6,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
maxFieldLen = 200
|
maxFieldLen = 200
|
||||||
|
maxDescLen = 5000
|
||||||
maxNoteLen = 500
|
maxNoteLen = 500
|
||||||
maxSlots = 20
|
maxSlots = 20
|
||||||
maxRsvps = 200
|
maxRsvps = 200
|
||||||
@@ -57,6 +59,7 @@ func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) {
|
|||||||
date := sanitize(r.FormValue("date"), maxFieldLen)
|
date := sanitize(r.FormValue("date"), maxFieldLen)
|
||||||
time_ := sanitize(r.FormValue("time"), maxFieldLen)
|
time_ := sanitize(r.FormValue("time"), maxFieldLen)
|
||||||
location := sanitize(r.FormValue("location"), maxFieldLen)
|
location := sanitize(r.FormValue("location"), maxFieldLen)
|
||||||
|
description := sanitize(r.FormValue("description"), maxDescLen)
|
||||||
|
|
||||||
if title == "" {
|
if title == "" {
|
||||||
http.Error(w, "Title is required", http.StatusBadRequest)
|
http.Error(w, "Title is required", http.StatusBadRequest)
|
||||||
@@ -66,7 +69,7 @@ func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) {
|
|||||||
slug := randomSlug()
|
slug := randomSlug()
|
||||||
token := randomToken()
|
token := randomToken()
|
||||||
event, err := s.q.CreateEvent(r.Context(), db.CreateEventParams{
|
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 {
|
if err != nil {
|
||||||
log.Printf("create event: %v", err)
|
log.Printf("create event: %v", err)
|
||||||
@@ -125,6 +128,7 @@ type EventPageData struct {
|
|||||||
TotalGoing int64
|
TotalGoing int64
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
BaseURL string
|
BaseURL string
|
||||||
|
DescriptionHTML template.HTML
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) {
|
func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) {
|
||||||
@@ -176,6 +180,11 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
|
|||||||
}
|
}
|
||||||
totalGoing += int64(len(rsvps))
|
totalGoing += int64(len(rsvps))
|
||||||
|
|
||||||
|
var descHTML template.HTML
|
||||||
|
if event.Description != "" {
|
||||||
|
descHTML = RenderMarkdown(event.Description)
|
||||||
|
}
|
||||||
|
|
||||||
return &EventPageData{
|
return &EventPageData{
|
||||||
Event: event,
|
Event: event,
|
||||||
Slots: slotViews,
|
Slots: slotViews,
|
||||||
@@ -183,6 +192,7 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
|
|||||||
TotalGoing: totalGoing,
|
TotalGoing: totalGoing,
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
BaseURL: s.baseURL,
|
BaseURL: s.baseURL,
|
||||||
|
DescriptionHTML: descHTML,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +435,28 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|||||||
pageTmpl["event"].ExecuteTemplate(w, "layout", data)
|
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) {
|
func (s *Server) handleCreateSlot(w http.ResponseWriter, r *http.Request) {
|
||||||
slug := chi.URLParam(r, "slug")
|
slug := chi.URLParam(r, "slug")
|
||||||
token := chi.URLParam(r, "token")
|
token := chi.URLParam(r, "token")
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ func main() {
|
|||||||
if _, err := database.Exec(schemaSQL); err != nil {
|
if _, err := database.Exec(schemaSQL); err != nil {
|
||||||
log.Fatal("schema init: ", err)
|
log.Fatal("schema init: ", err)
|
||||||
}
|
}
|
||||||
|
runMigrations(database)
|
||||||
|
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"pct": func(count, max int64) int64 {
|
"pct": func(count, max int64) int64 {
|
||||||
@@ -160,6 +161,7 @@ func main() {
|
|||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
r.Get("/e/{slug}/admin/{token}", srv.handleAdmin)
|
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.Post("/e/{slug}/admin/{token}/slot", srv.handleCreateSlot)
|
||||||
r.Delete("/e/{slug}/admin/{token}/slot/{slotID}", srv.handleDeleteSlot)
|
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"
|
time TEXT NOT NULL, -- e.g. "2:00 PM"
|
||||||
location TEXT NOT NULL,
|
location TEXT NOT NULL,
|
||||||
admin_token TEXT NOT NULL UNIQUE,
|
admin_token TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
{{if .Event.Time}}<span>🕒 {{.Event.Time}}</span>{{end}}
|
{{if .Event.Time}}<span>🕒 {{.Event.Time}}</span>{{end}}
|
||||||
{{if .Event.Location}}<span>📍 {{.Event.Location}}</span>{{end}}
|
{{if .Event.Location}}<span>📍 {{.Event.Location}}</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
{{if .DescriptionHTML}}
|
||||||
|
<div class="event-description">{{.DescriptionHTML}}</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="slots-container"
|
<div id="slots-container"
|
||||||
@@ -87,6 +90,17 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .IsAdmin}}
|
{{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="section-label" style="margin-top:40px">Admin: Add slot</div>
|
||||||
<div class="claim-form-wrapper">
|
<div class="claim-form-wrapper">
|
||||||
<form hx-post="/e/{{.Event.Slug}}/admin/{{.Event.AdminToken}}/slot"
|
<form hx-post="/e/{{.Event.Slug}}/admin/{{.Event.AdminToken}}/slot"
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
<label>Location</label>
|
<label>Location</label>
|
||||||
<input type="text" name="location" placeholder="e.g. Long Meadow, entrance at 9th St">
|
<input type="text" name="location" placeholder="e.g. Long Meadow, entrance at 9th St">
|
||||||
</div>
|
</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>
|
<div class="section-label" style="margin:24px 0 16px">Slots</div>
|
||||||
|
|
||||||
|
|||||||
+36
-1
@@ -97,6 +97,41 @@
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: #555;
|
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 {
|
.section-label {
|
||||||
font-family: 'DM Mono', monospace;
|
font-family: 'DM Mono', monospace;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
@@ -230,7 +265,7 @@
|
|||||||
transition: box-shadow 0.1s;
|
transition: box-shadow 0.1s;
|
||||||
appearance: none;
|
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);
|
box-shadow: 3px 3px 0 var(--ink);
|
||||||
}
|
}
|
||||||
.btn-submit {
|
.btn-submit {
|
||||||
|
|||||||
Reference in New Issue
Block a user