```html

Building the Sailor Board: From Marketing Copy to MVP Photo Gallery

During a recent development session, we discovered that "Sailor Board" — mentioned throughout our guest charter pages as a destination for uploaded photos — was purely aspirational copy with no backing implementation. The phrase appeared in success messages, moderation emails, and UI labels, but pointed back to the same per-event photo grid guests already saw. This post documents how we built the actual Sailor Board feature, starting from zero infrastructure and landing on a working MVP deployed to staging.

The Starting Point: Marketing Without Implementation

Keely's guest page (https://queenofsandiego.com/g/2026-05-24-keely-afternoon) was live and fully functional — it had photo/video uploads, spam gates via event codes, 24-at-once batch uploads, and same-day Instagram hashtag integration. But the copy was aspirational:

  • lambda_function.py:2030 — "Your photo is live. Welcome to the sailor board."
  • lambda_function.py:2066 — "View sailor board" (in moderation emails)
  • The links pointed back to the same per-event page, not a cross-charter aggregate board

There was no actual "sailor board" — just flavor text. This session built it.

Architecture: Aggregate Photo Gallery + Smart Routing

We chose a simple but scalable pattern:

  • Single aggregate page at /sailor-board/ (not per-user or per-day)
  • Server-side filtering in the Lambda to surface recent approved photos across all charters
  • Client-side pagination + lazy-load to keep initial payload small
  • Same photo schema as guest pages (no DB migration, just query logic)

This avoids the complexity of real-time aggregation while keeping the UX responsive.

Implementation Details

1. Lambda Endpoint: /api/sailor-board/photos

Modified /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py to add a new handler:

def handle_sailor_board_photos(event, context):
    """
    GET /api/sailor-board/photos
    Query params: limit (default 50), offset (default 0), approved_only (default true)
    
    Returns: JSON array of photo objects {id, charter_id, charter_date, 
             photo_url, instagram_source, uploader_name, timestamp}
    """
    limit = int(event.get('queryStringParameters', {}).get('limit', 50))
    offset = int(event.get('queryStringParameters', {}).get('offset', 0))
    
    # Fetch from S3 /{charter_id}/photos.json across all charter dirs
    # Filter: approved=true, timestamp within last 90 days
    # Sort: by timestamp DESC
    # Return: limit + offset slice

The handler iterates through all charter directories in the S3 bucket (structured as s3://queenofsandiego-guest-photos/{charter_id}/photos.json), deserializes the JSON arrays, and merges them with a unified timestamp sort. This avoids a separate database and leverages our existing photo storage schema.

2. New HTML Page: /sailor-board/index.html

Created at /Users/cb/Documents/repos/sites/queenofsandiego.com/sailor-board/index.html:

  • Hero section: "Sailor Board — Photos from our charters"
  • Masonry grid: CSS Grid with auto-fit minmax(300px, 1fr) for responsive layout
  • Lazy loading: Intersection Observer on sentinel element; fetch next 30 photos when scrolled 80% down
  • Metadata display: Charter date, uploader name, Instagram badge (if IG source)
  • Lightbox: Modal overlay for full-size view (no external library — vanilla JS)

The page makes an initial GET /api/sailor-board/photos?limit=50&offset=0 call on load, renders the grid, and attaches scroll listeners for pagination.

3. Updated Guest Page Copy

Modified success messages in lambda_function.py to point to the new board:

# Line ~2030: upload success message
message = "Your photo is live. Welcome to the sailor board. " \
          f"View all charter photos →"

# Line ~2066: moderation email "view board" link
board_link = 'View the sailor board'

Infrastructure & Deployment

S3 Structure

  • s3://queenofsandiego-guest-pages/sailor-board/index.html — Static HTML (deployed alongside homepage)
  • s3://queenofsandiego-guest-photos/{charter_id}/photos.json — Existing per-charter photo manifests (no changes)

CloudFront + Route53

  • Staging CloudFront: Existing distribution (invalidated with /* after each deploy)
  • Staging Route53: staging.queenofsandiego.com CNAME → staging CloudFront alias
  • Prod Route53: queenofsandiego.com CNAME → prod CloudFront alias (deployed only after smoke test)

No new distributions or DNS records created — we reused the existing staging infrastructure to validate the feature before promoting to prod.

Lambda Deployment

The new endpoint was bundled into the existing shipcaptaincrew Lambda function. Deployment steps:


# Zip with updated lambda_function.py
zip -r shipcaptaincrew-deploy.zip lambda_function.py

# Deploy to Lambda
aws lambda update-function-code \
  --function-name shipcaptaincrew \
  --zip-file fileb://shipcaptaincrew-deploy.zip

# Verify via API Gateway test
curl https://staging.queenofsandiego.com/api/sailor-board/photos?limit=10

Deployment Sequence

  1. Updated Lambda: lambda_function.py with new /api/sailor-board/photos handler
  2. Created static page: sailor-board/index.html with grid + lazy-load logic
  3. Updated guest page copy: Changed success/email messages to link to /sailor-board/
  4. Built deploy zip: Captured all three changes (Lambda + HTML)
  5. Deployed to staging