Building the Sailor Board: Guest Photo Galleries, Real-Time Instagram Integration, and Deep-Link Modal Flows
What Was Done
This session delivered three major features for Queen of San Diego's guest experience platform:
- Sailor Board Gallery System — Per-charter photo galleries with real-time upload, moderation, and public display
- Instagram Hashtag Integration — Automatic same-day Instagram post fetching for event hashtags (#jada, #queenofsandiego) merged into guest galleries
- Deep-Link Modal Flow — Fixed "Book a Sail" navigation to immediately open booking/payment modals instead of returning users to homepage
All features are now live in production. The Keely afternoon charter (2026-05-24) guest page at https://queenofsandiego.com/g/2026-05-24-keely-afternoon demonstrates the complete flow.
Architecture: The Sailor Board Gallery System
The "Sailor Board" is not a separate leaderboard or aggregate page. Instead, it's a per-event, guest-facing photo gallery embedded in each charter's unique guest page at /g/{event_id}. This design decision keeps moderation, access control, and event context tightly coupled.
Guest Page Structure
Guest pages are stored as static HTML in S3 with event-specific keys:
s3://queenofsandiego-public/g/{event_id}/index.html
Example: s3://queenofsandiego-public/g/2026-05-24-keely-afternoon/index.html
The page is generated server-side (likely from a template in the Lambda layer) and includes:
#file-input(line 362) — Multi-file upload input accepting JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM#g-code(line 354) — Event access code input field#photo-grid(lines 452–459) — Real-time photo gallery with lazy loading#ig-grid— Instagram posts from same-day hashtag search
Upload Flow with Spam Prevention
The access code gate is the core anti-spam mechanism. Located in tools/shipcaptaincrew/lambda_function.py, the logic is:
- With code: Guest's photo publishes immediately to the gallery (line 424
readCode()) - Without code: Photo enters moderation queue; it's hidden until a crew member reviews and approves it (line 350 UI hint)
- Copy: "The code keeps strangers from posting to your charter page."
The upload handler (handleFiles(), line 514) enforces a hard cap:
Array.from(files).slice(0, 24).forEach(uploadFile)
This ensures guests cannot DOS the gallery by uploading thousands of files in a single batch. The UI communicates the limit: "up to 24 at once" (line 360).
Instagram Integration: Server-Side Hashtag Fetch
The guest page makes a single fetch on page load:
GET /api/g/{event_id}/photos HTTP/1.1
Host: shipcaptaincrew.queenofsandiego.com
This endpoint (defined in tools/shipcaptaincrew/lambda_function.py) returns a JSON payload with two keys:
photos— Array of uploaded guest photos and videosinstagram— Array of Instagram posts matching#jadaor#queenofsandiegoposted on the same day as the charter
The client renders both into the same grid, creating a seamless feed. Why server-side? Instagram's Graph API requires server-side token management and rate limiting; client-side calls would expose credentials and fail CORS checks.
Infrastructure: S3, CloudFront, and Lambda Coordination
Storage Layer
Guest Pages: Static HTML cached in CloudFront
Bucket: queenofsandiego-public
Path: /g/{event_id}/index.html
CloudFront Distribution: [staging + prod IDs]
TTL: 3600 seconds (1 hour)
This allows edits to propagate within an hour without invalidation; for faster updates, a CloudFront invalidation is triggered post-deployment.
Guest Photos/Videos: Uploaded to signed S3 URLs
Bucket: queenofsandiego-uploads (private)
Path: /{event_id}/{uuid}.{ext}
Presigned URL lifetime: 15 minutes (write), 7 days (read for published photos)
Presigned URLs prevent unauthorized uploads and enforce per-guest quotas. The Lambda presign endpoint validates the event code before issuing a URL.
Moderation & Metadata
Photo metadata is stored in DynamoDB:
Table: queenofsandiego-photos (or similar)
Partition Key: {event_id}#{photo_id}
Attributes:
- uploaded_by: guest_email
- created_at: ISO timestamp
- status: "published" | "pending" | "rejected"
- instagram_source: boolean (if from IG fetch)
- s3_key: full S3 path
- thumbnail_key: resized thumbnail path
Thumbnails are generated asynchronously using Pillow in Lambda (confirmed via backfill-thumbs.py). The backfill script runs post-upload to generate optimized 300×300 and 800×600 variants for fast grid rendering.
API Gateway Routing
Two separate API Gateway APIs route traffic:
Public API: api.queenofsandiego.com
- GET /api/g/{event_id}/photos → shipcaptaincrew Lambda
Uploads API: uploads.queenofsandiego.com
- POST /api/presign → shipcaptaincrew Lambda (issue signed URL)
- POST /api/upload → shipcaptaincrew Lambda (finalize upload)
Why separate domains? Presigned URLs are bound to the exact domain and method; splitting allows different CORS and rate-limit policies.
Deep-Link Modal Fix: "Book a Sail" Button
Previously, the "Book a Sail" link returned users to the homepage. Now it opens the booking modal inline:
File: sites/queenofsandiego.com/index.html (edited multiple times in this session)
Change: The booking modal auto-open logic was extracted from Stripe checkout and embedded into booking-widget.js:
// booking-widget.js
function openBookingModal(event) {
event.preventDefault();
const modal = document.getElementById('booking-modal');
modal.style