feat(ui): add wedding theme styles and layout components
* 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.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
data/
|
||||
config.yaml
|
||||
ui/node_modules
|
||||
ui/build
|
||||
ui/.svelte-kit
|
||||
server/wedding-server
|
||||
.git
|
||||
@@ -0,0 +1,4 @@
|
||||
*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
||||
*.ogg filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -0,0 +1,8 @@
|
||||
data/
|
||||
config.yaml
|
||||
ui/node_modules/
|
||||
ui/build/
|
||||
ui/.svelte-kit/
|
||||
server/wedding-server
|
||||
server/web/dist/*
|
||||
!server/web/dist/index.html
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /app/ui
|
||||
COPY ui/package*.json ./
|
||||
RUN npm ci
|
||||
COPY ui/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.26-alpine AS backend-builder
|
||||
WORKDIR /app
|
||||
COPY server/go.mod server/go.sum ./
|
||||
RUN go mod download
|
||||
COPY server/ ./
|
||||
COPY --from=frontend-builder /app/ui/build ./web/dist
|
||||
RUN CGO_ENABLED=0 go build -o /wedding-server .
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates
|
||||
COPY --from=backend-builder /wedding-server /usr/local/bin/wedding-server
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["wedding-server"]
|
||||
@@ -0,0 +1,176 @@
|
||||
# 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](https://skeleton.dev) (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.FS` serves 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 `weddingDate` sits 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 from `GET /api/config`. RSVP button is hidden once the guest has RSVP'd (tracked client-side via `localStorage`, see below); replaced with a thank-you note.
|
||||
- RSVP page: its own live countdown to `rsvpByDate` ("RSVP by <date>"). Once `rsvpByDate` passes, the countdown and form are replaced by an "RSVP missed" notice pointing to `rsvpPhone`.
|
||||
- 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 (CSS `transform: translateX` + `opacity` transition, 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 `primary` blue 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.HEIC` in `ui/assets/photos` needs 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 `.heic` file is present.
|
||||
|
||||
## Photo upload gating
|
||||
- Gate value is `wedding.date` from 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>`.
|
||||
|
||||
## Email
|
||||
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.
|
||||
|
||||
```yaml
|
||||
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:alpine` builds the SvelteKit static output, (2) `golang:alpine` compiles the Go binary with the built frontend copied in for `go:embed`, (3) minimal `alpine` runtime stage with just the binary.
|
||||
- `docker-compose.yml`: one service, `wedding`, builds from the Dockerfile, mounts `./config.yaml:/app/config.yaml:ro` and `./data:/app/data`, published as `127.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](https://skeleton.dev/docs/design/themes) 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)
|
||||
1. ~~Domain / hosting target~~ — resolved: `zoeshaldon.warky.info`, Docker Compose (see Deployment).
|
||||
2. SMTP provider/credentials to send from (e.g. existing Gmail/SES/other).
|
||||
3. Timezone for the 7 Nov 2026 cutoff — assumed SAST (+02:00).
|
||||
4. Max photo size / count per submission — defaults proposed above, confirm.
|
||||
5. ~~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/photos` are 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.yml` not yet run end-to-end (only `go build`/`npm run build` and the local binary were verified).
|
||||
@@ -0,0 +1,52 @@
|
||||
# Zoé & Shaldon — Wedding Site
|
||||
|
||||
Save-the-date / RSVP / guest photo-upload site. Wedding: **7 November 2026**. Accessed via QR code from the printed save-the-date. Hosted at `https://zoeshaldon.warky.info`.
|
||||
|
||||
See [PLAN.md](PLAN.md) for full design.
|
||||
|
||||
## Stack
|
||||
- `ui/` — SvelteKit (static adapter), Tailwind + Skeleton theming
|
||||
- `server/` — Go, single binary, embeds the built frontend
|
||||
- `data/` — runtime SQLite DB + guest-uploaded photos (gitignored, not yet created)
|
||||
|
||||
## Status
|
||||
Working prototype. `ui/` builds and `server/` serves it + the API, verified locally (RSVP, gated photo upload, countdown). Not yet run via Docker end-to-end; SMTP not yet configured. See PLAN.md "Prototype status" for the current gap list.
|
||||
|
||||
## Develop locally
|
||||
```
|
||||
cd ui && npm install && npm run build # outputs ui/build
|
||||
rm -rf server/web/dist && cp -r ui/build server/web/dist
|
||||
cd ../server && go build -o wedding-server .
|
||||
cp ../config.example.yaml ../config.yaml # edit the wedding date/SMTP/etc.
|
||||
CONFIG_PATH=../config.yaml ./wedding-server # serves on :8080
|
||||
```
|
||||
|
||||
## Requirements
|
||||
- Countdown to the wedding date, with day/date/time and venue, on the landing page.
|
||||
- Landing page background: photos from `ui/assets/photos` slide in/out continuously behind the hero content.
|
||||
- RSVP form: one or more guest names (add/remove rows, at least one required), message (optional) — no email/phone captured → saved + emailed to couple. Shows the RSVP deadline and its own countdown.
|
||||
- Photo upload: name (required), message (optional), email/phone (optional) + photo files → saved to `data/photos/`, links emailed to couple.
|
||||
- Photo upload only opens **7 November 2026** (same date drives the countdown) — enforced server-side, not just hidden in the UI.
|
||||
- Venue, RSVP deadline, and RSVP phone number sourced from the formal invitation (`concept/IMG-20260811-WA0020.jpg`).
|
||||
|
||||
## Repo layout
|
||||
```
|
||||
ui/ SvelteKit frontend (assets/photos/ = curated background photos, checked in)
|
||||
server/ Go backend (API + static file serving)
|
||||
data/ runtime data (gitignored)
|
||||
concept/ source assets (save-the-date image)
|
||||
config.example.yaml config template (checked in)
|
||||
config.yaml real config: date, SMTP, storage path (gitignored)
|
||||
Dockerfile multi-stage: build ui -> build server -> runtime image
|
||||
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, and storage path. Path is resolved from `CONFIG_PATH` (default `./config.yaml`) — that's the only env var; everything else lives in the file.
|
||||
|
||||
## 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.
|
||||
```
|
||||
cp config.example.yaml config.yaml # edit it first
|
||||
docker compose up --build
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
site:
|
||||
url: "https://zoeshaldon.warky.info" # canonical URL, used for the /qr page
|
||||
|
||||
wedding:
|
||||
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
|
||||
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, receives RSVP + photo notifications
|
||||
|
||||
upload:
|
||||
max_size_mb: 15
|
||||
max_files: 10
|
||||
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
wedding:
|
||||
build: .
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
environment:
|
||||
- CONFIG_PATH=/app/config.yaml
|
||||
volumes:
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,17 @@
|
||||
module wedding-server
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.56.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
|
||||
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||
@@ -0,0 +1,267 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"wedding-server/internal/config"
|
||||
"wedding-server/internal/mail"
|
||||
"wedding-server/internal/store"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
store *store.Store
|
||||
mailer *mail.Mailer
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, st *store.Store, mailer *mail.Mailer) *Server {
|
||||
return &Server{cfg: cfg, store: st, mailer: mailer}
|
||||
}
|
||||
|
||||
func (s *Server) Routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /healthz", s.handleHealthz)
|
||||
mux.HandleFunc("GET /api/config", s.handleConfig)
|
||||
mux.HandleFunc("POST /api/rsvp", s.handleRSVP)
|
||||
mux.HandleFunc("POST /api/photos/upload", s.handlePhotoUpload)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
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),
|
||||
"ceremonyTime": s.cfg.Wedding.CeremonyTime,
|
||||
"rsvpByDate": s.cfg.Wedding.RSVPBy.Format(time.RFC3339),
|
||||
"venueName": s.cfg.Wedding.VenueName,
|
||||
"venueAddress": s.cfg.Wedding.VenueAddress,
|
||||
"rsvpPhone": s.cfg.Wedding.RSVPPhone,
|
||||
})
|
||||
}
|
||||
|
||||
type guestRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type rsvpRequest struct {
|
||||
Guests []guestRequest `json:"guests"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (s *Server) handleRSVP(w http.ResponseWriter, r *http.Request) {
|
||||
var req rsvpRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var guests []store.Guest
|
||||
for _, g := range req.Guests {
|
||||
name := strings.TrimSpace(g.Name)
|
||||
if name != "" {
|
||||
guests = append(guests, store.Guest{Name: name, IsChild: g.Type == "child"})
|
||||
}
|
||||
}
|
||||
if len(guests) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "at least one name is required")
|
||||
return
|
||||
}
|
||||
|
||||
rsvp := store.RSVP{
|
||||
Guests: guests,
|
||||
Message: strings.TrimSpace(req.Message),
|
||||
}
|
||||
if err := s.store.InsertRSVP(rsvp); err != nil {
|
||||
log.Printf("rsvp: insert failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save RSVP")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.mailer.SendRSVP(rsvp); err != nil {
|
||||
log.Printf("rsvp: email failed: %v", err)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
var allowedImageTypes = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/webp": true,
|
||||
"image/heic": true,
|
||||
"image/heif": true,
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid upload (too large or malformed)")
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
message := strings.TrimSpace(r.FormValue("message"))
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
phone := strings.TrimSpace(r.FormValue("phone"))
|
||||
|
||||
headers := r.MultipartForm.File["files"]
|
||||
if len(headers) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "at least one photo is required")
|
||||
return
|
||||
}
|
||||
if len(headers) > s.cfg.Upload.MaxFiles {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("at most %d photos per submission", s.cfg.Upload.MaxFiles))
|
||||
return
|
||||
}
|
||||
|
||||
maxFileBytes := int64(s.cfg.Upload.MaxSizeMB) * 1024 * 1024
|
||||
for _, fh := range headers {
|
||||
if fh.Size > maxFileBytes {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("%q is larger than %dMB", fh.Filename, s.cfg.Upload.MaxSizeMB))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Sniff and validate every file's actual content before creating any
|
||||
// database row or directory, so a rejected upload leaves no trace.
|
||||
for _, fh := range headers {
|
||||
file, err := fh.Open()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "could not read uploaded file")
|
||||
return
|
||||
}
|
||||
sniff := make([]byte, 512)
|
||||
n, _ := io.ReadFull(file, sniff)
|
||||
file.Close()
|
||||
sniff = sniff[:n]
|
||||
|
||||
contentType := http.DetectContentType(sniff)
|
||||
if contentType == "application/octet-stream" && isHEIC(sniff) {
|
||||
// net/http's sniffer table has no ISOBMFF/HEIC signature.
|
||||
contentType = "image/heic"
|
||||
}
|
||||
if !allowedImageTypes[contentType] {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a supported image type", fh.Filename))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
uploadID, err := s.store.CreatePhotoUpload(store.PhotoUpload{
|
||||
Name: name, Message: message, Email: email, Phone: phone,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("photos: create upload failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save upload")
|
||||
return
|
||||
}
|
||||
|
||||
uploadDir := filepath.Join(s.cfg.Storage.DataDir, "photos", strconv.FormatInt(uploadID, 10))
|
||||
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
|
||||
log.Printf("photos: mkdir failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save upload")
|
||||
return
|
||||
}
|
||||
|
||||
var saved []store.PhotoFile
|
||||
for _, fh := range headers {
|
||||
file, err := fh.Open()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "could not read uploaded file")
|
||||
return
|
||||
}
|
||||
|
||||
storedName := randomHex(8) + "_" + filepath.Base(fh.Filename)
|
||||
destPath := filepath.Join(uploadDir, storedName)
|
||||
dest, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
log.Printf("photos: create file failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save upload")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = io.Copy(dest, file)
|
||||
dest.Close()
|
||||
file.Close()
|
||||
if err != nil {
|
||||
log.Printf("photos: write file failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save upload")
|
||||
return
|
||||
}
|
||||
|
||||
saved = append(saved, store.PhotoFile{
|
||||
Filename: fh.Filename,
|
||||
Path: destPath,
|
||||
URL: fmt.Sprintf("/uploads/%d/%s", uploadID, storedName),
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.store.AddPhotoFiles(uploadID, saved); err != nil {
|
||||
log.Printf("photos: add files failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save upload")
|
||||
return
|
||||
}
|
||||
|
||||
upload := store.PhotoUpload{Name: name, Message: message, Email: email, Phone: phone}
|
||||
if err := s.mailer.SendPhotoUpload(upload, saved); err != nil {
|
||||
log.Printf("photos: email failed: %v", err)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// isHEIC checks for the ISOBMFF "ftyp" box and a HEIC/HEIF brand, since
|
||||
// net/http.DetectContentType does not recognize this container format.
|
||||
func isHEIC(b []byte) bool {
|
||||
if len(b) < 12 || string(b[4:8]) != "ftyp" {
|
||||
return false
|
||||
}
|
||||
switch string(b[8:12]) {
|
||||
case "heic", "heix", "hevc", "hevx", "heim", "heis", "hevm", "hevs", "mif1", "msf1":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Site SiteConfig `yaml:"site"`
|
||||
Wedding WeddingConfig `yaml:"wedding"`
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
Email EmailConfig `yaml:"email"`
|
||||
Upload UploadConfig `yaml:"upload"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
type SiteConfig struct {
|
||||
URL string `yaml:"url"`
|
||||
}
|
||||
|
||||
type WeddingConfig struct {
|
||||
Date time.Time `yaml:"date"`
|
||||
CeremonyTime string `yaml:"ceremony_time"`
|
||||
RSVPBy time.Time `yaml:"rsvp_by"`
|
||||
VenueName string `yaml:"venue_name"`
|
||||
VenueAddress string `yaml:"venue_address"`
|
||||
RSVPPhone string `yaml:"rsvp_phone"`
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
DataDir string `yaml:"data_dir"`
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
SMTPHost string `yaml:"smtp_host"`
|
||||
SMTPPort int `yaml:"smtp_port"`
|
||||
SMTPUser string `yaml:"smtp_user"`
|
||||
SMTPPass string `yaml:"smtp_pass"`
|
||||
From string `yaml:"from"`
|
||||
To string `yaml:"to"`
|
||||
}
|
||||
|
||||
type UploadConfig struct {
|
||||
MaxSizeMB int `yaml:"max_size_mb"`
|
||||
MaxFiles int `yaml:"max_files"`
|
||||
}
|
||||
|
||||
// Path resolves the config file location: $CONFIG_PATH, or ./config.yaml.
|
||||
func Path() string {
|
||||
if p := os.Getenv("CONFIG_PATH"); p != "" {
|
||||
return p
|
||||
}
|
||||
return "config.yaml"
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading config %s: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config %s: %w", path, err)
|
||||
}
|
||||
|
||||
if cfg.Server.Port == 0 {
|
||||
cfg.Server.Port = 8080
|
||||
}
|
||||
if cfg.Storage.DataDir == "" {
|
||||
cfg.Storage.DataDir = "./data"
|
||||
}
|
||||
if cfg.Upload.MaxSizeMB == 0 {
|
||||
cfg.Upload.MaxSizeMB = 15
|
||||
}
|
||||
if cfg.Upload.MaxFiles == 0 {
|
||||
cfg.Upload.MaxFiles = 10
|
||||
}
|
||||
if cfg.Wedding.Date.IsZero() {
|
||||
return nil, fmt.Errorf("wedding.date is required in %s", path)
|
||||
}
|
||||
if cfg.Wedding.RSVPBy.IsZero() {
|
||||
return nil, fmt.Errorf("wedding.rsvp_by is required in %s", path)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"wedding-server/internal/config"
|
||||
"wedding-server/internal/store"
|
||||
)
|
||||
|
||||
type Mailer struct {
|
||||
cfg config.EmailConfig
|
||||
}
|
||||
|
||||
func New(cfg config.EmailConfig) *Mailer {
|
||||
return &Mailer{cfg: cfg}
|
||||
}
|
||||
|
||||
func (m *Mailer) send(subject, body string) error {
|
||||
if m.cfg.SMTPHost == "" {
|
||||
log.Printf("mail: SMTP not configured, skipping email %q", subject)
|
||||
return nil
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", m.cfg.SMTPHost, m.cfg.SMTPPort)
|
||||
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,
|
||||
)
|
||||
|
||||
return smtp.SendMail(addr, auth, m.cfg.From, []string{m.cfg.To}, []byte(msg))
|
||||
}
|
||||
|
||||
func (m *Mailer) SendRSVP(r store.RSVP) error {
|
||||
names := make([]string, len(r.Guests))
|
||||
adults, children := 0, 0
|
||||
for i, g := range r.Guests {
|
||||
if g.IsChild {
|
||||
names[i] = g.Name + " (child)"
|
||||
children++
|
||||
} else {
|
||||
names[i] = g.Name
|
||||
adults++
|
||||
}
|
||||
}
|
||||
joined := strings.Join(names, ", ")
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "New RSVP for %d guest(s) — %d adult(s), %d child(ren): %s\n", len(r.Guests), adults, children, joined)
|
||||
if r.Message != "" {
|
||||
fmt.Fprintf(&b, "\nMessage:\n%s\n", r.Message)
|
||||
}
|
||||
return m.send(fmt.Sprintf("RSVP from %s", joined), b.String())
|
||||
}
|
||||
|
||||
func (m *Mailer) SendPhotoUpload(u store.PhotoUpload, files []store.PhotoFile) error {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s uploaded %d photo(s)\n\n", u.Name, len(files))
|
||||
if u.Email != "" {
|
||||
fmt.Fprintf(&b, "Email: %s\n", u.Email)
|
||||
}
|
||||
if u.Phone != "" {
|
||||
fmt.Fprintf(&b, "Phone: %s\n", u.Phone)
|
||||
}
|
||||
if u.Message != "" {
|
||||
fmt.Fprintf(&b, "\nMessage:\n%s\n", u.Message)
|
||||
}
|
||||
b.WriteString("\nPhotos:\n")
|
||||
for _, f := range files {
|
||||
fmt.Fprintf(&b, "- %s\n", f.URL)
|
||||
}
|
||||
return m.send(fmt.Sprintf("%s uploaded photos", u.Name), b.String())
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS rsvps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message TEXT,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rsvp_guests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rsvp_id INTEGER NOT NULL REFERENCES rsvps(id),
|
||||
name TEXT NOT NULL,
|
||||
is_child INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photo_uploads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
message TEXT,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photo_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
upload_id INTEGER NOT NULL REFERENCES photo_uploads(id),
|
||||
filename TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
func Open(dataDir string) (*Store, error) {
|
||||
dbPath := filepath.Join(dataDir, "rsvp.db")
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening database: %w", err)
|
||||
}
|
||||
// SQLite only supports one writer at a time; a single connection avoids
|
||||
// SQLITE_BUSY errors under concurrent requests instead of retry logic.
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrating schema: %w", err)
|
||||
}
|
||||
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
type Guest struct {
|
||||
Name string
|
||||
IsChild bool
|
||||
}
|
||||
|
||||
type RSVP struct {
|
||||
Guests []Guest
|
||||
Message string
|
||||
}
|
||||
|
||||
func (s *Store) InsertRSVP(r RSVP) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(
|
||||
`INSERT INTO rsvps (message, created_at) VALUES (?, ?)`,
|
||||
r.Message, time.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rsvpID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, guest := range r.Guests {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO rsvp_guests (rsvp_id, name, is_child) VALUES (?, ?, ?)`,
|
||||
rsvpID, guest.Name, guest.IsChild,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type PhotoFile struct {
|
||||
Filename string
|
||||
Path string
|
||||
URL string
|
||||
}
|
||||
|
||||
type PhotoUpload struct {
|
||||
Name string
|
||||
Message string
|
||||
Email string
|
||||
Phone string
|
||||
}
|
||||
|
||||
// CreatePhotoUpload inserts the upload record and returns its id, which the
|
||||
// caller uses as the on-disk directory name before attaching files.
|
||||
func (s *Store) CreatePhotoUpload(u PhotoUpload) (int64, error) {
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO photo_uploads (name, message, email, phone, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
u.Name, u.Message, u.Email, u.Phone, time.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (s *Store) AddPhotoFiles(uploadID int64, files []PhotoFile) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, f := range files {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO photo_files (upload_id, filename, path, url, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
uploadID, f.Filename, f.Path, f.URL, now,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"wedding-server/internal/api"
|
||||
"wedding-server/internal/config"
|
||||
"wedding-server/internal/mail"
|
||||
"wedding-server/internal/store"
|
||||
)
|
||||
|
||||
//go:embed web/dist
|
||||
var embeddedWeb embed.FS
|
||||
|
||||
func main() {
|
||||
cfgPath := config.Path()
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.Storage.DataDir, 0o755); err != nil {
|
||||
log.Fatalf("data dir: %v", err)
|
||||
}
|
||||
|
||||
st, err := store.Open(cfg.Storage.DataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("store: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
mailer := mail.New(cfg.Email)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
api.New(cfg, st, mailer).Routes(mux)
|
||||
|
||||
uploadsDir := filepath.Join(cfg.Storage.DataDir, "photos")
|
||||
mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadsDir))))
|
||||
|
||||
webFS, err := fs.Sub(embeddedWeb, "web/dist")
|
||||
if err != nil {
|
||||
log.Fatalf("embedded web assets: %v", err)
|
||||
}
|
||||
mux.Handle("/", staticHandler(webFS))
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||
log.Printf("listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// staticHandler serves the prerendered SvelteKit build, where routes are
|
||||
// written as flat files (e.g. "rsvp.html") rather than "rsvp/index.html".
|
||||
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, "/")
|
||||
if p == "" {
|
||||
p = "index.html"
|
||||
}
|
||||
if _, err := fs.Stat(fsys, p); err != nil {
|
||||
if _, err := fs.Stat(fsys, p+".html"); err == nil {
|
||||
r2 := *r
|
||||
r2.URL.Path = "/" + p + ".html"
|
||||
fileServer.ServeHTTP(w, &r2)
|
||||
return
|
||||
}
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<!-- placeholder, replaced by the ui build output before building the Go binary -->
|
||||
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
npx sv@0.17.0 create --template minimal --types ts --add tailwindcss="plugins:none" sveltekit-adapter="adapter:static" --no-download-check --install npm ui
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 MiB |
BIN
Binary file not shown.
Generated
+2739
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "ui",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@skeletonlabs/skeleton": "^5.0.0",
|
||||
"@skeletonlabs/skeleton-svelte": "^5.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
Generated
+1751
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@skeletonlabs/skeleton';
|
||||
@import './lib/wedding-theme.css';
|
||||
|
||||
@font-face {
|
||||
font-family: 'Malibu';
|
||||
src:
|
||||
url('../assets/fonts/Malibu.woff') format('woff'),
|
||||
url('../assets/fonts/Malibu.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Bilbo';
|
||||
src: url('../assets/fonts/Bilbo-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* Script face reserved for "Zoé & Shaldon" — nav title, hero, RSVP, QR page. */
|
||||
--font-malibu: 'Malibu', cursive;
|
||||
/* Formal face for everything else: headings, body, buttons, inputs. */
|
||||
--font-serif: 'Bilbo', Georgia, 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Palatino, serif;
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="wedding">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
export interface WeddingConfig {
|
||||
siteUrl: string;
|
||||
weddingDate: string;
|
||||
ceremonyTime: string;
|
||||
rsvpByDate: string;
|
||||
venueName: string;
|
||||
venueAddress: string;
|
||||
rsvpPhone: string;
|
||||
}
|
||||
|
||||
export type GuestType = 'adult' | 'child';
|
||||
|
||||
export interface Guest {
|
||||
name: string;
|
||||
type: GuestType;
|
||||
}
|
||||
|
||||
export interface RsvpPayload {
|
||||
guests: Guest[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PhotoUploadPayload {
|
||||
name: string;
|
||||
message?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
files: FileList;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseError(response: Response): Promise<string> {
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (typeof body?.error === 'string') return body.error;
|
||||
} catch {
|
||||
// fall through to status text
|
||||
}
|
||||
return response.statusText || `Request failed (${response.status})`;
|
||||
}
|
||||
|
||||
export async function fetchConfig(fetchImpl: typeof fetch = fetch): Promise<WeddingConfig> {
|
||||
const response = await fetchImpl('/api/config');
|
||||
if (!response.ok) throw new ApiError(await parseError(response), response.status);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function submitRsvp(payload: RsvpPayload): Promise<void> {
|
||||
const response = await fetch('/api/rsvp', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!response.ok) throw new ApiError(await parseError(response), response.status);
|
||||
}
|
||||
|
||||
export async function uploadPhotos(payload: PhotoUploadPayload): Promise<void> {
|
||||
const form = new FormData();
|
||||
form.set('name', payload.name);
|
||||
if (payload.message) form.set('message', payload.message);
|
||||
if (payload.email) form.set('email', payload.email);
|
||||
if (payload.phone) form.set('phone', payload.phone);
|
||||
for (const file of payload.files) form.append('files', file);
|
||||
|
||||
const response = await fetch('/api/photos/upload', { method: 'POST', body: form });
|
||||
if (!response.ok) throw new ApiError(await parseError(response), response.status);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
target: Date;
|
||||
compact?: boolean;
|
||||
pastMessage?: string;
|
||||
}
|
||||
|
||||
let { target, compact = false, pastMessage = 'The big day is here!' }: Props = $props();
|
||||
|
||||
let now = $state(Date.now());
|
||||
|
||||
$effect(() => {
|
||||
const id = setInterval(() => (now = Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
const diffMs = $derived(Math.max(0, target.getTime() - now));
|
||||
const isPast = $derived(diffMs <= 0);
|
||||
|
||||
const days = $derived(Math.floor(diffMs / 86_400_000));
|
||||
const hours = $derived(Math.floor((diffMs % 86_400_000) / 3_600_000));
|
||||
const minutes = $derived(Math.floor((diffMs % 3_600_000) / 60_000));
|
||||
const seconds = $derived(Math.floor((diffMs % 60_000) / 1000));
|
||||
|
||||
const units = $derived([
|
||||
{ label: 'days', value: days },
|
||||
{ label: 'hours', value: hours },
|
||||
{ label: 'minutes', value: minutes },
|
||||
{ label: 'seconds', value: seconds }
|
||||
]);
|
||||
</script>
|
||||
|
||||
{#if isPast}
|
||||
<p class={compact ? 'text-lg font-medium sm:text-xl' : 'text-surface-50 text-xl font-medium'}>{pastMessage}</p>
|
||||
{:else if compact}
|
||||
<div class="flex items-baseline gap-2 text-lg sm:text-xl" 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">{unit.value}</span>
|
||||
<span class="text-secondary-100/80 text-base">{unit.label}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex gap-4 sm:gap-8" role="timer" aria-live="polite">
|
||||
{#each units as unit (unit.label)}
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-secondary-400 text-4xl font-bold tabular-nums sm:text-6xl">
|
||||
{String(unit.value).padStart(2, '0')}
|
||||
</span>
|
||||
<span class="text-surface-200 text-base tracking-wide uppercase sm:text-lg">{unit.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { backOut, cubicIn } from 'svelte/easing';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const modules = import.meta.glob('../../../assets/photos/*.{jpg,jpeg,png,JPG,JPEG,PNG}', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
query: '?url'
|
||||
}) as Record<string, string>;
|
||||
|
||||
function shuffle<T>(items: T[]): T[] {
|
||||
const result = [...items];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Randomized client-side only: shuffling during SSR/prerender would bake one
|
||||
// order into the static HTML while the client picks another, causing a
|
||||
// hydration mismatch on the <img src>.
|
||||
let photos = $state<string[]>([]);
|
||||
const intervalMs = 6000;
|
||||
|
||||
let index = $state(0);
|
||||
let reducedMotion = $state(false);
|
||||
|
||||
// Each photo flies in/out from a random angle with its own spin and pop, so
|
||||
// no two transitions look alike.
|
||||
function funky(node: HTMLElement, { duration, variant }: { duration: number; variant: 'in' | 'out' }) {
|
||||
if (reducedMotion) {
|
||||
return { duration: 400, css: (t: number) => `opacity: ${t}` };
|
||||
}
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const dist = 55 + Math.random() * 55;
|
||||
const rot = Math.random() * 80 - 40;
|
||||
const scaleFrom = 0.5 + Math.random() * 0.3;
|
||||
const x = Math.cos(angle) * dist;
|
||||
const y = Math.sin(angle) * dist;
|
||||
return {
|
||||
duration: duration + Math.random() * 300,
|
||||
easing: variant === 'in' ? backOut : cubicIn,
|
||||
css: (t: number, u: number) => `
|
||||
transform: translate(${u * x}%, ${u * y}%) rotate(${u * rot}deg) scale(${1 - u * (1 - scaleFrom)});
|
||||
opacity: ${t};
|
||||
`
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
photos = shuffle(Object.values(modules));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
reducedMotion = query.matches;
|
||||
const onChange = (event: MediaQueryListEvent) => (reducedMotion = event.matches);
|
||||
query.addEventListener('change', onChange);
|
||||
return () => query.removeEventListener('change', onChange);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (photos.length < 2) return;
|
||||
const id = setInterval(() => {
|
||||
index = (index + 1) % photos.length;
|
||||
}, intervalMs);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if photos.length > 0}
|
||||
<div class="pointer-events-none bg-surface-950 absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
{#key index}
|
||||
<img
|
||||
src={photos[index]}
|
||||
alt=""
|
||||
class="absolute inset-0 h-full w-full object-contain"
|
||||
in:funky={{ duration: 900, variant: 'in' }}
|
||||
out:funky={{ duration: 700, variant: 'out' }}
|
||||
/>
|
||||
{/key}
|
||||
<div class="absolute inset-0 bg-surface-950/65 dark:bg-surface-950/75"></div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Matches wedding.* / site.* in config.example.yaml. Used as an immediate
|
||||
// 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_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_VENUE_ADDRESS =
|
||||
"Langenhoven Rd, Sherman Park AH, Meyerton, 1961";
|
||||
export const FALLBACK_RSVP_PHONE = "076 925 1718";
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -0,0 +1,24 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const STORAGE_KEY = 'zoe-shaldon-rsvp-submitted';
|
||||
|
||||
export function createRsvpStatus() {
|
||||
let submitted = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
submitted = localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
});
|
||||
|
||||
function markSubmitted() {
|
||||
submitted = true;
|
||||
if (browser) localStorage.setItem(STORAGE_KEY, 'true');
|
||||
}
|
||||
|
||||
return {
|
||||
get submitted() {
|
||||
return submitted;
|
||||
},
|
||||
markSubmitted
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { fetchConfig } from './api';
|
||||
import {
|
||||
FALLBACK_SITE_URL,
|
||||
FALLBACK_WEDDING_DATE,
|
||||
FALLBACK_CEREMONY_TIME,
|
||||
FALLBACK_RSVP_BY_DATE,
|
||||
FALLBACK_VENUE_NAME,
|
||||
FALLBACK_VENUE_ADDRESS,
|
||||
FALLBACK_RSVP_PHONE
|
||||
} from './config';
|
||||
|
||||
export function createWeddingInfo() {
|
||||
let siteUrl = $state(FALLBACK_SITE_URL);
|
||||
let date = $state(new Date(FALLBACK_WEDDING_DATE));
|
||||
let ceremonyTime = $state(FALLBACK_CEREMONY_TIME);
|
||||
let rsvpByDate = $state(new Date(FALLBACK_RSVP_BY_DATE));
|
||||
let venueName = $state(FALLBACK_VENUE_NAME);
|
||||
let venueAddress = $state(FALLBACK_VENUE_ADDRESS);
|
||||
let rsvpPhone = $state(FALLBACK_RSVP_PHONE);
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
fetchConfig()
|
||||
.then((config) => {
|
||||
siteUrl = config.siteUrl;
|
||||
date = new Date(config.weddingDate);
|
||||
ceremonyTime = config.ceremonyTime;
|
||||
rsvpByDate = new Date(config.rsvpByDate);
|
||||
venueName = config.venueName;
|
||||
venueAddress = config.venueAddress;
|
||||
rsvpPhone = config.rsvpPhone;
|
||||
})
|
||||
.catch(() => {
|
||||
// keep the fallback values; the API may not be running yet in dev
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
get siteUrl() {
|
||||
return siteUrl;
|
||||
},
|
||||
get date() {
|
||||
return date;
|
||||
},
|
||||
get ceremonyTime() {
|
||||
return ceremonyTime;
|
||||
},
|
||||
get rsvpByDate() {
|
||||
return rsvpByDate;
|
||||
},
|
||||
get venueName() {
|
||||
return venueName;
|
||||
},
|
||||
get venueAddress() {
|
||||
return venueAddress;
|
||||
},
|
||||
get rsvpPhone() {
|
||||
return rsvpPhone;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const dayDateFormatter = new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
export function formatFullDate(date: Date): string {
|
||||
return dayDateFormatter.format(date);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
[data-theme='wedding'] {
|
||||
--spacing: 0.25rem;
|
||||
--text-scaling: 1;
|
||||
--typo-base--font-family:
|
||||
'Bilbo', Georgia, 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Palatino, serif;
|
||||
--typo-base--font-size: inherit;
|
||||
--typo-base--color-light: var(--color-surface-950);
|
||||
--typo-base--color-dark: var(--color-surface-50);
|
||||
--typo-base--line-height: inherit;
|
||||
--typo-base--font-weight: bold;
|
||||
--typo-base--font-style: normal;
|
||||
--typo-base--letter-spacing: 0em;
|
||||
--typo-base--font-stretch: inherit;
|
||||
--typo-base--font-kerning: inherit;
|
||||
--typo-base--text-shadow: inherit;
|
||||
--typo-base--word-spacing: inherit;
|
||||
--typo-base--hyphens: inherit;
|
||||
--typo-base--text-transform: inherit;
|
||||
--typo-heading--font-family: inherit;
|
||||
--typo-heading--color-light: inherit;
|
||||
--typo-heading--color-dark: inherit;
|
||||
--typo-heading--font-weight: bold;
|
||||
--typo-heading--font-style: normal;
|
||||
--typo-heading--letter-spacing: inherit;
|
||||
--typo-heading--font-stretch: inherit;
|
||||
--typo-heading--font-kerning: inherit;
|
||||
--typo-heading--text-shadow: inherit;
|
||||
--typo-heading--word-spacing: inherit;
|
||||
--typo-heading--hyphens: inherit;
|
||||
--typo-heading--text-transform: inherit;
|
||||
--typo-anchor--font-family: inherit;
|
||||
--typo-anchor--font-size: inherit;
|
||||
--typo-anchor--color-light: var(--color-primary-500);
|
||||
--typo-anchor--color-dark: var(--color-primary-400);
|
||||
--typo-anchor--line-height: inherit;
|
||||
--typo-anchor--font-weight: inherit;
|
||||
--typo-anchor--font-style: inherit;
|
||||
--typo-anchor--letter-spacing: inherit;
|
||||
--typo-anchor--font-stretch: inherit;
|
||||
--typo-anchor--font-kerning: inherit;
|
||||
--typo-anchor--text-shadow: inherit;
|
||||
--typo-anchor--word-spacing: inherit;
|
||||
--typo-anchor--hyphens: inherit;
|
||||
--typo-anchor--text-transform: inherit;
|
||||
--typo-anchor--text-decoration-line: none;
|
||||
--typo-anchor--text-decoration-color: inherit;
|
||||
--typo-anchor--text-decoration-style: inherit;
|
||||
--typo-anchor--text-decoration-thickness: inherit;
|
||||
--typo-anchor--text-underline-offset: inherit;
|
||||
--typo-anchor--text-underline-position: inherit;
|
||||
--typo-anchor--hover--text-decoration-line: underline;
|
||||
--typo-anchor--hover--text-decoration-color: inherit;
|
||||
--typo-anchor--hover--text-decoration-style: inherit;
|
||||
--typo-anchor--hover--text-decoration-thickness: inherit;
|
||||
--typo-anchor--hover--text-underline-offset: inherit;
|
||||
--typo-anchor--hover--text-underline-position: inherit;
|
||||
--typo-anchor--active--text-decoration-line: none;
|
||||
--typo-anchor--active--text-decoration-color: inherit;
|
||||
--typo-anchor--active--text-decoration-style: inherit;
|
||||
--typo-anchor--active--text-decoration-thickness: inherit;
|
||||
--typo-anchor--active--text-underline-offset: inherit;
|
||||
--typo-anchor--active--text-underline-position: inherit;
|
||||
--typo-anchor--focus--text-decoration-line: none;
|
||||
--typo-anchor--focus--text-decoration-color: inherit;
|
||||
--typo-anchor--focus--text-decoration-style: inherit;
|
||||
--typo-anchor--focus--text-decoration-thickness: inherit;
|
||||
--typo-anchor--focus--text-underline-offset: inherit;
|
||||
--typo-anchor--focus--text-underline-position: inherit;
|
||||
--radius-base: 0.5rem;
|
||||
--radius-container: 0.5rem;
|
||||
--default-border-width: 1px;
|
||||
--default-outline-width: 1px;
|
||||
--default-ring-width: 1px;
|
||||
--corner-shape-base: squircle;
|
||||
--corner-shape-container: squircle;
|
||||
--color-root-bg-light: var(--color-surface-50);
|
||||
--color-root-bg-dark: var(--color-surface-950);
|
||||
--color-brand-light: var(--color-primary-500);
|
||||
--color-brand-contrast-light: var(--color-primary-contrast-500);
|
||||
--color-brand-dark: var(--color-primary-500);
|
||||
--color-brand-contrast-dark: var(--color-primary-contrast-500);
|
||||
|
||||
/* primary (blue) — seed #1E4E79, from the groom's shirt / dusk sky in the save-the-date photo */
|
||||
--color-primary-50: oklch(0.97 0.0133 248.73);
|
||||
--color-primary-100: oklch(0.93 0.0266 248.73);
|
||||
--color-primary-200: oklch(0.86 0.0488 248.73);
|
||||
--color-primary-300: oklch(0.78 0.0666 248.73);
|
||||
--color-primary-400: oklch(0.7 0.0799 248.73);
|
||||
--color-primary-500: oklch(0.62 0.0888 248.73);
|
||||
--color-primary-600: oklch(0.54 0.0817 248.73);
|
||||
--color-primary-700: oklch(0.46 0.071 248.73);
|
||||
--color-primary-800: oklch(0.38 0.0577 248.73);
|
||||
--color-primary-900: oklch(0.3 0.0444 248.73);
|
||||
--color-primary-950: oklch(0.22 0.0311 248.73);
|
||||
--color-primary-contrast-dark: var(--color-primary-950);
|
||||
--color-primary-contrast-light: var(--color-primary-50);
|
||||
--color-primary-contrast-50: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-100: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-200: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-300: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-400: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-500: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-600: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-700: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-800: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-900: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-950: var(--color-primary-contrast-light);
|
||||
|
||||
/* secondary (yellow) — seed #D9A441, from the warm gold title text in the same photo */
|
||||
--color-secondary-50: oklch(0.97 0.0194 79.85);
|
||||
--color-secondary-100: oklch(0.93 0.0389 79.85);
|
||||
--color-secondary-200: oklch(0.86 0.0712 79.85);
|
||||
--color-secondary-300: oklch(0.78 0.0971 79.85);
|
||||
--color-secondary-400: oklch(0.7 0.1166 79.85);
|
||||
--color-secondary-500: oklch(0.62 0.1295 79.85);
|
||||
--color-secondary-600: oklch(0.54 0.1191 79.85);
|
||||
--color-secondary-700: oklch(0.46 0.1036 79.85);
|
||||
--color-secondary-800: oklch(0.38 0.0842 79.85);
|
||||
--color-secondary-900: oklch(0.3 0.0648 79.85);
|
||||
--color-secondary-950: oklch(0.22 0.0453 79.85);
|
||||
--color-secondary-contrast-dark: var(--color-secondary-950);
|
||||
--color-secondary-contrast-light: var(--color-secondary-50);
|
||||
--color-secondary-contrast-50: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-100: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-200: var(--color-secondary-contrast-dark);
|
||||
--color-secondary-contrast-300: var(--color-secondary-contrast-dark);
|
||||
--color-secondary-contrast-400: var(--color-secondary-contrast-dark);
|
||||
--color-secondary-contrast-500: var(--color-secondary-contrast-dark);
|
||||
--color-secondary-contrast-600: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-700: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-800: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-900: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-950: var(--color-secondary-contrast-light);
|
||||
|
||||
/* tertiary/success/warning/error/surface: unmodified neutrals, not part of the brand ask */
|
||||
--color-tertiary-50: oklch(0.91 0.08 328.89);
|
||||
--color-tertiary-100: oklch(0.83 0.13 339.66);
|
||||
--color-tertiary-200: oklch(0.76 0.18 345.54);
|
||||
--color-tertiary-300: oklch(0.7 0.23 350.67);
|
||||
--color-tertiary-400: oklch(0.66 0.25 355.84);
|
||||
--color-tertiary-500: oklch(0.65 0.26 2.47);
|
||||
--color-tertiary-600: oklch(0.59 0.24 1.69);
|
||||
--color-tertiary-700: oklch(0.54 0.22 0.5);
|
||||
--color-tertiary-800: oklch(0.48 0.2 359.65);
|
||||
--color-tertiary-900: oklch(0.43 0.17 357.7);
|
||||
--color-tertiary-950: oklch(0.37 0.15 355.33);
|
||||
--color-tertiary-contrast-dark: var(--color-tertiary-950);
|
||||
--color-tertiary-contrast-light: var(--color-tertiary-50);
|
||||
--color-tertiary-contrast-50: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-100: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-200: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-300: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-400: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-500: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-600: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-700: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-800: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-900: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-950: var(--color-tertiary-contrast-light);
|
||||
--color-success-50: oklch(0.94 0.09 178.68);
|
||||
--color-success-100: oklch(0.92 0.1 178.62);
|
||||
--color-success-200: oklch(0.89 0.11 177.17);
|
||||
--color-success-300: oklch(0.87 0.12 176.91);
|
||||
--color-success-400: oklch(0.85 0.13 175.46);
|
||||
--color-success-500: oklch(0.83 0.13 174.96);
|
||||
--color-success-600: oklch(0.73 0.12 175.71);
|
||||
--color-success-700: oklch(0.62 0.1 176);
|
||||
--color-success-800: oklch(0.51 0.08 178.29);
|
||||
--color-success-900: oklch(0.4 0.06 179.75);
|
||||
--color-success-950: oklch(0.27 0.04 185.3);
|
||||
--color-success-contrast-dark: var(--color-success-950);
|
||||
--color-success-contrast-light: var(--color-success-50);
|
||||
--color-success-contrast-50: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-100: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-200: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-300: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-400: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-500: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-600: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-700: var(--color-success-contrast-light);
|
||||
--color-success-contrast-800: var(--color-success-contrast-light);
|
||||
--color-success-contrast-900: var(--color-success-contrast-light);
|
||||
--color-success-contrast-950: var(--color-success-contrast-light);
|
||||
--color-warning-50: oklch(0.96 0.05 84.57);
|
||||
--color-warning-100: oklch(0.93 0.06 82.17);
|
||||
--color-warning-200: oklch(0.9 0.08 80.34);
|
||||
--color-warning-300: oklch(0.88 0.1 80.02);
|
||||
--color-warning-400: oklch(0.85 0.12 78.36);
|
||||
--color-warning-500: oklch(0.82 0.14 76.72);
|
||||
--color-warning-600: oklch(0.76 0.13 72.26);
|
||||
--color-warning-700: oklch(0.7 0.13 68.1);
|
||||
--color-warning-800: oklch(0.64 0.13 63.18);
|
||||
--color-warning-900: oklch(0.58 0.13 57.97);
|
||||
--color-warning-950: oklch(0.52 0.13 51.44);
|
||||
--color-warning-contrast-dark: var(--color-warning-950);
|
||||
--color-warning-contrast-light: var(--color-warning-50);
|
||||
--color-warning-contrast-50: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-100: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-200: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-300: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-400: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-500: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-600: var(--color-warning-contrast-light);
|
||||
--color-warning-contrast-700: var(--color-warning-contrast-light);
|
||||
--color-warning-contrast-800: var(--color-warning-contrast-light);
|
||||
--color-warning-contrast-900: var(--color-warning-contrast-light);
|
||||
--color-warning-contrast-950: var(--color-warning-contrast-light);
|
||||
--color-error-50: oklch(0.9 0.04 14);
|
||||
--color-error-100: oklch(0.83 0.07 19.8);
|
||||
--color-error-200: oklch(0.77 0.11 21.97);
|
||||
--color-error-300: oklch(0.72 0.15 24.89);
|
||||
--color-error-400: oklch(0.67 0.19 26.71);
|
||||
--color-error-500: oklch(0.64 0.22 28.71);
|
||||
--color-error-600: oklch(0.59 0.21 28.53);
|
||||
--color-error-700: oklch(0.55 0.2 28.58);
|
||||
--color-error-800: oklch(0.51 0.19 28.72);
|
||||
--color-error-900: oklch(0.46 0.18 28.88);
|
||||
--color-error-950: oklch(0.42 0.17 29.23);
|
||||
--color-error-contrast-dark: var(--color-error-950);
|
||||
--color-error-contrast-light: var(--color-error-50);
|
||||
--color-error-contrast-50: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-100: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-200: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-300: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-400: var(--color-error-contrast-light);
|
||||
--color-error-contrast-500: var(--color-error-contrast-light);
|
||||
--color-error-contrast-600: var(--color-error-contrast-light);
|
||||
--color-error-contrast-700: var(--color-error-contrast-light);
|
||||
--color-error-contrast-800: var(--color-error-contrast-light);
|
||||
--color-error-contrast-900: var(--color-error-contrast-light);
|
||||
--color-error-contrast-950: var(--color-error-contrast-light);
|
||||
--color-surface-50: oklch(0.99 0 0);
|
||||
--color-surface-100: oklch(0.91 0 0);
|
||||
--color-surface-200: oklch(0.81 0 0);
|
||||
--color-surface-300: oklch(0.72 0 0);
|
||||
--color-surface-400: oklch(0.62 0 0);
|
||||
--color-surface-500: oklch(0.51 0 0);
|
||||
--color-surface-600: oklch(0.45 0 0);
|
||||
--color-surface-700: oklch(0.39 0 0);
|
||||
--color-surface-800: oklch(0.32 0 0);
|
||||
--color-surface-900: oklch(0.25 0 0);
|
||||
--color-surface-950: oklch(0.18 0 0);
|
||||
--color-surface-contrast-dark: var(--color-surface-950);
|
||||
--color-surface-contrast-light: var(--color-surface-50);
|
||||
--color-surface-contrast-50: var(--color-surface-contrast-dark);
|
||||
--color-surface-contrast-100: var(--color-surface-contrast-dark);
|
||||
--color-surface-contrast-200: var(--color-surface-contrast-dark);
|
||||
--color-surface-contrast-300: var(--color-surface-contrast-dark);
|
||||
--color-surface-contrast-400: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-500: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-600: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-700: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-800: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-900: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-950: var(--color-surface-contrast-light);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
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';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const wedding = createWeddingInfo();
|
||||
|
||||
const links = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/rsvp', label: 'RSVP' },
|
||||
{ href: '/photos', label: 'Photos' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
|
||||
<div class="bg-surface-50 dark:bg-surface-950 flex min-h-screen flex-col">
|
||||
<div class="sticky top-0 z-20 flex flex-col">
|
||||
<nav class="bg-primary-950 flex items-center justify-between px-4 py-3 sm:px-8">
|
||||
<a href="/" class="text-surface-50 font-malibu text-xl tracking-wide">Zoé & Shaldon</a>
|
||||
<div class="flex gap-2">
|
||||
{#each links as link (link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
class="btn field-sm {page.url.pathname === link.href
|
||||
? 'preset-filled-secondary-500'
|
||||
: 'text-surface-100 hover:text-secondary-400'}"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</nav>
|
||||
<div class="text-surface-50 flex justify-center px-4 py-3 drop-shadow-[0_2px_6px_rgba(0,0,0,0.6)]">
|
||||
<Countdown target={wedding.date} compact pastMessage="Already married! 🎉" />
|
||||
</div>
|
||||
</div>
|
||||
<main class="flex flex-1 flex-col">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
export const prerender = true;
|
||||
@@ -0,0 +1,49 @@
|
||||
<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>
|
||||
|
||||
<section
|
||||
class="relative flex flex-1 items-center justify-center overflow-hidden px-4 py-16 text-center"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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)}
|
||||
</p>
|
||||
<p class="text-surface-200 text-2xl tracking-wide">
|
||||
{wedding.ceremonyTime}
|
||||
</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">
|
||||
{#if !rsvpStatus.submitted}
|
||||
<a href="/rsvp" class="btn preset-filled-secondary-500 text-2xl">RSVP</a
|
||||
>
|
||||
{:else}
|
||||
<p class="text-secondary-300 text-2xl">✓ You've RSVP'd — thank you!</p>
|
||||
{/if}
|
||||
<a href="/photos" class="btn preset-filled-primary-500 text-2xl"
|
||||
>Share photos</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import { createWeddingInfo, formatFullDate } from '$lib/wedding-info.svelte';
|
||||
import { uploadPhotos, ApiError } from '$lib/api';
|
||||
|
||||
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('');
|
||||
|
||||
async function onSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!files || files.length === 0) {
|
||||
status = 'error';
|
||||
errorMessage = 'Please choose at least one photo.';
|
||||
return;
|
||||
}
|
||||
if (files.length > MAX_FILES) {
|
||||
status = 'error';
|
||||
errorMessage = `Please upload at most ${MAX_FILES} photos at a time.`;
|
||||
return;
|
||||
}
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
|
||||
status = 'error';
|
||||
errorMessage = `"${file.name}" is larger than ${MAX_SIZE_MB}MB.`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
status = 'submitting';
|
||||
errorMessage = '';
|
||||
try {
|
||||
await uploadPhotos({
|
||||
name,
|
||||
message: message || undefined,
|
||||
email: email || undefined,
|
||||
phone: phone || undefined,
|
||||
files
|
||||
});
|
||||
status = 'done';
|
||||
} catch (err) {
|
||||
status = 'error';
|
||||
errorMessage = err instanceof ApiError ? err.message : 'Something went wrong. Please try again.';
|
||||
}
|
||||
}
|
||||
</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-lg">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">
|
||||
<h1 class="font-serif text-4xl">Share your photos</h1>
|
||||
<p class="text-surface-600-400 mt-2 mb-8 text-lg">
|
||||
Upload photos from the day — up to {MAX_FILES} at a time, {MAX_SIZE_MB}MB each.
|
||||
</p>
|
||||
|
||||
<form class="flex flex-col gap-4" onsubmit={onSubmit}>
|
||||
<label class="label">
|
||||
<span class="label-text">Name *</span>
|
||||
<input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700" type="text" required bind:value={name} disabled={status === 'submitting'} />
|
||||
</label>
|
||||
|
||||
<label class="label">
|
||||
<span class="label-text">Message to the couple (optional)</span>
|
||||
<textarea class="textarea border border-surface-300 focus:border-primary-500 dark:border-surface-700" rows="3" bind:value={message} disabled={status === 'submitting'}
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="label">
|
||||
<span class="label-text">Email (optional)</span>
|
||||
<input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700" type="email" bind:value={email} disabled={status === 'submitting'} />
|
||||
</label>
|
||||
|
||||
<label class="label">
|
||||
<span class="label-text">Phone (optional)</span>
|
||||
<input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700" type="tel" bind:value={phone} disabled={status === 'submitting'} />
|
||||
</label>
|
||||
|
||||
<label class="label">
|
||||
<span class="label-text">Photos *</span>
|
||||
<input
|
||||
class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
required
|
||||
bind:files
|
||||
disabled={status === 'submitting'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if status === 'error'}
|
||||
<p class="text-error-500 text-base">{errorMessage}</p>
|
||||
{/if}
|
||||
|
||||
<button class="btn preset-filled-secondary-500 mt-2" type="submit" disabled={status === 'submitting'}>
|
||||
{status === 'submitting' ? 'Uploading…' : 'Upload photos'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { QrCode } from '@skeletonlabs/skeleton-svelte';
|
||||
import { createWeddingInfo } from '$lib/wedding-info.svelte';
|
||||
|
||||
const wedding = createWeddingInfo();
|
||||
</script>
|
||||
|
||||
<section class="flex flex-1 items-center justify-center px-4 py-16">
|
||||
<div class="border-surface-300 dark:border-surface-700 flex flex-col items-center gap-4 border px-6 py-10 text-center">
|
||||
<p class="text-primary-600-400 text-base tracking-[0.25em] uppercase">Scan to visit</p>
|
||||
<h1 class="font-malibu text-4xl">Zoé & Shaldon</h1>
|
||||
|
||||
<QrCode value={wedding.siteUrl} class="flex flex-col items-center gap-4">
|
||||
<QrCode.Frame class="w-56">
|
||||
<QrCode.Pattern />
|
||||
</QrCode.Frame>
|
||||
|
||||
<p class="text-surface-700 dark:text-surface-300 text-base break-all">{wedding.siteUrl}</p>
|
||||
|
||||
<QrCode.DownloadTrigger mimeType="image/png" fileName="zoe-and-shaldon-qr.png">
|
||||
Download PNG
|
||||
</QrCode.DownloadTrigger>
|
||||
</QrCode>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
import { submitRsvp, ApiError, type Guest, type GuestType } from "$lib/api";
|
||||
import { createWeddingInfo, formatFullDate } from "$lib/wedding-info.svelte";
|
||||
import { createRsvpStatus } from "$lib/rsvp-status.svelte";
|
||||
import Countdown from "$lib/components/Countdown.svelte";
|
||||
import PhotoBackground from "$lib/components/PhotoBackground.svelte";
|
||||
|
||||
const wedding = createWeddingInfo();
|
||||
const rsvpStatus = createRsvpStatus();
|
||||
|
||||
let now = $state(Date.now());
|
||||
$effect(() => {
|
||||
const id = setInterval(() => (now = Date.now()), 30_000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
const isRsvpOpen = $derived(now < wedding.rsvpByDate.getTime());
|
||||
|
||||
let guests = $state<Guest[]>([{ name: "", type: "adult" }]);
|
||||
let message = $state("");
|
||||
|
||||
let status = $state<"idle" | "submitting" | "done" | "error">("idle");
|
||||
let errorMessage = $state("");
|
||||
|
||||
function addGuest(type: GuestType) {
|
||||
guests.push({ name: "", type });
|
||||
}
|
||||
|
||||
function removeGuest(index: number) {
|
||||
guests.splice(index, 1);
|
||||
}
|
||||
|
||||
async function onSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const trimmedGuests = guests
|
||||
.map((g) => ({ name: g.name.trim(), type: g.type }))
|
||||
.filter((g) => g.name !== "");
|
||||
if (trimmedGuests.length === 0) {
|
||||
status = "error";
|
||||
errorMessage = "Please add at least one name.";
|
||||
return;
|
||||
}
|
||||
|
||||
status = "submitting";
|
||||
errorMessage = "";
|
||||
try {
|
||||
await submitRsvp({
|
||||
guests: trimmedGuests,
|
||||
message: message || undefined,
|
||||
});
|
||||
status = "done";
|
||||
rsvpStatus.markSubmitted();
|
||||
} catch (err) {
|
||||
status = "error";
|
||||
errorMessage =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "Something went wrong. Please try again.";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="relative flex flex-1 items-center justify-center overflow-hidden px-4 py-16"
|
||||
>
|
||||
<PhotoBackground />
|
||||
|
||||
<div
|
||||
class="border-surface-50/70 relative z-10 w-full max-w-md border px-6 py-12 sm:px-10 sm:py-14"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
<p class="text-surface-200 text-base tracking-[0.25em] uppercase">
|
||||
Please join us to celebrate the union of
|
||||
</p>
|
||||
<h1 class="text-surface-50 font-malibu text-5xl">Zoé & Shaldon</h1>
|
||||
<p class="text-surface-50 mt-3 text-2xl">
|
||||
{formatFullDate(wedding.date)}
|
||||
</p>
|
||||
<p class="text-surface-50 text-2xl">{wedding.ceremonyTime}</p>
|
||||
<p class="text-surface-50 mt-3 text-2xl">{wedding.venueName}</p>
|
||||
<p class="text-surface-50 text-2xl">{wedding.venueAddress}</p>
|
||||
</div>
|
||||
|
||||
{#if isRsvpOpen}
|
||||
<div class="mt-8 flex flex-col items-center gap-3 text-center">
|
||||
<p class="text-surface-100 text-2xl">
|
||||
RSVP by {formatFullDate(wedding.rsvpByDate)}
|
||||
</p>
|
||||
<Countdown target={wedding.rsvpByDate} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isRsvpOpen}
|
||||
<div class="border-error-400 rounded-base mt-8 border p-4 text-center">
|
||||
<p class="text-surface-50 text-lg font-medium">RSVP missed</p>
|
||||
<p class="text-surface-200 mt-1 text-base">
|
||||
The RSVP deadline has passed. Please contact us directly on {wedding.rsvpPhone}.
|
||||
</p>
|
||||
</div>
|
||||
{:else if status === "done"}
|
||||
<div class="preset-filled-primary-500 rounded-base mt-8 p-4 text-center">
|
||||
Thank you — your RSVP has been received!
|
||||
</div>
|
||||
{:else}
|
||||
<form class="mt-8 flex flex-col gap-4 w-full" onsubmit={onSubmit}>
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<span class="label-text text-surface-100 text-2xl"
|
||||
>Who's coming? *</span
|
||||
>
|
||||
{#each guests as guest, i (i)}
|
||||
<div class="flex gap-2">
|
||||
<span
|
||||
class="preset-tonal-{guest.type === 'child'
|
||||
? 'secondary'
|
||||
: 'primary'} rounded-base flex shrink-0 items-center px-2 text-2xl font-medium"
|
||||
>
|
||||
{guest.type === "child" ? "Child" : "Adult"}
|
||||
</span>
|
||||
<input
|
||||
class="input border-surface-300 dark:border-surface-700 focus:border-primary-500 border text-2xl"
|
||||
type="text"
|
||||
placeholder={guest.type === "child"
|
||||
? "Child's name"
|
||||
: "Guest name"}
|
||||
required
|
||||
bind:value={guest.name}
|
||||
disabled={status === "submitting"}
|
||||
/>
|
||||
{#if guests.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
class="btn preset-filled-surface-200-800 text-2xl"
|
||||
aria-label="Remove guest"
|
||||
onclick={() => removeGuest(i)}
|
||||
disabled={status === "submitting"}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="flex gap-2 w-full">
|
||||
<button
|
||||
type="button"
|
||||
class="btn preset-filled-surface-200-800 text-2xl self-start"
|
||||
onclick={() => addGuest("adult")}
|
||||
disabled={status === "submitting"}
|
||||
>
|
||||
+ Add guest
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn preset-filled-surface-200-800 text-2xl self-start"
|
||||
onclick={() => addGuest("child")}
|
||||
disabled={status === "submitting"}
|
||||
>
|
||||
+ Add child
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="label">
|
||||
<span class="label-text text-surface-100"
|
||||
>Message to the couple (optional)</span
|
||||
>
|
||||
<textarea
|
||||
class="textarea border-surface-300 dark:border-surface-700 focus:border-primary-500 border text-2xl"
|
||||
rows="3"
|
||||
bind:value={message}
|
||||
disabled={status === "submitting"}
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
{#if status === "error"}
|
||||
<p class="text-error-500 text-2xl">{errorMessage}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="btn preset-filled-secondary-500 mt-2 text-2xl"
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
>
|
||||
{status === "submitting" ? "Sending…" : "Send RSVP"}
|
||||
</button>
|
||||
|
||||
<p class="text-surface-200 text-center text-2xl">
|
||||
Or RSVP by phone: {wedding.rsvpPhone}
|
||||
</p>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
fs: { allow: ['assets'] }
|
||||
},
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit({
|
||||
compilerOptions: {
|
||||
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||
runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
||||
},
|
||||
adapter: adapter()
|
||||
})
|
||||
]
|
||||
});
|
||||
Reference in New Issue
Block a user