```html

Building the Sailor Board: Implementing Post-Sail Photo Galleries with Event-Scoped Upload Controls

This post documents the design and deployment of the Sailor Board feature—a per-charter photo gallery system with spam-resistant uploads, real-time Instagram integration, and event-scoped moderation workflows. We'll cover the architecture decisions, infrastructure wiring, and the multi-stage deployment process that brought it from concept to production.

What We Built

The Sailor Board is a guest-facing photo gallery deployed at per-event URLs like /g/{event_id} that guests access during and after their sail. It has four core capabilities:

  • Authenticated photo/video uploads with 24-file batch caps and format validation (JPEG/PNG/WebP/HEIC/HEIF + MP4/MOV/AVI/WebM)
  • Event code spam gate—guests with the event code bypass moderation; guests without it go to a review queue
  • Same-day Instagram hashtag aggregation pulling #jada and #queenofsandiego posts during the charter window
  • Per-event moderation workflows with email notifications, approval toggles, and captain-controlled display settings

The feature launched live on the Keely afternoon charter event (2026-05-24-keely-afternoon) and is now serving as the template for all future events.

Architecture Overview

The Sailor Board splits responsibility across three layers:

  • Client tier: Static HTML/CSS/JS deployed to CloudFront at queenofsandiego.com/g/{event_id}/index.html
  • API tier: AWS Lambda handler at shipcaptaincrew.queenofsandiego.com/api/g/{event_id}/photos managing presigned uploads, photo fetches, and IG aggregation
  • Storage tier: DynamoDB for metadata (photo records, event config, moderation state), S3 buckets for raw media (segregated by environment: staging vs. prod)

Client-Side Implementation

The guest page HTML lives at /Users/cb/Documents/repos/sites/queenofsandiego.com/sailor-board/index.html and is deployed to S3 + CloudFront. Key implementation details:

# File inputs and form structure
<input type="file" id="file-input" accept="image/*,video/*" multiple />
<input type="text" id="g-code" placeholder="Event code" />

# Line 514-515: Upload handler with 24-file cap
Array.from(files).slice(0, 24).forEach(uploadFile)

# Line 452-459: Instagram grid rendered from server response
Object.values(d.instagram).forEach(post => {
  // Render IG grid item with photo, caption, timestamp
})

The page uses a conditional rendering system based on FLIP_UTC (the event end time in UTC). Before sail time, the upload UI is hidden; after sail time, it's visible. For Keely's event, FLIP_UTC = 2026-05-25T00:00Z (May 24 at 5 PM Pacific), which flipped the page to post-sail mode immediately after the sail ended.

The upload function generates presigned S3 URLs by calling the Lambda endpoint, then streams files directly to S3 using the presigned PUT URL. This keeps the Lambda function lightweight and avoids payload limits.

Lambda Photo Handler

The core API logic lives in /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py. The handler provides three key operations:

  • POST /api/g/{event_id}/photos/presign — Issues presigned S3 PUT URLs for direct uploads
  • GET /api/g/{event_id}/photos — Returns paginated photo list + Instagram feed for the event
  • POST /api/g/{event_id}/photos/moderate — Captain-only endpoint for approval/rejection

The presign endpoint validates the incoming event code against a DynamoDB table (PhotoGalleries) and branches based on result:

# Pseudocode: Presign handler logic
def presign_handler(event_id, guest_code):
    event_config = get_event_config(event_id)  # DynamoDB lookup
    
    if guest_code == event_config['code']:
        approval_status = 'approved'  # Bypass queue
    else:
        approval_status = 'pending'   # Goes to review
    
    # Generate S3 PUT presigned URL valid for 15 minutes
    presigned_url = s3_client.generate_presigned_url(
        'put_object',
        Bucket=PROD_BUCKET,
        Key=f"g/{event_id}/{photo_id}.jpg",
        ExpiresIn=900
    )
    
    return {'url': presigned_url, 'status': approval_status}

The GET photos endpoint aggregates three sources: approved photos from DynamoDB, pending photos (for captain view), and Instagram posts. The IG integration fetches from the Instagram Insights API using credentials stored in Lambda environment variables, filters for posts tagged with #jada or #queenofsandiego created during the event's date window, and returns them in the same JSON shape as uploaded photos.

Infrastructure & Deployment

The Lambda function is deployed as a zipped package via the deploy_shipcaptaincrew.sh script. The deployment process:

  1. Bundles the Python source + vendored dependencies (e.g., py_vapid for push notifications) into a zip file
  2. Uploads the zip to S3 at s3://deployment-artifacts-prod/shipcaptaincrew-{timestamp}.zip
  3. Updates the Lambda function code using the AWS CLI
  4. Validates the function syntax and runs a smoke test against the existing photos endpoint

The guest page itself is deployed separately. After creating the sailor-board/ directory, the build process:

  1. Uploads index.html to s3://qos-prod/g/{event_id}/index.html
  2. Invalidates the CloudFront distribution (ID: E{prod-dist-id}) with path /g/{event_id}/*
  3. Verifies the page is live by hitting the staging CloudFront distribution first, then prod

The booking modal deep-linking required careful JS ordering. The modal initialization is injected into the homepage (/Users/cb/Documents/repos/sites/queenofsandiego.com/index.html) and listens for URL query parameters (?open=booking). The "Book a Sail" button now passes this parameter instead of linking to a separate checkout page, ensuring the modal opens in-place without navigation.

S3 CORS configuration was updated to allow cross-origin presigned uploads from queenofsandiego.com:

# S3 CORS rule for presigned uploads
{