import { useMemo, useState } from "react"; import { data } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/activities.$id"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; import { SportBadge } from "~/components/SportBadge"; import { StatRow } from "~/components/StatRow"; import { ElevationProfile } from "~/components/ElevationProfile"; import { SurfaceBreakdown } from "~/components/SurfaceBreakdown"; import { useSurfaceBackfillUpdates } from "~/hooks/useSurfaceBackfill"; import { activityStatItems } from "~/lib/stats"; import { loadActivityDetail, activityDetailAction } from "./activities.$id.server"; import { federationEnabled, wantsActivityJson, handleFederationRequest, } from "~/lib/federation.server"; // Content negotiation: this URL doubles as the ActivityPub Note IRI for // the activity (the id our outbox/push deliveries emit). AP clients get // the Note object from Fedify's object dispatcher; browsers fall through // to the HTML page. Same pattern as the actor route (users.$username). export const middleware: Route.MiddlewareFunction[] = [ async ({ request }, next) => { if (federationEnabled() && wantsActivityJson(request)) { return handleFederationRequest(request); } return next(); }, ]; export async function loader({ params, request }: Route.LoaderArgs) { return data(await loadActivityDetail(request, params.id)); } export async function action({ params, request }: Route.ActionArgs) { return await activityDetailAction(request, params.id); } 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"); // Live-update when the async surface backfill lands (owner-only). useSurfaceBackfillUpdates("activity", activity.id, isOwner && !activity.surfaceBreakdown); // Elevation profile ↔ map sync via a shared "active" sample index. const elevation = activity.elevation; const [activeIndex, setActiveIndex] = useState(null); const [centerOn, setCenterOn] = useState<{ lat: number; lng: number; v: number } | null>(null); const hoverSeries = useMemo( () => elevation.map((s) => [s.lat, s.lng] as [number, number]), [elevation], ); const activePoint = activeIndex != null && elevation[activeIndex] ? { lat: elevation[activeIndex]!.lat, lng: elevation[activeIndex]!.lng } : null; const seek = (i: number) => { const s = elevation[i]; if (s) setCenterOn((prev) => ({ lat: s.lat, lng: s.lng, v: (prev?.v ?? 0) + 1 })); setActiveIndex(i); }; return (

{activity.name}

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

{activity.description}

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