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.comCNAME → staging CloudFront alias - Prod Route53:
queenofsandiego.comCNAME → 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
- Updated Lambda:
lambda_function.pywith new/api/sailor-board/photoshandler - Created static page:
sailor-board/index.htmlwith grid + lazy-load logic - Updated guest page copy: Changed success/email messages to link to
/sailor-board/ - Built deploy zip: Captured all three changes (Lambda + HTML)
- Deployed to staging