Building the Sailor Board: Real-Time Photo Upload, Moderation, and Instagram Integration for Charter Guest Pages
This session focused on completing and validating the photo upload and social media integration infrastructure for per-charter guest pages on Queen of San Diego. The work involved deploying a Lambda-backed photo ingestion pipeline, implementing client-side upload UI with file validation, integrating Instagram hashtag search, and fixing a critical UX bug in the booking modal flow.
What Was Done
- Validated and deployed Lambda photo handler (`shipcaptaincrew/lambda_function.py`) with CORS configuration for cross-origin uploads
- Created the
/sailor-board/directory and landing page to explain the feature to guests - Fixed "Book a Sail" button behavior: changed from navigation link to modal trigger using deep-link auto-open
- Implemented Instagram hashtag search integration (
#jada,#queenofsandiego) in the Lambda photo endpoint - Built photo thumbnail backfill utility to pre-generate optimized images for existing uploads
- Deployed booking widget as standalone JavaScript module (`booking-widget.js`) for embedding in guest pages
Technical Architecture: The Guest Page Photo Pipeline
Client-Side Upload Flow
Guest pages (e.g., /g/2026-05-24-keely-afternoon/index.html) include an upload form with the following constraints:
- File types accepted: JPEG, PNG, WebP, HEIC, HEIF (photos); MP4, MOV, AVI, WebM (video)
- Batch upload cap: 24 files maximum per submission (enforced client-side:
Array.from(files).slice(0, 24)) - Event code gate: Optional 4-6 character code (stored in DynamoDB against the event ID) bypasses manual review; uploads without code enter JADA team review queue
The upload handler lives in `/Users/cb/Documents/repos/sites/queenofsandiego.com/sailor-board/index.html` and calls the Lambda endpoint at POST https://shipcaptaincrew.queenofsandiego.com/api/g/{event_id}/upload. The request includes:
{
"event_id": "2026-05-24-keely-afternoon",
"guest_code": "ABC123",
"files": [
{
"filename": "beach-photo.jpg",
"data": "<base64-encoded file>",
"size": 2048576
}
]
}
Server-Side Processing (Lambda)
The Lambda handler at shipcaptaincrew/lambda_function.py implements the following logic:
- Event code validation: Checks guest-supplied code against event record in DynamoDB. Valid code = immediate publish; invalid or missing = manual review flag
- S3 storage: Photos written to bucket path
s3://queenofsandiego-photos/{event_id}/{uuid}/{filename}with metadata tags (event_id, guest_code_used, upload_timestamp) - Thumbnail generation: Creates 300px and 1200px variants using Pillow, stored as separate S3 objects (e.g.,
_thumb-300.jpg,_thumb-1200.jpg) - DynamoDB write: Inserts photo record into
photostable with keys:event_id(PK),timestamp#uuid(SK). Status field set topublishedorreview_pendingbased on code validation - SNS notification: Publishes to
arn:aws:sns:us-west-2:ACCOUNT_ID:jada-photo-uploadtopic for moderation queue and downstream integrations
Instagram Integration
When a guest page is requested, the Lambda also executes GET https://shipcaptaincrew.queenofsandiego.com/api/g/{event_id}/photos, which:
- Queries DynamoDB for all photos from that event with status
published - Calls Instagram Graph API (OAuth token stored in AWS Secrets Manager) searching for posts with hashtags
#jadaor#queenofsandiegoposted within 24 hours of the charter date - Returns merged JSON response with both uploaded photos and IG posts in a single
d.instagramarray - Client-side code renders both into a single grid (
#ig-grid, lines 452–459 of guest page HTML)
S3 and CORS Configuration
A critical blocker was CORS headers: the guest page (served from queenofsandiego.com) was making direct PUT requests to the S3 upload endpoint, but S3 was not allowing cross-origin requests. Resolved with:
# S3 CORS configuration for queenofsandiego-photos bucket
[
{
"AllowedOrigins": [
"https://queenofsandiego.com",
"https://www.queenofsandiego.com",
"https://staging.queenofsandiego.com"
],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["x-amz-version-id"],
"MaxAgeSeconds": 3600
}
]
The presigned URL flow now works: guest submits file → Lambda generates presigned PUT URL → client performs preflight OPTIONS check → file uploaded directly to S3, bypassing Lambda bandwidth.
Booking Modal Deep-Link Integration
The "Book a Sail" button was originally a hyperlink that navigated to / (homepage). This was a poor UX: guests wanted to book without leaving the charter page. Fixed by:
- Converting button to an anchor with hash parameter:
<a href="#booking-open"> - Adding initialization hook in
booking-widget.jsthat watches forwindow.location.hash === "#booking-open" - Modal auto-opens on page load if hash is present, allowing deep-linkable booking flows
- Stripe checkout embed re-initializes within the modal context
This pattern is now used on all guest pages and the homepage, centralizing booking UX across the site.
Thumbnail Backfill and Image Optimization
Existing photos in S3 lacked optimized thumbnails, causing slow gallery loads. Created /tmp/backfill-thumbs.py to:
- Scan
s3://queenofsandiego-photos/for all image objects - Download each, resize to 300px and 1200px widths using Pillow
- Upload variants back to S3 with naming convention
{filename}_thumb-{size}.