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>
This commit is contained in:
Ullrich Schäfer 2026-04-19 09:11:39 +02:00
parent 74dca52ecc
commit 5905cdadea
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
17 changed files with 1079 additions and 61 deletions

View file

@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { eq, desc, and, sql } from "drizzle-orm";
import { getDb } from "./db.ts";
import { activities, routes, syncImports } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal";
import { parseGpxAsync } from "@trails-cool/gpx";
import { setGeomFromGpx } from "./routes.server.ts";
@ -13,6 +14,21 @@ export interface ActivityInput {
distance?: number | null;
duration?: number | null;
startedAt?: Date | null;
visibility?: Visibility;
}
export async function updateActivityVisibility(
id: string,
ownerId: string,
visibility: Visibility,
): Promise<boolean> {
const db = getDb();
const result = await db
.update(activities)
.set({ visibility })
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)))
.returning({ id: activities.id });
return result.length > 0;
}
export async function createActivity(ownerId: string, input: ActivityInput) {
@ -100,6 +116,24 @@ export async function listActivities(ownerId: string) {
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}
/**
* List the *public* activities of a given owner. Used for cross-user
* listings (the public profile page); never includes `unlisted` or
* `private` content.
*/
export async function listPublicActivitiesForOwner(ownerId: string) {
const db = getDb();
const rows = await db
.select()
.from(activities)
.where(and(eq(activities.ownerId, ownerId), eq(activities.visibility, "public")))
.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) {
const db = getDb();
await db

View file

@ -13,6 +13,7 @@ import type {
} from "@simplewebauthn/types";
import { getDb } from "./db.ts";
import { users, credentials, magicTokens } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal";
const RP_NAME = "trails.cool";
const RP_ID = process.env.DOMAIN ?? "localhost";
@ -410,6 +411,45 @@ export const sessionStorage = createCookieSessionStorage({
},
});
/**
* A row that carries the minimum a visibility check needs.
*/
export interface Viewable {
ownerId: string;
visibility: Visibility;
}
/**
* The caller's identity. `null` represents a logged-out visitor.
*/
export interface Viewer {
id: string;
}
/**
* Decide whether a viewer may see a piece of content.
*
* - `public` content is viewable by anyone.
* - `unlisted` content is viewable only on direct-link access listings
* should omit it. Callers rendering a detail page pass `asDirectLink:
* true`; listings default to `false`.
* - `private` content is viewable only by the owner.
*
* Centralised here so detail loaders and listing queries use the same
* rule. Intentionally does not throw; callers handle the `false` case
* (usually by returning HTTP 404 rather than 403 to avoid leaking
* existence).
*/
export function canView(
content: Viewable,
viewer: Viewer | null,
{ asDirectLink = false }: { asDirectLink?: boolean } = {},
): boolean {
if (content.visibility === "public") return true;
if (content.visibility === "unlisted" && asDirectLink) return true;
return viewer?.id === content.ownerId;
}
/**
* Record the user's acceptance of the current Terms version. Updates both
* `terms_accepted_at` (NOW) and `terms_version`. Used when an existing user

View file

@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import { canView, type Viewable, type Viewer } from "./auth.server.ts";
const owner: Viewer = { id: "owner-id" };
const other: Viewer = { id: "other-id" };
function row(visibility: "private" | "unlisted" | "public"): Viewable {
return { ownerId: owner.id, visibility };
}
describe("canView", () => {
describe("public content", () => {
it("is viewable by the owner", () => {
expect(canView(row("public"), owner)).toBe(true);
});
it("is viewable by another logged-in user", () => {
expect(canView(row("public"), other)).toBe(true);
});
it("is viewable by a logged-out visitor", () => {
expect(canView(row("public"), null)).toBe(true);
});
it("ignores asDirectLink (always viewable)", () => {
expect(canView(row("public"), null, { asDirectLink: false })).toBe(true);
expect(canView(row("public"), null, { asDirectLink: true })).toBe(true);
});
});
describe("unlisted content", () => {
it("is viewable by the owner", () => {
expect(canView(row("unlisted"), owner)).toBe(true);
});
it("is viewable by another user on a direct link", () => {
expect(canView(row("unlisted"), other, { asDirectLink: true })).toBe(true);
});
it("is viewable by a logged-out visitor on a direct link", () => {
expect(canView(row("unlisted"), null, { asDirectLink: true })).toBe(true);
});
it("is NOT visible in a listing to another user", () => {
expect(canView(row("unlisted"), other, { asDirectLink: false })).toBe(false);
});
it("is NOT visible in a listing to a logged-out visitor", () => {
expect(canView(row("unlisted"), null, { asDirectLink: false })).toBe(false);
});
it("defaults to the listing rule when asDirectLink is omitted", () => {
expect(canView(row("unlisted"), other)).toBe(false);
expect(canView(row("unlisted"), null)).toBe(false);
});
});
describe("private content", () => {
it("is viewable by the owner", () => {
expect(canView(row("private"), owner)).toBe(true);
expect(canView(row("private"), owner, { asDirectLink: true })).toBe(true);
});
it("is NOT viewable by another user", () => {
expect(canView(row("private"), other)).toBe(false);
expect(canView(row("private"), other, { asDirectLink: true })).toBe(false);
});
it("is NOT viewable by a logged-out visitor", () => {
expect(canView(row("private"), null)).toBe(false);
expect(canView(row("private"), null, { asDirectLink: true })).toBe(false);
});
});
});

View file

@ -16,4 +16,4 @@ export const TERMS_VERSION = "2026-04-19";
* require re-acceptance (the policy is informational, not contract), so this
* is display-only not persisted.
*/
export const PRIVACY_LAST_UPDATED = "2026-04-19";
export const PRIVACY_LAST_UPDATED = "2026-04-20";

View file

@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { eq, desc, and } from "drizzle-orm";
import { getDb } from "./db.ts";
import { routes, routeVersions } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal";
import { parseGpxAsync } from "@trails-cool/gpx";
import { sql } from "drizzle-orm";
@ -10,6 +11,7 @@ export interface RouteInput {
description?: string;
gpx?: string;
routingProfile?: string;
visibility?: Visibility;
}
export async function createRoute(ownerId: string, input: RouteInput) {
@ -96,6 +98,23 @@ export async function listRoutes(ownerId: string) {
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}
/**
* List the *public* routes of a given owner. Used for cross-user listings
* (the public profile page); never includes `unlisted` or `private` content.
*/
export async function listPublicRoutesForOwner(ownerId: string) {
const db = getDb();
const rows = await db
.select()
.from(routes)
.where(and(eq(routes.ownerId, ownerId), eq(routes.visibility, "public")))
.orderBy(desc(routes.updatedAt));
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(
id: string,
ownerId: string,
@ -106,6 +125,7 @@ export async function updateRoute(
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (input.name !== undefined) updateData.name = input.name;
if (input.description !== undefined) updateData.description = input.description;
if (input.visibility !== undefined) updateData.visibility = input.visibility;
if (input.gpx) {
updateData.gpx = input.gpx;

View file

@ -1,12 +1,15 @@
import { data, redirect } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/activities.$id";
import { getSessionUser } from "~/lib/auth.server";
import { getActivity, deleteActivity, linkActivityToRoute, createRouteFromActivity } from "~/lib/activities.server";
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<Visibility>(["private", "unlisted", "public"]);
export async function loader({ params, request }: Route.LoaderArgs) {
const activity = await getActivity(params.id);
@ -15,6 +18,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
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({
@ -30,6 +39,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
hasGpx: !!activity.gpx,
geojson: activity.geojson ?? null,
startedAt: activity.startedAt?.toISOString() ?? null,
visibility: activity.visibility,
createdAt: activity.createdAt.toISOString(),
importSource: activity.importSource,
},
@ -66,12 +76,41 @@ export async function action({ params, request }: Route.ActionArgs) {
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 name = (loaderData as { activity: { name: string } })?.activity?.name ?? "Activity";
return [{ title: `${name} — trails.cool` }];
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<Record<string, string>> = [{ 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) {
@ -177,6 +216,35 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
</div>
)}
{isOwner && (
<div className="mt-8 border-t border-gray-200 pt-6">
<form method="post" className="flex items-end gap-3">
<input type="hidden" name="intent" value="set-visibility" />
<div>
<label htmlFor="visibility" className="block text-sm font-medium text-gray-700">
{t("activities.visibility.label")}
</label>
<select
id="visibility"
name="visibility"
defaultValue={activity.visibility}
className="mt-1 block rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="private">{t("activities.visibility.private")}</option>
<option value="unlisted">{t("activities.visibility.unlisted")}</option>
<option value="public">{t("activities.visibility.public")}</option>
</select>
</div>
<button
type="submit"
className="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
{t("routes.saveChanges")}
</button>
</form>
</div>
)}
{isOwner && (
<div className="mt-8 border-t border-gray-200 pt-6">
<form method="post" onSubmit={(e) => { if (!confirm(t("activities.deleteConfirm"))) e.preventDefault(); }}>

View file

@ -375,6 +375,14 @@ export default function PrivacyPage() {
<li>Routes: GPX, geometry, title, description</li>
<li>Activities: title, description, date, linked route</li>
<li>GPX / JSON export available per object and overall</li>
<li>
Routes and activities each carry a visibility setting
(<code>private</code> / <code>unlisted</code> / <code>public</code>)
that defaults to <code>private</code>. Content you explicitly mark
<code> public</code> is visible to anyone on the internet,
including on your public profile at <code>/users/&lt;you&gt;</code>
and on search engines that index those pages.
</li>
</ul>
</section>

View file

@ -1,7 +1,11 @@
import { data, redirect } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/routes.$id.edit";
import { getSessionUser } from "~/lib/auth.server";
import { getRoute, updateRoute } from "~/lib/routes.server";
import type { Visibility } from "@trails-cool/db/schema/journal";
const VISIBILITY_VALUES = new Set<Visibility>(["private", "unlisted", "public"]);
export async function loader({ params, request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
@ -12,7 +16,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
if (route.ownerId !== user.id) throw data({ error: "Not authorized" }, { status: 403 });
return data({
route: { id: route.id, name: route.name, description: route.description },
route: {
id: route.id,
name: route.name,
description: route.description,
visibility: route.visibility,
},
});
}
@ -24,13 +33,17 @@ export async function action({ params, request }: Route.ActionArgs) {
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const gpxFile = formData.get("gpx") as File | null;
const visibilityRaw = formData.get("visibility") as string | null;
const input: { name?: string; description?: string; gpx?: string } = {};
const input: { name?: string; description?: string; gpx?: string; visibility?: Visibility } = {};
if (name) input.name = name;
if (description !== null) input.description = description;
if (gpxFile && gpxFile.size > 0) {
input.gpx = await gpxFile.text();
}
if (visibilityRaw && VISIBILITY_VALUES.has(visibilityRaw as Visibility)) {
input.visibility = visibilityRaw as Visibility;
}
await updateRoute(params.id, user.id, input);
return redirect(`/routes/${params.id}`);
@ -42,6 +55,7 @@ export function meta(_args: Route.MetaArgs) {
export default function EditRoutePage({ loaderData }: Route.ComponentProps) {
const { route } = loaderData;
const { t } = useTranslation("journal");
return (
<div className="mx-auto max-w-2xl px-4 py-8">
@ -75,6 +89,27 @@ export default function EditRoutePage({ loaderData }: Route.ComponentProps) {
/>
</div>
<div>
<label htmlFor="visibility" className="block text-sm font-medium text-gray-700">
{t("routes.visibility.label")}
</label>
<select
id="visibility"
name="visibility"
defaultValue={route.visibility}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="private">{t("routes.visibility.private")}</option>
<option value="unlisted">{t("routes.visibility.unlisted")}</option>
<option value="public">{t("routes.visibility.public")}</option>
</select>
<p className="mt-1 text-xs text-gray-500">
{route.visibility === "private" && t("routes.visibility.privateHelp")}
{route.visibility === "unlisted" && t("routes.visibility.unlistedHelp")}
{route.visibility === "public" && t("routes.visibility.publicHelp")}
</p>
</div>
<div>
<label htmlFor="gpx" className="block text-sm font-medium text-gray-700">
Update GPX (optional creates new version)

View file

@ -2,7 +2,7 @@ import { useState, useCallback } 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 { 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";
@ -19,6 +19,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
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) {
@ -44,6 +50,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
hasGpx: !!route.gpx,
dayBreaks: route.dayBreaks ?? [],
geojson: routeWithGeojson?.geojson ?? null,
visibility: route.visibility,
createdAt: route.createdAt.toISOString(),
updatedAt: route.updatedAt.toISOString(),
},
@ -89,8 +96,29 @@ export async function action({ params, request }: Route.ActionArgs) {
}
export function meta({ data: loaderData }: Route.MetaArgs) {
const name = (loaderData as { route: { name: string } })?.route?.name ?? "Route";
return [{ title: `${name} — trails.cool` }];
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) {

View file

@ -1,9 +1,13 @@
import { data } from "react-router";
import type { Route } from "./+types/users.$username";
import { useTranslation } from "react-i18next";
import { getDb } from "~/lib/db";
import { users } from "@trails-cool/db/schema/journal";
import { eq } from "drizzle-orm";
import { getSessionUser } from "~/lib/auth.server";
import { listPublicRoutesForOwner } from "~/lib/routes.server";
import { listPublicActivitiesForOwner } from "~/lib/activities.server";
import { ClientDate } from "~/components/ClientDate";
export async function loader({ params, request }: Route.LoaderArgs) {
const db = getDb();
@ -13,9 +17,20 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw data({ error: "User not found" }, { status: 404 });
}
const currentUser = await getSessionUser(request);
const [publicRoutes, publicActivities, currentUser] = await Promise.all([
listPublicRoutesForOwner(user.id),
listPublicActivitiesForOwner(user.id),
getSessionUser(request),
]);
const isOwn = currentUser?.id === user.id;
// 404 for users with no public content at all, to prevent account
// enumeration. Owners still see their own profile even when empty.
if (!isOwn && publicRoutes.length === 0 && publicActivities.length === 0) {
throw data({ error: "User not found" }, { status: 404 });
}
return data({
user: {
username: user.username,
@ -24,20 +39,54 @@ export async function loader({ params, request }: Route.LoaderArgs) {
domain: user.domain,
createdAt: user.createdAt.toISOString(),
},
routes: publicRoutes.map((r) => ({
id: r.id,
name: r.name,
description: r.description,
distance: r.distance,
elevationGain: r.elevationGain,
updatedAt: r.updatedAt.toISOString(),
})),
activities: publicActivities.map((a) => ({
id: a.id,
name: a.name,
description: a.description,
distance: a.distance,
duration: a.duration,
startedAt: a.startedAt?.toISOString() ?? null,
createdAt: a.createdAt.toISOString(),
})),
isOwn,
});
}
export function meta({ data: loaderData }: Route.MetaArgs) {
const username = (loaderData as { user: { username: string } })?.user?.username ?? "User";
return [{ title: `@${username} — trails.cool` }];
const user = (loaderData as { user?: { username: string; displayName: string | null; domain: string; bio: string | null } })?.user;
const displayName = user?.displayName ?? user?.username ?? "Profile";
const title = `${displayName} (@${user?.username}) — trails.cool`;
const description = user?.bio && user.bio.length > 0
? user.bio.slice(0, 280)
: `${displayName} on trails.cool`;
if (!user) return [{ title }];
return [
{ title },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:type", content: "profile" },
{ property: "og:site_name", content: "trails.cool" },
{ name: "twitter:card", content: "summary" },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
];
}
export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
const { user, isOwn } = loaderData;
const { user, routes, activities, isOwn } = loaderData;
const { t } = useTranslation("journal");
return (
<div className="mx-auto max-w-2xl px-4 py-8">
<div className="mx-auto max-w-3xl px-4 py-8">
<div className="flex items-start gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-blue-100 text-2xl font-bold text-blue-600">
{user.username[0]?.toUpperCase()}
@ -54,22 +103,77 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
</div>
{isOwn && (
<div className="mt-6 flex gap-2">
<form action="/auth/logout" method="post">
<button
type="submit"
className="rounded bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200"
>
Log out
</button>
</form>
<div className="mt-6 rounded-md border border-blue-100 bg-blue-50 p-3 text-sm text-blue-800">
{t("profile.ownNote")}{" "}
<a href="/settings" className="underline hover:text-blue-900">
{t("profile.goToSettings")}
</a>
</div>
)}
<div className="mt-8">
<h2 className="text-lg font-semibold text-gray-900">Routes</h2>
<p className="mt-2 text-sm text-gray-500">No routes yet.</p>
</div>
<section className="mt-8">
<h2 className="text-lg font-semibold text-gray-900">
{t("routes.title")} ({routes.length})
</h2>
{routes.length === 0 ? (
<p className="mt-2 text-sm text-gray-500">{t("profile.noPublicRoutes")}</p>
) : (
<ul className="mt-3 divide-y divide-gray-200 rounded-md border border-gray-200">
{routes.map((r) => (
<li key={r.id} className="px-4 py-3">
<a href={`/routes/${r.id}`} className="block hover:bg-gray-50">
<div className="flex items-baseline justify-between gap-4">
<span className="font-medium text-gray-900">{r.name}</span>
{r.distance != null && (
<span className="shrink-0 text-sm tabular-nums text-gray-600">
{(r.distance / 1000).toFixed(1)} km
</span>
)}
</div>
{r.description && (
<p className="mt-1 line-clamp-1 text-sm text-gray-500">{r.description}</p>
)}
<p className="mt-1 text-xs text-gray-400">
<ClientDate iso={r.updatedAt} />
</p>
</a>
</li>
))}
</ul>
)}
</section>
<section className="mt-8">
<h2 className="text-lg font-semibold text-gray-900">
{t("activities.title")} ({activities.length})
</h2>
{activities.length === 0 ? (
<p className="mt-2 text-sm text-gray-500">{t("profile.noPublicActivities")}</p>
) : (
<ul className="mt-3 divide-y divide-gray-200 rounded-md border border-gray-200">
{activities.map((a) => (
<li key={a.id} className="px-4 py-3">
<a href={`/activities/${a.id}`} className="block hover:bg-gray-50">
<div className="flex items-baseline justify-between gap-4">
<span className="font-medium text-gray-900">{a.name}</span>
{a.distance != null && (
<span className="shrink-0 text-sm tabular-nums text-gray-600">
{(a.distance / 1000).toFixed(1)} km
</span>
)}
</div>
{a.description && (
<p className="mt-1 line-clamp-1 text-sm text-gray-500">{a.description}</p>
)}
<p className="mt-1 text-xs text-gray-400">
<ClientDate iso={a.startedAt ?? a.createdAt} />
</p>
</a>
</li>
))}
</ul>
)}
</section>
</div>
);
}