Add map previews to journal route and activity pages

- Route/activity list pages: map thumbnails with route drawn on OSM tiles
- Route/activity detail pages: interactive Leaflet map with zoom controls
- Server: expose GeoJSON from PostGIS via ST_AsGeoJSON (simplified for lists)
- RouteMapThumbnail component: shared, supports thumbnail and interactive modes
- Placeholder shown for routes/activities without geometry
- Archive journal-route-previews change, sync specs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-04 09:42:07 +01:00
parent 3a4af698ca
commit 6aac8bd885
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
16 changed files with 399 additions and 39 deletions

View file

@ -0,0 +1,51 @@
import { useEffect, useRef } from "react";
import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet";
import L from "leaflet";
import type { GeoJsonObject } from "geojson";
import "leaflet/dist/leaflet.css";
function FitBounds({ data }: { data: GeoJsonObject }) {
const map = useMap();
const fitted = useRef(false);
useEffect(() => {
if (fitted.current) return;
const layer = L.geoJSON(data);
const bounds = layer.getBounds();
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [20, 20] });
fitted.current = true;
}
}, [data, map]);
return null;
}
interface RouteMapProps {
geojson: string;
interactive?: boolean;
className?: string;
}
export function RouteMapThumbnail({ geojson, interactive, className }: RouteMapProps) {
const data: GeoJsonObject = JSON.parse(geojson);
return (
<MapContainer
center={[50, 10]}
zoom={6}
className={className ?? "h-36 w-full rounded"}
zoomControl={interactive ?? false}
attributionControl={interactive ?? false}
dragging={interactive ?? false}
scrollWheelZoom={interactive ?? false}
doubleClickZoom={interactive ?? false}
touchZoom={interactive ?? false}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution={interactive ? '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' : undefined}
/>
<GeoJSON data={data} style={{ color: "#2563eb", weight: 3, opacity: 0.8 }} />
<FitBounds data={data} />
</MapContainer>
);
}

View file

@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { eq, desc } from "drizzle-orm";
import { eq, desc, sql } from "drizzle-orm";
import { getDb } from "./db.ts";
import { activities, routes } from "@trails-cool/db/schema/journal";
import { parseGpxAsync } from "@trails-cool/gpx";
@ -59,16 +59,22 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
export async function getActivity(id: string) {
const db = getDb();
const [activity] = await db.select().from(activities).where(eq(activities.id, id));
return activity ?? null;
if (!activity) return null;
const geojson = await getActivityGeojson(id);
return { ...activity, geojson };
}
export async function listActivities(ownerId: string) {
const db = getDb();
return db
const rows = await db
.select()
.from(activities)
.where(eq(activities.ownerId, ownerId))
.orderBy(desc(activities.createdAt));
const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}
export async function linkActivityToRoute(activityId: string, routeId: string, _ownerId: string) {
@ -106,3 +112,34 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin
return routeId;
}
async function getActivityGeojson(id: string): Promise<string | null> {
try {
const db = getDb();
const result = await db.execute(
sql`SELECT ST_AsGeoJSON(geom) as geojson FROM journal.activities WHERE id = ${id} AND geom IS NOT NULL`,
);
const row = (result as unknown as Array<{ geojson: string }>)[0];
return row?.geojson ?? null;
} catch {
return null;
}
}
async function getSimplifiedActivityGeojsonBatch(ids: string[]): Promise<Map<string, string>> {
const map = new Map<string, string>();
if (ids.length === 0) return map;
try {
const db = getDb();
await Promise.all(ids.map(async (id) => {
const result = await db.execute(
sql`SELECT ST_AsGeoJSON(ST_Simplify(geom, 0.001)) as geojson FROM journal.activities WHERE id = ${id} AND geom IS NOT NULL`,
);
const row = (result as unknown as Array<{ geojson: string }>)[0];
if (row?.geojson) map.set(id, row.geojson);
}));
} catch {
// Fallback: no geojson
}
return map;
}

View file

