)}
diff --git a/src/components/RouteMap/MediaMarkers.tsx b/src/components/RouteMap/MediaMarkers.tsx
index 996d82e..702c307 100644
--- a/src/components/RouteMap/MediaMarkers.tsx
+++ b/src/components/RouteMap/MediaMarkers.tsx
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useMap } from '@vis.gl/react-google-maps';
import type { Marker } from '@googlemaps/markerclusterer';
-import { useMediaPosts, type MediaPost } from '../../hooks/useMediaPosts';
+import { useMediaPosts, type MediaPost, compareCapturedAtDesc } from '../../hooks/useMediaPosts';
import MediaMarker from './MediaMarker';
import MediaLightbox from '../MediaLightbox';
import { useClusterer } from './useClusterer';
@@ -10,12 +10,12 @@ import { postsWithinRadius } from './mediaProximity';
type GeotaggedPost = MediaPost & { lat: number; lon: number };
-/** Newest-first, matching how `useMediaPosts`/`PhotoStream` already order
- * posts - rowKeys are generated with an inverted timestamp prefix, so
- * ascending `id` sort is newest-first. Keeps lightbox ordering consistent
- * everywhere it's opened from. */
+/** Newest-*captured*-first (not newest-uploaded) - matches how
+ * `useMediaPosts` sorts. Crews upload with a time lag, so sorting by
+ * `capturedAt` keeps lightbox ordering on trip-timeline order everywhere
+ * it's opened from. */
function sortNewestFirst(posts: readonly MediaPost[]): MediaPost[] {
- return [...posts].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
+ return [...posts].sort(compareCapturedAtDesc);
}
const NEARBY_RADIUS_METERS = 500;
diff --git a/src/components/RouteMap/PhotoStreamTile.tsx b/src/components/RouteMap/PhotoStreamTile.tsx
index eb7f2fb..a95207e 100644
--- a/src/components/RouteMap/PhotoStreamTile.tsx
+++ b/src/components/RouteMap/PhotoStreamTile.tsx
@@ -1,16 +1,17 @@
import { useState } from 'react';
-import { Video, Camera } from 'lucide-react';
+import { Video } from 'lucide-react';
import { useMediaPosts } from '../../hooks/useMediaPosts';
import MediaLightbox from '../MediaLightbox';
/**
* Compact preview of the live photo stream, shown next to the map so
- * visitors don't have to scroll all the way down to `/photos` / the
- * `FollowSection` grid to see what's just been uploaded. Shows the most
- * recent thumbnails; tapping one opens the same shared `MediaLightbox`
- * used everywhere else, seeded with the *full* posts list (not just this
- * tile's truncated subset) so browsing from here isn't more limited than
- * browsing from the main stream.
+ * visitors don't have to scroll all the way down to the `FollowSection`
+ * grid to see the most recently *captured* photos/videos. Shows the
+ * newest-captured thumbnails (see `useMediaPosts`, sorted by
+ * `capturedAt` rather than upload time); tapping one opens the same
+ * shared `MediaLightbox` used everywhere else, seeded with the *full*
+ * posts list (not just this tile's truncated subset) so browsing from
+ * here isn't more limited than browsing from the main stream.
*/
const TILE_COUNT = 6;
@@ -26,7 +27,7 @@ export default function PhotoStreamTile() {
@@ -67,13 +68,6 @@ export default function PhotoStreamTile() {
))}
-
- Share a photo
-
-
{selectedIndex !== null && (
setSelectedIndex(null)} />
)}
diff --git a/src/components/RouteMap/mediaClusterRenderer.ts b/src/components/RouteMap/mediaClusterRenderer.ts
index 78dd09e..846b201 100644
--- a/src/components/RouteMap/mediaClusterRenderer.ts
+++ b/src/components/RouteMap/mediaClusterRenderer.ts
@@ -1,5 +1,5 @@
import type { Cluster, Marker, Renderer } from '@googlemaps/markerclusterer';
-import type { MediaPost } from '../../hooks/useMediaPosts';
+import { type MediaPost, compareCapturedAtDesc } from '../../hooks/useMediaPosts';
/** Markers tagged with their post data by `MediaMarkers.tsx` on ref-set. */
export type TaggedMediaMarker = Marker & { __mediaPost?: MediaPost };
@@ -15,14 +15,13 @@ function pickThumbnailPost(markers: readonly Marker[]): MediaPost | undefined {
if (posts.length === 0) return undefined;
// Apple Photos-style: prefer a photo so the cluster bubble feels like
// "one of your photos", falling back to a video only if the cluster has
- // no photos at all. Pick deterministically (newest first, by `id` -
- // rowKeys are generated with an inverted timestamp prefix so they sort
- // newest-first ascending) rather than randomly, so the same cluster
- // doesn't flicker between different thumbnails on every pan/zoom
- // re-render.
+ // no photos at all. Pick deterministically (newest *captured* first,
+ // with an `id` tie-breaker baked into `compareCapturedAtDesc`) rather
+ // than randomly, so the same cluster doesn't flicker between different
+ // thumbnails on every pan/zoom re-render.
const photos = posts.filter((p) => p.mediaType === 'photo');
const pool = photos.length > 0 ? photos : posts;
- return [...pool].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0];
+ return [...pool].sort(compareCapturedAtDesc)[0];
}
/**
diff --git a/src/hooks/useMediaPosts.ts b/src/hooks/useMediaPosts.ts
index 311ea8a..c88da7b 100644
--- a/src/hooks/useMediaPosts.ts
+++ b/src/hooks/useMediaPosts.ts
@@ -13,6 +13,28 @@ export interface MediaPost {
const POLL_INTERVAL_MS = 30_000;
+/**
+ * Numeric compare of two posts by `capturedAt`, newest first. Crews upload
+ * with a time lag, but followers should see photos in the trip's actual
+ * timeline order regardless of when they landed in Blob/Table Storage.
+ * Parses to epoch ms rather than comparing the raw strings - `capturedAt`
+ * is client-supplied (see `mediaComplete` in the API) so its precision/
+ * offset can vary, and lexicographic string comparison can mis-order
+ * those. Falls back to `id` as a deterministic tie-breaker when
+ * timestamps are equal (or both fail to parse), so results stay stable
+ * across re-renders instead of depending on array/marker iteration order.
+ */
+export function compareCapturedAtDesc(a: MediaPost, b: MediaPost): number {
+ const at = Date.parse(a.capturedAt) || 0;
+ const bt = Date.parse(b.capturedAt) || 0;
+ if (at !== bt) return bt - at;
+ return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
+}
+
+function sortByCapturedAtDesc(posts: MediaPost[]): MediaPost[] {
+ return [...posts].sort(compareCapturedAtDesc);
+}
+
/**
* Polls the `/api/media` Azure Function (backed by Table Storage, blob
* bytes served directly from Blob Storage via `blobUrl`) for recently
@@ -32,7 +54,11 @@ export function useMediaPosts(): MediaPost[] {
const res = await fetch('/api/media', { cache: 'no-store' });
if (!res.ok) return;
const data: MediaPost[] = await res.json();
- if (isMounted.current) setPosts(data);
+ // The API returns newest-*uploaded*-first (cheap Table Storage
+ // pagination via an inverted-timestamp rowKey) - re-sort here by
+ // when the photo was actually taken so every consumer of this
+ // hook gets trip-timeline order "for free".
+ if (isMounted.current) setPosts(sortByCapturedAtDesc(data));
} catch {
// Network hiccup or offline - keep showing the last known posts
// and try again on the next tick.
diff --git a/src/sections/ConceptSection.tsx b/src/sections/ConceptSection.tsx
deleted file mode 100644
index 16f305a..0000000
--- a/src/sections/ConceptSection.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { Wrench, Mountain, Map, Users, MapPin, Banknote, CheckCircle } from 'lucide-react';
-
-const CARDS = [
- {
- Icon: Wrench,
- title: 'Cheap, old, or characterful',
- body: 'Any car works if it is interesting. A Ferrari is welcome but would look a bit out of place next to someone\'s 200k km classic.',
- },
- {
- Icon: Wrench,
- title: 'Breakdowns welcome',
- body: 'Old cars fail sometimes. When they do, we fix them on the road ourselves. Bring more tools than you think you need and know roughly how your car works.',
- },
- {
- Icon: Map,
- title: 'Shared plan, your pace',
- body: 'There\'s a rough itinerary, but it is not binding. Split off, take detours, meet at stops, or skip things. We share live location so everyone can find each other.',
- },
- {
- Icon: Users,
- title: 'Multiple crews',
- body: 'We travel loosely together in more than one car. More stories, more help when something fails, better evenings.',
- },
- {
- Icon: MapPin,
- title: 'Live tracking',
- body: 'Anyone can follow along online - live map, photos, and updates as they happen.',
- },
- {
- Icon: Banknote,
- title: 'Low-cost by design',
- body: 'No luxury package, no shared budget. Keep it simple, split what makes sense, spend money where it is worth it.',
- },
-];
-
-const WHAT_TO_BRING = [
- 'Valid documents for every country on the route',
- 'European breakdown kit (warning triangle, vest, first-aid)',
- 'Vignettes for Austria and Switzerland - mandatory',
- 'Basic tools and spare fluids for your specific car',
- 'Cash - some alpine passes and toll booths don\'t take cards',
- 'A dashcam (you will want the footage)',
- 'Sense of humour',
-];
-
-export default function ConceptSection() {
- return (
-
-
-
-
-
- The Idea
-
-
- NOT YOUR USUAL
- VACATION.
-
-
- Cicerone Rallye is a week on the road in fun, old, unreliable cars with a decent chance something will need fixing.
-
-
- We drive thousands of kilometers and stop at interesting places along the way. Going by car lets us experience a lot in one week without turning it into a polished tour.
-
-
- Not many rules. But the ones we have, we take seriously.
-
- List updates as confirmations come in. Want to see your name here?{' '}
-
- Sign up below →
-
-
diff --git a/src/sections/FollowSection.tsx b/src/sections/FollowSection.tsx
index 3cda6fe..c130f8e 100644
--- a/src/sections/FollowSection.tsx
+++ b/src/sections/FollowSection.tsx
@@ -1,4 +1,4 @@
-import { Radio, Camera } from 'lucide-react';
+import { Radio } from 'lucide-react';
import PhotoStream from '../components/PhotoStream';
export default function FollowSection() {
@@ -19,11 +19,6 @@ export default function FollowSection() {
Scroll up for the live map. Below is the live photo/video stream, straight from the
crews' phones. Also check our Instagram Stories for daily highlights.
-
-
- Share a photo from your phone
-
-
diff --git a/src/sections/Footer.tsx b/src/sections/Footer.tsx
index fbcf92f..936eeb1 100644
--- a/src/sections/Footer.tsx
+++ b/src/sections/Footer.tsx
@@ -11,12 +11,11 @@ export default function Footer() {
{/* Nav links */}