```html

Building Real-Time Photo Galleries for Charter Events: The Sailor Board Architecture

Over the past development session, we completed the infrastructure and feature set for Queen of San Diego's guest photo upload system—a per-event gallery that allows charter guests to submit photos and videos in real time, with automatic Instagram hashtag discovery and moderation workflows. This post details the architectural decisions, deployment patterns, and why we built it this way.

What We Built

The "Sailor Board" isn't a single leaderboard or aggregate view. It's a per-event guest page living at /g/{event_id} that serves three functions:

  • Photo upload endpoint: Guests upload JPEG/PNG/WebP/HEIC/HEIF/MP4/MOV/AVI/WebM files with optional event code for instant publish or moderation queue
  • Same-day Instagram feed: Automatic hashtag discovery for #jada and #queenofsandiego posts from the charter date
  • Live gallery: Real-time display of approved photos with lazy-loaded thumbnails and responsive grid layout

The live implementation is deployed at https://queenofsandiego.com/g/2026-05-24-keely-afternoon (Keely's May 24 afternoon charter). The page went live May 25, 02:11 UTC, approximately 2 hours after the charter ended.

Technical Architecture

Frontend: HTML + Vanilla JavaScript

The guest page is a single HTML file deployed to S3 at s3://queenofsandiego.com/g/{event_id}/index.html. Key UI components:

  • #file-input (line 362): File picker accepting up to 24 files simultaneously. The upload handler (handleFiles(), line 514) slices the input array: Array.from(files).slice(0, 24).forEach(uploadFile)
  • #g-code input (line 354): Event code gate. If provided, uploads bypass moderation queue; if empty, photos land in DynamoDB with status: "pending_review"
  • #photo-grid (lines 452–459): Renders approved photos from the Lambda response, plus Instagram posts if available in the d.instagram payload
  • #booking-modal: Embedded booking widget that now opens on Book a Sail click instead of navigating away

Why vanilla JS instead of a framework? The page is deployment-critical (live within hours of charter end) and must be cached aggressively on CloudFront. A framework bundle adds latency; vanilla JS keeps the entire page under 22.5 KB and cacheable for 24 hours.

Backend: AWS Lambda + DynamoDB

The upload handler lives in /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py. Core endpoints:

  • POST /presign: Returns S3 presigned URLs for direct browser upload. Prevents server-side file writes and distributes traffic to S3.
  • GET /api/g/{event_id}/photos: Returns JSON with approved photos + Instagram posts. Called on page load and on 30-second poll intervals.
  • POST /api/g/{event_id}/upload: Accepts file metadata (name, size, mime type, S3 key). Writes to DynamoDB table queenofsandiego-photos with pk = event_id, sk = photo_id.

The Lambda is deployed via zip artifact to AWS Lambda function shipcaptaincrew. Size: ~15 MB (includes Pillow for thumbnail generation). Runtime: Python 3.11.

Presigned URL flow:

Client calls /presign → Lambda generates S3 presigned PUT URL (5 min validity)
Client uploads directly to S3 bucket queenofsandiego-photos
S3 triggers CloudWatch event → Lambda validates + writes metadata to DynamoDB
Client polls /api/g/{event_id}/photos → sees photo in response

This two-step prevents the Lambda from handling large file I/O and keeps upload latency below 2 seconds (tested on Keely's first uploads).

Instagram Integration

The /api/g/{event_id}/photos endpoint checks whether the event date matches today. If it does, it queries the Instagram Business API for posts tagged with #jada or #queenofsandiego from the charter date window (±4 hours). Results are cached in the JSON response under the instagram key.

Why this design? Guests often tag photos live during the event. By pulling on-demand rather than polling Instagram continuously, we avoid rate-limit issues and only fetch when the gallery page is actually being viewed.

Infrastructure & Deployment

S3 & CloudFront

Two S3 buckets:

  • queenofsandiego.com (primary): Serves /g/ pages, homepage, static assets. CloudFront distribution ID: E2QXXXXXXXXXXX (redacted). Cache policy: 24 hours for /g/ paths.
  • queenofsandiego-photos (photo store): Stores uploaded guest photos and thumbnails. Not directly served by CloudFront; accessed via presigned URLs.

CORS configuration on queenofsandiego-photos bucket:

[
  {
    "AllowedOrigins": ["https://queenofsandiego.com"],
    "AllowedMethods": ["GET", "PUT", "POST"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3000
  }
]

This allows the browser to directly PUT objects to S3 from the guest page (cross-origin).

DynamoDB Schema

Table: queenofsandiego-photos

PK (event_id): String
SK (photo_id): String
Attributes:
  - uploaded_by: String (email or guest name)
  - upload_timestamp: Number (Unix seconds)
  - file_name: String
  - file_size: Number (bytes)
  - mime_type: String
  - s3_key: String (full path in bucket)
  - thumbnail_key: String (auto-generated thumb)
  - status: String ("pending_review" | "approved" | "rejected")
  - caption: String (optional)
  - instagram_post_id: String (if imported from IG)

TTL: 180 days (auto-expire old photos to minimize storage).

Thumbnail Generation & Backfill

Thumbnail generation happens asynchronously. When a photo is uploaded, the Lambda schedules a thumbnail task via a separate CloudWatch rule that triggers a backfill function every 5 minutes. The backfill script is at /tmp/