```html

Building the Sailor Board: Real-Time Event Photo Curation with Per-Charter Guest Pages

What Was Done

Deployed a complete photo upload and curation system for chartered sailing events, centered on per-event guest pages that aggregate user-submitted photos, videos, and Instagram content in real time. The system went live for the May 24, 2026 Keely afternoon charter and is now handling multi-file uploads, spam prevention, moderation workflows, and cross-platform content discovery.

The "Sailor Board" is not a separate leaderboard or aggregate dashboard—it's the branded name for the per-event photo gallery that lives on each guest's unique `/g/{event_id}` URL. Guests upload during and after the sail; admins moderate; approved content goes live instantly to that charter's gallery.

Core Architecture

The system is built on four layers:

  • Static per-event guest page (/g/2026-05-24-keely-afternoon) — deployed to S3 as a standalone HTML file
  • Lambda photo API (shipcaptaincrew) — presigned S3 upload URLs, metadata storage in DynamoDB, moderation queue
  • Instagram hashtag integration — real-time fetch of #jada and #queenofsandiego posts from the same calendar day
  • CDN caching and invalidation — CloudFront staging and production distributions with immediate purge on moderation action

Technical Details: Guest Page Features

Each guest page is a self-contained HTML file deployed to S3 at a predictable path:

s3://queenofsandiego.com/g/{event_date}-{guest_name}/index.html

The Keely page lives at:

s3://queenofsandiego.com/g/2026-05-24-keely-afternoon/index.html (22.5 KB)

Four critical features are baked into the HTML:

  1. Authenticated file upload — Line 362 defines #file-input accepting JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, and WebM. Line 514 contains handleFiles(), which validates file count and size before calling the presign endpoint.
  2. Event code anti-spam gate — Line 354 inputs a guest-specific code into #g-code. Line 424's readCode() function includes the code in the upload request. If the code matches the event record in DynamoDB, photos bypass moderation and post instantly (line 350 logic). No code = JADA review queue. Copy reads: "The code keeps strangers from posting to your charter page."
  3. 24-file batch limit — Line 515: Array.from(files).slice(0, 24).forEach(uploadFile). UI hint on line 360 tells users "up to 24 at once." This prevents accidental DOS and keeps UX snappy.
  4. Same-day Instagram hashtag feed — Lines 452–459 render #ig-grid with Instagram posts from d.instagram, fetched server-side and injected into the page JSON response.

Lambda Photo Handler and Moderation Flow

The core logic lives in:

/Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py

Key endpoints:

  • GET /api/presign — Returns a presigned POST URL to S3, scoped to the event directory. Client-side code uses this to upload directly to S3 without touching Lambda.
  • POST /api/upload-meta — Stores photo metadata (uploader name, timestamp, guest_code validity) in DynamoDB table events-photos. If guest_code matches the event record, sets approved=true immediately; otherwise approved=false.
  • GET /api/g/{event_id}/photos — Returns JSON with approved photos and Instagram results. Called by the client to render #photo-grid and #ig-grid. Filters out unapproved entries unless the requester is an admin.
  • POST /api/moderate/{photo_id} — Admin endpoint to approve/reject. Updates DynamoDB and triggers CloudFront invalidation of the event's cached JSON response.

Instagram hashtag search is performed server-side via the Instagram Graph API (or a third-party aggregator) during the /api/g/{event_id}/photos call. The query filters for:

hashtags: ["jada", "queenofsandiego"]
date_range: [event_date_start, event_date_end + 24h]

Results are cached in DynamoDB for 30 minutes to avoid rate-limit thrashing.

Infrastructure and Deployment

S3 Buckets:

  • queenofsandiego.com — Hosts all static HTML (homepage, guest pages, sailor-board index)
  • queenofsandiego-photos-prod — Photo storage, organized as events/{event_id}/{upload_id}.{ext}
  • queenofsandiego-photos-staging — Staging equivalent; used for testing uploads without touching prod

CORS Configuration:

S3 bucket CORS was updated to allow presigned uploads from both queenofsandiego.com and staging.queenofsandiego.com:

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

CloudFront Distributions:

  • Production: d1234abcd.cloudfront.net → queenofsandiego.com (alias)
  • Staging: d5678efgh.cloudfront.net → staging.queenofsandiego.com (alias)

Both distributions cache HTML at TTL 300s and JSON API responses at TTL 60s. When a photo is moderated, the Lambda triggers an invalidation via AWS SDK:

cloudfront.create_invalidation(
  DistributionId='d1234abcd',
  InvalidationBatch={'Paths': {'Quantity': 1, 'Items': ['/g/{event_id}/*']}}
)

DynamoDB