Building the Sailor Board: Real-Time Photo Galleries for Charter Events
Over the past development session, we completed the infrastructure and frontend implementation for per-event photo galleries—what we're calling the "Sailor Board"—deployed live on Keely's May 24 afternoon charter. This post walks through the architectural decisions, deployment pipeline, and cross-service integration that powers guest photo uploads, moderation, and real-time display.
What Was Built
The Sailor Board is a per-event guest page that lives at /g/{YYYY-MM-DD}-{guest-name}-{time-of-day} (e.g., https://queenofsandiego.com/g/2026-05-24-keely-afternoon). It serves two flows:
- Pre-sail: Event countdown and CTA to book additional sails
- Post-sail (5 PM PT event-day cutoff): Photo/video upload UI, real-time Instagram hashtag feed (#jada, #queenofsandiego), and moderation workflow
Key constraints implemented:
- 24-photo-at-once upload cap (line 515 in sailor-board/index.html:
Array.from(files).slice(0, 24)) - Event code gating (spam prevention; no code → review queue)
- Accepted formats: JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM
- Same-day Instagram hashtag integration via Lambda enrichment
File Structure and Deployment Paths
New files created:
/Users/cb/Documents/repos/sites/queenofsandiego.com/sailor-board/index.html— Guest-facing page; 14 iterative edits during this session/Users/cb/Documents/repos/sites/queenofsandiego.com/booking-widget.js— Extracted Stripe checkout logic into a reusable widget/tmp/backfill-thumbs.py— Thumbnail generation backfill utility for existing photos
Modified files:
tools/shipcaptaincrew/lambda_function.py— 7 edits to wire up photo upload presigning, Instagram feed enrichment, and moderation workflowsindex.html(site root) — 2 edits to add navigation tokens and "Book a Sail" deep-link modal opening
Infrastructure and API Integration
Lambda Function: shipcaptaincrew
The core upload and feed logic lives in tools/shipcaptaincrew/lambda_function.py. Key endpoints:
POST /api/g/{event_id}/presign— Issues S3 presigned URLs for multipart upload. Validates event code (if required) and rate-limits by IP.GET /api/g/{event_id}/photos— Returns gallery metadata: user-uploaded photos from DynamoDB + same-day Instagram posts matching #jada or #queenofsandiego hashtags (fetched via Instagram Graph API during request).POST /api/g/{event_id}/confirm— Marks uploads as confirmed; triggers Lambda invocation for thumbnail generation and moderation notification emails.
S3 Bucket Strategy
Guest uploads land in a private staging bucket pending moderation. On approval, they're copied to the public gallery bucket. CORS configuration was updated to allow cross-origin requests from https://queenofsandiego.com (and staging/dev origins) to support presigned URL uploads from the browser.
Thumbnail generation runs asynchronously via Lambda; backfill was necessary for photos uploaded before thumbnail support existed. The backfill-thumbs.py script iterates DynamoDB, generates missing thumbnails via Pillow, and uploads them to S3 with metadata updates.
DynamoDB Schema
Photo records include:
event_id(partition key)upload_ts(sort key, ISO 8601)guest_email,guest_names3_key,file_size,content_typethumb_key(populated post-generation),thumb_sizemoderation_status(pending, approved, rejected)source(user_upload, instagram)
Frontend: Sailor Board Page Structure
The page is a single-file HTML app (~22.5 KB deployed) with embedded CSS and JavaScript. Architecture:
#hero— Pre-sail countdown and booking CTA#upload-section— Post-sail file picker, event code input, progress tracking#photo-grid— Renders both user uploads and Instagram feed in chronological order#ig-grid— Instagram posts (rendered inline in the photo grid)
Key JavaScript Functions
readCode()(line 424) — Validates event code; stores insessionStorage. No code → uploads go to review queue.handleFiles()(line 514) — Accepts up to 24 files; validates MIME types; initiates multipart uploads.uploadFile(file)— Calls presign endpoint, streams chunks to S3, confirms on completion.refreshPhotos()— PollsGET /api/g/{event_id}/photosevery 10 seconds post-sail.
Event Mode Flip
The page checks FLIP_UTC (event date at 5 PM PT = 00:00 UTC next day) to switch from pre-sail to post-sail UI. In production, this is hardcoded per event during page generation; dynamic events would require a metadata endpoint.
Booking Widget Extraction and Modal Deep-Linking
During this session, we isolated the Stripe checkout logic into booking-widget.js to enable reusable booking modals. The root index.html was updated so "Book a Sail" buttons immediately invoke the modal rather than navigating away.
Implementation:
- Modal auto-open on page load if URL contains
?open-booking=true - All "Book a Sail" links now pass this query param instead of href to external booking page
- Deployed to staging CloudFront distribution; invalidated cache to pick up changes
Deployment Pipeline
Lambda Deployment