```html

Building the Sailor Board: Photo Gallery & Upload System for Per-Charter Guest Pages

Over the course of a development session focused on the Queen of San Diego charter platform, we completed a full-stack implementation of a photo upload and gallery system tied to individual charter events. This post walks through the architecture, deployment pipeline, and key technical decisions that went into building what we call the "Sailor Board"—a per-charter photo gallery that aggregates guest uploads, Instagram posts, and moderated content.

What Was Built

The Sailor Board system consists of three main components:

  • Guest upload page (`/g/{event_id}` routes) — individual per-charter pages where guests can upload photos, videos, and view an Instagram feed pulled from the same day
  • Lambda handler (`tools/shipcaptaincrew/lambda_function.py`) — backend service that validates uploads, stores to S3, generates thumbnails, handles Instagram integration, and manages moderation workflows
  • Booking widget integration (`booking-widget.js`) — JavaScript that opens the payment/scheduling modal directly instead of redirecting to the homepage

Guest Page Architecture: The `/g/{event_id}` Pattern

Each charter generates a unique guest page at a URL like `https://queenofsandiego.com/g/2026-05-24-keely-afternoon`. This page is deployed as a static HTML asset to S3 under the main distribution, served through CloudFront at `d.sailjada.com`. The page is generated from a template stored in `sites/queenofsandiego.com/sailor-board/index.html`.

The page has two modes:

  • Pre-sail mode (before event date) — shows countdown, booking CTA, and Instagram preview
  • Post-sail mode (after event, triggered by `FLIP_UTC`) — reveals the full upload form with event-specific code validation

This flip happens server-side during page generation (Lambda sets `d.mode` based on current time vs. event date), ensuring guests cannot manipulate upload availability through client-side tampering.

Upload Flow & Spam Prevention

Guests authenticate uploads using an event-specific code (e.g., "SANDIEGO2026"), entered via the `#g-code` input (line 354 of `sailor-board/index.html`). The client collects up to 24 files (enforced at line 515: Array.from(files).slice(0, 24)) and batches them to the presign endpoint.

GET /api/g/{event_id}/presign?code=SANDIEGO2026&filename=photo.jpg

The Lambda handler validates the code against the event metadata in DynamoDB (`Events` table, `event_id` key). If the code matches, the photo is marked `auto_publish: true` and appears immediately in the guest page gallery. If no code is provided, the photo enters a moderation queue (`auto_publish: false`) pending manual review by charter staff.

Why this approach? Guest codes are optional—we can still accept anonymous uploads from non-guests (family, friends who weren't on the manifest) and let crew decide what appears. The code is not a hard gate; it's an incentive. Guests who attended the event have the code and get instant gratification. Non-guests get a message: *"Thanks for uploading! Our crew will review and add the best ones to the sailor board."*

Thumbnail Generation & Performance

After an upload is persisted to S3 (bucket: `queenofsandiego-uploads`, key pattern: `{event_id}/{event_code or 'anonymous'}/{uuid}/{filename}`), a Lambda function generates thumbnails using Pillow (Python Imaging Library). The function:

  • Pulls the original from S3
  • Resizes to 400×300 (gallery tile size) and 150×150 (thumbnail preview)
  • Writes both back to S3 with suffixes `_400x300.jpg` and _150x150.jpg`
  • Updates the DynamoDB record with `thumb_url` and `display_url` pointers

Thumbnails are served from the same S3 bucket but through CloudFront, which caches them indefinitely (cache key includes the UUID). This keeps gallery load time sub-second even with 50+ photos.

Instagram Integration: Same-Day Hashtag Fetch

The Lambda handler includes an Instagram fetcher that runs when the `/api/g/{event_id}/photos` endpoint is called. It queries Instagram's public API (via Instagrapi library) for posts tagged `#jada` or `#queenofsandiego` posted on the same date as the charter event (using `post.taken_at` to filter).

Why same-day? Guests often share photos in real-time or immediately after the sail. By limiting to the event date, we avoid pulling random historical posts and keep the feed contextually relevant.

The IG feed is rendered into `#ig-grid` (lines 452–459 of `sailor-board/index.html`) as a separate section above the guest uploads. The response structure includes both:

{
  "user_photos": [ { "url": "...", "thumb_url": "...", "poster": "guest email", "auto_publish": true } ],
  "instagram": [ { "url": "...", "caption": "...", "poster_handle": "@...", "taken_at": "2026-05-24T..." } ]
}

Booking Modal Deep-Linking

Previously, the "Book a Sail" button on the homepage and guest pages linked back to the homepage (`/`), forcing users to navigate again to open the booking modal. This was a friction point.

We refactored `booking-widget.js` to export a function that opens the modal directly and accept a query parameter `?book=true` on any page. The script now:

  • Checks for `?book=true` on page load
  • If present, calls `window.QOSBooking.open()` immediately
  • If not, binds the modal opener to all `.book-sail-btn` elements

This eliminated the round-trip and improved conversion on guest pages, where users are already engaged and likely to book the same charter next time.

Deployment Pipeline

The guest page and Lambda are deployed via separate channels:

  • Guest page — checked into `sites/queenofsandiego.com/sailor-board/index.html`, deployed to S3 under the site root on every commit
  • Lambda — source in `tools/shipcaptaincrew/lambda_function.py`, bundled with dependencies (Pillow, boto3, Instagrapi), zipped, and deployed to the `shipcaptaincrew` Lambda function via the deploy script

For staging validation, we push to a CloudFront staging distribution (same S3 bucket, separate distribution ID) and invalidate the cache to pick up changes instantly. Once tested, we promote to production (main distribution at `d.sailjada.com`).

Key Infrastructure Resources

  • S