feat(api): add endpoint to list guest photos

* implement handleListPhotos to retrieve uploaded photos
* create PhotoFileRecord struct for photo data
* add fetchGuestPhotos function in the UI
* display guest photos in the photo upload page
* remove index.html as it is no longer needed
* add calendar functionality to download wedding event
This commit is contained in:
2026-08-23 10:33:50 +02:00
parent 4eddecea8e
commit f16d280f9a
9 changed files with 198 additions and 46 deletions
+1 -2
View File
@@ -4,5 +4,4 @@ ui/node_modules/
ui/build/
ui/.svelte-kit/
server/wedding-server
server/web/dist/*
!server/web/dist/index.html
server/web/dist/
+22 -5
View File
@@ -36,6 +36,7 @@ func (s *Server) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/config", s.handleConfig)
mux.HandleFunc("POST /api/rsvp", s.handleRSVP)
mux.HandleFunc("GET /api/guests", s.handleGuestList)
mux.HandleFunc("GET /api/photos", s.handleListPhotos)
mux.HandleFunc("POST /api/photos/upload", s.handlePhotoUpload)
}
@@ -146,6 +147,27 @@ func (s *Server) handleGuestList(w http.ResponseWriter, r *http.Request) {
})
}
type photoResponse struct {
URL string `json:"url"`
UploaderName string `json:"uploaderName"`
}
func (s *Server) handleListPhotos(w http.ResponseWriter, r *http.Request) {
files, err := s.store.ListPhotoFiles()
if err != nil {
log.Printf("photos: list failed: %v", err)
writeError(w, http.StatusInternalServerError, "could not load photos")
return
}
out := make([]photoResponse, 0, len(files))
for _, f := range files {
out = append(out, photoResponse{URL: f.URL, UploaderName: f.UploaderName})
}
writeJSON(w, http.StatusOK, map[string]any{"photos": out})
}
var allowedImageTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
@@ -155,11 +177,6 @@ var allowedImageTypes = map[string]bool{
}
func (s *Server) handlePhotoUpload(w http.ResponseWriter, r *http.Request) {
if time.Now().Before(s.cfg.Wedding.Date) {
writeError(w, http.StatusForbidden, "photo uploads open on the wedding day")
return
}
maxTotalBytes := int64(s.cfg.Upload.MaxSizeMB) * int64(s.cfg.Upload.MaxFiles) * 1024 * 1024
r.Body = http.MaxBytesReader(w, r.Body, maxTotalBytes+1<<20)
+30
View File
@@ -159,6 +159,36 @@ func (s *Store) CreatePhotoUpload(u PhotoUpload) (int64, error) {
return res.LastInsertId()
}
type PhotoFileRecord struct {
URL string
UploaderName string
}
// ListPhotoFiles returns every uploaded photo, most recent first.
func (s *Store) ListPhotoFiles() ([]PhotoFileRecord, error) {
rows, err := s.db.Query(
`SELECT f.url, u.name
FROM photo_files f
JOIN photo_uploads u ON u.id = f.upload_id
ORDER BY f.created_at DESC, f.id DESC
LIMIT 200`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PhotoFileRecord
for rows.Next() {
var rec PhotoFileRecord
if err := rows.Scan(&rec.URL, &rec.UploaderName); err != nil {
return nil, err
}
out = append(out, rec)
}
return out, rows.Err()
}
func (s *Store) AddPhotoFiles(uploadID int64, files []PhotoFile) error {
tx, err := s.db.Begin()
if err != nil {
-1
View File
@@ -1 +0,0 @@
<!-- placeholder, replaced by the ui build output before building the Go binary -->
+12
View File
@@ -20,6 +20,11 @@ export interface RsvpPayload {
message?: string;
}
export interface GuestPhoto {
url: string;
uploaderName: string;
}
export interface PhotoUploadPayload {
name: string;
message?: string;
@@ -80,6 +85,13 @@ export async function fetchGuests(fetchImpl: typeof fetch = fetch): Promise<Gues
return response.json();
}
export async function fetchGuestPhotos(fetchImpl: typeof fetch = fetch): Promise<GuestPhoto[]> {
const response = await fetchImpl('/api/photos');
if (!response.ok) throw new ApiError(await parseError(response), response.status);
const body = await response.json();
return body.photos ?? [];
}
export async function uploadPhotos(payload: PhotoUploadPayload): Promise<void> {
const form = new FormData();
form.set('name', payload.name);
+33
View File
@@ -0,0 +1,33 @@
function toICSDate(date: Date): string {
return date.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
}
function escapeICSText(text: string): string {
return text.replace(/([,;])/g, "\\$1");
}
export function buildIcsDataUrl(event: {
start: Date;
end: Date;
summary: string;
location: string;
description: string;
}): string {
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Zoe & Shaldon Wedding//EN",
"BEGIN:VEVENT",
`UID:${event.start.getTime()}@zoeshaldon.warky.info`,
`DTSTAMP:${toICSDate(new Date())}`,
`DTSTART:${toICSDate(event.start)}`,
`DTEND:${toICSDate(event.end)}`,
`SUMMARY:${escapeICSText(event.summary)}`,
`LOCATION:${escapeICSText(event.location)}`,
`DESCRIPTION:${escapeICSText(event.description)}`,
"END:VEVENT",
"END:VCALENDAR",
].join("\r\n");
return `data:text/calendar;charset=utf8,${encodeURIComponent(lines)}`;
}
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
const { count = 6 }: { count?: number } = $props();
const photos = $derived(
__PHOTO_FILES__.slice(0, count).map(
(name) => `/photos/${encodeURIComponent(name)}`,
),
);
</script>
{#if photos.length > 0}
<div class="mb-10">
<h2 class="font-serif text-center text-3xl">A Few of Our Favourites</h2>
<div class="mt-4 grid grid-cols-3 gap-2">
{#each photos as src (src)}
<img
{src}
alt=""
class="border-surface-300 dark:border-surface-700 aspect-square w-full rounded-base border object-cover"
loading="lazy"
/>
{/each}
</div>
</div>
{/if}
+18
View File
@@ -1,8 +1,19 @@
<script lang="ts">
import { createWeddingInfo, formatFullDate } from "$lib/wedding-info.svelte";
import { buildIcsDataUrl } from "$lib/calendar";
const wedding = createWeddingInfo();
const icsUrl = $derived(
buildIcsDataUrl({
start: wedding.date,
end: new Date(wedding.date.getTime() + 7.5 * 60 * 60 * 1000),
summary: "Zoé & Shaldon's Wedding",
location: `${wedding.venueName}, ${wedding.venueAddress}`,
description: `Join us as we say I do! ${wedding.ceremonyTime}.`,
}),
);
const events = [
{ time: "16:00", label: "Guest Arrival" },
{ time: "16:30", label: "Wedding Ceremony Begins" },
@@ -57,6 +68,13 @@
<p class="text-surface-600-400 mt-2 text-2xl">
Zoé &amp; Shaldon — Saturday, 7 November 2026
</p>
<a
href={icsUrl}
download="zoe-and-shaldon-wedding.ics"
class="btn preset-tonal-secondary mt-3 text-2xl"
>
Add to calendar
</a>
<ul class="mt-4 flex flex-col gap-2">
{#each events as event (event.time)}
<li
+53 -34
View File
@@ -1,28 +1,37 @@
<script lang="ts">
import { createWeddingInfo, formatFullDate } from '$lib/wedding-info.svelte';
import { uploadPhotos, ApiError } from '$lib/api';
import { browser } from '$app/environment';
import { uploadPhotos, fetchGuestPhotos, ApiError, type GuestPhoto } from '$lib/api';
import PhotoGallery from '$lib/components/PhotoGallery.svelte';
const MAX_FILES = 10;
const MAX_SIZE_MB = 15;
const wedding = createWeddingInfo();
let now = $state(Date.now());
$effect(() => {
const id = setInterval(() => (now = Date.now()), 30_000);
return () => clearInterval(id);
});
const isOpen = $derived(now >= wedding.date.getTime());
let name = $state('');
let message = $state('');
let email = $state('');
let phone = $state('');
let files: FileList | undefined = $state();
let status = $state<'idle' | 'submitting' | 'done' | 'error'>('idle');
let errorMessage = $state('');
let guestPhotos = $state<GuestPhoto[]>([]);
let guestPhotosStatus = $state<'loading' | 'done' | 'error'>('loading');
function loadGuestPhotos() {
fetchGuestPhotos()
.then((photos) => {
guestPhotos = photos;
guestPhotosStatus = 'done';
})
.catch(() => {
guestPhotosStatus = 'error';
});
}
$effect(() => {
if (!browser) return;
loadGuestPhotos();
});
async function onSubmit(event: SubmitEvent) {
event.preventDefault();
if (!files || files.length === 0) {
@@ -49,11 +58,10 @@
await uploadPhotos({
name,
message: message || undefined,
email: email || undefined,
phone: phone || undefined,
files
});
status = 'done';
loadGuestPhotos();
} catch (err) {
status = 'error';
errorMessage = err instanceof ApiError ? err.message : 'Something went wrong. Please try again.';
@@ -62,20 +70,17 @@
</script>
<section class="flex flex-1 items-center justify-center px-4 py-16">
{#if !isOpen}
<div class="flex flex-col items-center gap-6 text-center">
<h1 class="font-serif text-4xl">Photo uploads open on the wedding day</h1>
<p class="text-surface-600-400 text-2xl">Come back on {formatFullDate(wedding.date)} to share your photos with Zoé &amp; Shaldon.</p>
</div>
{:else if status === 'done'}
<div class="preset-filled-primary-500 rounded-base max-w-md p-4 text-center">
<div class="w-full max-w-md">
<PhotoGallery />
{#if status === 'done'}
<div class="preset-filled-primary-500 rounded-base p-4 text-center">
Thank you — your photos have been uploaded!
</div>
{:else}
<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-2xl">
Upload photos from the day — up to {MAX_FILES} at a time, {MAX_SIZE_MB}MB each.
Upload your photos of Zoé &amp; Shaldon — up to {MAX_FILES} at a time, {MAX_SIZE_MB}MB each.
</p>
<form class="flex flex-col gap-4" onsubmit={onSubmit}>
@@ -90,16 +95,6 @@
></textarea>
</label>
<label class="label">
<span class="label-text text-2xl">Email (optional)</span>
<input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700 text-2xl" type="email" bind:value={email} disabled={status === 'submitting'} />
</label>
<label class="label">
<span class="label-text text-2xl">Phone (optional)</span>
<input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700 text-2xl" type="tel" bind:value={phone} disabled={status === 'submitting'} />
</label>
<label class="label">
<span class="label-text text-2xl">Photos *</span>
<input
@@ -121,6 +116,30 @@
{status === 'submitting' ? 'Uploading…' : 'Upload photos'}
</button>
</form>
{/if}
<div class="border-surface-300 dark:border-surface-700 mt-10 border-t pt-6">
<h2 class="font-serif text-center text-3xl">Photos From Our Guests</h2>
{#if guestPhotosStatus === 'loading'}
<p class="text-surface-600-400 mt-4 text-center text-2xl">Loading…</p>
{:else if guestPhotosStatus === 'error'}
<p class="text-error-500 mt-4 text-center text-2xl">Could not load guest photos.</p>
{:else if guestPhotos.length === 0}
<p class="text-surface-600-400 mt-4 text-center text-2xl">No photos yet — be the first to share!</p>
{:else}
<div class="mt-4 grid grid-cols-3 gap-2">
{#each guestPhotos as photo (photo.url)}
<a href={photo.url} target="_blank" rel="noopener noreferrer">
<img
src={photo.url}
alt="Shared by {photo.uploaderName}"
class="border-surface-300 dark:border-surface-700 aspect-square w-full rounded-base border object-cover"
loading="lazy"
/>
</a>
{/each}
</div>
{/if}
</div>
</div>
</section>