trails/apps/journal/app/routes/routes.$id.tsx
Ullrich Schäfer 5905cdadea
Apply public-content-visibility: visibility flag + public profile
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>
2026-04-19 09:11:39 +02:00

327 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}