Building Keely's Post-Charter Guest Page: Upload Gates, Instagram Integration, and the Sailor Board Pattern

When Keely's afternoon charter on Queen of San Diego wrapped up on May 24, 2026, we had a live guest page deployed within hours. This post covers the technical architecture behind that page—specifically the photo/video upload pipeline, the spam-prevention code gate, batch upload limits, and same-day Instagram hashtag integration that powers the "Sailor Board" feature.

Overview: What Got Built

Keely's guest page lives at https://queenofsandiego.com/g/2026-05-24-keely-afternoon and does four critical things:

  • Photo/video uploads with client-side format validation (JPEG, PNG, WebP, HEIC, HEIF for stills; MP4, MOV, AVI, WebM for video)
  • Event code verification to prevent spam—code holders bypass review queue, strangers go to moderation
  • Batch upload cap at 24 files per submission to prevent resource exhaustion
  • Same-day Instagram hashtag search pulling #jada and #queenofsandiego posts and rendering them into a live grid

The page was deployed to production S3 on May 25 at 02:11 UTC, roughly 2 hours after the event ended. No local source file was checked in—this was a generated artifact—but the release snapshot exists in releases/v1.0.0-keely-qos/index.html for audit purposes.

Technical Architecture

Upload Handler and Validation

The upload flow starts client-side. The page exposes an <input type="file" id="file-input" multiple> element (line 362 of the deployed HTML). When a user selects files, the handleFiles() function (line 514) fires:

Array.from(files).slice(0, 24).forEach(uploadFile)

This enforces the 24-file-per-submission ceiling immediately. The UI hint at line 360 tells users "up to 24 at once," setting expectations before they upload.

Before sending to the server, each file is validated client-side:

  • Images: JPEG, PNG, WebP, HEIC, HEIF (covers iPhone photos natively)
  • Video: MP4, MOV, AVI, WebM

This reduces server load and gives instant user feedback. Rejected files are logged but not uploaded.

Event Code Gate: The Spam Barrier

The critical anti-spam mechanism is the #g-code input (line 354) paired with the readCode() function (line 424). Here's the pattern:

  • If a user enters the correct event code (distributed to actual charter passengers), their uploads are instant published to the public grid
  • If no code or wrong code: uploads go into a JADA review queue (line 350) where moderation staff can approve before public display

The page includes the copy: "The code keeps strangers from posting to your charter page." This is transparent messaging—users understand why the code exists and that it's a trust mechanism, not arbitrary friction.

Event codes themselves are not stored on the page. They're validated server-side during upload submission, likely via a Lambda function that queries a DynamoDB table keyed by event_id (in this case, 2026-05-24) and the submitted code. This keeps code validation off the client.

Instagram Integration: The Sailor Board Data Source

This is where things get interesting. The page renders a grid of Instagram posts (lines 452–459) inside #ig-grid:

d.instagram.forEach(post => {
  // render post to grid
})

The data comes from a server-side API call:

GET shipcaptaincrew.queenofsandiego.com/api/g/{event_id}/photos

For Keely's event, that resolves to:

GET shipcaptaincrew.queenofsandiego.com/api/g/2026-05-24/photos

The d.instagram object is injected into the HTML at render time, which means the page was server-side rendered (likely by a build step or a Lambda@Edge function) to populate Instagram data before S3 delivery.

The Lambda handler at /api/g/{event_id}/photos is where the hashtag search happens. It presumably:

  1. Accepts the event date as a path parameter
  2. Queries Instagram's API (or a cached Instagram data layer) for posts matching #jada and #queenofsandiego
  3. Filters by same-day timestamp (only posts from the charter date itself)
  4. Returns a JSON array of posts with media URLs, captions, and timestamps

This avoids exposing Instagram API calls to the client, keeping tokens server-side and enabling rate-limit caching.

Infrastructure and Deployment

S3 and CloudFront

The guest page is served from an S3 bucket (likely queenofsandiego.com-guests or similar) and fronted by CloudFront for CDN distribution and HTTPS termination. The Route53 DNS entry for queenofsandiego.com is ALIAS'd to the CloudFront distribution endpoint.

The page itself is a single HTML file (22.5 KB), self-contained with embedded CSS and JavaScript. This is intentional: guest pages are ephemeral, often accessed once or twice, so there's no cache-busting benefit to splitting assets. The small payload keeps CloudFront hit rates high.

API Layer: shipcaptaincrew Lambda

The shipcaptaincrew.queenofsandiego.com subdomain routes to a Lambda function (or Lambda Function URL) that handles /api/g/{event_id}/photos. This Lambda:

  • Accepts event ID as a path parameter
  • Queries Instagram API (likely cached in ElastiCache or DynamoDB) for hashtag posts
  • Filters by date to ensure same-day posts only
  • Returns JSON
  • Sets Cache-Control: max-age=3600 (or similar) to avoid hammering Instagram's rate limits

Pre-Sail to Post-Sail Mode Flip

The page includes a time-based state switch at FLIP_UTC = May 25 00:00 UTC (May 24 5 PM PT). Before that moment, the upload UI is hidden; after, it's visible. This prevents uploads before the event ends. The client-side check is simple:

if (new Date().getTime() >= FLIP_UTC) {
  document.getElementById