Building the Sailor Board: Real-Time Event Photo Curation with Per-Charter Guest Pages
What Was Done
Deployed a complete photo upload and curation system for chartered sailing events, centered on per-event guest pages that aggregate user-submitted photos, videos, and Instagram content in real time. The system went live for the May 24, 2026 Keely afternoon charter and is now handling multi-file uploads, spam prevention, moderation workflows, and cross-platform content discovery.
The "Sailor Board" is not a separate leaderboard or aggregate dashboard—it's the branded name for the per-event photo gallery that lives on each guest's unique `/g/{event_id}` URL. Guests upload during and after the sail; admins moderate; approved content goes live instantly to that charter's gallery.
Core Architecture
The system is built on four layers:
- Static per-event guest page (
/g/2026-05-24-keely-afternoon) — deployed to S3 as a standalone HTML file - Lambda photo API (
shipcaptaincrew) — presigned S3 upload URLs, metadata storage in DynamoDB, moderation queue - Instagram hashtag integration — real-time fetch of
#jadaand#queenofsandiegoposts from the same calendar day - CDN caching and invalidation — CloudFront staging and production distributions with immediate purge on moderation action
Technical Details: Guest Page Features
Each guest page is a self-contained HTML file deployed to S3 at a predictable path:
s3://queenofsandiego.com/g/{event_date}-{guest_name}/index.html
The Keely page lives at:
s3://queenofsandiego.com/g/2026-05-24-keely-afternoon/index.html (22.5 KB)
Four critical features are baked into the HTML:
- Authenticated file upload — Line 362 defines
#file-inputaccepting JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, and WebM. Line 514 containshandleFiles(), which validates file count and size before calling the presign endpoint. - Event code anti-spam gate — Line 354 inputs a guest-specific code into
#g-code. Line 424'sreadCode()function includes the code in the upload request. If the code matches the event record in DynamoDB, photos bypass moderation and post instantly (line 350 logic). No code = JADA review queue. Copy reads: "The code keeps strangers from posting to your charter page." - 24-file batch limit — Line 515:
Array.from(files).slice(0, 24).forEach(uploadFile). UI hint on line 360 tells users "up to 24 at once." This prevents accidental DOS and keeps UX snappy. - Same-day Instagram hashtag feed — Lines 452–459 render
#ig-gridwith Instagram posts fromd.instagram, fetched server-side and injected into the page JSON response.
Lambda Photo Handler and Moderation Flow
The core logic lives in:
/Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py
Key endpoints:
- GET /api/presign — Returns a presigned POST URL to S3, scoped to the event directory. Client-side code uses this to upload directly to S3 without touching Lambda.
- POST /api/upload-meta — Stores photo metadata (uploader name, timestamp, guest_code validity) in DynamoDB table
events-photos. Ifguest_codematches the event record, setsapproved=trueimmediately; otherwiseapproved=false. - GET /api/g/{event_id}/photos — Returns JSON with approved photos and Instagram results. Called by the client to render
#photo-gridand#ig-grid. Filters out unapproved entries unless the requester is an admin. - POST /api/moderate/{photo_id} — Admin endpoint to approve/reject. Updates DynamoDB and triggers CloudFront invalidation of the event's cached JSON response.
Instagram hashtag search is performed server-side via the Instagram Graph API (or a third-party aggregator) during the /api/g/{event_id}/photos call. The query filters for:
hashtags: ["jada", "queenofsandiego"]
date_range: [event_date_start, event_date_end + 24h]
Results are cached in DynamoDB for 30 minutes to avoid rate-limit thrashing.
Infrastructure and Deployment
S3 Buckets:
queenofsandiego.com— Hosts all static HTML (homepage, guest pages, sailor-board index)queenofsandiego-photos-prod— Photo storage, organized asevents/{event_id}/{upload_id}.{ext}queenofsandiego-photos-staging— Staging equivalent; used for testing uploads without touching prod
CORS Configuration:
S3 bucket CORS was updated to allow presigned uploads from both queenofsandiego.com and staging.queenofsandiego.com:
[
{
"AllowedOrigins": [
"https://queenofsandiego.com",
"https://staging.queenofsandiego.com"
],
"AllowedMethods": ["GET", "POST", "PUT"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3000
}
]
CloudFront Distributions:
- Production:
d1234abcd.cloudfront.net→queenofsandiego.com(alias) - Staging:
d5678efgh.cloudfront.net→staging.queenofsandiego.com(alias)
Both distributions cache HTML at TTL 300s and JSON API responses at TTL 60s. When a photo is moderated, the Lambda triggers an invalidation via AWS SDK:
cloudfront.create_invalidation(
DistributionId='d1234abcd',
InvalidationBatch={'Paths': {'Quantity': 1, 'Items': ['/g/{event_id}/*']}}
)
DynamoDB