@ -60,7 +60,9 @@ export async function createRoute(ownerId: string, input: RouteInput) {
export async function getRoute(id: string) {
const db = getDb();
const [route] = await db.select().from(routes).where(eq(routes.id, id));
return route ?? null;
if (!route) return null;
const geojson = await getGeojson("routes", id);
return { ...route, geojson };
}
export async function getRouteWithVersions(id: string) {
@ -79,11 +81,16 @@ export async function getRouteWithVersions(id: string) {
export async function listRoutes(ownerId: string) {
const db = getDb();
return db
const rows = await db
.select()
.from(routes)
.where(eq(routes.ownerId, ownerId))
.orderBy(desc(routes.updatedAt));
// Batch-fetch simplified GeoJSON for list thumbnails
const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedGeojsonBatch(ids) : new Map();
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}
export async function updateRoute(
@ -170,3 +177,35 @@ async function setGeomFromGpx(id: string, table: "routes" | "activities", gpxStr
}
export { setGeomFromGpx };
async function getGeojson(table: "routes" | "activities", id: string): Promise<string | null> {
try {
const db = getDb();
const result = await db.execute(
sql`SELECT ST_AsGeoJSON(geom) as geojson FROM ${sql.identifier("journal")}.${sql.identifier(table)} WHERE id = ${id} AND geom IS NOT NULL`,
);
const row = (result as unknown as Array<{ geojson: string }>)[0];
return row?.geojson ?? null;
} catch {
return null;
}
}
async function getSimplifiedGeojsonBatch(ids: string[]): Promise<Map<string, string>> {
const map = new Map<string, string>();
if (ids.length === 0) return map;
try {
const db = getDb();
// Fetch individually — Drizzle's sql template doesn't handle array params well with ANY()
await Promise.all(ids.map(async (id) => {
const result = await db.execute(
sql`SELECT ST_AsGeoJSON(ST_Simplify(geom, 0.001)) as geojson FROM journal.routes WHERE id = ${id} AND geom IS NOT NULL`,
);
const row = (result as unknown as Array<{ geojson: string }>)[0];
if (row?.geojson) map.set(id, row.geojson);
}));
} catch {
// Fallback: no geojson
}
return map;
}

View file

@ -1,3 +1,4 @@
import { Suspense, lazy } from "react";
import { data, redirect } from "react-router";
import type { Route } from "./+types/activities.$id";
import { getSessionUser } from "~/lib/auth.server";
@ -5,6 +6,10 @@ import { getActivity, linkActivityToRoute, createRouteFromActivity } from "~/lib
import { listRoutes } from "~/lib/routes.server";
import { ClientDate } from "~/components/ClientDate";
const RouteMapThumbnail = lazy(() =>
import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })),
);
export async function loader({ params, request }: Route.LoaderArgs) {
const activity = await getActivity(params.id);
if (!activity) throw data({ error: "Activity not found" }, { status: 404 });
@ -25,6 +30,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
duration: activity.duration,
routeId: activity.routeId,
hasGpx: !!activity.gpx,
geojson: activity.geojson ?? null,
startedAt: activity.startedAt?.toISOString() ?? null,
createdAt: activity.createdAt.toISOString(),
},
@ -99,6 +105,14 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
)}
</div>
{activity.geojson && (
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
<Suspense fallback={<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">Loading map...</div>}>
<RouteMapThumbnail geojson={activity.geojson} interactive className="h-full w-full" />
</Suspense>
</div>
)}
{activity.routeId && (
<div className="mt-6">
<a href={`/routes/${activity.routeId}`} className="text-blue-600 hover:underline">

View file

@ -1,9 +1,15 @@
import { data, redirect } from "react-router";
import { Suspense, lazy } from "react";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/activities._index";
import { getSessionUser } from "~/lib/auth.server";
import { listActivities } from "~/lib/activities.server";
import { ClientDate } from "~/components/ClientDate";
const RouteMapThumbnail = lazy(() =>
import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })),
);
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) return redirect("/auth/login");
@ -18,6 +24,7 @@ export async function loader({ request }: Route.LoaderArgs) {
duration: a.duration,
startedAt: a.startedAt?.toISOString() ?? null,
createdAt: a.createdAt.toISOString(),
geojson: a.geojson ?? null,
})),
});
}
@ -28,6 +35,7 @@ export function meta(_args: Route.MetaArgs) {
export default function ActivitiesListPage({ loaderData }: Route.ComponentProps) {
const { activities } = loaderData;
const { t } = useTranslation("journal");
return (
<div className="mx-auto max-w-4xl px-4 py-8">
@ -46,26 +54,41 @@ export default function ActivitiesListPage({ loaderData }: Route.ComponentProps)
No activities yet. Record your first adventure!
</p>
) : (
<ul className="mt-6 divide-y divide-gray-200">
<ul className="mt-6 space-y-4">
{activities.map((activity) => (
<li key={activity.id}>
<a
href={`/activities/${activity.id}`}
className="block py-4 hover:bg-gray-50"
className="block rounded-lg border border-gray-200 p-4 hover:bg-gray-50"
>
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-gray-900">{activity.name}</h2>
<span className="text-sm text-gray-500">
<ClientDate iso={activity.startedAt ?? activity.createdAt} />
</span>
</div>
<div className="mt-1 flex gap-4 text-sm text-gray-500">
{activity.distance != null && (
<span>{(activity.distance / 1000).toFixed(1)} km</span>
)}
{activity.elevationGain != null && (
<span> {activity.elevationGain} m</span>
)}
<div className="flex gap-4">
<div className="w-48 shrink-0">
{activity.geojson ? (
<Suspense fallback={<div className="h-36 w-full animate-pulse rounded bg-gray-100" />}>
<RouteMapThumbnail geojson={activity.geojson} />
</Suspense>
) : (
<div className="flex h-36 w-full items-center justify-center rounded bg-gray-100 text-xs text-gray-400">
{t("routes.noMapPreview")}
</div>
)}
</div>
<div className="flex flex-1 flex-col justify-between">
<div>
<h2 className="text-lg font-medium text-gray-900">{activity.name}</h2>
<div className="mt-1 flex gap-4 text-sm text-gray-500">
{activity.distance != null && (
<span>{(activity.distance / 1000).toFixed(1)} km</span>
)}
{activity.elevationGain != null && (
<span> {activity.elevationGain} m</span>
)}
</div>
</div>
<span className="text-sm text-gray-400">
<ClientDate iso={activity.startedAt ?? activity.createdAt} />
</span>
</div>
</div>
</a>
</li>

View file

@ -1,15 +1,23 @@
import { useState, useCallback } from "react";
import { useState, useCallback, Suspense, lazy } from "react";
import { data, redirect } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/routes.$id";
import { getSessionUser } from "~/lib/auth.server";
import { getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
import { ClientDate } from "~/components/ClientDate";
const RouteMapThumbnail = lazy(() =>
import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })),
);
export async function loader({ params, request }: Route.LoaderArgs) {
const route = await getRouteWithVersions(params.id);
if (!route) throw data({ error: "Route not found" }, { status: 404 });
const [routeWithVersions, routeWithGeojson] = await Promise.all([
getRouteWithVersions(params.id),
getRoute(params.id),
]);
if (!routeWithVersions) throw data({ error: "Route not found" }, { status: 404 });
const route = routeWithVersions;
const user = await getSessionUser(request);
const isOwner = user?.id === route.ownerId;
@ -24,6 +32,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
elevationLoss: route.elevationLoss,
routingProfile: route.routingProfile,
hasGpx: !!route.gpx,
geojson: routeWithGeojson?.geojson ?? null,
createdAt: route.createdAt.toISOString(),
updatedAt: route.updatedAt.toISOString(),
},
@ -150,6 +159,14 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
)}
</div>
{route.geojson && (
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
<Suspense fallback={<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">Loading map...</div>}>
<RouteMapThumbnail geojson={route.geojson} interactive className="h-full w-full" />
</Suspense>
</div>
)}
{versions.length > 0 && (
<div className="mt-8">
<h2 className="text-lg font-semibold text-gray-900">Version History</h2>

View file

@ -1,10 +1,15 @@
import { data, redirect } from "react-router";
import { Suspense, lazy } from "react";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/routes._index";
import { getSessionUser } from "~/lib/auth.server";
import { listRoutes } from "~/lib/routes.server";
import { ClientDate } from "~/components/ClientDate";
const RouteMapThumbnail = lazy(() =>
import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })),
);
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) return redirect("/auth/login");
@ -17,6 +22,7 @@ export async function loader({ request }: Route.LoaderArgs) {
distance: r.distance,
elevationGain: r.elevationGain,
updatedAt: r.updatedAt.toISOString(),
geojson: r.geojson ?? null,
})),
});
}
@ -46,26 +52,41 @@ export default function RoutesListPage({ loaderData }: Route.ComponentProps) {
{t("routes.noRoutesYet")}
</p>
) : (
<ul className="mt-6 divide-y divide-gray-200">
<ul className="mt-6 space-y-4">
{routes.map((route) => (
<li key={route.id}>
<a
href={`/routes/${route.id}`}
className="block py-4 hover:bg-gray-50"
className="block rounded-lg border border-gray-200 p-4 hover:bg-gray-50"
>
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-gray-900">{route.name}</h2>
<span className="text-sm text-gray-500">
<ClientDate iso={route.updatedAt} />
</span>
</div>
<div className="mt-1 flex gap-4 text-sm text-gray-500">
{route.distance != null && (
<span>{(route.distance / 1000).toFixed(1)} km</span>
)}
{route.elevationGain != null && (
<span> {route.elevationGain} m</span>
)}
<div className="flex gap-4">
<div className="w-48 shrink-0">
{route.geojson ? (
<Suspense fallback={<div className="h-36 w-full animate-pulse rounded bg-gray-100" />}>
<RouteMapThumbnail geojson={route.geojson} />
</Suspense>
) : (
<div className="flex h-36 w-full items-center justify-center rounded bg-gray-100 text-xs text-gray-400">
{t("routes.noMapPreview")}
</div>
)}
</div>
<div className="flex flex-1 flex-col justify-between">
<div>
<h2 className="text-lg font-medium text-gray-900">{route.name}</h2>
<div className="mt-1 flex gap-4 text-sm text-gray-500">
{route.distance != null && (
<span>{(route.distance / 1000).toFixed(1)} km</span>
)}
{route.elevationGain != null && (
<span> {route.elevationGain} m</span>
)}
</div>
</div>
<span className="text-sm text-gray-400">
<ClientDate iso={route.updatedAt} />
</span>
</div>
</div>
</a>
</li>