```html

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 videos
  • instagram — Array of Instagram posts matching #jada or #queenofsandiego posted 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