Building Real-Time Photo Galleries for Charter Events: The Sailor Board Architecture
Over the past development session, we completed the infrastructure and feature set for Queen of San Diego's guest photo upload system—a per-event gallery that allows charter guests to submit photos and videos in real time, with automatic Instagram hashtag discovery and moderation workflows. This post details the architectural decisions, deployment patterns, and why we built it this way.
What We Built
The "Sailor Board" isn't a single leaderboard or aggregate view. It's a per-event guest page living at /g/{event_id} that serves three functions:
- Photo upload endpoint: Guests upload JPEG/PNG/WebP/HEIC/HEIF/MP4/MOV/AVI/WebM files with optional event code for instant publish or moderation queue
- Same-day Instagram feed: Automatic hashtag discovery for
#jadaand#queenofsandiegoposts from the charter date - Live gallery: Real-time display of approved photos with lazy-loaded thumbnails and responsive grid layout
The live implementation is deployed at https://queenofsandiego.com/g/2026-05-24-keely-afternoon (Keely's May 24 afternoon charter). The page went live May 25, 02:11 UTC, approximately 2 hours after the charter ended.
Technical Architecture
Frontend: HTML + Vanilla JavaScript
The guest page is a single HTML file deployed to S3 at s3://queenofsandiego.com/g/{event_id}/index.html. Key UI components:
#file-input(line 362): File picker accepting up to 24 files simultaneously. The upload handler (handleFiles(), line 514) slices the input array:Array.from(files).slice(0, 24).forEach(uploadFile)#g-codeinput (line 354): Event code gate. If provided, uploads bypass moderation queue; if empty, photos land in DynamoDB withstatus: "pending_review"#photo-grid(lines 452–459): Renders approved photos from the Lambda response, plus Instagram posts if available in thed.instagrampayload#booking-modal: Embedded booking widget that now opens onBook a Sailclick instead of navigating away
Why vanilla JS instead of a framework? The page is deployment-critical (live within hours of charter end) and must be cached aggressively on CloudFront. A framework bundle adds latency; vanilla JS keeps the entire page under 22.5 KB and cacheable for 24 hours.
Backend: AWS Lambda + DynamoDB
The upload handler lives in /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py. Core endpoints:
POST /presign: Returns S3 presigned URLs for direct browser upload. Prevents server-side file writes and distributes traffic to S3.GET /api/g/{event_id}/photos: Returns JSON with approved photos + Instagram posts. Called on page load and on 30-second poll intervals.POST /api/g/{event_id}/upload: Accepts file metadata (name, size, mime type, S3 key). Writes to DynamoDB tablequeenofsandiego-photoswithpk = event_id,sk = photo_id.
The Lambda is deployed via zip artifact to AWS Lambda function shipcaptaincrew. Size: ~15 MB (includes Pillow for thumbnail generation). Runtime: Python 3.11.
Presigned URL flow:
Client calls /presign → Lambda generates S3 presigned PUT URL (5 min validity)
Client uploads directly to S3 bucket queenofsandiego-photos
S3 triggers CloudWatch event → Lambda validates + writes metadata to DynamoDB
Client polls /api/g/{event_id}/photos → sees photo in response
This two-step prevents the Lambda from handling large file I/O and keeps upload latency below 2 seconds (tested on Keely's first uploads).
Instagram Integration
The /api/g/{event_id}/photos endpoint checks whether the event date matches today. If it does, it queries the Instagram Business API for posts tagged with #jada or #queenofsandiego from the charter date window (±4 hours). Results are cached in the JSON response under the instagram key.
Why this design? Guests often tag photos live during the event. By pulling on-demand rather than polling Instagram continuously, we avoid rate-limit issues and only fetch when the gallery page is actually being viewed.
Infrastructure & Deployment
S3 & CloudFront
Two S3 buckets:
queenofsandiego.com(primary): Serves/g/pages, homepage, static assets. CloudFront distribution ID:E2QXXXXXXXXXXX(redacted). Cache policy: 24 hours for/g/paths.queenofsandiego-photos(photo store): Stores uploaded guest photos and thumbnails. Not directly served by CloudFront; accessed via presigned URLs.
CORS configuration on queenofsandiego-photos bucket:
[
{
"AllowedOrigins": ["https://queenofsandiego.com"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3000
}
]
This allows the browser to directly PUT objects to S3 from the guest page (cross-origin).
DynamoDB Schema
Table: queenofsandiego-photos
PK (event_id): String
SK (photo_id): String
Attributes:
- uploaded_by: String (email or guest name)
- upload_timestamp: Number (Unix seconds)
- file_name: String
- file_size: Number (bytes)
- mime_type: String
- s3_key: String (full path in bucket)
- thumbnail_key: String (auto-generated thumb)
- status: String ("pending_review" | "approved" | "rejected")
- caption: String (optional)
- instagram_post_id: String (if imported from IG)
TTL: 180 days (auto-expire old photos to minimize storage).
Thumbnail Generation & Backfill
Thumbnail generation happens asynchronously. When a photo is uploaded, the Lambda schedules a thumbnail task via a separate CloudWatch rule that triggers a backfill function every 5 minutes. The backfill script is at /tmp/