Implements the public-content-visibility OpenSpec change. Adds the
smallest social surface that lets us demo the product to logged-out
visitors without user signup.
Schema:
- `visibility text NOT NULL DEFAULT 'private'` on routes + activities.
- Shared Visibility type exported from the schema module.
Access:
- New canView(content, viewer, { asDirectLink }) helper in auth.server.ts
centralises the rule: public → anyone; unlisted → anyone on direct
link; private → owner only.
- routes.$id and activities.$id loaders return 404 (not 403) when
canView rejects, so existence of private content isn't leaked.
- Detail pages emit Open Graph + Twitter Card meta on public/unlisted
content only.
Editing:
- Visibility <select> on routes/:id/edit with owner-only access.
- Activity detail page gets a small visibility form + set-visibility
action intent (no separate activity-edit page needed).
- EN + DE i18n under routes.visibility.* and activities.visibility.*.
Listings:
- Listing helpers listPublicRoutesForOwner / listPublicActivitiesForOwner
for cross-user queries. Existing owner-scoped listRoutes/listActivities
stay — owners see their own content regardless of visibility.
Public profile:
- /users/:username is now truly public. Renders the user's public
routes + activities, 404s when no public content exists AND viewer
isn't the owner (prevents account enumeration).
- Owner sees a short "this is your profile" note linking to settings.
- Open Graph meta (og:type=profile) for shareable preview.
Privacy manifest:
- Added a bullet noting public content is world-visible on profile
and indexable by search engines.
- Bumped PRIVACY_LAST_UPDATED to 2026-04-20 + rendered legal-archive
snapshot.
Tests:
- 13 unit tests for canView covering the full matrix.
- 6 E2E tests in e2e/public-content.test.ts covering:
- Private route → 404 for logged-out visitor
- Public route → reachable + OG tags present (og:title, og:type,
og:site_name)
- Owner still sees own private content
- Profile 404 when no public content
- Profile renders when at least one public route exists
- Unlisted route reachable via direct URL but hidden from profile
- Test file runs serially (describe.configure mode=serial) to avoid
WebAuthn virtual-authenticator races under Playwright's default
parallel workers.
- New public-content Playwright project added to config.
Rollout safety: every existing row in prod keeps visibility='private'
by default — nothing becomes visible to outsiders until an owner
explicitly opts in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
327 lines
13 KiB
TypeScript
327 lines
13 KiB
TypeScript
import { useState, useCallback } from "react";
|
||
import { data, redirect } from "react-router";
|
||
import { useTranslation } from "react-i18next";
|
||
import type { Route } from "./+types/routes.$id";
|
||
import { canView, getSessionUser } from "~/lib/auth.server";
|
||
import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
|
||
import { ClientDate } from "~/components/ClientDate";
|
||
import { ClientMap } from "~/components/ClientMap";
|
||
|
||
|
||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||
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;
|
||
|
||
// Visibility gate: public always renders, unlisted renders on direct link,
|
||
// private requires ownership. Return 404 (not 403) to avoid leaking existence.
|
||
if (!canView(route, user, { asDirectLink: true })) {
|
||
throw data({ error: "Route not found" }, { status: 404 });
|
||
}
|
||
|
||
// Compute per-day stats if route has day breaks and GPX
|
||
let dayStats: Array<{ dayNumber: number; startName?: string; endName?: string; distance: number; ascent: number; descent: number }> = [];
|
||
if (route.dayBreaks && route.dayBreaks.length > 0 && route.gpx) {
|
||
try {
|
||
const { computeDays } = await import("@trails-cool/gpx");
|
||
const { parseGpxAsync } = await import("@trails-cool/gpx");
|
||
const gpxData = await parseGpxAsync(route.gpx);
|
||
dayStats = computeDays(gpxData.waypoints, gpxData.tracks);
|
||
} catch {
|
||
// Fall back to no day stats
|
||
}
|
||
}
|
||
|
||
return data({
|
||
route: {
|
||
id: route.id,
|
||
name: route.name,
|
||
description: route.description,
|
||
distance: route.distance,
|
||
elevationGain: route.elevationGain,
|
||
elevationLoss: route.elevationLoss,
|
||
routingProfile: route.routingProfile,
|
||
hasGpx: !!route.gpx,
|
||
dayBreaks: route.dayBreaks ?? [],
|
||
geojson: routeWithGeojson?.geojson ?? null,
|
||
visibility: route.visibility,
|
||
createdAt: route.createdAt.toISOString(),
|
||
updatedAt: route.updatedAt.toISOString(),
|
||
},
|
||
dayStats,
|
||
versions: route.versions.map((v) => ({
|
||
version: v.version,
|
||
changeDescription: v.changeDescription,
|
||
createdAt: v.createdAt.toISOString(),
|
||
})),
|
||
isOwner,
|
||
});
|
||
}
|
||
|
||
export async function action({ params, request }: Route.ActionArgs) {
|
||
const user = await getSessionUser(request);
|
||
if (!user) return redirect("/auth/login");
|
||
|
||
const formData = await request.formData();
|
||
const intent = formData.get("intent");
|
||
|
||
if (intent === "delete") {
|
||
await deleteRoute(params.id, user.id);
|
||
return redirect("/routes");
|
||
}
|
||
|
||
if (intent === "update") {
|
||
const name = formData.get("name") as string;
|
||
const description = formData.get("description") as string;
|
||
const gpxFile = formData.get("gpx") as File | null;
|
||
|
||
const input: Record<string, unknown> = {};
|
||
if (name) input.name = name;
|
||
if (description !== null) input.description = description;
|
||
if (gpxFile && gpxFile.size > 0) {
|
||
input.gpx = await gpxFile.text();
|
||
}
|
||
|
||
await updateRoute(params.id, user.id, input as { name?: string; description?: string; gpx?: string });
|
||
return redirect(`/routes/${params.id}`);
|
||
}
|
||
|
||
return data({ error: "Unknown action" }, { status: 400 });
|
||
}
|
||
|
||
export function meta({ data: loaderData }: Route.MetaArgs) {
|
||
const route = (loaderData as { route?: { name: string; description: string | null; visibility: string } })?.route;
|
||
const name = route?.name ?? "Route";
|
||
const title = `${name} — trails.cool`;
|
||
const tags: Array<Record<string, string>> = [{ title }];
|
||
|
||
// Only emit Open Graph / Twitter Card tags for publicly-reachable content.
|
||
// Private routes are gated before meta is even called, but belt-and-braces.
|
||
if (route && (route.visibility === "public" || route.visibility === "unlisted")) {
|
||
const description = (route.description && route.description.length > 0)
|
||
? route.description.slice(0, 280)
|
||
: `A route on trails.cool`;
|
||
tags.push(
|
||
{ property: "og:title", content: title },
|
||
{ property: "og:description", content: description },
|
||
{ property: "og:type", content: "article" },
|
||
{ property: "og:site_name", content: "trails.cool" },
|
||
{ name: "twitter:card", content: "summary" },
|
||
{ name: "twitter:title", content: title },
|
||
{ name: "twitter:description", content: description },
|
||
);
|
||
}
|
||
|
||
return tags;
|
||
}
|
||
|
||
export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
||
const { route, dayStats, versions, isOwner } = loaderData;
|
||
const { t } = useTranslation("journal");
|
||
const [editLoading, setEditLoading] = useState(false);
|
||
const [highlightedDay, setHighlightedDay] = useState<number | null>(null);
|
||
|
||
// A route is "empty" when it has no geometry and no computed distance —
|
||
// i.e. nobody has planned any waypoints yet. Surfaced as a dedicated
|
||
// empty-state below so the page isn't a dead end.
|
||
const isEmpty = !route.geojson && route.distance == null;
|
||
|
||
const handleEditInPlanner = useCallback(async () => {
|
||
setEditLoading(true);
|
||
try {
|
||
const resp = await fetch(`/api/routes/${route.id}/edit-in-planner`, { method: "POST" });
|
||
const result = await resp.json();
|
||
if (result.url) {
|
||
window.location.href = result.url;
|
||
}
|
||
} finally {
|
||
setEditLoading(false);
|
||
}
|
||
}, [route.id]);
|
||
|
||
return (
|
||
<div className="mx-auto max-w-4xl px-4 py-8">
|
||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900">{route.name}</h1>
|
||
{route.description && (
|
||
<p className="mt-2 text-gray-600">{route.description}</p>
|
||
)}
|
||
</div>
|
||
{isOwner && !isEmpty && (
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
onClick={handleEditInPlanner}
|
||
disabled={editLoading}
|
||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||
>
|
||
{editLoading ? t("routes.opening") : t("routes.editInPlanner")}
|
||
</button>
|
||
<a
|
||
href={`/routes/${route.id}/edit`}
|
||
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||
>
|
||
{t("routes.edit")}
|
||
</a>
|
||
{route.hasGpx && (
|
||
<a
|
||
href={`/api/routes/${route.id}/gpx`}
|
||
download
|
||
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||
>
|
||
{t("routes.exportGpx")}
|
||
</a>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||
{route.distance != null && (
|
||
<div className="rounded-md bg-gray-50 p-4">
|
||
<p className="text-2xl font-bold text-gray-900">
|
||
{(route.distance / 1000).toFixed(1)} km
|
||
</p>
|
||
<p className="text-sm text-gray-500">{t("routes.distance")}</p>
|
||
</div>
|
||
)}
|
||
{route.elevationGain != null && (
|
||
<div className="rounded-md bg-gray-50 p-4">
|
||
<p className="text-2xl font-bold text-gray-900">↑ {route.elevationGain} m</p>
|
||
<p className="text-sm text-gray-500">Ascent</p>
|
||
</div>
|
||
)}
|
||
{route.elevationLoss != null && (
|
||
<div className="rounded-md bg-gray-50 p-4">
|
||
<p className="text-2xl font-bold text-gray-900">↓ {route.elevationLoss} m</p>
|
||
<p className="text-sm text-gray-500">Descent</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{dayStats.length > 1 && (
|
||
<div className="mt-6">
|
||
<h2 className="text-lg font-semibold text-gray-900">{t("routes.dayBreakdown")}</h2>
|
||
<div className="mt-3 divide-y divide-gray-200 rounded-md border border-gray-200">
|
||
{dayStats.map((day) => (
|
||
<div
|
||
key={day.dayNumber}
|
||
className="flex items-center gap-4 px-4 py-3 hover:bg-gray-50 transition-colors"
|
||
onMouseEnter={() => setHighlightedDay(day.dayNumber)}
|
||
onMouseLeave={() => setHighlightedDay(null)}
|
||
>
|
||
<span className="text-sm font-medium text-gray-700">
|
||
{t("routes.dayLabel", { n: day.dayNumber })}
|
||
</span>
|
||
<span className="min-w-0 flex-1 truncate text-sm text-gray-500">
|
||
{day.startName && day.endName
|
||
? `${day.startName} → ${day.endName}`
|
||
: day.startName || day.endName || ""}
|
||
</span>
|
||
<span className="text-sm tabular-nums text-gray-700">
|
||
{(day.distance / 1000).toFixed(1)} km
|
||
</span>
|
||
<span className="text-xs text-gray-500">
|
||
↑{day.ascent} m
|
||
</span>
|
||
<span className="text-xs text-gray-500">
|
||
↓{day.descent} m
|
||
</span>
|
||
{route.hasGpx && (
|
||
<a
|
||
href={`/api/routes/${route.id}/gpx?day=${day.dayNumber}`}
|
||
download
|
||
className="shrink-0 rounded border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||
title={t("routes.exportGpx")}
|
||
>
|
||
GPX
|
||
</a>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{route.geojson && (
|
||
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
|
||
<ClientMap geojson={route.geojson} interactive className="h-full w-full" dayBreaks={route.dayBreaks.length > 0 ? route.dayBreaks : undefined} highlightedDay={highlightedDay} />
|
||
</div>
|
||
)}
|
||
|
||
{isEmpty && (
|
||
<div className="mt-6 flex flex-col items-center rounded-lg border border-dashed border-gray-300 bg-gray-50 px-6 py-12 text-center">
|
||
<div className="text-4xl" aria-hidden="true">🗺️</div>
|
||
<h2 className="mt-4 text-lg font-semibold text-gray-900">
|
||
{t("routes.empty.heading")}
|
||
</h2>
|
||
<p className="mt-2 max-w-md text-sm text-gray-600">
|
||
{isOwner ? t("routes.empty.bodyOwner") : t("routes.empty.bodyViewer")}
|
||
</p>
|
||
{isOwner && (
|
||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||
<button
|
||
onClick={handleEditInPlanner}
|
||
disabled={editLoading}
|
||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||
>
|
||
{editLoading ? t("routes.opening") : t("routes.empty.openInPlanner")}
|
||
</button>
|
||
<a
|
||
href={`/routes/${route.id}/edit`}
|
||
className="text-sm text-gray-600 underline hover:text-gray-900"
|
||
>
|
||
{t("routes.empty.uploadGpx")}
|
||
</a>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{versions.length > 0 && (
|
||
<div className="mt-8">
|
||
<h2 className="text-lg font-semibold text-gray-900">Version History</h2>
|
||
<ul className="mt-3 divide-y divide-gray-200">
|
||
{versions.map((v) => (
|
||
<li key={v.version} className="py-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="font-medium text-gray-700">v{v.version}</span>
|
||
<span className="text-sm text-gray-500">
|
||
<ClientDate iso={v.createdAt} />
|
||
</span>
|
||
</div>
|
||
{v.changeDescription && (
|
||
<p className="text-sm text-gray-500">{v.changeDescription}</p>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{isOwner && (
|
||
<div className="mt-8 border-t border-gray-200 pt-6">
|
||
<form method="post" onSubmit={(e) => {
|
||
if (!confirm("Are you sure you want to delete this route?")) {
|
||
e.preventDefault();
|
||
}
|
||
}}>
|
||
<input type="hidden" name="intent" value="delete" />
|
||
<button
|
||
type="submit"
|
||
className="rounded-md bg-red-50 px-4 py-2 text-sm text-red-700 hover:bg-red-100"
|
||
>
|
||
{t("routes.delete")}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|