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
#jadaand#queenofsandiegoposts 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/photosThe
d.instagramobject 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}/photosis where the hashtag search happens. It presumably:
- Accepts the event date as a path parameter
- Queries Instagram's API (or a cached Instagram data layer) for posts matching
#jadaand#queenofsandiego- Filters by same-day timestamp (only posts from the charter date itself)
- 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-guestsor similar) and fronted by CloudFront for CDN distribution and HTTPS termination. The Route53 DNS entry forqueenofsandiego.comis 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.comsubdomain 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