```html

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 #jada and #queenofsandiego hashtags 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": [