Author SHA1 Message Date
warkanum 91c9617255 Merge pull request 'fix(ui): keep compact countdown values visible' (#3) from fix/countdown-number-contrast into master
Reviewed-on: #3
Reviewed-by: Warky <2+warkanum@noreply@warky.dev>
2026-08-24 06:53:15 +00:00
SG Command 4571c59020 fix(ui): keep compact countdown values visible 2026-08-24 08:50:55 +02:00
warkanum 59428a27ce fix(config): update RSVP deadline time in config 2026-08-23 19:09:19 +02:00
warkanum 15bdbdd26f fix(config): correct RSVP deadline date in config 2026-08-23 19:06:21 +02:00
warkanum 23ab28d07e fix(ui): remove wedding info display from homepage 2026-08-23 15:03:34 +02:00
warkanum 9ecfd44248 feat(ui): add thank you page and update wedding party section
* Introduce a new thank you page with a list of acknowledgments.
* Enhance the wedding party section with photos and improved layout.
* Update welcome messages for clarity and consistency.
2026-08-23 14:42:45 +02:00
warkanum 143b2361f6 feat(mail): add site URL handling and notification headers
* include site URL in Mailer for absolute URLs
* add header method for email notifications
2026-08-23 11:53:07 +02:00
warkanum f96465b798 feat(cli): add CLI for managing RSVPs and photo uploads
* implement runCLI function to handle subcommands
* add runRSVPCLI and runPhotosCLI for RSVP and photo management
* include usage instructions for CLI commands
* integrate CLI execution in main function
2026-08-23 11:45:48 +02:00
warkanum 79e4c11a16 fix(mail): include guest list in RSVP emails 2026-08-23 11:41:08 +02:00
warkanum 5b3c256a5e fix(mail): add support for BCC in email sending 2026-08-23 11:31:43 +02:00
warkanum 3dc2e09aeb fix(mail): add Message-ID to email messages 2026-08-23 11:00:17 +02:00
warkanum f16d280f9a feat(api): add endpoint to list guest photos
* implement handleListPhotos to retrieve uploaded photos
* create PhotoFileRecord struct for photo data
* add fetchGuestPhotos function in the UI
* display guest photos in the photo upload page
* remove index.html as it is no longer needed
* add calendar functionality to download wedding event
2026-08-23 10:33:50 +02:00
warkanum 4eddecea8e fix(ui): update wedding details and add new pages
* change RSVP deadline and venue details
* add venue and wedding party pages with relevant information
* enhance layout with additional meta descriptions and links
2026-08-23 10:19:26 +02:00
warkanum 3c8f07978c Merge pull request 'fix: use generic Origin service type' (#2) from fix/origin-generic-service-type into master
Reviewed-on: #2
2026-08-20 08:38:52 +00:00
SG Command 10ca893bca fix: use generic Origin service type 2026-08-13 16:56:50 +02:00
warkanum 084588fbf1 Merge pull request 'feat: register ZoeShaldon with Origin' (#1) from feat/origin-client-registration into master
Reviewed-on: #1
2026-08-13 11:16:25 +00:00
31 changed files with 1036 additions and 86 deletions
+1 -2
View File
@@ -4,5 +4,4 @@ ui/node_modules/
ui/build/ ui/build/
ui/.svelte-kit/ ui/.svelte-kit/
server/wedding-server server/wedding-server
server/web/dist/* server/web/dist/
!server/web/dist/index.html
+1 -1
View File
@@ -44,7 +44,7 @@ docker-compose.yml wedding app, published to 127.0.0.1:8080 only
## Config ## 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, 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.
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 ## 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. 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.
+4 -3
View File
@@ -12,9 +12,9 @@ site:
wedding: wedding:
date: "2026-11-07T16:00:00+02:00" # RFC3339, guest arrival time; drives countdown + photo upload gate date: "2026-11-07T16:00:00+02:00" # RFC3339, guest arrival time; drives countdown + photo upload gate
ceremony_time: "16:00 for 16:30" # display text 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 rsvp_by: "2026-10-01T08:00:00+02:00" # RFC3339, RSVP deadline; drives the RSVP countdown
venue_name: "Rivier Plaas" venue_name: "Rivier Plaas Wedding Venue"
venue_address: "Langenhoven Rd, Sherman Park AH, Meyerton, 1961" venue_address: "Langenhoven Street, Riversdale, Meyerton, 1961"
rsvp_phone: "076 925 1718" rsvp_phone: "076 925 1718"
storage: storage:
@@ -27,6 +27,7 @@ email:
smtp_pass: "" smtp_pass: ""
from: "" from: ""
to: "" # couple's inbox, receives RSVP + photo notifications to: "" # couple's inbox, receives RSVP + photo notifications
bcc: "" # optional; comma-separated list of extra blind-copy recipients
upload: upload:
max_size_mb: 15 max_size_mb: 15
+171
View File
@@ -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"
}
+27 -6
View File
@@ -36,6 +36,7 @@ func (s *Server) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/config", s.handleConfig) mux.HandleFunc("GET /api/config", s.handleConfig)
mux.HandleFunc("POST /api/rsvp", s.handleRSVP) mux.HandleFunc("POST /api/rsvp", s.handleRSVP)
mux.HandleFunc("GET /api/guests", s.handleGuestList) mux.HandleFunc("GET /api/guests", s.handleGuestList)
mux.HandleFunc("GET /api/photos", s.handleListPhotos)
mux.HandleFunc("POST /api/photos/upload", s.handlePhotoUpload) mux.HandleFunc("POST /api/photos/upload", s.handlePhotoUpload)
} }
@@ -104,7 +105,11 @@ func (s *Server) handleRSVP(w http.ResponseWriter, r *http.Request) {
return 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) log.Printf("rsvp: email failed: %v", err)
} }
if err := s.webhook.SendRSVP(rsvp); err != nil { if err := s.webhook.SendRSVP(rsvp); err != nil {
@@ -146,6 +151,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{ var allowedImageTypes = map[string]bool{
"image/jpeg": true, "image/jpeg": true,
"image/png": true, "image/png": true,
@@ -155,11 +181,6 @@ var allowedImageTypes = map[string]bool{
} }
func (s *Server) handlePhotoUpload(w http.ResponseWriter, r *http.Request) { 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 maxTotalBytes := int64(s.cfg.Upload.MaxSizeMB) * int64(s.cfg.Upload.MaxFiles) * 1024 * 1024
r.Body = http.MaxBytesReader(w, r.Body, maxTotalBytes+1<<20) r.Body = http.MaxBytesReader(w, r.Body, maxTotalBytes+1<<20)
+1
View File
@@ -53,6 +53,7 @@ type EmailConfig struct {
SMTPPass string `yaml:"smtp_pass"` SMTPPass string `yaml:"smtp_pass"`
From string `yaml:"from"` From string `yaml:"from"`
To string `yaml:"to"` To string `yaml:"to"`
Bcc string `yaml:"bcc"` // optional; comma-separated list of extra blind-copy recipients
} }
type UploadConfig struct { type UploadConfig struct {
+68 -9
View File
@@ -1,10 +1,13 @@
package mail package mail
import ( import (
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"log" "log"
"net/smtp" "net/smtp"
"strings" "strings"
"time"
"wedding-server/internal/config" "wedding-server/internal/config"
"wedding-server/internal/store" "wedding-server/internal/store"
@@ -12,10 +15,43 @@ import (
type Mailer struct { type Mailer struct {
cfg config.EmailConfig cfg config.EmailConfig
siteURL string
} }
func New(cfg config.EmailConfig) *Mailer { func New(cfg config.EmailConfig, siteURL string) *Mailer {
return &Mailer{cfg: cfg} 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 { 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) auth := smtp.PlainAuth("", m.cfg.SMTPUser, m.cfg.SMTPPass, m.cfg.SMTPHost)
msg := fmt.Sprintf( msg := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", "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, body, 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)
}
} }
func (m *Mailer) SendRSVP(r store.RSVP) error { return smtp.SendMail(addr, auth, m.cfg.From, recipients, []byte(msg))
}
func (m *Mailer) SendRSVP(r store.RSVP, allGuests []store.Guest) error {
names := make([]string, len(r.Guests)) names := make([]string, len(r.Guests))
adults, children := 0, 0 adults, children := 0, 0
for i, g := range r.Guests { for i, g := range r.Guests {
@@ -54,7 +97,23 @@ func (m *Mailer) SendRSVP(r store.RSVP) error {
if r.Message != "" { if r.Message != "" {
fmt.Fprintf(&b, "\nMessage:\n%s\n", 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 { 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") b.WriteString("\nPhotos:\n")
for _, f := range files { 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())
} }
+192
View File
@@ -102,6 +102,76 @@ func (s *Store) ListGuests() ([]Guest, error) {
return guests, rows.Err() 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 { func (s *Store) InsertRSVP(r RSVP) error {
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
@@ -159,6 +229,128 @@ func (s *Store) CreatePhotoUpload(u PhotoUpload) (int64, error) {
return res.LastInsertId() 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 { func (s *Store) AddPhotoFiles(uploadID int64, files []PhotoFile) error {
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
+15 -5
View File
@@ -37,9 +37,15 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("store: %v", err) 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() defer st.Close()
mailer := mail.New(cfg.Email) mailer := mail.New(cfg.Email, cfg.Site.URL)
wh := webhook.New(cfg.Webhook) wh := webhook.New(cfg.Webhook)
if cfg.Origin.ServiceKey == "" { if cfg.Origin.ServiceKey == "" {
log.Printf("origin registration disabled: origin.service_key is not configured") log.Printf("origin registration disabled: origin.service_key is not configured")
@@ -47,7 +53,7 @@ func main() {
origin, err := originclient.New(originclient.Config{ origin, err := originclient.New(originclient.Config{
URL: cfg.Origin.URL, URL: cfg.Origin.URL,
ServiceKey: cfg.Origin.ServiceKey, ServiceKey: cfg.Origin.ServiceKey,
Type: "CronoCraft App", Type: "generic",
Version: cfg.Origin.Version, Version: cfg.Origin.Version,
Name: "ZoeShaldon", Name: "ZoeShaldon",
AppType: "go", AppType: "go",
@@ -85,12 +91,16 @@ func main() {
func staticHandler(fsys fs.FS) http.Handler { func staticHandler(fsys fs.FS) http.Handler {
fileServer := http.FileServer(http.FS(fsys)) fileServer := http.FileServer(http.FS(fsys))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 == "" { if p == "" {
p = "index.html" p = "index.html"
} }
if _, err := fs.Stat(fsys, p); err != nil { // A route name (e.g. "photos") can collide with a static asset
if _, err := fs.Stat(fsys, p+".html"); err == nil { // 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 := *r
r2.URL.Path = "/" + p + ".html" r2.URL.Path = "/" + p + ".html"
fileServer.ServeHTTP(w, &r2) fileServer.ServeHTTP(w, &r2)
-1
View File
@@ -1 +0,0 @@
<!-- placeholder, replaced by the ui build output before building the Go binary -->
+12
View File
@@ -20,6 +20,11 @@ export interface RsvpPayload {
message?: string; message?: string;
} }
export interface GuestPhoto {
url: string;
uploaderName: string;
}
export interface PhotoUploadPayload { export interface PhotoUploadPayload {
name: string; name: string;
message?: string; message?: string;
@@ -80,6 +85,13 @@ export async function fetchGuests(fetchImpl: typeof fetch = fetch): Promise<Gues
return response.json(); 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> { export async function uploadPhotos(payload: PhotoUploadPayload): Promise<void> {
const form = new FormData(); const form = new FormData();
form.set('name', payload.name); form.set('name', payload.name);
+33
View File
@@ -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)}`;
}
+1 -1
View File
@@ -46,7 +46,7 @@
<div class="flex items-baseline gap-2 z-10" role="timer" aria-live="polite"> <div class="flex items-baseline gap-2 z-10" role="timer" aria-live="polite">
{#each units as unit, i (unit.label)} {#each units as unit, i (unit.label)}
{#if i > 0}<span class="text-secondary-300/60">·</span>{/if} {#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 >{unit.value}</span
> >
<span class="text-secondary-100/80 text-md md:text-3xl">{unit.label}</span <span class="text-secondary-100/80 text-md md:text-3xl">{unit.label}</span
+25
View File
@@ -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}
+3 -3
View File
@@ -3,8 +3,8 @@
export const FALLBACK_SITE_URL = "https://zoeshaldon.warky.info"; export const FALLBACK_SITE_URL = "https://zoeshaldon.warky.info";
export const FALLBACK_WEDDING_DATE = "2026-11-07T16:00:00+02:00"; export const FALLBACK_WEDDING_DATE = "2026-11-07T16:00:00+02:00";
export const FALLBACK_CEREMONY_TIME = "16:00 for 16:30"; 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_RSVP_BY_DATE = "2026-10-17T00:00:00+02:00";
export const FALLBACK_VENUE_NAME = "Rivier Plaas"; export const FALLBACK_VENUE_NAME = "Rivier Plaas Wedding Venue";
export const FALLBACK_VENUE_ADDRESS = 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"; export const FALLBACK_RSVP_PHONE = "076 925 1718";
+27 -2
View File
@@ -3,16 +3,26 @@
import favicon from "$lib/assets/favicon.svg"; import favicon from "$lib/assets/favicon.svg";
import { page } from "$app/state"; import { page } from "$app/state";
import Countdown from "$lib/components/Countdown.svelte"; 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(); let { children } = $props();
const wedding = createWeddingInfo(); 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 = [ const links = [
{ href: "/", label: "Home" }, { href: "/", label: "Home" },
{ href: "/rsvp", label: "RSVP" }, { href: "/rsvp", label: "RSVP" },
{ href: "/photos", label: "Photos" }, { href: "/photos", label: "Photos" },
{ href: "/venue", label: "Venue" },
{ href: "/details", label: "Details" },
{ href: "/wedding-party", label: "Wedding Party" },
]; ];
let menuOpen = $state(false); let menuOpen = $state(false);
@@ -26,6 +36,19 @@
<svelte:head> <svelte:head>
<title>Zoé &amp; Shaldon are getting married</title> <title>Zoé &amp; Shaldon are getting married</title>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
<meta name="description" content={metaDescription} />
<meta property="og:type" content="website" />
<meta property="og:title" content="Zoé &amp; 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é &amp; Shaldon are getting married" />
<meta name="twitter:description" content={shareDescription} />
<meta name="twitter:image" content="{wedding.siteUrl}/photos/IMG_0528.JPG" />
</svelte:head> </svelte:head>
<div <div
@@ -104,7 +127,9 @@
</div> </div>
{/if} {/if}
</div> </div>
<main class="flex flex-1 flex-col"> <main
class="text-shadow-sm text-shadow-white/80 dark:text-shadow-black/70 flex flex-1 flex-col"
>
{@render children()} {@render children()}
</main> </main>
</div> </div>
+20 -14
View File
@@ -1,9 +1,7 @@
<script lang="ts"> <script lang="ts">
import PhotoBackground from "$lib/components/PhotoBackground.svelte"; import PhotoBackground from "$lib/components/PhotoBackground.svelte";
import { createWeddingInfo, formatFullDate } from "$lib/wedding-info.svelte";
import { createRsvpStatus } from "$lib/rsvp-status.svelte"; import { createRsvpStatus } from "$lib/rsvp-status.svelte";
const wedding = createWeddingInfo();
const rsvpStatus = createRsvpStatus(); const rsvpStatus = createRsvpStatus();
</script> </script>
@@ -13,28 +11,31 @@
<PhotoBackground /> <PhotoBackground />
<div <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"> <h1 class="text-surface-50 font-malibu text-6xl sm:text-8xl">
Zoé &amp; Shaldon Zoé &amp; Shaldon
</h1> </h1>
<p class="text-surface-100 text-2xl">are getting married</p> <p class="text-surface-100 text-2xl">are getting married</p>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-4 text-center">
<p class="text-surface-50 text-2xl font-medium sm:text-3xl"> <p class="text-surface-100 text-xl">
{formatFullDate(wedding.date)} Welcome to our wedding website! &nbsp; 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>
<p class="text-surface-200 text-2xl tracking-wide"> <p class="text-surface-100 text-xl">
{wedding.ceremonyTime} Browse the menu above to find venue directions, dress code details, and
RSVP information.
</p> </p>
<p class="text-surface-100 text-xl">We can't wait to see you soon!</p>
</div> </div>
<div class="flex flex-col gap-0.5"> <div class="mt-2 flex flex-wrap items-center justify-center gap-4">
<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">
{#if !rsvpStatus.submitted} {#if !rsvpStatus.submitted}
<a href="/rsvp" class="btn preset-filled-secondary-500 text-2xl">RSVP</a <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" <a href="/photos" class="btn preset-filled-primary-500 text-2xl"
>Share photos</a >Share photos</a
> >
<a href="/details" class="btn preset-tonal-secondary text-2xl">Details</a>
</div> </div>
<p class="text-surface-200 font-serif text-2xl">
With love,<br />Zoé &amp; Shaldon
</p>
</div> </div>
</section> </section>
+105
View File
@@ -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: new Date(wedding.date.getTime() + 7.5 * 60 * 60 * 1000),
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 &amp; 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é &amp; 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>
+53 -34
View File
@@ -1,28 +1,37 @@
<script lang="ts"> <script lang="ts">
import { createWeddingInfo, formatFullDate } from '$lib/wedding-info.svelte'; import { browser } from '$app/environment';
import { uploadPhotos, ApiError } from '$lib/api'; import { uploadPhotos, fetchGuestPhotos, ApiError, type GuestPhoto } from '$lib/api';
import PhotoGallery from '$lib/components/PhotoGallery.svelte';
const MAX_FILES = 10; const MAX_FILES = 10;
const MAX_SIZE_MB = 15; 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 name = $state('');
let message = $state(''); let message = $state('');
let email = $state('');
let phone = $state('');
let files: FileList | undefined = $state(); let files: FileList | undefined = $state();
let status = $state<'idle' | 'submitting' | 'done' | 'error'>('idle'); let status = $state<'idle' | 'submitting' | 'done' | 'error'>('idle');
let errorMessage = $state(''); 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) { async function onSubmit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
if (!files || files.length === 0) { if (!files || files.length === 0) {
@@ -49,11 +58,10 @@
await uploadPhotos({ await uploadPhotos({
name, name,
message: message || undefined, message: message || undefined,
email: email || undefined,
phone: phone || undefined,
files files
}); });
status = 'done'; status = 'done';
loadGuestPhotos();
} catch (err) { } catch (err) {
status = 'error'; status = 'error';
errorMessage = err instanceof ApiError ? err.message : 'Something went wrong. Please try again.'; errorMessage = err instanceof ApiError ? err.message : 'Something went wrong. Please try again.';
@@ -62,20 +70,17 @@
</script> </script>
<section class="flex flex-1 items-center justify-center px-4 py-16"> <section class="flex flex-1 items-center justify-center px-4 py-16">
{#if !isOpen} <div class="w-full max-w-md">
<div class="flex flex-col items-center gap-6 text-center"> <PhotoGallery />
<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é &amp; Shaldon.</p> {#if status === 'done'}
</div> <div class="preset-filled-primary-500 rounded-base p-4 text-center">
{: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! Thank you — your photos have been uploaded!
</div> </div>
{:else} {:else}
<div class="w-full max-w-md">
<h1 class="font-serif text-4xl">Share your photos</h1> <h1 class="font-serif text-4xl">Share your photos</h1>
<p class="text-surface-600-400 mt-2 mb-8 text-2xl"> <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é &amp; Shaldon — up to {MAX_FILES} at a time, {MAX_SIZE_MB}MB each.
</p> </p>
<form class="flex flex-col gap-4" onsubmit={onSubmit}> <form class="flex flex-col gap-4" onsubmit={onSubmit}>
@@ -90,16 +95,6 @@
></textarea> ></textarea>
</label> </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"> <label class="label">
<span class="label-text text-2xl">Photos *</span> <span class="label-text text-2xl">Photos *</span>
<input <input
@@ -121,6 +116,30 @@
{status === 'submitting' ? 'Uploading…' : 'Upload photos'} {status === 'submitting' ? 'Uploading…' : 'Upload photos'}
</button> </button>
</form> </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> </div>
{/if} {/if}
</div>
</div>
</section> </section>
+51
View File
@@ -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>
+64
View File
@@ -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>
+151
View File
@@ -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 &amp; Chloe Muller"
class="border-surface-300 dark:border-surface-700 size-20 shrink-0 rounded-full border object-cover"
/>
<p class="text-2xl">Roxi &amp; Chloe Muller</p>
</div>
</div>
</div>
</section>
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.
Binary file not shown.