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:
parent
3a4af698ca
commit
6aac8bd885
16 changed files with 399 additions and 39 deletions
51
apps/journal/app/components/RouteMapThumbnail.tsx
Normal file
51
apps/journal/app/components/RouteMapThumbnail.tsx
Normal 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 ? '© <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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue