Building the Sailor Board: Implementing Real-Time Photo Galleries for Charter Events

During this development session, we completed the infrastructure and frontend implementation for Queen of San Diego's guest photo upload system, colloquially known as the "Sailor Board." What started as marketing copy on Keely's charter page evolved into a production-ready feature that captures post-sail memories, gates spam uploads with event codes, and surfaces Instagram content from the same day. Here's what we built and why.

The Feature: What Keely's Guests Actually See

Keely's guest page (https://queenofsandiego.com/g/2026-05-24-keely-afternoon) went live on May 25, 2026, immediately after her afternoon charter concluded. The page implements four key capabilities:

  • Photo/video upload with format filtering: Guests drag-and-drop or select up to 24 files at once. The frontend accepts JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, and WebM formats.
  • Event code spam gate: Guests enter a code provided at the end of the sail. With code → instant public; without code → moderation queue.
  • Batch upload ceiling: Hard cap of 24 files per submission to prevent storage/bandwidth abuse.
  • Same-day Instagram integration: The Lambda backend fetches posts tagged with #jada or #queenofsandiego from the event date and renders them in the photo grid alongside uploaded content.

Frontend Implementation: sailor-board/index.html

We created a new directory structure to house the guest page template:

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

This file serves as the template for all per-charter guest pages. It's deployed as a static asset and rendered with charter-specific metadata (dates, URLs, event IDs) injected at deployment time.

Key DOM elements:

  • #file-input (line 362): Hidden file input, accepts multiple files with type filtering
  • #g-code (line 354): Text input for the event code, validated client-side before submission
  • #photo-grid (lines 452–459): Container for uploaded photos and Instagram content
  • #ig-grid: Dedicated section for Instagram posts fetched server-side

Critical JavaScript functions:

  • handleFiles() (line 514): Processes selected files, enforces 24-file limit via Array.from(files).slice(0, 24).forEach(uploadFile), and validates MIME types
  • readCode() (line 424): Reads and validates the guest-provided event code against a hardcoded charter identifier
  • uploadFile(file, code): Makes a multipart POST to the Lambda endpoint with file data and code

The page operates in two modes: pre-sail (upload UI hidden until event ends) and post-sail (upload enabled). The transition triggers at FLIP_UTC = May 25 00:00 UTC (May 24 5 PM PT for San Diego time).

Lambda Backend: shipcaptaincrew/lambda_function.py

The core logic lives in a single Lambda function deployed to AWS. We made multiple edits to this file across the session to wire up the Instagram fetcher, photo handler, and moderation email flows.

Key handler endpoints:

  • GET /api/g/{event_id}/photos: Returns a JSON object with { "uploaded": [...], "instagram": [...] }. Fetches same-day Instagram posts by querying the Instagram Graph API with hashtags #jada and #queenofsandiego.
  • POST /api/g/{event_id}/upload: Accepts multipart form data (file, code, guest_name). Validates code against the charter's pre-shared event code. If valid, writes directly to S3 and marks photo as "published". If invalid, queues for moderation and sends an email to the charter host.
  • POST /api/g/{event_id}/moderation: Allows charter hosts to approve/reject queued photos via email links. Approved photos are moved from the review prefix to the published prefix in S3.

S3 bucket structure:

s3://qos-guest-photos/
  g/
    {event_id}/
      published/
        {file_id}.{ext}
      review/
        {file_id}.{ext}
      instagram/
        {post_id}.json

The Lambda function reads the bucket name from an environment variable (no hardcoding). Staging uses qos-guest-photos-staging; production uses qos-guest-photos.

Instagram Integration: Why We Did It Server-Side

The frontend page declares d.instagram in its data structure, and the JavaScript renders whatever the Lambda provides. This is a deliberate architectural choice: the server owns the Instagram API credential and handles rate-limiting and token refresh. The client never talks to Instagram directly.

The Lambda handler for GET /api/g/{event_id}/photos fetches posts using the Instagram Graph API, filters by:

  • Hashtag presence (#jada OR #queenofsandiego)
  • Post timestamp matching the charter date (within 24 hours of event start)
  • Public account status (private posts are skipped)

We cache the results in S3 for 1 hour to avoid repeated API calls. Cache key: g/{event_id}/instagram/cache.json, with expiration stored in the file's metadata.

Deployment Pipeline

Homepage and sailor-board changes:

We edited /index.html twice to update the navigation markup and ensure the "Book a Sail" button immediately triggers the modal instead of linking to a separate booking page:

<a href="#" class="book-sail-btn" data-toggle="modal" data-target="#booking-modal">
  Book a Sail
</a>

This leverages Bootstrap's modal data attributes. The modal itself is initialized in a script tag with show: false, allowing the button to trigger it via JavaScript event delegation.

Lambda deployment:

We built and deployed a new Lambda function zip that includes the Python code plus a bundled py_vapid dependency (required for Web Push Notifications, though not yet active). Deployment steps:

  1. Snapshot current production code from Lambda
  2. Apply local changes (IG fetcher, moderation flow, Instagram cache)
  3. Run syntax validation: python3 -m py_compile lambda_function.py
  4. Build deployment zip: zip -r shipcaptaincrew-deploy.zip lambda_function.py lib/
  5. Verify zip contents