* Introduced wedding-themed CSS variables for styling. * Created layout component for the wedding site with navigation and countdown. * Added RSVP and photo upload pages with form handling. * Implemented QR code page for wedding site access. * Configured TypeScript and Vite for the project. * Added robots.txt for search engine crawling.
12 KiB
Plan: Zoé & Shaldon Wedding Site
Goal
QR-code-accessible site for save-the-date, RSVP, and guest photo uploads. Wedding date: 7 November 2026.
Stack
- Frontend: SvelteKit,
adapter-static, prerendered. No SSR needed. - UI/theming: Tailwind CSS + Skeleton (Svelte), custom theme for the blue/yellow palette — Skeleton handles light/dark switching itself, no hand-rolled CSS variables needed.
- Backend: Go, single binary,
embed.FSserves the built static site + JSON API on one port. - Storage: SQLite via
modernc.org/sqlite(pure Go, no cgo — keeps the Docker build static/CGO_ENABLED=0) for RSVP/upload metadata. Photos on disk. - Email: Go
net/smtp(STARTTLS) to notify the couple on new RSVP / photo upload.
Repo layout
zoe-shaldon/
├── ui/ SvelteKit frontend
│ ├── tailwind.config.ts
│ ├── assets/photos/ curated couple photos (checked in), source for the background slideshow
│ └── src/
│ ├── app.css Tailwind + Skeleton custom theme (wedding-theme)
│ ├── lib/components/PhotoBackground.svelte ambient slide-in/out photo background
│ └── routes/
│ ├── +page.svelte landing: hero, countdown, photo background, nav
│ ├── rsvp/+page.svelte RSVP form
│ └── photos/+page.svelte upload form (gated) + status message before open date
├── server/ Go backend
│ ├── main.go
│ ├── internal/api/ HTTP handlers
│ ├── internal/store/ SQLite access
│ ├── internal/mail/ SMTP sending
│ └── web/ embedded static build (go:embed)
├── data/ runtime data, gitignored
│ ├── rsvp.db
│ └── photos/<upload-id>/<file>
├── concept/ source assets (save-the-date image)
├── config.example.yaml template, checked in
├── config.yaml real config, gitignored
├── Dockerfile
└── docker-compose.yml
Data model (SQLite)
rsvps(id, message, created_at)
rsvp_guests(id, rsvp_id FK, name)
photo_uploads(id, name, email, phone, message, created_at)
photo_files(id, upload_id FK, filename, path, url, created_at)
RSVP is a party: one or more guest names attached to a single RSVP + optional shared message — no email/phone captured for RSVP (contact for RSVP questions is the phone number on the invite instead). Photo upload keeps its own name/email/phone/message, unchanged.
API
| 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 |
| 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 |
Countdowns
- Global: a compact countdown to
weddingDatesits below the nav bar on every page (+layout.svelte). Once passed, it reads "Already married! 🎉" instead of digits. - Landing page: hero shows the day/date (e.g. "Saturday, 7 November 2026"),
ceremonyTime("16:00 for 16:30"), and venue name/address — all fromGET /api/config. RSVP button is hidden once the guest has RSVP'd (tracked client-side vialocalStorage, see below); replaced with a thank-you note. - RSVP page: its own live countdown to
rsvpByDate("RSVP by "). OncersvpByDatepasses, the countdown and form are replaced by an "RSVP missed" notice pointing torsvpPhone. - Photos page: reuses
weddingDate— before that time shows "uploads open on the wedding day" instead of the upload form (no separate countdown here, the global one covers it). - One date per purpose, no duplicated "opens at" settings to keep in sync.
RSVP-submitted tracking
No accounts/auth — localStorage flag (zoe-shaldon-rsvp-submitted) set after a successful POST /api/rsvp, read by the landing page to decide whether to show the RSVP button or a confirmation. Per-browser only; a guest RSVPing from a different device won't see the flag, which is an acceptable prototype limitation.
Photo background (landing page)
Curated couple photos already checked in at ui/assets/photos (33 images) — separate from data/photos/ (runtime, guest-uploaded, gitignored). These are the source for an ambient background behind the hero content.
PhotoBackground.svelte: full-bleed layer behind the hero text, cycling through a shuffled list of the photos on an interval (~6s). Each photo slides in from one edge and slides out the opposite edge (CSStransform: translateX+opacitytransition, one photo at a time, next one queued behind).- Photo list built at compile time via Vite's
import.meta.glob('/assets/photos/*.{jpg,jpeg,png}', { eager: true })— drop a file in the folder, it's picked up on next build, no code change. - A semi-transparent overlay in the theme's
primaryblue sits between the photos and the text so the hero copy (white/yellow) stays legible over any photo. - Respect
prefers-reduced-motion: reduce— fall back to a plain crossfade (opacity only, no slide) or a static single image. IMG_E8488.HEICinui/assets/photosneeds converting to JPG before build — browsers don't render HEIC natively and Vite won't transform it; either convert now or add a build-step check that fails loudly if a.heicfile is present.
Photo upload gating
- Gate value is
wedding.datefrom the config file (see Config below) — no separate open-date field. - Client: countdown shown before that time; upload form hidden until then.
- Server: hard check on every upload request — client-side gating is UX only, not security.
Upload validation
- Allowed types: jpg/png/webp/heic, sniffed via
http.DetectContentType(not trusted from filename/extension). - Max file size and max files per submission — values TBD, suggest 15MB/file, 10 files/submission.
- Stored at
data/photos/<upload-id>/<uuid>_<original-filename>.
SMTP settings come from the config file (see below).
- On RSVP: send guest names + message.
- On photo upload: send name/message/contact + direct links to each stored photo.
Config
Single YAML file, path resolved from CONFIG_PATH env var (default ./config.yaml) — this is the only env var; everything else lives in the file so it can be edited/mounted without touching Docker env blocks.
server:
port: 8080
wedding:
date: "2026-11-07T16:00:00+02:00" # RFC3339, guest arrival time; drives countdown + upload gate
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_phone: "076 925 1718"
storage:
data_dir: "./data" # SQLite db + uploaded photos
email:
smtp_host: ""
smtp_port: 587
smtp_user: ""
smtp_pass: ""
from: ""
to: "" # couple's inbox
upload:
max_size_mb: 15
max_files: 10
config.example.yaml is checked in as a template; config.yaml is gitignored (holds real SMTP credentials). Venue/RSVP details sourced from concept/IMG-20260811-WA0020.jpg (the formal invitation).
Deployment
Host: zoeshaldon.warky.info, Docker Compose, behind an existing Apache reverse proxy on the host (Apache owns TLS/certs — no Caddy/in-container TLS needed). The container publishes only to 127.0.0.1:8080, and Apache proxies to it.
Docker
Dockerfile: multi-stage — (1)node:alpinebuilds the SvelteKit static output, (2)golang:alpinecompiles the Go binary with the built frontend copied in forgo:embed, (3) minimalalpineruntime stage with just the binary.docker-compose.yml: one service,wedding, builds from the Dockerfile, mounts./config.yaml:/app/config.yaml:roand./data:/app/data, published as127.0.0.1:8080:8080(loopback only — Apache is the only thing that should reach it).
Apache config (host side, not part of this repo)
<VirtualHost *:443>
ServerName zoeshaldon.warky.info
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/
# existing SSL cert config for warky.info
</VirtualHost>
Needs mod_proxy and mod_proxy_http enabled. Multipart photo uploads: raise LimitRequestBody (or leave unset/0) on this vhost so Apache doesn't reject large uploads ahead of the app's own upload.max_size_mb check.
QR code
Generate pointing at https://zoeshaldon.warky.info once the container is deployed behind Apache.
Colour palette
Skeleton custom theme (wedding-theme), generated with the Skeleton theme generator from two seed colours and applied via data-theme="wedding-theme" in app.html. Light/dark mode is Skeleton's built-in mode switch (data-mode) — it derives both from the same theme, so there's no separate light/dark table to hand-maintain.
| Slot | Seed colour | Source |
|---|---|---|
primary (blue) |
#1E4E79 |
groom's shirt / dusk sky in concept/IMG-20260811-WA0019.jpg |
secondary (yellow) |
#D9A441 |
warm gold title text in the same image |
Skeleton generates the full 50–900 scale and light/dark contrast pairs from these two seeds. Tailwind utility classes (bg-primary-500, text-secondary-600, etc.) and Skeleton components consume the theme directly — no custom CSS variables needed on top.
Design — invitation framing
Landing and RSVP pages borrow the framed-card look from the formal invitation (concept/IMG-20260811-WA0020.jpg): a thin border around the core content block, small-caps tracked micro-copy above the couple's names ("Save the date" / "Please join us to celebrate the union of"), day+date formatted as "Saturday, 7 November 2026" (Intl.DateTimeFormat('en-GB', {weekday:'long', day:'numeric', month:'long', year:'numeric'})), and the venue name/address displayed beneath.
Open decisions (need input before/at implementation)
Domain / hosting target— resolved:zoeshaldon.warky.info, Docker Compose (see Deployment).- SMTP provider/credentials to send from (e.g. existing Gmail/SES/other).
- Timezone for the 7 Nov 2026 cutoff — assumed SAST (+02:00).
- Max photo size / count per submission — defaults proposed above, confirm.
Colour palette— resolved, see above. Fonts still open (image uses a script face for names/heading + serif for the date — confirm if we should source similar web fonts).
Prototype status
Working end-to-end: ui/ (landing with countdown + sliding photo background, invitation-framed RSVP form with party-of-guests entry + its own countdown, gated photo upload form) builds and is served by server/ (Go, SQLite via modernc.org/sqlite, SMTP notifications, server-side upload gate). Verified locally: page rendering, multi-guest RSVP submit (add/remove guest rows, blank-name filtering), photo upload with content-type sniffing (rejects non-images, detects HEIC), and both the wedding-date and RSVP-deadline gates/countdowns.
Known gaps before this is real:
- SMTP not configured (open decision #2) — emails currently log "skipped" instead of sending.
- Photos in
ui/assets/photosare unoptimized (several multi-MB originals) — fine for the background slideshow (one loads at a time), but worth compressing before going live given the site is reached over mobile data via QR code. - No automated tests yet.
Dockerfile/docker-compose.ymlnot yet run end-to-end (onlygo build/npm run buildand the local binary were verified).