Skip to content

Multi-trip storage scoping, "My uploads", and Apple Photos-style map thumbnails - #32

Merged
TomProkop merged 4 commits into
mainfrom
users/tomas.prokop/turbo-potato
Jul 23, 2026
Merged

TomProkop merged 4 commits into
mainfrom
users/tomas.prokop/turbo-potato

Conversation

@TomProkop

Copy link
Copy Markdown
Member

Summary

Three features for the live photo/video upload page (cicerallye.com/photos):

1. My uploads (ownership)

  • Uploads are tagged with an uploadedBy email (captured once, saved in localStorage) — lays groundwork for real sign-in later.
  • New GET /api/media/mine?email=... lists a person's own uploads.
  • New DELETE/PATCH /api/media/{id} let someone delete their own post or edit its location/date — both gated on the shared upload token and a matching uploadedBy.
  • photos.ts gained a "Your uploads" section: thumbnail grid with edit-location (reuses the existing map picker), edit-date, and delete.

2. Multi-trip storage scoping

  • The site will host multiple trips over time. Table Storage partition key and blob paths are now trip-scoped ({tripId}/{day}/...), driven by api/src/tripId.ts, which mirrors the active trip id in src/data/editions.ts (documented as needing manual sync when a new trip starts).
  • Only the current trip is live/browsable for now — no migration needed for existing data.

3. Apple Photos-style map thumbnails

  • Individual map pins and cluster bubbles now show real photo/video thumbnails instead of generic icons, like Apple Photos' map view. Clusters show a random photo from the group plus the count badge.
  • To avoid downloading full-size originals just to render a ~36px pin, a small thumbnail (~200px, JPEG q0.7) is generated client-side (canvas draw from an <img> for photos, a seeked <video> frame for videos) and uploaded as a second tiny blob via an extended SAS response — no server-side compute added to the upload path.
  • Best-effort: if thumbnail generation fails in a given browser, the upload still succeeds and the map falls back to the full-size blobUrl for that post.

4. Lightbox close button

  • Switched to a solid bg-black/70 chip with a white ring and a larger (44px) tap target so it's visible against any photo.

Verification

  • npx tsc --noEmit (api) — clean
  • npm run build — clean
  • npm run lint — clean
  • Syntax-checked the embedded photos.ts script (large inline template literal, no build step) via new Function()

Co-authored-by: Copilot App 223556219+Copilot@users.noreply.github.com

TomProkop and others added 3 commits July 23, 2026 17:15
Three follow-ups from live use on the trip:

1. The shareable link required ?token=... - now the shared secret is
   embedded server-side into the rendered page (same pattern as the
   Maps API key) instead of being read from the URL, so crews can just
   be sent cicerallye.com/photos. Server-side validation is unchanged,
   so this is the same protection level as before.

2. Uploads were getting stuck forever on "Preparing upload for..."
   with no way to recover except reloading. Plain fetch() has no
   timeout, so a stalled mobile connection just hung indefinitely.
   Added an AbortController-based timeout (25s for the small JSON
   calls, 60s for the actual file PUT) with one automatic retry before
   surfacing a clear failure message. Confirmed via Azure CLI that the
   app settings and storage account config are otherwise healthy, so
   this was a client-side gap rather than a server regression.

3. Researched why the iOS gallery Location toggle still doesn't always
   work: confirmed this is a known WebKit/iOS inconsistency specific to
   HEIC (JPEG is far more reliable), not fixable client-side. Expanded
   the in-modal tip to mention switching Camera format to "Most
   Compatible" for anyone who wants reliable auto-geotagging.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- api/src/tripId.ts: getCurrentTripId() as the API-side source of truth
  for the active trip id, mirroring src/data/editions.ts (must be kept
  in sync manually - documented in the file).
- mediaTable.ts/mediaBlob.ts: partition key and blob paths are now
  trip-scoped ({tripId}/{day}/...); mediaComplete.ts's path regexes
  updated to match.
- Client-side thumbnail generation (photos.ts): generates a small
  (~200px, JPEG q0.7) thumbnail in-browser before upload (canvas draw
  from an img element for photos, a seeked video frame for videos) so
  map pins/clusters never download full-size originals. Best-effort -
  on any decode failure the upload proceeds without a thumbnail.
- mediaSas.ts now issues a second SAS URL for the thumbnail blob
  alongside the original; mediaComplete.ts stores thumbBlobPath/thumbUrl
  when present; media.ts/mediaMine.ts DTOs expose thumbUrl.
- MediaMarker.tsx: individual map pins now render the real photo/video
  thumbnail (circular, thumbUrl falling back to blobUrl) instead of a
  generic icon.
- MediaMarkers.tsx/mediaClusterRenderer.ts: markers are tagged with
  their post data on ref-set so cluster bubbles can show a random
  photo from the cluster (Apple Photos map-view style) plus the count
  badge, falling back to the amber camera badge if no thumbnail is
  available.
- MediaLightbox.tsx: close button switched to a solid bg-black/70 chip
  with a white ring and a larger (44px) tap target for visibility
  against bright photos.
- Also includes the previously-drafted "My uploads" ownership work
  (uploadedBy tracking, mediaMine.ts, mediaItem.ts delete/patch,
  email-capture + uploads list UI in photos.ts).

