import { data, redirect } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/activities.$id"; import { canView, getSessionUser } from "~/lib/auth.server"; import { getActivity, deleteActivity, linkActivityToRoute, createRouteFromActivity, updateActivityVisibility } from "~/lib/activities.server"; import { deleteImportByActivity } from "~/lib/sync/imports.server"; import { listRoutes } from "~/lib/routes.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; import type { Visibility } from "@trails-cool/db/schema/journal"; const VISIBILITY_VALUES = new Set(["private", "unlisted", "public"]); export async function loader({ params, request }: Route.LoaderArgs) { const activity = await getActivity(params.id); if (!activity) throw data({ error: "Activity not found" }, { status: 404 }); const user = await getSessionUser(request); const isOwner = user?.id === activity.ownerId; // Visibility gate — public always, unlisted on direct link, private owner-only. // 404 (not 403) to avoid leaking existence. if (!canView(activity, user, { asDirectLink: true })) { throw data({ error: "Activity not found" }, { status: 404 }); } const userRoutes = isOwner && user ? await listRoutes(user.id) : []; return data({ activity: { id: activity.id, name: activity.name, description: activity.description, distance: activity.distance, elevationGain: activity.elevationGain, elevationLoss: activity.elevationLoss, duration: activity.duration, routeId: activity.routeId, hasGpx: !!activity.gpx, geojson: activity.geojson ?? null, startedAt: activity.startedAt?.toISOString() ?? null, visibility: activity.visibility, createdAt: activity.createdAt.toISOString(), importSource: activity.importSource, }, isOwner, routes: userRoutes.map((r) => ({ id: r.id, name: r.name })), }); } 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 === "link-route") { const routeId = formData.get("routeId") as string; if (routeId) { await linkActivityToRoute(params.id, routeId, user.id); } return redirect(`/activities/${params.id}`); } if (intent === "create-route") { const routeId = await createRouteFromActivity(params.id, user.id); if (routeId) return redirect(`/routes/${routeId}`); return data({ error: "No GPX data to create route from" }, { status: 400 }); } if (intent === "delete") { await deleteImportByActivity(params.id); const deleted = await deleteActivity(params.id, user.id); if (deleted) return redirect("/activities"); return data({ error: "Activity not found" }, { status: 404 }); } if (intent === "set-visibility") { const raw = formData.get("visibility") as string | null; if (!raw || !VISIBILITY_VALUES.has(raw as Visibility)) { return data({ error: "Invalid visibility" }, { status: 400 }); } const ok = await updateActivityVisibility(params.id, user.id, raw as Visibility); if (!ok) return data({ error: "Activity not found" }, { status: 404 }); return redirect(`/activities/${params.id}`); } return data({ error: "Unknown action" }, { status: 400 }); } export function meta({ data: loaderData }: Route.MetaArgs) { const activity = (loaderData as { activity?: { name: string; description: string | null; visibility: string } })?.activity; const name = activity?.name ?? "Activity"; const title = `${name} — trails.cool`; const tags: Array> = [{ title }]; if (activity && (activity.visibility === "public" || activity.visibility === "unlisted")) { const description = (activity.description && activity.description.length > 0) ? activity.description.slice(0, 280) : `An activity 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 ActivityDetailPage({ loaderData }: Route.ComponentProps) { const { activity, isOwner, routes } = loaderData; const { t } = useTranslation("journal"); return (

{activity.name}

{activity.importSource && ( {t("activities.importedFrom", { provider: activity.importSource.provider })} )}
{activity.description && (

{activity.description}

)}
{activity.distance != null && (

{(activity.distance / 1000).toFixed(1)} km

Distance

)} {activity.elevationGain != null && (

↑ {activity.elevationGain} m

Ascent

)} {activity.elevationLoss != null && (

↓ {activity.elevationLoss} m

Descent

)}
{activity.geojson && (
)} {activity.routeId && (
View linked route →
)} {isOwner && !activity.routeId && (
{routes.length > 0 && (
)} {activity.hasGpx && (
)}
)} {isOwner && (
)} {isOwner && (
{ if (!confirm(t("activities.deleteConfirm"))) e.preventDefault(); }}>
)}
); }