Building the Sailor Board: Photo Gallery Pipeline, Guest Upload Flow, and Deep-Link Booking Modal
What Was Done
During this session, we completed a full-stack implementation of the photo upload and moderation system for chartered event guest pages, with three critical fixes:
- Verified and extended the photo upload pipeline (Lambda + S3 + DynamoDB) to support same-day Instagram hashtag ingestion and real-time gallery updates
- Deployed the Sailor Board — a dedicated page at
/sailor-board/index.htmlthat aggregates photo galleries across charters and implements search/filtering - Fixed the "Book a Sail" button to open the booking modal immediately (via deep-link) instead of navigating to the homepage
- Created and deployed a standalone booking widget (
booking-widget.js) to enable embeddable checkout flows across multiple pages - Backfilled thumbnail images for existing photo uploads to improve gallery load performance
Technical Details: Photo Upload & Moderation Pipeline
The core upload flow lives in /tools/shipcaptaincrew/lambda_function.py. Here's the architecture:
Upload Handler (Lambda)
When a guest on a charter page (e.g., /g/2026-05-24-keely-afternoon) submits photos, the client-side handler in the guest page HTML calls a presigned S3 POST endpoint. The Lambda function:
- Validates the event code (line ~424 in the guest page JS): If provided, photos are flagged for instant publish. If not, they enter a DynamoDB review queue for moderation.
- Accepts 24 files simultaneously (enforced client-side:
Array.from(files).slice(0, 24), line 515 in guest HTML). This prevents spam and keeps uploads manageable. - Stores metadata in DynamoDB: `{event_id, guest_id, file_key, status, timestamp, ig_source}`
- Publishes success notifications: The Lambda returns copy like "Your photo is live. Welcome to the sailor board." (line 2030 in `lambda_function.py`) for approved uploads.
Same-Day Instagram Hashtag Ingestion
The guest page requests photos via:
GET /api/g/{event_id}/photos
This endpoint in the Lambda handler fetches:
- Published guest uploads from S3 (keyed by
event_id) - Instagram posts matching
#jadaor#queenofsandiegohashtags posted on the same calendar day as the charter
The IG fetch is handled by the Instagram Graph API integration in the Lambda. The response object includes an instagram array that gets rendered into the guest page's #ig-grid (lines 452–459 in guest HTML).
S3 & Bucket Structure
Photo files are stored in a structure like:
s3://queenofsandiego-charter-uploads/
├── events/
│ └── {event_id}/
│ └── {guest_id}/
│ ├── {timestamp}-{filename}.jpg
│ ├── {timestamp}-{filename}_thumb.jpg
│ └── {timestamp}-{filename}.mp4
└── metadata/
└── {event_id}/
└── index.json
CORS Configuration: S3 CORS was updated to allow presigned POST uploads from queenofsandiego.com origins. Without this, browser preflight requests would fail and block uploads.
The Sailor Board: Aggregation & Search
Previously, "Sailor Board" was just marketing copy—each charter had its own guest page with a local photo gallery. We've now built an actual cross-charter aggregation page:
- File:
/sailor-board/index.html - Endpoint:
https://queenofsandiego.com/sailor-board - Data Source: Calls
/api/charters/photosto fetch published photos across all past/current charters - Features:
- Timeline view of all charters
- Filter by hashtag (
#jada,#queenofsandiego, etc.) - Infinite scroll / pagination
- Lightbox preview for photos & videos
The page pulls from a DynamoDB query that scans across all `event_id` partitions and sorts by upload timestamp (descending).
Thumbnail Backfill & Performance
Existing photo uploads didn't have thumbnails generated. We created /tmp/backfill-thumbs.py to:
- Enumerate all S3 objects in the uploads bucket
- For each image, use Pillow to generate a 300×300px thumbnail (JPEG, quality 85)
- Write the thumbnail back to S3 with a
_thumbsuffix
This reduced initial page load for the Sailor Board and guest galleries, since the client now loads small thumbnails first, then fetches full-res on demand.
Booking Modal Deep-Linking
Previously, the "Book a Sail" button on the homepage and guest pages navigated to /, forcing users to manually trigger the booking modal. We fixed this:
Changes to Homepage (index.html)
Added a URL hash fragment listener:
if (window.location.hash === '#book') {
document.getElementById('booking-modal').style.display = 'block';
}
Updated all "Book a Sail" button links to point to #book instead of just /.
Booking Widget Module (booking-widget.js)
Extracted the booking modal, Stripe integration, and form handling into a reusable module:
- Export: `window.QOSBookingWidget` — can be instantiated on any page
- Dependencies: Stripe.js (loaded dynamically), Moment.js for date formatting
- Usage: Guest pages and the Sailor Board can now embed the booking flow without duplicating code
The widget accepts a config object:
new QOSBookingWidget({
containerId: 'booking-modal',
stripePublishableKey: '...',
onSuccess: (checkoutSession) => { /* redirect */ }
})