Verified: npx tsc --noEmit (api), npm run build, npm run lint, and a
syntax check of the embedded photos.ts script. Not merged/shipped per
instructions - holding on this branch pending further feature work.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR enhances the live photo/video upload and map experience by introducing per-uploader management (“My uploads”), scoping media storage by trip/edition, and rendering map pins/clusters with real media thumbnails to avoid pulling full-size originals.

Changes:

  • Added self-reported uploader ownership (uploadedBy) plus new endpoints to list/manage a user’s own uploads (GET /api/media/mine, PATCH/DELETE /api/media/{id}).
  • Trip-scoped storage/partitioning via a current trip id ({tripId}/{day}/...) to support multiple editions over time.
  • Added client-generated thumbnail blobs (thumbUrl) used by map pins/clusters and other UI tweaks (lightbox close button).

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/hooks/useMediaPosts.ts Extends frontend media model to include optional thumbUrl.
src/components/RouteMap/MediaMarkers.tsx Tags marker instances with post data for cluster thumbnail selection.
src/components/RouteMap/MediaMarker.tsx Updates individual map pin rendering to show media thumbnails.
src/components/RouteMap/mediaClusterRenderer.ts Renders cluster bubbles using a representative thumbnail + count badge.
src/components/MediaLightbox.tsx Improves close button visibility and tap target.
api/src/tripId.ts Introduces current trip id configuration (app setting + fallback).
api/src/mediaTable.ts Adds ownership + thumbnail fields; scopes Table partition key by trip id.
api/src/mediaEmail.ts Adds normalization/validation for self-reported uploader emails.
api/src/mediaBlob.ts Trip-scopes blob paths and adds thumbnail blob path generation.
api/src/functions/photos.ts Adds email capture, thumbnail generation/upload, and “Your uploads” UI with edit/delete.
api/src/functions/mediaSas.ts Extends SAS issuance to include an optional thumbnail upload SAS.
api/src/functions/mediaMine.ts Adds endpoint to list posts by uploader email for the current trip.
api/src/functions/mediaItem.ts Adds per-owner delete/patch endpoints gated by token + uploadedBy match.
api/src/functions/mediaComplete.ts Records uploadedBy and optional thumbnail metadata on completion; validates new path shapes.
api/src/functions/media.ts Includes thumbUrl in the public media listing payload.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +28 to 31
<div className="relative w-9 h-9 rounded-full shadow-lg border-[3px] border-white overflow-hidden bg-gray-300">
{post.mediaType === 'video' ? (
<Video size={16} strokeWidth={2.25} color="#fff" />
<video src={thumbSrc} muted preload="metadata" playsInline className="w-full h-full object-cover" />
) : (
Comment on lines +69 to +71
const container = await getMediaContainer();
await container.getBlockBlobClient(entity.blobPath).deleteIfExists();
await table.deleteEntity(mediaPartitionKey(), id);
Comment on lines +7 to +16
function pickThumbnailPost(markers: readonly Marker[]): MediaPost | undefined {
const posts = markers.map((m) => (m as TaggedMarker).__mediaPost).filter((p): p is MediaPost => !!p);
if (posts.length === 0) return undefined;
// Apple Photos-style: prefer a random photo so the cluster bubble feels
// like "one of your photos", falling back to a random video only if the
// cluster has no photos at all.
const photos = posts.filter((p) => p.mediaType === 'photo');
const pool = photos.length > 0 ? photos : posts;
return pool[Math.floor(Math.random() * pool.length)];
}
Comment on lines +36 to +46
const post = pickThumbnailPost(markers ?? []);
const thumbSrc = post ? post.thumbUrl ?? post.blobUrl : undefined;
if (thumbSrc) {
const img = document.createElement('img');
img.src = thumbSrc;
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'cover';
img.style.display = 'block';
div.appendChild(img);
} else {
Comment on lines +783 to +788
const thumb = document.createElement('div');
thumb.className = 'mineThumb';
thumb.innerHTML = post.mediaType === 'video'
? '<video src="' + post.blobUrl + '" muted></video>'
: '<img src="' + post.blobUrl + '" />';

Comment thread api/src/functions/mediaComplete.ts Outdated
Comment on lines +48 to +53
if (!blobPath || !BLOB_PATH_PATTERN.test(blobPath)) {
return { status: 400, body: 'Missing or invalid blobPath' };
}
if (thumbBlobPath !== undefined && !THUMB_BLOB_PATH_PATTERN.test(thumbBlobPath)) {
return { status: 400, body: 'Invalid thumbBlobPath' };
}
- MediaMarker.tsx: only render a <video> element when no thumbUrl is
  present; thumbUrl is always a JPEG frame, so it must go through <img>.
- mediaItem.ts: DELETE now also removes the thumbnail blob
  (entity.thumbBlobPath) when present, instead of leaving it orphaned.
- mediaClusterRenderer.ts: pick the cluster thumbnail deterministically
  (newest post by id) instead of Math.random(), so it no longer
  flickers between different photos on every pan/zoom re-render. Also
  stopped falling back to blobUrl for videos without a generated
  thumbnail (that would point an <img> at a video file).
- photos.ts "Your uploads" list: prefer thumbUrl for the preview
  thumbnail, only falling back to a <video>/full-size <img> for older
  posts without one.
- mediaComplete.ts: blobPath/thumbBlobPath validation is now pinned to
  the current trip id (same value mediaPartitionKey() uses) instead of
  accepting any trip prefix, closing a cross-trip tampering gap.

Verified: npx tsc --noEmit (api), npm run build, npm run lint, and the
embedded photos.ts script syntax check.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants