Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e28dec20b | ||
|
|
91c9617255 | ||
|
|
4571c59020 | ||
|
|
59428a27ce | ||
|
|
15bdbdd26f | ||
|
|
23ab28d07e | ||
|
|
9ecfd44248 | ||
|
|
143b2361f6 | ||
|
|
f96465b798 | ||
|
|
79e4c11a16 | ||
|
|
5b3c256a5e | ||
|
|
3dc2e09aeb | ||
|
|
f16d280f9a | ||
|
|
4eddecea8e | ||
|
|
3c8f07978c | ||
|
|
10ca893bca | ||
|
|
084588fbf1 |
@@ -4,5 +4,4 @@ ui/node_modules/
|
||||
ui/build/
|
||||
ui/.svelte-kit/
|
||||
server/wedding-server
|
||||
server/web/dist/*
|
||||
!server/web/dist/index.html
|
||||
server/web/dist/
|
||||
|
||||
@@ -52,7 +52,7 @@ RSVP is a party: one or more guest names attached to a single RSVP + optional sh
|
||||
| Method | Path | Body | Notes |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/rsvp` | `{names: string[], message?}` | at least one non-blank name required; insert party + guests, email couple |
|
||||
| GET | `/api/config` | — | `{weddingDate, ceremonyTime, rsvpByDate, venueName, venueAddress, rsvpPhone}` (all RFC3339 where dates) — single source of truth for countdowns, gate, and displayed details |
|
||||
| GET | `/api/config` | — | `{weddingDate, eventEnd, ceremonyTime, rsvpByDate, venueName, venueAddress, rsvpPhone}` (all RFC3339 where dates) — single source of truth for countdowns, calendar, gate, and displayed details |
|
||||
| POST | `/api/photos/upload` | multipart: `name, message?, email?, phone?, files[]` | rejected with 403 if `now < weddingDate`, **enforced server-side** regardless of client clock |
|
||||
| GET | `/healthz` | — | liveness |
|
||||
|
||||
|
||||
@@ -42,9 +42,9 @@ docker-compose.yml wedding app, published to 127.0.0.1:8080 only
|
||||
```
|
||||
|
||||
## Config
|
||||
Copy `config.example.yaml` to `config.yaml` and fill in the wedding date, SMTP settings, storage path, and (if enabled) the Origin service key. Path is resolved from `CONFIG_PATH` (default `./config.yaml`) — that's the only env var; everything else lives in the file.
|
||||
Copy `config.example.yaml` to `config.yaml` and fill in the wedding date, event end time, SMTP settings, storage path, and (if enabled) the Origin service key. `wedding.event_end` is the explicit local RFC3339 end time used for calendar downloads. Path is resolved from `CONFIG_PATH` (default `./config.yaml`) — that's the only env var; everything else lives in the file.
|
||||
|
||||
When `origin.service_key` is configured, the server registers itself with Origin as the `CronoCraft App` named `ZoeShaldon` immediately at startup and repeats the check-in every 24 hours. Registration failures are logged without taking down the wedding site. Leave the key empty to disable registration.
|
||||
When `origin.service_key` is configured, the server registers itself with Origin as the `generic` service type named `ZoeShaldon` immediately at startup and repeats the check-in every 24 hours. Registration failures are logged without taking down the wedding site. Leave the key empty to disable registration.
|
||||
|
||||
## Run with Docker
|
||||
Runs behind an existing Apache reverse proxy on the host (Apache owns TLS for `zoeshaldon.warky.info`) — see `PLAN.md` for the Apache vhost config. Not yet verified end-to-end.
|
||||
|
||||
@@ -11,10 +11,11 @@ site:
|
||||
|
||||
wedding:
|
||||
date: "2026-11-07T16:00:00+02:00" # RFC3339, guest arrival time; drives countdown + photo upload gate
|
||||
event_end: "2026-11-07T23:30:00+02:00" # RFC3339, local end time used by calendar downloads
|
||||
ceremony_time: "16:00 for 16:30" # display text
|
||||
rsvp_by: "2026-09-01T00:00:00+02:00" # RFC3339, RSVP deadline; drives the RSVP countdown
|
||||
venue_name: "Rivier Plaas"
|
||||
venue_address: "Langenhoven Rd, Sherman Park AH, Meyerton, 1961"
|
||||
rsvp_by: "2026-10-01T08:00:00+02:00" # RFC3339, RSVP deadline; drives the RSVP countdown
|
||||
venue_name: "Rivier Plaas Wedding Venue"
|
||||
venue_address: "Langenhoven Street, Riversdale, Meyerton, 1961"
|
||||
rsvp_phone: "076 925 1718"
|
||||
|
||||
storage:
|
||||
@@ -27,6 +28,7 @@ email:
|
||||
smtp_pass: ""
|
||||
from: ""
|
||||
to: "" # couple's inbox, receives RSVP + photo notifications
|
||||
bcc: "" # optional; comma-separated list of extra blind-copy recipients
|
||||
|
||||
upload:
|
||||
max_size_mb: 15
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"wedding-server/internal/store"
|
||||
)
|
||||
|
||||
// runCLI handles admin subcommands (listing/removing bad RSVPs or photo
|
||||
// uploads) so fixing a mistake doesn't require touching the database by hand.
|
||||
// Returns the process exit code.
|
||||
func runCLI(dataDir string, st *store.Store, args []string) int {
|
||||
if len(args) == 0 {
|
||||
printCLIUsage()
|
||||
return 1
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "rsvp":
|
||||
return runRSVPCLI(st, args[1:])
|
||||
case "photos":
|
||||
return runPhotosCLI(dataDir, st, args[1:])
|
||||
case "help", "-h", "--help":
|
||||
printCLIUsage()
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", args[0])
|
||||
printCLIUsage()
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func printCLIUsage() {
|
||||
fmt.Println(`Usage:
|
||||
wedding-server start the web server
|
||||
wedding-server rsvp list list all RSVPs
|
||||
wedding-server rsvp delete <id> delete an RSVP and its guests
|
||||
wedding-server photos list list all photo uploads
|
||||
wedding-server photos delete <id> delete a photo upload and its files`)
|
||||
}
|
||||
|
||||
func runRSVPCLI(st *store.Store, args []string) int {
|
||||
if len(args) == 0 {
|
||||
printCLIUsage()
|
||||
return 1
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "list":
|
||||
rsvps, err := st.ListRSVPs()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if len(rsvps) == 0 {
|
||||
fmt.Println("no RSVPs yet")
|
||||
return 0
|
||||
}
|
||||
for _, r := range rsvps {
|
||||
names := make([]string, len(r.Guests))
|
||||
for i, g := range r.Guests {
|
||||
names[i] = g.Name
|
||||
if g.IsChild {
|
||||
names[i] += " (child)"
|
||||
}
|
||||
}
|
||||
fmt.Printf("#%d %s %s\n", r.ID, r.CreatedAt.Local().Format("2006-01-02 15:04"), strings.Join(names, ", "))
|
||||
if r.Message != "" {
|
||||
fmt.Printf(" message: %s\n", r.Message)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
|
||||
case "delete":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: wedding-server rsvp delete <id>")
|
||||
return 1
|
||||
}
|
||||
id, err := strconv.ParseInt(args[1], 10, 64)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid id %q\n", args[1])
|
||||
return 1
|
||||
}
|
||||
if !confirm(fmt.Sprintf("Delete RSVP #%d? This cannot be undone.", id)) {
|
||||
fmt.Println("cancelled")
|
||||
return 0
|
||||
}
|
||||
if err := st.DeleteRSVP(id); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("deleted RSVP #%d\n", id)
|
||||
return 0
|
||||
|
||||
default:
|
||||
printCLIUsage()
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func runPhotosCLI(dataDir string, st *store.Store, args []string) int {
|
||||
if len(args) == 0 {
|
||||
printCLIUsage()
|
||||
return 1
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "list":
|
||||
uploads, err := st.ListPhotoUploads()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if len(uploads) == 0 {
|
||||
fmt.Println("no photo uploads yet")
|
||||
return 0
|
||||
}
|
||||
for _, u := range uploads {
|
||||
fmt.Printf("#%d %s %s (%d photo(s))\n", u.ID, u.CreatedAt.Local().Format("2006-01-02 15:04"), u.Name, len(u.Files))
|
||||
for _, f := range u.Files {
|
||||
fmt.Printf(" %s\n", f.URL)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
|
||||
case "delete":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: wedding-server photos delete <id>")
|
||||
return 1
|
||||
}
|
||||
id, err := strconv.ParseInt(args[1], 10, 64)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid id %q\n", args[1])
|
||||
return 1
|
||||
}
|
||||
if !confirm(fmt.Sprintf("Delete photo upload #%d and its files? This cannot be undone.", id)) {
|
||||
fmt.Println("cancelled")
|
||||
return 0
|
||||
}
|
||||
files, err := st.DeletePhotoUpload(id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := os.Remove(f.Path); err != nil && !os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not remove %s: %v\n", f.Path, err)
|
||||
}
|
||||
}
|
||||
// Best-effort: only removes the per-upload directory if now empty.
|
||||
_ = os.Remove(filepath.Join(dataDir, "photos", strconv.FormatInt(id, 10)))
|
||||
fmt.Printf("deleted photo upload #%d (%d file(s))\n", id, len(files))
|
||||
return 0
|
||||
|
||||
default:
|
||||
printCLIUsage()
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func confirm(prompt string) bool {
|
||||
fmt.Printf("%s [y/N]: ", prompt)
|
||||
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
line = strings.ToLower(strings.TrimSpace(line))
|
||||
return line == "y" || line == "yes"
|
||||
}
|
||||
@@ -36,6 +36,7 @@ func (s *Server) Routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/config", s.handleConfig)
|
||||
mux.HandleFunc("POST /api/rsvp", s.handleRSVP)
|
||||
mux.HandleFunc("GET /api/guests", s.handleGuestList)
|
||||
mux.HandleFunc("GET /api/photos", s.handleListPhotos)
|
||||
mux.HandleFunc("POST /api/photos/upload", s.handlePhotoUpload)
|
||||
}
|
||||
|
||||
@@ -57,6 +58,7 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"siteUrl": s.cfg.Site.URL,
|
||||
"weddingDate": s.cfg.Wedding.Date.Format(time.RFC3339),
|
||||
"eventEnd": s.cfg.Wedding.EventEnd.Format(time.RFC3339),
|
||||
"ceremonyTime": s.cfg.Wedding.CeremonyTime,
|
||||
"rsvpByDate": s.cfg.Wedding.RSVPBy.Format(time.RFC3339),
|
||||
"venueName": s.cfg.Wedding.VenueName,
|
||||
@@ -104,7 +106,11 @@ func (s *Server) handleRSVP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.mailer.SendRSVP(rsvp); err != nil {
|
||||
allGuests, err := s.store.ListGuests()
|
||||
if err != nil {
|
||||
log.Printf("rsvp: list guests failed: %v", err)
|
||||
}
|
||||
if err := s.mailer.SendRSVP(rsvp, allGuests); err != nil {
|
||||
log.Printf("rsvp: email failed: %v", err)
|
||||
}
|
||||
if err := s.webhook.SendRSVP(rsvp); err != nil {
|
||||
@@ -146,6 +152,27 @@ func (s *Server) handleGuestList(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
type photoResponse struct {
|
||||
URL string `json:"url"`
|
||||
UploaderName string `json:"uploaderName"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListPhotos(w http.ResponseWriter, r *http.Request) {
|
||||
files, err := s.store.ListPhotoFiles()
|
||||
if err != nil {
|
||||
log.Printf("photos: list failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not load photos")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]photoResponse, 0, len(files))
|
||||
for _, f := range files {
|
||||
out = append(out, photoResponse{URL: f.URL, UploaderName: f.UploaderName})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"photos": out})
|
||||
}
|
||||
|
||||
var allowedImageTypes = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
@@ -155,11 +182,6 @@ var allowedImageTypes = map[string]bool{
|
||||
}
|
||||
|
||||
func (s *Server) handlePhotoUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if time.Now().Before(s.cfg.Wedding.Date) {
|
||||
writeError(w, http.StatusForbidden, "photo uploads open on the wedding day")
|
||||
return
|
||||
}
|
||||
|
||||
maxTotalBytes := int64(s.cfg.Upload.MaxSizeMB) * int64(s.cfg.Upload.MaxFiles) * 1024 * 1024
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTotalBytes+1<<20)
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ type SiteConfig struct {
|
||||
|
||||
type WeddingConfig struct {
|
||||
Date time.Time `yaml:"date"`
|
||||
EventEnd time.Time `yaml:"event_end"`
|
||||
CeremonyTime string `yaml:"ceremony_time"`
|
||||
RSVPBy time.Time `yaml:"rsvp_by"`
|
||||
VenueName string `yaml:"venue_name"`
|
||||
@@ -53,6 +54,7 @@ type EmailConfig struct {
|
||||
SMTPPass string `yaml:"smtp_pass"`
|
||||
From string `yaml:"from"`
|
||||
To string `yaml:"to"`
|
||||
Bcc string `yaml:"bcc"` // optional; comma-separated list of extra blind-copy recipients
|
||||
}
|
||||
|
||||
type UploadConfig struct {
|
||||
@@ -111,6 +113,9 @@ func Load(path string) (*Config, error) {
|
||||
if cfg.Wedding.RSVPBy.IsZero() {
|
||||
return nil, fmt.Errorf("wedding.rsvp_by is required in %s", path)
|
||||
}
|
||||
if cfg.Wedding.EventEnd.IsZero() {
|
||||
return nil, fmt.Errorf("wedding.event_end is required in %s", path)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -1,21 +1,57 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"wedding-server/internal/config"
|
||||
"wedding-server/internal/store"
|
||||
)
|
||||
|
||||
type Mailer struct {
|
||||
cfg config.EmailConfig
|
||||
cfg config.EmailConfig
|
||||
siteURL string
|
||||
}
|
||||
|
||||
func New(cfg config.EmailConfig) *Mailer {
|
||||
return &Mailer{cfg: cfg}
|
||||
func New(cfg config.EmailConfig, siteURL string) *Mailer {
|
||||
return &Mailer{cfg: cfg, siteURL: strings.TrimSuffix(siteURL, "/")}
|
||||
}
|
||||
|
||||
// absoluteURL turns a site-relative path (e.g. "/uploads/1/foo.jpg") into a
|
||||
// full URL so it's clickable straight from an email client.
|
||||
func (m *Mailer) absoluteURL(path string) string {
|
||||
if m.siteURL == "" {
|
||||
return path
|
||||
}
|
||||
return m.siteURL + path
|
||||
}
|
||||
|
||||
// header prefixes every notification with a one-line explainer of where it
|
||||
// came from and what triggered it, so it's never a mystery in an inbox.
|
||||
func (m *Mailer) header(event string) string {
|
||||
site := m.siteURL
|
||||
if site == "" {
|
||||
site = "the wedding site"
|
||||
}
|
||||
return fmt.Sprintf("This is an automated email that gets triggered for %s when %s.\n\n", site, event)
|
||||
}
|
||||
|
||||
// newMessageID builds an RFC 5322 Message-ID, scoped to the sender's own
|
||||
// domain so it satisfies mail servers that reject messages lacking one.
|
||||
func newMessageID(from string) string {
|
||||
domain := "localhost"
|
||||
if i := strings.LastIndex(from, "@"); i != -1 {
|
||||
domain = strings.TrimSuffix(from[i+1:], ">")
|
||||
}
|
||||
|
||||
var buf [16]byte
|
||||
_, _ = rand.Read(buf[:])
|
||||
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), hex.EncodeToString(buf[:]), domain)
|
||||
}
|
||||
|
||||
func (m *Mailer) send(subject, body string) error {
|
||||
@@ -28,14 +64,21 @@ func (m *Mailer) send(subject, body string) error {
|
||||
auth := smtp.PlainAuth("", m.cfg.SMTPUser, m.cfg.SMTPPass, m.cfg.SMTPHost)
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
|
||||
m.cfg.From, m.cfg.To, subject, body,
|
||||
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMessage-ID: %s\r\nDate: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
|
||||
m.cfg.From, m.cfg.To, subject, newMessageID(m.cfg.From), time.Now().Format(time.RFC1123Z), body,
|
||||
)
|
||||
|
||||
return smtp.SendMail(addr, auth, m.cfg.From, []string{m.cfg.To}, []byte(msg))
|
||||
recipients := []string{m.cfg.To}
|
||||
for bcc := range strings.SplitSeq(m.cfg.Bcc, ",") {
|
||||
if bcc = strings.TrimSpace(bcc); bcc != "" {
|
||||
recipients = append(recipients, bcc)
|
||||
}
|
||||
}
|
||||
|
||||
return smtp.SendMail(addr, auth, m.cfg.From, recipients, []byte(msg))
|
||||
}
|
||||
|
||||
func (m *Mailer) SendRSVP(r store.RSVP) error {
|
||||
func (m *Mailer) SendRSVP(r store.RSVP, allGuests []store.Guest) error {
|
||||
names := make([]string, len(r.Guests))
|
||||
adults, children := 0, 0
|
||||
for i, g := range r.Guests {
|
||||
@@ -54,7 +97,23 @@ func (m *Mailer) SendRSVP(r store.RSVP) error {
|
||||
if r.Message != "" {
|
||||
fmt.Fprintf(&b, "\nMessage:\n%s\n", r.Message)
|
||||
}
|
||||
return m.send(fmt.Sprintf("RSVP from %s", joined), b.String())
|
||||
|
||||
if len(allGuests) > 0 {
|
||||
totalAdults, totalChildren := 0, 0
|
||||
fmt.Fprintf(&b, "\nRSVPs so far (%d guest(s)):\n", len(allGuests))
|
||||
for _, g := range allGuests {
|
||||
if g.IsChild {
|
||||
totalChildren++
|
||||
fmt.Fprintf(&b, "- %s (child)\n", g.Name)
|
||||
} else {
|
||||
totalAdults++
|
||||
fmt.Fprintf(&b, "- %s\n", g.Name)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "\n%d adult(s), %d child(ren) total\n", totalAdults, totalChildren)
|
||||
}
|
||||
|
||||
return m.send(fmt.Sprintf("RSVP from %s", joined), m.header("an RSVP is made")+b.String())
|
||||
}
|
||||
|
||||
func (m *Mailer) SendPhotoUpload(u store.PhotoUpload, files []store.PhotoFile) error {
|
||||
@@ -71,7 +130,7 @@ func (m *Mailer) SendPhotoUpload(u store.PhotoUpload, files []store.PhotoFile) e
|
||||
}
|
||||
b.WriteString("\nPhotos:\n")
|
||||
for _, f := range files {
|
||||
fmt.Fprintf(&b, "- %s\n", f.URL)
|
||||
fmt.Fprintf(&b, "- %s\n", m.absoluteURL(f.URL))
|
||||
}
|
||||
return m.send(fmt.Sprintf("%s uploaded photos", u.Name), b.String())
|
||||
return m.send(fmt.Sprintf("%s uploaded photos", u.Name), m.header("photos are uploaded")+b.String())
|
||||
}
|
||||
|
||||
@@ -102,6 +102,76 @@ func (s *Store) ListGuests() ([]Guest, error) {
|
||||
return guests, rows.Err()
|
||||
}
|
||||
|
||||
type RSVPRecord struct {
|
||||
ID int64
|
||||
Message string
|
||||
CreatedAt time.Time
|
||||
Guests []Guest
|
||||
}
|
||||
|
||||
// ListRSVPs returns every RSVP submission (not flattened per-guest), oldest first.
|
||||
func (s *Store) ListRSVPs() ([]RSVPRecord, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT r.id, r.message, r.created_at, g.name, g.is_child
|
||||
FROM rsvps r
|
||||
LEFT JOIN rsvp_guests g ON g.rsvp_id = r.id
|
||||
ORDER BY r.created_at ASC, r.id ASC, g.id ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []RSVPRecord
|
||||
index := map[int64]int{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var message string
|
||||
var createdAt time.Time
|
||||
var name sql.NullString
|
||||
var isChild sql.NullBool
|
||||
if err := rows.Scan(&id, &message, &createdAt, &name, &isChild); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i, ok := index[id]
|
||||
if !ok {
|
||||
out = append(out, RSVPRecord{ID: id, Message: message, CreatedAt: createdAt})
|
||||
i = len(out) - 1
|
||||
index[id] = i
|
||||
}
|
||||
if name.Valid {
|
||||
out[i].Guests = append(out[i].Guests, Guest{Name: name.String, IsChild: isChild.Bool})
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteRSVP removes an RSVP submission and its guests.
|
||||
func (s *Store) DeleteRSVP(id int64) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM rsvp_guests WHERE rsvp_id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(`DELETE FROM rsvps WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("rsvp %d not found", id)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) InsertRSVP(r RSVP) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
@@ -159,6 +229,128 @@ func (s *Store) CreatePhotoUpload(u PhotoUpload) (int64, error) {
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
type PhotoFileRecord struct {
|
||||
URL string
|
||||
UploaderName string
|
||||
}
|
||||
|
||||
// ListPhotoFiles returns every uploaded photo, most recent first.
|
||||
func (s *Store) ListPhotoFiles() ([]PhotoFileRecord, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT f.url, u.name
|
||||
FROM photo_files f
|
||||
JOIN photo_uploads u ON u.id = f.upload_id
|
||||
ORDER BY f.created_at DESC, f.id DESC
|
||||
LIMIT 200`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []PhotoFileRecord
|
||||
for rows.Next() {
|
||||
var rec PhotoFileRecord
|
||||
if err := rows.Scan(&rec.URL, &rec.UploaderName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type PhotoUploadRecord struct {
|
||||
ID int64
|
||||
Name string
|
||||
Message string
|
||||
CreatedAt time.Time
|
||||
Files []PhotoFile
|
||||
}
|
||||
|
||||
// ListPhotoUploads returns every upload submission (not flattened per-file), oldest first.
|
||||
func (s *Store) ListPhotoUploads() ([]PhotoUploadRecord, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT u.id, u.name, u.message, u.created_at, f.filename, f.path, f.url
|
||||
FROM photo_uploads u
|
||||
LEFT JOIN photo_files f ON f.upload_id = u.id
|
||||
ORDER BY u.created_at ASC, u.id ASC, f.id ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []PhotoUploadRecord
|
||||
index := map[int64]int{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var name, message string
|
||||
var createdAt time.Time
|
||||
var filename, path, url sql.NullString
|
||||
if err := rows.Scan(&id, &name, &message, &createdAt, &filename, &path, &url); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i, ok := index[id]
|
||||
if !ok {
|
||||
out = append(out, PhotoUploadRecord{ID: id, Name: name, Message: message, CreatedAt: createdAt})
|
||||
i = len(out) - 1
|
||||
index[id] = i
|
||||
}
|
||||
if filename.Valid {
|
||||
out[i].Files = append(out[i].Files, PhotoFile{Filename: filename.String, Path: path.String, URL: url.String})
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeletePhotoUpload removes an upload's DB rows and returns the files it had,
|
||||
// so the caller can also remove them from disk.
|
||||
func (s *Store) DeletePhotoUpload(id int64) ([]PhotoFile, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
rows, err := tx.Query(`SELECT filename, path, url FROM photo_files WHERE upload_id = ?`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var files []PhotoFile
|
||||
for rows.Next() {
|
||||
var f PhotoFile
|
||||
if err := rows.Scan(&f.Filename, &f.Path, &f.URL); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM photo_files WHERE upload_id = ?`, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := tx.Exec(`DELETE FROM photo_uploads WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, fmt.Errorf("photo upload %d not found", id)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (s *Store) AddPhotoFiles(uploadID int64, files []PhotoFile) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
|
||||
@@ -37,9 +37,15 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("store: %v", err)
|
||||
}
|
||||
|
||||
if len(os.Args) > 1 {
|
||||
code := runCLI(cfg.Storage.DataDir, st, os.Args[1:])
|
||||
st.Close()
|
||||
os.Exit(code)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
mailer := mail.New(cfg.Email)
|
||||
mailer := mail.New(cfg.Email, cfg.Site.URL)
|
||||
wh := webhook.New(cfg.Webhook)
|
||||
if cfg.Origin.ServiceKey == "" {
|
||||
log.Printf("origin registration disabled: origin.service_key is not configured")
|
||||
@@ -47,7 +53,7 @@ func main() {
|
||||
origin, err := originclient.New(originclient.Config{
|
||||
URL: cfg.Origin.URL,
|
||||
ServiceKey: cfg.Origin.ServiceKey,
|
||||
Type: "CronoCraft App",
|
||||
Type: "generic",
|
||||
Version: cfg.Origin.Version,
|
||||
Name: "ZoeShaldon",
|
||||
AppType: "go",
|
||||
@@ -85,12 +91,16 @@ func main() {
|
||||
func staticHandler(fsys fs.FS) http.Handler {
|
||||
fileServer := http.FileServer(http.FS(fsys))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := strings.TrimPrefix(r.URL.Path, "/")
|
||||
p := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/")
|
||||
if p == "" {
|
||||
p = "index.html"
|
||||
}
|
||||
if _, err := fs.Stat(fsys, p); err != nil {
|
||||
if _, err := fs.Stat(fsys, p+".html"); err == nil {
|
||||
// A route name (e.g. "photos") can collide with a static asset
|
||||
// directory of the same name; prefer the prerendered page in
|
||||
// that case instead of falling through to a directory listing.
|
||||
info, statErr := fs.Stat(fsys, p)
|
||||
if statErr != nil || info.IsDir() {
|
||||
if hinfo, err := fs.Stat(fsys, p+".html"); err == nil && !hinfo.IsDir() {
|
||||
r2 := *r
|
||||
r2.URL.Path = "/" + p + ".html"
|
||||
fileServer.ServeHTTP(w, &r2)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<!-- placeholder, replaced by the ui build output before building the Go binary -->
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface WeddingConfig {
|
||||
siteUrl: string;
|
||||
weddingDate: string;
|
||||
eventEnd: string;
|
||||
ceremonyTime: string;
|
||||
rsvpByDate: string;
|
||||
venueName: string;
|
||||
@@ -20,6 +21,11 @@ export interface RsvpPayload {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface GuestPhoto {
|
||||
url: string;
|
||||
uploaderName: string;
|
||||
}
|
||||
|
||||
export interface PhotoUploadPayload {
|
||||
name: string;
|
||||
message?: string;
|
||||
@@ -80,6 +86,13 @@ export async function fetchGuests(fetchImpl: typeof fetch = fetch): Promise<Gues
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchGuestPhotos(fetchImpl: typeof fetch = fetch): Promise<GuestPhoto[]> {
|
||||
const response = await fetchImpl('/api/photos');
|
||||
if (!response.ok) throw new ApiError(await parseError(response), response.status);
|
||||
const body = await response.json();
|
||||
return body.photos ?? [];
|
||||
}
|
||||
|
||||
export async function uploadPhotos(payload: PhotoUploadPayload): Promise<void> {
|
||||
const form = new FormData();
|
||||
form.set('name', payload.name);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
function toICSDate(date: Date): string {
|
||||
return date.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
|
||||
}
|
||||
|
||||
function escapeICSText(text: string): string {
|
||||
return text.replace(/([,;])/g, "\\$1");
|
||||
}
|
||||
|
||||
export function buildIcsDataUrl(event: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
summary: string;
|
||||
location: string;
|
||||
description: string;
|
||||
}): string {
|
||||
const lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Zoe & Shaldon Wedding//EN",
|
||||
"BEGIN:VEVENT",
|
||||
`UID:${event.start.getTime()}@zoeshaldon.warky.info`,
|
||||
`DTSTAMP:${toICSDate(new Date())}`,
|
||||
`DTSTART:${toICSDate(event.start)}`,
|
||||
`DTEND:${toICSDate(event.end)}`,
|
||||
`SUMMARY:${escapeICSText(event.summary)}`,
|
||||
`LOCATION:${escapeICSText(event.location)}`,
|
||||
`DESCRIPTION:${escapeICSText(event.description)}`,
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
].join("\r\n");
|
||||
|
||||
return `data:text/calendar;charset=utf8,${encodeURIComponent(lines)}`;
|
||||
}
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="flex items-baseline gap-2 z-10" role="timer" aria-live="polite">
|
||||
{#each units as unit, i (unit.label)}
|
||||
{#if i > 0}<span class="text-secondary-300/60">·</span>{/if}
|
||||
<span class="font-semibold tabular-nums text-md md:text-3xl"
|
||||
<span class="text-surface-50 font-semibold tabular-nums text-md md:text-3xl"
|
||||
>{unit.value}</span
|
||||
>
|
||||
<span class="text-secondary-100/80 text-md md:text-3xl">{unit.label}</span
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
const { count = 6 }: { count?: number } = $props();
|
||||
|
||||
const photos = $derived(
|
||||
__PHOTO_FILES__.slice(0, count).map(
|
||||
(name) => `/photos/${encodeURIComponent(name)}`,
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if photos.length > 0}
|
||||
<div class="mb-10">
|
||||
<h2 class="font-serif text-center text-3xl">A Few of Our Favourites</h2>
|
||||
<div class="mt-4 grid grid-cols-3 gap-2">
|
||||
{#each photos as src (src)}
|
||||
<img
|
||||
{src}
|
||||
alt=""
|
||||
class="border-surface-300 dark:border-surface-700 aspect-square w-full rounded-base border object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2,9 +2,10 @@
|
||||
// fallback while /api/config loads (and when developing the UI without the Go server).
|
||||
export const FALLBACK_SITE_URL = "https://zoeshaldon.warky.info";
|
||||
export const FALLBACK_WEDDING_DATE = "2026-11-07T16:00:00+02:00";
|
||||
export const FALLBACK_EVENT_END = "2026-11-07T23:30:00+02:00";
|
||||
export const FALLBACK_CEREMONY_TIME = "16:00 for 16:30";
|
||||
export const FALLBACK_RSVP_BY_DATE = "2026-09-01T00:00:00+02:00";
|
||||
export const FALLBACK_VENUE_NAME = "Rivier Plaas";
|
||||
export const FALLBACK_RSVP_BY_DATE = "2026-10-17T00:00:00+02:00";
|
||||
export const FALLBACK_VENUE_NAME = "Rivier Plaas Wedding Venue";
|
||||
export const FALLBACK_VENUE_ADDRESS =
|
||||
"Langenhoven Rd, Sherman Park AH, Meyerton, 1961";
|
||||
"Langenhoven Street, Riversdale, Meyerton, 1961";
|
||||
export const FALLBACK_RSVP_PHONE = "076 925 1718";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { fetchConfig } from './api';
|
||||
import {
|
||||
FALLBACK_SITE_URL,
|
||||
FALLBACK_WEDDING_DATE,
|
||||
FALLBACK_EVENT_END,
|
||||
FALLBACK_CEREMONY_TIME,
|
||||
FALLBACK_RSVP_BY_DATE,
|
||||
FALLBACK_VENUE_NAME,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
export function createWeddingInfo() {
|
||||
let siteUrl = $state(FALLBACK_SITE_URL);
|
||||
let date = $state(new Date(FALLBACK_WEDDING_DATE));
|
||||
let eventEnd = $state(new Date(FALLBACK_EVENT_END));
|
||||
let ceremonyTime = $state(FALLBACK_CEREMONY_TIME);
|
||||
let rsvpByDate = $state(new Date(FALLBACK_RSVP_BY_DATE));
|
||||
let venueName = $state(FALLBACK_VENUE_NAME);
|
||||
@@ -25,6 +27,7 @@ export function createWeddingInfo() {
|
||||
.then((config) => {
|
||||
siteUrl = config.siteUrl;
|
||||
date = new Date(config.weddingDate);
|
||||
eventEnd = new Date(config.eventEnd);
|
||||
ceremonyTime = config.ceremonyTime;
|
||||
rsvpByDate = new Date(config.rsvpByDate);
|
||||
venueName = config.venueName;
|
||||
@@ -46,6 +49,9 @@ export function createWeddingInfo() {
|
||||
get ceremonyTime() {
|
||||
return ceremonyTime;
|
||||
},
|
||||
get eventEnd() {
|
||||
return eventEnd;
|
||||
},
|
||||
get rsvpByDate() {
|
||||
return rsvpByDate;
|
||||
},
|
||||
|
||||
@@ -3,16 +3,26 @@
|
||||
import favicon from "$lib/assets/favicon.svg";
|
||||
import { page } from "$app/state";
|
||||
import Countdown from "$lib/components/Countdown.svelte";
|
||||
import { createWeddingInfo } from "$lib/wedding-info.svelte";
|
||||
import { createWeddingInfo, formatFullDate } from "$lib/wedding-info.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const wedding = createWeddingInfo();
|
||||
|
||||
const metaDescription = $derived(
|
||||
`Zoé & Shaldon are getting married on ${formatFullDate(wedding.date)} at ${wedding.venueName}, ${wedding.venueAddress}. Find venue directions, dress code, the order of events, and RSVP here.`,
|
||||
);
|
||||
const shareDescription = $derived(
|
||||
`${formatFullDate(wedding.date)} at ${wedding.venueName}, ${wedding.venueAddress}. Directions, dress code, the schedule, and RSVP.`,
|
||||
);
|
||||
|
||||
const links = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/rsvp", label: "RSVP" },
|
||||
{ href: "/photos", label: "Photos" },
|
||||
{ href: "/venue", label: "Venue" },
|
||||
{ href: "/details", label: "Details" },
|
||||
{ href: "/wedding-party", label: "Wedding Party" },
|
||||
];
|
||||
|
||||
let menuOpen = $state(false);
|
||||
@@ -26,6 +36,19 @@
|
||||
<svelte:head>
|
||||
<title>Zoé & Shaldon are getting married</title>
|
||||
<link rel="icon" href={favicon} />
|
||||
|
||||
<meta name="description" content={metaDescription} />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Zoé & Shaldon are getting married" />
|
||||
<meta property="og:description" content={shareDescription} />
|
||||
<meta property="og:url" content={wedding.siteUrl} />
|
||||
<meta property="og:image" content="{wedding.siteUrl}/photos/IMG_0528.JPG" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Zoé & Shaldon are getting married" />
|
||||
<meta name="twitter:description" content={shareDescription} />
|
||||
<meta name="twitter:image" content="{wedding.siteUrl}/photos/IMG_0528.JPG" />
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
@@ -104,7 +127,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<main class="flex flex-1 flex-col">
|
||||
<main
|
||||
class="text-surface-900 dark:text-surface-100 text-shadow-sm text-shadow-white/80 dark:text-shadow-black/70 flex flex-1 flex-col"
|
||||
>
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script lang="ts">
|
||||
import PhotoBackground from "$lib/components/PhotoBackground.svelte";
|
||||
import { createWeddingInfo, formatFullDate } from "$lib/wedding-info.svelte";
|
||||
import { createRsvpStatus } from "$lib/rsvp-status.svelte";
|
||||
|
||||
const wedding = createWeddingInfo();
|
||||
const rsvpStatus = createRsvpStatus();
|
||||
</script>
|
||||
|
||||
@@ -13,28 +11,31 @@
|
||||
<PhotoBackground />
|
||||
|
||||
<div
|
||||
class="border-surface-50/70 relative z-10 flex max-w-xl flex-col items-center gap-6 border px-6 py-12 sm:px-14 sm:py-16"
|
||||
class="border-surface-50/70 text-shadow-sm text-shadow-black/60 relative z-10 flex max-w-xl flex-col items-center gap-6 border px-6 py-12 sm:px-14 sm:py-16"
|
||||
>
|
||||
<p class="text-secondary-300 font-serif text-2xl italic">
|
||||
"Every love story is beautiful, but ours is my favourite."
|
||||
</p>
|
||||
|
||||
<h1 class="text-surface-50 font-malibu text-6xl sm:text-8xl">
|
||||
Zoé & Shaldon
|
||||
</h1>
|
||||
<p class="text-surface-100 text-2xl">are getting married</p>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-surface-50 text-2xl font-medium sm:text-3xl">
|
||||
{formatFullDate(wedding.date)}
|
||||
<div class="flex flex-col gap-4 text-center">
|
||||
<p class="text-surface-100 text-xl">
|
||||
Welcome to our wedding website! We are so excited to celebrate
|
||||
our love with our favourite people. As our forever begins, we want to
|
||||
thank you for your love and support.
|
||||
</p>
|
||||
<p class="text-surface-200 text-2xl tracking-wide">
|
||||
{wedding.ceremonyTime}
|
||||
<p class="text-surface-100 text-xl">
|
||||
Browse the menu above to find venue directions, dress code details, and
|
||||
RSVP information.
|
||||
</p>
|
||||
<p class="text-surface-100 text-xl">We can't wait to see you soon!</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="text-surface-50 text-2xl">{wedding.venueName}</p>
|
||||
<p class="text-surface-100 text-2xl">{wedding.venueAddress}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-4">
|
||||
<div class="mt-2 flex flex-wrap items-center justify-center gap-4">
|
||||
{#if !rsvpStatus.submitted}
|
||||
<a href="/rsvp" class="btn preset-filled-secondary-500 text-2xl">RSVP</a
|
||||
>
|
||||
@@ -44,6 +45,11 @@
|
||||
<a href="/photos" class="btn preset-filled-primary-500 text-2xl"
|
||||
>Share photos</a
|
||||
>
|
||||
<a href="/details" class="btn preset-tonal-secondary text-2xl">Details</a>
|
||||
</div>
|
||||
|
||||
<p class="text-surface-200 font-serif text-2xl">
|
||||
With love,<br />Zoé & Shaldon
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import PhotoBackground from "$lib/components/PhotoBackground.svelte";
|
||||
import { createWeddingInfo, formatFullDate } from "$lib/wedding-info.svelte";
|
||||
import { buildIcsDataUrl } from "$lib/calendar";
|
||||
|
||||
const wedding = createWeddingInfo();
|
||||
|
||||
const icsUrl = $derived(
|
||||
buildIcsDataUrl({
|
||||
start: wedding.date,
|
||||
end: wedding.eventEnd,
|
||||
summary: "Zoé & Shaldon's Wedding",
|
||||
location: `${wedding.venueName}, ${wedding.venueAddress}`,
|
||||
description: `Join us as we say I do! ${wedding.ceremonyTime}.`,
|
||||
}),
|
||||
);
|
||||
|
||||
const events = [
|
||||
{ time: "16:00", label: "Guest Arrival" },
|
||||
{ time: "16:30", label: "Wedding Ceremony Begins" },
|
||||
{ time: "17:00", label: "The New Mr. & Mrs. (Ceremony Concludes)" },
|
||||
{ time: "17:15", label: "Canapés, Drinks & Family Photos" },
|
||||
{ time: "18:15", label: "Reception Hall Doors Open" },
|
||||
{ time: "18:30", label: "Grand Entrance & Welcoming" },
|
||||
{ time: "19:00", label: "Speeches and Toasting" },
|
||||
{ time: "19:30", label: "Dinner is Served" },
|
||||
{ time: "20:30", label: "Cutting of the Cake & First Dance" },
|
||||
{ time: "20:40", label: "Dance Floor Opens" },
|
||||
{ time: "23:30", label: "Last Dance & Farewell" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="relative flex flex-1 items-center justify-center overflow-hidden px-4 py-16 z-10"
|
||||
>
|
||||
<PhotoBackground />
|
||||
|
||||
<div
|
||||
class="border-surface-50/70 text-shadow-sm text-shadow-black/60 relative z-10 w-full max-w-md border px-6 py-12 sm:px-14 sm:py-16"
|
||||
>
|
||||
<h1 class="text-surface-50 font-serif text-4xl">Important Information</h1>
|
||||
<p class="text-secondary-300 mt-2 text-2xl">
|
||||
Please <a href="/rsvp" class="underline hover:text-secondary-400">RSVP</a> by {formatFullDate(wedding.rsvpByDate)}.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-1">
|
||||
<h2 class="text-surface-50 font-serif text-3xl">Dress Code: Formal</h2>
|
||||
<p class="text-surface-100 text-2xl">
|
||||
We ask that our guests dress in formal attire. Think elegant dresses
|
||||
and sharp suits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-surface-50/30 mt-10 border-t pt-6">
|
||||
<h2 class="text-surface-50 font-serif text-3xl">Food & Drinks</h2>
|
||||
<p class="text-surface-100 mt-2 text-2xl">
|
||||
A delicious dinner will be served. Please note that a cash bar is
|
||||
available for the evening. Card facilities will be available, but we
|
||||
suggest bringing cash just in case.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-surface-50/30 mt-10 border-t pt-6">
|
||||
<h2 class="text-surface-50 font-serif text-3xl">Honeymoon Fund</h2>
|
||||
<p class="text-surface-100 mt-2 text-2xl">
|
||||
Your presence on our special day is the greatest gift we could ask
|
||||
for. However, if you would like to honour us with a gift, a
|
||||
contribution toward our future home or honeymoon would be warmly
|
||||
appreciated.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-surface-50/30 mt-10 border-t pt-6">
|
||||
<h2 class="text-surface-50 font-serif text-3xl">Order of Events</h2>
|
||||
<p class="text-surface-100 mt-2 text-2xl">
|
||||
Zoé & Shaldon — Saturday, 7 November 2026
|
||||
</p>
|
||||
<a
|
||||
href={icsUrl}
|
||||
download="zoe-and-shaldon-wedding.ics"
|
||||
class="btn preset-tonal-secondary mt-3 text-2xl"
|
||||
>
|
||||
Add to calendar
|
||||
</a>
|
||||
<ul class="mt-4 flex flex-col gap-2">
|
||||
{#each events as event (event.time)}
|
||||
<li
|
||||
class="border-surface-50/30 flex items-baseline gap-4 border-b py-2"
|
||||
>
|
||||
<span class="text-secondary-300 w-16 shrink-0 text-2xl"
|
||||
>{event.time}</span
|
||||
>
|
||||
<span class="text-surface-100 text-2xl">{event.label}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-surface-50/30 mt-10 border-t pt-6 text-center">
|
||||
<a href="/thanks" class="btn preset-tonal-secondary text-2xl"
|
||||
>A Special Note of Thanks</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,28 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { createWeddingInfo, formatFullDate } from '$lib/wedding-info.svelte';
|
||||
import { uploadPhotos, ApiError } from '$lib/api';
|
||||
import { browser } from '$app/environment';
|
||||
import { uploadPhotos, fetchGuestPhotos, ApiError, type GuestPhoto } from '$lib/api';
|
||||
import PhotoGallery from '$lib/components/PhotoGallery.svelte';
|
||||
|
||||
const MAX_FILES = 10;
|
||||
const MAX_SIZE_MB = 15;
|
||||
|
||||
const wedding = createWeddingInfo();
|
||||
|
||||
let now = $state(Date.now());
|
||||
$effect(() => {
|
||||
const id = setInterval(() => (now = Date.now()), 30_000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
const isOpen = $derived(now >= wedding.date.getTime());
|
||||
|
||||
let name = $state('');
|
||||
let message = $state('');
|
||||
let email = $state('');
|
||||
let phone = $state('');
|
||||
let files: FileList | undefined = $state();
|
||||
|
||||
let status = $state<'idle' | 'submitting' | 'done' | 'error'>('idle');
|
||||
let errorMessage = $state('');
|
||||
|
||||
let guestPhotos = $state<GuestPhoto[]>([]);
|
||||
let guestPhotosStatus = $state<'loading' | 'done' | 'error'>('loading');
|
||||
|
||||
function loadGuestPhotos() {
|
||||
fetchGuestPhotos()
|
||||
.then((photos) => {
|
||||
guestPhotos = photos;
|
||||
guestPhotosStatus = 'done';
|
||||
})
|
||||
.catch(() => {
|
||||
guestPhotosStatus = 'error';
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
loadGuestPhotos();
|
||||
});
|
||||
|
||||
async function onSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!files || files.length === 0) {
|
||||
@@ -49,11 +58,10 @@
|
||||
await uploadPhotos({
|
||||
name,
|
||||
message: message || undefined,
|
||||
email: email || undefined,
|
||||
phone: phone || undefined,
|
||||
files
|
||||
});
|
||||
status = 'done';
|
||||
loadGuestPhotos();
|
||||
} catch (err) {
|
||||
status = 'error';
|
||||
errorMessage = err instanceof ApiError ? err.message : 'Something went wrong. Please try again.';
|
||||
@@ -62,20 +70,17 @@
|
||||
</script>
|
||||
|
||||
<section class="flex flex-1 items-center justify-center px-4 py-16">
|
||||
{#if !isOpen}
|
||||
<div class="flex flex-col items-center gap-6 text-center">
|
||||
<h1 class="font-serif text-4xl">Photo uploads open on the wedding day</h1>
|
||||
<p class="text-surface-600-400 text-2xl">Come back on {formatFullDate(wedding.date)} to share your photos with Zoé & Shaldon.</p>
|
||||
</div>
|
||||
{:else if status === 'done'}
|
||||
<div class="preset-filled-primary-500 rounded-base max-w-md p-4 text-center">
|
||||
Thank you — your photos have been uploaded!
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full max-w-md">
|
||||
<div class="w-full max-w-md">
|
||||
<PhotoGallery />
|
||||
|
||||
{#if status === 'done'}
|
||||
<div class="preset-filled-primary-500 rounded-base p-4 text-center">
|
||||
Thank you — your photos have been uploaded!
|
||||
</div>
|
||||
{:else}
|
||||
<h1 class="font-serif text-4xl">Share your photos</h1>
|
||||
<p class="text-surface-600-400 mt-2 mb-8 text-2xl">
|
||||
Upload photos from the day — up to {MAX_FILES} at a time, {MAX_SIZE_MB}MB each.
|
||||
Upload your photos of Zoé & Shaldon — up to {MAX_FILES} at a time, {MAX_SIZE_MB}MB each.
|
||||
</p>
|
||||
|
||||
<form class="flex flex-col gap-4" onsubmit={onSubmit}>
|
||||
@@ -90,16 +95,6 @@
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="label">
|
||||
<span class="label-text text-2xl">Email (optional)</span>
|
||||
<input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700 text-2xl" type="email" bind:value={email} disabled={status === 'submitting'} />
|
||||
</label>
|
||||
|
||||
<label class="label">
|
||||
<span class="label-text text-2xl">Phone (optional)</span>
|
||||
<input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700 text-2xl" type="tel" bind:value={phone} disabled={status === 'submitting'} />
|
||||
</label>
|
||||
|
||||
<label class="label">
|
||||
<span class="label-text text-2xl">Photos *</span>
|
||||
<input
|
||||
@@ -121,6 +116,30 @@
|
||||
{status === 'submitting' ? 'Uploading…' : 'Upload photos'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="border-surface-300 dark:border-surface-700 mt-10 border-t pt-6">
|
||||
<h2 class="font-serif text-center text-3xl">Photos From Our Guests</h2>
|
||||
{#if guestPhotosStatus === 'loading'}
|
||||
<p class="text-surface-600-400 mt-4 text-center text-2xl">Loading…</p>
|
||||
{:else if guestPhotosStatus === 'error'}
|
||||
<p class="text-error-500 mt-4 text-center text-2xl">Could not load guest photos.</p>
|
||||
{:else if guestPhotos.length === 0}
|
||||
<p class="text-surface-600-400 mt-4 text-center text-2xl">No photos yet — be the first to share!</p>
|
||||
{:else}
|
||||
<div class="mt-4 grid grid-cols-3 gap-2">
|
||||
{#each guestPhotos as photo (photo.url)}
|
||||
<a href={photo.url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={photo.url}
|
||||
alt="Shared by {photo.uploaderName}"
|
||||
class="border-surface-300 dark:border-surface-700 aspect-square w-full rounded-base border object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import PhotoBackground from "$lib/components/PhotoBackground.svelte";
|
||||
|
||||
const thanks = [
|
||||
{ name: "Gayle Muller-Lombard", role: "Mother of the Bride" },
|
||||
{ name: "Jan Lombard", role: "Stepdad of the Bride" },
|
||||
{ name: "Vera Muller", role: "Grandmother of the Bride" },
|
||||
{ name: "Christian Els", role: "Cousin of the Bride" },
|
||||
{ name: "Divan Swart", role: "Father of the Bride" },
|
||||
{ name: "Louis Pietser", role: "Father of the Groom" },
|
||||
{ name: "Dezelle Ras", role: "Friend" },
|
||||
{ name: "Cindy Berriman-Puth", role: "Friend" },
|
||||
{ name: "Hein Puth (Warky Devs)", role: "Website & Friend" },
|
||||
{ name: "Ronel Gous", role: "Ronel Events and Designs & Friend" },
|
||||
{ name: "Monica", role: "Rivier Plaas Wedding Venue" },
|
||||
{ name: "Anton (Music Fanatics)", role: "DJ" },
|
||||
{ name: "Alan Cameron (AA Photography)", role: "Photography" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="relative flex flex-1 items-center justify-center overflow-hidden px-4 py-16 z-10"
|
||||
>
|
||||
<PhotoBackground />
|
||||
|
||||
<div
|
||||
class="border-surface-50/70 text-shadow-sm text-shadow-black/60 relative z-10 w-full max-w-md border px-6 py-12 sm:px-14 sm:py-16"
|
||||
>
|
||||
<h1 class="text-surface-50 font-serif text-4xl">
|
||||
A Special Note of Thanks
|
||||
</h1>
|
||||
<p class="text-surface-100 mt-2 text-2xl">
|
||||
To some very special people in our lives who have helped us make our day
|
||||
magical:
|
||||
</p>
|
||||
<ul class="mt-4 flex flex-col gap-2">
|
||||
{#each thanks as person (person.name)}
|
||||
<li
|
||||
class="border-surface-50/30 flex items-center justify-between border-b py-2"
|
||||
>
|
||||
<span class="text-surface-50 text-2xl">{person.name}</span>
|
||||
<span class="text-secondary-300 text-lg">{person.role}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="text-surface-100 mt-6 text-xl">
|
||||
If there is anyone we have forgotten about, please know that you are all
|
||||
special to us!
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import PhotoBackground from "$lib/components/PhotoBackground.svelte";
|
||||
import { createWeddingInfo } from "$lib/wedding-info.svelte";
|
||||
|
||||
const wedding = createWeddingInfo();
|
||||
|
||||
const directionsUrl = $derived(
|
||||
`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(
|
||||
`${wedding.venueName}, ${wedding.venueAddress}`,
|
||||
)}`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="relative flex flex-1 items-center justify-center overflow-hidden px-4 py-16 z-10"
|
||||
>
|
||||
<PhotoBackground />
|
||||
|
||||
<div
|
||||
class="border-surface-50/70 text-shadow-sm text-shadow-black/60 relative z-10 w-full max-w-md border px-6 py-12 sm:px-14 sm:py-16"
|
||||
>
|
||||
<h1 class="text-surface-50 font-serif text-4xl">The Venue</h1>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-1">
|
||||
<p class="text-surface-100 text-2xl">{wedding.venueName}</p>
|
||||
<p class="text-surface-200 text-2xl">{wedding.venueAddress}</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={directionsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn preset-filled-secondary-500 mt-4 text-2xl"
|
||||
>
|
||||
Get directions
|
||||
</a>
|
||||
|
||||
<div class="border-surface-50/30 mt-10 border-t pt-6">
|
||||
<h2 class="text-surface-50 font-serif text-3xl">Parking</h2>
|
||||
<p class="text-surface-100 mt-2 text-2xl">
|
||||
Secure parking is available on-site at the venue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-surface-50/30 mt-10 border-t pt-6">
|
||||
<h2 class="text-surface-50 font-serif text-3xl">Accommodation</h2>
|
||||
<p class="text-surface-100 mt-2 text-2xl">Plaaskombuis Meyerton</p>
|
||||
<div class="text-surface-200 mt-1 flex flex-col text-2xl">
|
||||
<a href="tel:0163644144" class="hover:text-secondary-400">016 364 4144</a>
|
||||
<a href="mailto:marisel@plaaskombuis.co.za" class="hover:text-secondary-400"
|
||||
>marisel@plaaskombuis.co.za</a
|
||||
>
|
||||
<a
|
||||
href="https://www.plaaskombuis.co.za"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-secondary-400"
|
||||
>
|
||||
www.plaaskombuis.co.za
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
function photo(file: string) {
|
||||
return `/people/${encodeURIComponent(file)}`;
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((word) => word[0])
|
||||
.slice(0, 2)
|
||||
.join("");
|
||||
}
|
||||
|
||||
const bride = [
|
||||
{
|
||||
role: "Maid of Honour",
|
||||
name: "Mart-Marie Neethling",
|
||||
note: "Keeper of the bride's sanity and secrets.",
|
||||
photo: photo("Mart-Marie.jpeg"),
|
||||
},
|
||||
{
|
||||
role: "Bridesmaid",
|
||||
name: "Bronwin Williams",
|
||||
note: "Childhood friend and the leader of the ultimate hype squad.",
|
||||
photo: photo("Bronwin.jpeg"),
|
||||
},
|
||||
{
|
||||
role: "Bridesmaid",
|
||||
name: "Noel Du Toit",
|
||||
note: '"The life of every party" — Cousin.',
|
||||
photo: photo("Noel Du Toit.jpeg"),
|
||||
},
|
||||
{
|
||||
role: "Flower Girl",
|
||||
name: "Echo",
|
||||
note: "The cutest little Flower Girl ever seen.",
|
||||
photo: photo("echo.jpg"),
|
||||
},
|
||||
];
|
||||
|
||||
const groom = [
|
||||
{
|
||||
role: "Best Man",
|
||||
name: "Justin Pieterse",
|
||||
note: "Tasked with keeping the speech short and the groom sane. (Not that the groom has ever been sane.)",
|
||||
photo: photo("Justin.jpeg"),
|
||||
},
|
||||
{
|
||||
role: "Groomsman",
|
||||
name: "Harold Harding",
|
||||
note: "Childhood friend, and always has the groom's back.",
|
||||
photo: photo("Harold.jpeg"),
|
||||
},
|
||||
{
|
||||
role: "Groomsman",
|
||||
name: "Dehan Kruger",
|
||||
note: "Chilled, laid back, and the life of every party!",
|
||||
photo: photo("Dehan.jpeg"),
|
||||
},
|
||||
{
|
||||
role: "Groomsman",
|
||||
name: "Karabo Makhubo",
|
||||
note: "Sharp dresser, always with a joke to share.",
|
||||
photo: photo("Karabo.jpeg"),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<section class="flex flex-1 items-center justify-center px-4 py-16">
|
||||
<div class="w-full max-w-4xl">
|
||||
<h1 class="font-serif text-4xl">The I-Do Crew</h1>
|
||||
<p class="text-surface-600-400 mt-2 text-2xl">
|
||||
Meet the legendary lineup helping us make it down the aisle at Rivier
|
||||
Plaas!
|
||||
</p>
|
||||
|
||||
<div class="mt-10 grid grid-cols-1 gap-x-10 gap-y-10 md:grid-cols-2">
|
||||
<div class="border-surface-300 dark:border-surface-700 border-t pt-6">
|
||||
<h2 class="font-serif text-3xl">Behind the Bride</h2>
|
||||
<ul class="mt-4 flex flex-col gap-5">
|
||||
{#each bride as person (person.name)}
|
||||
<li class="flex items-center gap-4">
|
||||
{#if person.photo}
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
class="border-surface-300 dark:border-surface-700 size-20 shrink-0 rounded-full border object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="border-surface-300 dark:border-surface-700 bg-surface-100 dark:bg-surface-800 text-surface-600-400 flex size-20 shrink-0 items-center justify-center rounded-full border font-serif text-2xl"
|
||||
>
|
||||
{initials(person.name)}
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<p class="text-2xl">
|
||||
<span class="font-medium">{person.role}:</span>
|
||||
{person.name}
|
||||
</p>
|
||||
<p class="text-surface-600-400 text-xl">{person.note}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-surface-300 dark:border-surface-700 border-t pt-6">
|
||||
<h2 class="font-serif text-3xl">Behind the Groom</h2>
|
||||
<ul class="mt-4 flex flex-col gap-5">
|
||||
{#each groom as person (person.name)}
|
||||
<li class="flex items-center gap-4">
|
||||
{#if person.photo}
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name}
|
||||
class="border-surface-300 dark:border-surface-700 size-20 shrink-0 rounded-full border object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="border-surface-300 dark:border-surface-700 bg-surface-100 dark:bg-surface-800 text-surface-600-400 flex size-20 shrink-0 items-center justify-center rounded-full border font-serif text-2xl"
|
||||
>
|
||||
{initials(person.name)}
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<p class="text-2xl">
|
||||
<span class="font-medium">{person.role}:</span>
|
||||
{person.name}
|
||||
</p>
|
||||
<p class="text-surface-600-400 text-xl">{person.note}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-surface-300 dark:border-surface-700 mt-10 border-t pt-6">
|
||||
<h2 class="font-serif text-3xl">The Dog-bearers</h2>
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<img
|
||||
src={photo("ringpups.jpg")}
|
||||
alt="Roxi & Chloe Muller"
|
||||
class="border-surface-300 dark:border-surface-700 size-20 shrink-0 rounded-full border object-cover"
|
||||
/>
|
||||
<p class="text-2xl">Roxi & Chloe Muller</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 108 KiB |