Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import Nav from './components/Nav';
import Hero from './sections/Hero';
import ConceptSection from './sections/ConceptSection';
import RouteSection from './sections/RouteSection';
import FollowSection from './sections/FollowSection';
import ItinerarySection from './sections/ItinerarySection';
import LastYearSection from './sections/LastYearSection';
import CrewsSection from './sections/CrewsSection';
import FollowSection from './sections/FollowSection';
import SignupSection from './sections/SignupSection';
import LastYearSection from './sections/LastYearSection';
import Footer from './sections/Footer';

export default function App() {
Expand All @@ -16,12 +14,10 @@ export default function App() {
<main>
<Hero />
<RouteSection />
<FollowSection />
<ItinerarySection />
<LastYearSection />
<ConceptSection />
<CrewsSection />
<FollowSection />
<SignupSection />
<LastYearSection />
</main>
<Footer />
</div>
Expand Down
15 changes: 2 additions & 13 deletions src/components/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@ import { useState, useEffect } from 'react';
import { Menu, X } from 'lucide-react';

const NAV_ITEMS = [
{ href: '#last-year', label: 'Last Year' },
{ href: '#concept', label: 'The Idea' },
{ href: '#route', label: 'Route' },
{ href: '#follow', label: 'Follow' },
{ href: '#itinerary', label: 'Itinerary' },
{ href: '#crews', label: 'Crews' },
{ href: '#follow', label: 'Follow' },
{ href: '#last-year', label: 'Last Year' },
];

export default function Nav() {
Expand Down Expand Up @@ -52,11 +51,6 @@ export default function Nav() {
</a>
</li>
))}
<li className="ml-2">
<a href="#join" className="btn-primary text-sm px-4 py-2">
Join →
</a>
</li>
</ul>

{/* Mobile hamburger */}
Expand Down Expand Up @@ -85,11 +79,6 @@ export default function Nav() {
</a>
</li>
))}
<li className="pt-3">
<a href="#join" onClick={close} className="btn-primary w-full justify-center">
Join the Rallye →
</a>
</li>
</ul>
</div>
)}
Expand Down
12 changes: 6 additions & 6 deletions src/components/RouteMap/MediaMarkers.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down
24 changes: 9 additions & 15 deletions src/components/RouteMap/PhotoStreamTile.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -26,7 +27,7 @@ export default function PhotoStreamTile() {
<div className="bg-white border border-asphalt-700 shadow-sm rounded flex flex-col">
<div className="flex items-center justify-between px-4 pt-4 pb-3">
<h3 className="text-sm font-semibold uppercase tracking-wide text-asphalt-300">Latest photos</h3>
<a href="/photos" className="text-xs font-semibold text-rally-600 hover:text-rally-700">
<a href="#follow" className="text-xs font-semibold text-rally-600 hover:text-rally-700">
View all
</a>
</div>
Expand Down Expand Up @@ -67,13 +68,6 @@ export default function PhotoStreamTile() {
))}
</div>

<a
href="/photos"
className="flex-none flex items-center justify-center gap-1.5 text-xs font-semibold text-rally-600 border-t border-asphalt-700 py-2.5 hover:bg-asphalt-900/5 transition-colors"
>
<Camera size={13} /> Share a photo
</a>

{selectedIndex !== null && (
<MediaLightbox posts={posts} initialIndex={selectedIndex} onClose={() => setSelectedIndex(null)} />
)}
Expand Down
13 changes: 6 additions & 7 deletions src/components/RouteMap/mediaClusterRenderer.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -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];
}

/**
Expand Down
28 changes: 27 additions & 1 deletion src/hooks/useMediaPosts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
105 changes: 0 additions & 105 deletions src/sections/ConceptSection.tsx

This file was deleted.

10 changes: 2 additions & 8 deletions src/sections/CrewsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,11 @@ export default function CrewsSection() {
<div className="max-w-7xl mx-auto">
<div className="text-center mb-12">
<p className="text-rally-500 text-sm font-semibold uppercase tracking-widest mb-3">
Who's Coming
On The Road
</p>
<h2 className="font-display text-5xl md:text-6xl text-asphalt-100 tracking-wide">
SIGNED UP CREWS
THE CREWS
</h2>
<p className="text-asphalt-400 text-sm mt-4">
List updates as confirmations come in. Want to see your name here?{' '}
<a href="#join" className="text-rally-400 hover:text-rally-300 underline underline-offset-2">
Sign up below →
</a>
</p>
</div>

<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
Expand Down
7 changes: 1 addition & 6 deletions src/sections/FollowSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Radio, Camera } from 'lucide-react';
import { Radio } from 'lucide-react';
import PhotoStream from '../components/PhotoStream';

export default function FollowSection() {
Expand All @@ -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.
</p>
<a href="/photos" className="btn-primary inline-flex items-center gap-2 mb-8">
<Camera size={16} strokeWidth={1.75} />
Share a photo from your phone
</a>

<PhotoStream />
</div>
</div>
Expand Down
5 changes: 2 additions & 3 deletions src/sections/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,11 @@ export default function Footer() {
{/* Nav links */}
<nav className="flex flex-wrap justify-center gap-x-6 gap-y-2">
{[
['#last-year', 'Last Year'],
['#concept', 'The Idea'],
['#route', 'Route'],
['#follow', 'Follow'],
['#itinerary', 'Itinerary'],
['#crews', 'Crews'],
['#join', 'Join'],
['#last-year', 'Last Year'],
].map(([href, label]) => (
<a
key={href}
Expand Down
7 changes: 2 additions & 5 deletions src/sections/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,9 @@ export default function Hero() {
18–26 July 2026 · Fun cars. Great places. Breakdowns welcome.
</p>

{/* CTAs */}
{/* CTA */}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a href="#join" className="btn-primary text-base px-8 py-4">
Join the Rallye
</a>
<a href="#route" className="inline-flex items-center gap-2 rounded font-semibold transition-all duration-150 cursor-pointer select-none border-2 border-white text-white px-8 py-4 text-base hover:bg-white hover:text-asphalt-100 active:scale-95">
<a href="#route" className="btn-primary text-base px-8 py-4">
See the Route
</a>
</div>
Expand Down
Loading