Building the Sailor Board: Real-Time Photo Gallery & Deep-Link UX for Charter Guest Events
What Was Done
Over this development session, we completed the technical implementation of a real-time photo upload and moderation system for charter guest events, with a focus on fixing critical UX issues and ensuring the booking flow doesn't regress to the homepage.
- Fixed the "Book a Sail" deep-link modal behavior — the primary CTA now opens the scheduling/payment modal in-place rather than navigating to homepage
- Implemented Sailor Board as a per-event photo gallery with same-day Instagram hashtag integration
- Deployed photo upload capability with event-code gating, 24-at-once cap, and moderation queue
- Built booking widget as a reusable, embeddable JavaScript component with Stripe integration
- Established S3 CORS policy to allow cross-origin uploads from guest pages to production S3
- Created thumbnail backfill pipeline to generate optimized image variants for gallery performance
Technical Details: Architecture & Implementation
Photo Upload Flow & Event Code Gating
Guest pages (e.g., /g/2026-05-24-keely-afternoon) serve pre-rendered HTML with embedded logic to handle file uploads. The upload handler validates against an event code to prevent spam while allowing event organizers to curate the gallery.
// From /sites/queenofsandiego.com/sailor-board/index.html
const readCode = () => {
const code = document.getElementById('g-code').value;
if (code) {
fetch(`/api/g/${eventId}/authorize`, {
method: 'POST',
body: JSON.stringify({ code })
})
.then(r => r.json())
.then(d => {
if (d.authorized) {
document.getElementById('instant-publish').style.display = 'block';
}
});
}
};
const handleFiles = (fileList) => {
Array.from(fileList).slice(0, 24).forEach(file => {
uploadFile(file, isAuthorized);
});
};
The isAuthorized flag determines whether uploads bypass the moderation queue or enter a JADA_REVIEW state. This gating prevents malicious gallery pollution while maintaining a frictionless experience for registered attendees.
Lambda Handler: Photo Metadata & Instagram Integration
The /api/g/{event_id}/photos endpoint (served from tools/shipcaptaincrew/lambda_function.py, ~2400 lines) orchestrates:
- Photo retrieval & filtering — queries DynamoDB for uploaded photos, filters by event date, orders by timestamp
- Instagram hashtag search — fetches same-day posts matching
#jadaand#queenofsandiegohashtags from the Instagram Graph API - Thumbnail generation — returns photo metadata including thumbnail S3 URIs for client-side gallery rendering
- CORS headers — allows cross-origin requests from guest pages and production domain
def get_event_photos(event_id):
"""
Retrieve moderation-approved photos + same-day IG posts.
Event ID format: 2026-05-24-keely-afternoon
"""
event_date = event_id.split('-')[0:3] # Extract YYYY-MM-DD
# Query DynamoDB for approved uploads
photos = ddb.query(
IndexName='event_id-created_at-index',
KeyConditionExpression='event_id = :eid AND created_at >= :date',
ExpressionAttributeValues={
':eid': event_id,
':date': f"{event_date}T00:00:00Z"
},
FilterExpression='#status = :approved',
ExpressionAttributeNames={'#status': 'status'},
ExpressionAttributeValues={':approved': 'APPROVED'}
)
# Fetch Instagram posts for same day
ig_posts = fetch_instagram_hashtags(
hashtags=['jada', 'queenofsandiego'],
date_range=(event_date, event_date)
)
return {'photos': photos, 'instagram': ig_posts}
Thumbnail Generation & S3 Optimization
Original uploads can be large video files or high-resolution photos. We implemented a backfill pipeline that generates three sizes:
thumb-small— 300×300px (gallery grid)thumb-medium— 800×800px (lightbox preview)thumb-large— 1600×1600px (full-screen mobile)
The backfill script (/tmp/backfill-thumbs.py) runs as a one-off Lambda invocation or local utility, scanning S3 for photos without thumbnails and generating variants using Pillow:
#!/usr/bin/env python3
import boto3
from PIL import Image
from io import BytesIO
s3 = boto3.client('s3')
bucket = 'queenofsandiego-guest-uploads'
def backfill_thumbnails():
paginator = s3.get_paginator('list_objects_v2')
for page in paginator.paginate(Bucket=bucket, Prefix='photos/'):
for obj in page.get('Contents', []):
key = obj['Key']
if not key.endswith(('.jpg', '.png', '.webp')):
continue
if 'thumb-' in key:
continue # Skip existing thumbnails
# Download original
resp = s3.get_object(Bucket=bucket, Key=key)
img = Image.open(BytesIO(resp['Body'].read()))
# Generate variants
for size, label in [(300, 'small'), (800, 'medium'), (1600, 'large')]:
thumb = img.copy()
thumb.thumbnail((size, size), Image.LANCZOS)
thumb_key = key.replace('.jpg', f'-thumb-{label}.jpg')
thumb_buffer = BytesIO()
thumb.save(thumb_buffer, 'JPEG', quality=85)
s3.put_object(
Bucket=bucket,
Key=thumb_key,
Body=thumb_buffer.getvalue(),
ContentType='image/jpeg'
)
print(f"Backfilled thumbnails for {key}")
if __name__ == '__main__':
backfill_thumbnails()
Infrastructure Changes
S3 CORS Configuration
Guest pages make direct PUT requests to S3 for file uploads. We updated the CORS policy on the queenofsandiego-guest-uploads S3 bucket to allow cross-origin requests from production and staging origins:
{
"CORSRules": [
{
"AllowedOrigins": [