Merge branch 'main' into spec-drift/high-severity

This commit is contained in:
Ullrich Schäfer 2026-05-24 11:27:33 +02:00 committed by GitHub
commit dd0098e35c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 2149 additions and 1966 deletions

View file

@ -0,0 +1,95 @@
// Server-only loader/action for /activities/:id. See `home.server.ts`.
import { data, redirect } from "react-router";
import { canView } from "~/lib/auth.server";
import { getSessionUser, requireSessionUser } from "~/lib/auth/session.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 type { Visibility } from "@trails-cool/db/schema/journal";
const VISIBILITY_VALUES = new Set<Visibility>(["private", "unlisted", "public"]);
export async function loadActivityDetail(request: Request, id: string | undefined) {
const activity = await getActivity(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 {
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 activityDetailAction(request: Request, id: string | undefined) {
const user = await requireSessionUser(request);
const activityId = id ?? "";
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(activityId, routeId, user.id);
}
return redirect(`/activities/${activityId}`);
}
if (intent === "create-route") {
const routeId = await createRouteFromActivity(activityId, 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(activityId);
const deleted = await deleteActivity(activityId, 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(activityId, user.id, raw as Visibility);
if (!ok) return data({ error: "Activity not found" }, { status: 404 });
return redirect(`/activities/${activityId}`);
}
return data({ error: "Unknown action" }, { status: 400 });
}

View file

@ -1,92 +1,16 @@
import { data, redirect } from "react-router";
import { data } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/activities.$id";
import { canView } from "~/lib/auth.server";
import { getSessionUser, requireSessionUser } from "~/lib/auth/session.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"]);
import { loadActivityDetail, activityDetailAction } from "./activities.$id.server";
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 })),
});
return data(await loadActivityDetail(request, params.id));
}
export async function action({ params, request }: Route.ActionArgs) {
const user = await requireSessionUser(request);
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 });
return await activityDetailAction(request, params.id);
}
export function meta({ data: loaderData }: Route.MetaArgs) {

View file

@ -0,0 +1,27 @@
// Server-only loader for /activities index. See `home.server.ts`.
import { requireSessionUser } from "~/lib/auth/session.server";
import { listActivities } from "~/lib/activities.server";
export async function loadActivitiesIndex(request: Request) {
const user = await requireSessionUser(request);
const url = new URL(request.url);
const sortParam = url.searchParams.get("sort");
const activitySort = sortParam === "addedAt" ? "addedAt" : ("startedAt" as const);
const userActivities = await listActivities(user.id, activitySort);
return {
activitySort,
activities: userActivities.map((a) => ({
id: a.id,
name: a.name,
distance: a.distance,
elevationGain: a.elevationGain,
duration: a.duration,
startedAt: a.startedAt?.toISOString() ?? null,
createdAt: a.createdAt.toISOString(),
geojson: a.geojson ?? null,
})),
};
}

View file

@ -1,32 +1,12 @@
import { data } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/activities._index";
import { requireSessionUser } from "~/lib/auth/session.server";
import { listActivities } from "~/lib/activities.server";
import { ClientDate } from "~/components/ClientDate";
import { ClientMap } from "~/components/ClientMap";
import { loadActivitiesIndex } from "./activities._index.server";
export async function loader({ request }: Route.LoaderArgs) {
const user = await requireSessionUser(request);
const url = new URL(request.url);
const sortParam = url.searchParams.get("sort");
const activitySort = sortParam === "addedAt" ? "addedAt" : ("startedAt" as const);
const userActivities = await listActivities(user.id, activitySort);
return data({
activitySort,
activities: userActivities.map((a) => ({
id: a.id,
name: a.name,
distance: a.distance,
elevationGain: a.elevationGain,
duration: a.duration,
startedAt: a.startedAt?.toISOString() ?? null,
createdAt: a.createdAt.toISOString(),
geojson: a.geojson ?? null,
})),
});
return data(await loadActivitiesIndex(request));
}
export function meta(_args: Route.MetaArgs) {

View file

@ -0,0 +1,41 @@
// Server-only loader/action for /activities/new. See `home.server.ts`.
import { data, redirect } from "react-router";
import { requireSessionUser } from "~/lib/auth/session.server";
import { createActivity } from "~/lib/activities.server";
import { listRoutes } from "~/lib/routes.server";
export async function loadActivitiesNew(request: Request) {
const user = await requireSessionUser(request);
const userRoutes = await listRoutes(user.id);
return {
routes: userRoutes.map((r) => ({ id: r.id, name: r.name })),
};
}
export async function activitiesNewAction(request: Request) {
const user = await requireSessionUser(request);
const formData = await request.formData();
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const routeId = formData.get("routeId") as string | null;
const gpxFile = formData.get("gpx") as File | null;
if (!name) return data({ error: "Name is required" }, { status: 400 });
let gpx: string | undefined;
if (gpxFile && gpxFile.size > 0) {
gpx = await gpxFile.text();
}
const activityId = await createActivity(user.id, {
name,
description,
gpx,
routeId: routeId || undefined,
});
return redirect(`/activities/${activityId}`);
}

View file

@ -1,42 +1,13 @@
import { data, redirect } from "react-router";
import { data } from "react-router";
import type { Route } from "./+types/activities.new";
import { requireSessionUser } from "~/lib/auth/session.server";
import { createActivity } from "~/lib/activities.server";
import { listRoutes } from "~/lib/routes.server";
import { loadActivitiesNew, activitiesNewAction } from "./activities.new.server";
export async function loader({ request }: Route.LoaderArgs) {
const user = await requireSessionUser(request);
const userRoutes = await listRoutes(user.id);
return data({
routes: userRoutes.map((r) => ({ id: r.id, name: r.name })),
});
return data(await loadActivitiesNew(request));
}
export async function action({ request }: Route.ActionArgs) {
const user = await requireSessionUser(request);
const formData = await request.formData();
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const routeId = formData.get("routeId") as string | null;
const gpxFile = formData.get("gpx") as File | null;
if (!name) return data({ error: "Name is required" }, { status: 400 });
let gpx: string | undefined;
if (gpxFile && gpxFile.size > 0) {
gpx = await gpxFile.text();
}
const activityId = await createActivity(user.id, {
name,
description,
gpx,
routeId: routeId || undefined,
});
return redirect(`/activities/${activityId}`);
return await activitiesNewAction(request);
}
export function meta(_args: Route.MetaArgs) {

View file

@ -0,0 +1,47 @@
// Server-only loader/action for /auth/accept-terms. See `home.server.ts`.
import { data, redirect } from "react-router";
import { recordTermsAcceptance } from "~/lib/auth.server";
import { getSessionUser } from "~/lib/auth/session.server";
import { TERMS_VERSION } from "~/lib/legal";
/**
* Paths we'll bounce back to after a successful acceptance. We only allow
* same-origin absolute paths to avoid being used as an open redirect.
*/
function safeReturnTo(raw: string | null): string {
if (!raw) return "/";
if (!raw.startsWith("/") || raw.startsWith("//")) return "/";
return raw;
}
export async function loadAcceptTerms(request: Request) {
const user = await getSessionUser(request);
if (!user) {
throw redirect("/auth/login");
}
// If the user is already current, bounce them back (e.g. double-submit).
if (user.termsVersion === TERMS_VERSION) {
const returnTo = safeReturnTo(new URL(request.url).searchParams.get("returnTo"));
throw redirect(returnTo);
}
return { previousVersion: user.termsVersion };
}
export async function acceptTermsAction(request: Request) {
const user = await getSessionUser(request);
if (!user) {
throw redirect("/auth/login");
}
const form = await request.formData();
const accepted = form.get("termsAccepted") === "on" || form.get("termsAccepted") === "true";
if (!accepted) {
return data({ error: "Terms of Service must be accepted to continue" }, { status: 400 });
}
await recordTermsAcceptance(user.id, TERMS_VERSION);
const returnTo = safeReturnTo(form.get("returnTo")?.toString() ?? null);
throw redirect(returnTo);
}

View file

@ -1,10 +1,9 @@
import { useState } from "react";
import { Form, data, redirect, useLoaderData, useSearchParams } from "react-router";
import { Form, useLoaderData, useSearchParams } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/auth.accept-terms";
import { recordTermsAcceptance } from "~/lib/auth.server";
import { getSessionUser } from "~/lib/auth/session.server";
import { TERMS_VERSION } from "~/lib/legal";
import { loadAcceptTerms, acceptTermsAction } from "./auth.accept-terms.server";
export function meta() {
return [
@ -13,45 +12,12 @@ export function meta() {
];
}
/**
* Paths we'll bounce back to after a successful acceptance. We only allow
* same-origin absolute paths to avoid being used as an open redirect.
*/
function safeReturnTo(raw: string | null): string {
if (!raw) return "/";
if (!raw.startsWith("/") || raw.startsWith("//")) return "/";
return raw;
}
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) {
throw redirect("/auth/login");
}
// If the user is already current, bounce them back (e.g. double-submit).
if (user.termsVersion === TERMS_VERSION) {
const returnTo = safeReturnTo(new URL(request.url).searchParams.get("returnTo"));
throw redirect(returnTo);
}
return { previousVersion: user.termsVersion };
return await loadAcceptTerms(request);
}
export async function action({ request }: Route.ActionArgs) {
const user = await getSessionUser(request);
if (!user) {
throw redirect("/auth/login");
}
const form = await request.formData();
const accepted = form.get("termsAccepted") === "on" || form.get("termsAccepted") === "true";
if (!accepted) {
return data({ error: "Terms of Service must be accepted to continue" }, { status: 400 });
}
await recordTermsAcceptance(user.id, TERMS_VERSION);
const returnTo = safeReturnTo(form.get("returnTo")?.toString() ?? null);
throw redirect(returnTo);
return await acceptTermsAction(request);
}
export default function AcceptTermsPage() {

View file

@ -0,0 +1,35 @@
// Server-only loader for /auth/verify. See `home.server.ts` for the pattern.
import { redirect, data } from "react-router";
import { verifyMagicToken, verifyEmailChange } from "~/lib/auth.server";
import { requireSessionUser } from "~/lib/auth/session.server";
import { completeAuth } from "~/lib/auth/completion.server";
export async function loadAuthVerify(request: Request) {
const url = new URL(request.url);
const token = url.searchParams.get("token");
const isEmailChange = url.searchParams.get("email-change") === "1";
if (!token) {
return data({ error: "Missing token" }, { status: 400 });
}
try {
if (isEmailChange) {
const user = await requireSessionUser(request);
await verifyEmailChange(token, user.id);
return redirect("/settings/account");
}
const userId = await verifyMagicToken(token);
// Default destination after magic-link sign-in is "/?add-passkey=1"
// (prompt to set up a passkey now that they're in). If the link
// carried a returnTo, completeAuth's safeReturnTo will honor any
// same-origin path and otherwise fall back to "/" — handle the
// add-passkey default before delegating.
const returnTo = url.searchParams.get("returnTo") ?? "/?add-passkey=1";
return completeAuth({ userId, request, returnTo, mode: "redirect" });
} catch (e) {
return data({ error: (e as Error).message }, { status: 400 });
}
}

View file

@ -1,36 +1,8 @@
import { redirect, data } from "react-router";
import type { Route } from "./+types/auth.verify";
import { verifyMagicToken, verifyEmailChange } from "~/lib/auth.server";
import { requireSessionUser } from "~/lib/auth/session.server";
import { completeAuth } from "~/lib/auth/completion.server";
import { loadAuthVerify } from "./auth.verify.server";
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const token = url.searchParams.get("token");
const isEmailChange = url.searchParams.get("email-change") === "1";
if (!token) {
return data({ error: "Missing token" }, { status: 400 });
}
try {
if (isEmailChange) {
const user = await requireSessionUser(request);
await verifyEmailChange(token, user.id);
return redirect("/settings/account");
}
const userId = await verifyMagicToken(token);
// Default destination after magic-link sign-in is "/?add-passkey=1"
// (prompt to set up a passkey now that they're in). If the link
// carried a returnTo, completeAuth's safeReturnTo will honor any
// same-origin path and otherwise fall back to "/" — handle the
// add-passkey default before delegating.
const returnTo = url.searchParams.get("returnTo") ?? "/?add-passkey=1";
return completeAuth({ userId, request, returnTo, mode: "redirect" });
} catch (e) {
return data({ error: (e as Error).message }, { status: 400 });
}
return await loadAuthVerify(request);
}
export default function VerifyPage() {

View file

@ -0,0 +1,74 @@
// Server-only loader for /explore. See `home.server.ts`.
import { getSessionUser } from "~/lib/auth/session.server";
import {
EXPLORE_DEFAULT_PAGE_SIZE,
countFollowersBatch,
getFollowStateBatch,
listActiveRecently,
listDirectory,
} from "~/lib/explore.server";
import { loadPersona } from "~/lib/demo-bot.server";
const BIO_TRUNCATE = 120;
function truncateBio(bio: string | null): string | null {
if (!bio) return null;
const trimmed = bio.trim();
if (trimmed.length === 0) return null;
if (trimmed.length <= BIO_TRUNCATE) return trimmed;
return trimmed.slice(0, BIO_TRUNCATE).trimEnd() + "…";
}
export async function loadExplore(request: Request) {
const viewer = await getSessionUser(request);
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "1");
const perPage = Number(url.searchParams.get("perPage") ?? String(EXPLORE_DEFAULT_PAGE_SIZE));
const [activeRecently, directory] = await Promise.all([
listActiveRecently(),
listDirectory({ page, perPage }),
]);
// Per-row data: follower count (for everyone) + follow state (for
// signed-in viewers only). Both are batched so the page issues at
// most two extra queries regardless of page size.
const allRows = [...activeRecently, ...directory.rows];
const allIds = allRows.map((r) => r.id);
const followerCounts = await countFollowersBatch(allIds);
const followStates = viewer
? await getFollowStateBatch(viewer.id, allRows.map((r) => ({ id: r.id, username: r.username })))
: new Map();
const isSelf = (rowId: string) => viewer?.id === rowId;
const personaUsername = loadPersona().username;
const decorate = (row: typeof allRows[number]) => ({
id: row.id,
username: row.username,
displayName: row.displayName,
bio: truncateBio(row.bio),
followerCount: followerCounts.get(row.id) ?? 0,
followState: followStates.get(row.id) ?? null,
isSelf: isSelf(row.id),
isDemoUser: row.username === personaUsername,
});
// Resolved page size (after loader-side clamping inside listDirectory)
// for the pagination math here. We can compute totalPages without
// re-querying since `directory.totalCount` is authoritative.
const resolvedPerPage = Math.max(1, Math.min(100, Math.floor(Number.isFinite(perPage) ? perPage : EXPLORE_DEFAULT_PAGE_SIZE)));
const resolvedPage = Math.max(1, Math.floor(Number.isFinite(page) ? page : 1));
const totalPages = Math.max(1, Math.ceil(directory.totalCount / resolvedPerPage));
return {
isSignedIn: !!viewer,
activeRecently: activeRecently.map(decorate),
directory: directory.rows.map(decorate),
page: resolvedPage,
perPage: resolvedPerPage,
totalPages,
totalCount: directory.totalCount,
};
}

View file

@ -2,78 +2,11 @@ import { data } from "react-router";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/explore";
import { getSessionUser } from "~/lib/auth/session.server";
import {
EXPLORE_DEFAULT_PAGE_SIZE,
countFollowersBatch,
getFollowStateBatch,
listActiveRecently,
listDirectory,
} from "~/lib/explore.server";
import { loadPersona } from "~/lib/demo-bot.server";
import { FollowButton } from "~/components/FollowButton";
const BIO_TRUNCATE = 120;
function truncateBio(bio: string | null): string | null {
if (!bio) return null;
const trimmed = bio.trim();
if (trimmed.length === 0) return null;
if (trimmed.length <= BIO_TRUNCATE) return trimmed;
return trimmed.slice(0, BIO_TRUNCATE).trimEnd() + "…";
}
import { loadExplore } from "./explore.server";
export async function loader({ request }: Route.LoaderArgs) {
const viewer = await getSessionUser(request);
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "1");
const perPage = Number(url.searchParams.get("perPage") ?? String(EXPLORE_DEFAULT_PAGE_SIZE));
const [activeRecently, directory] = await Promise.all([
listActiveRecently(),
listDirectory({ page, perPage }),
]);
// Per-row data: follower count (for everyone) + follow state (for
// signed-in viewers only). Both are batched so the page issues at
// most two extra queries regardless of page size.
const allRows = [...activeRecently, ...directory.rows];
const allIds = allRows.map((r) => r.id);
const followerCounts = await countFollowersBatch(allIds);
const followStates = viewer
? await getFollowStateBatch(viewer.id, allRows.map((r) => ({ id: r.id, username: r.username })))
: new Map();
const isSelf = (rowId: string) => viewer?.id === rowId;
const personaUsername = loadPersona().username;
const decorate = (row: typeof allRows[number]) => ({
id: row.id,
username: row.username,
displayName: row.displayName,
bio: truncateBio(row.bio),
followerCount: followerCounts.get(row.id) ?? 0,
followState: followStates.get(row.id) ?? null,
isSelf: isSelf(row.id),
isDemoUser: row.username === personaUsername,
});
// Resolved page size (after loader-side clamping inside listDirectory)
// for the pagination math here. We can compute totalPages without
// re-querying since `directory.totalCount` is authoritative.
const resolvedPerPage = Math.max(1, Math.min(100, Math.floor(Number.isFinite(perPage) ? perPage : EXPLORE_DEFAULT_PAGE_SIZE)));
const resolvedPage = Math.max(1, Math.floor(Number.isFinite(page) ? page : 1));
const totalPages = Math.max(1, Math.ceil(directory.totalCount / resolvedPerPage));
return data({
isSignedIn: !!viewer,
activeRecently: activeRecently.map(decorate),
directory: directory.rows.map(decorate),
page: resolvedPage,
perPage: resolvedPerPage,
totalPages,
totalCount: directory.totalCount,
});
return data(await loadExplore(request));
}
export function meta(_args: Route.MetaArgs) {

View file

@ -0,0 +1,36 @@
// Server-only loader for /feed. See `home.server.ts`.
import { redirect } from "react-router";
import { getSessionUser } from "~/lib/auth/session.server";
import { listSocialFeed, listRecentPublicActivities } from "~/lib/activities.server";
type View = "followed" | "public";
export async function loadFeed(request: Request) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
const url = new URL(request.url);
const view: View = url.searchParams.get("view") === "public" ? "public" : "followed";
const rows =
view === "public"
? await listRecentPublicActivities(50)
: await listSocialFeed(user.id, 50);
return {
view,
activities: rows.map((a) => ({
id: a.id,
name: a.name,
distance: a.distance,
elevationGain: a.elevationGain,
duration: a.duration,
startedAt: a.startedAt?.toISOString() ?? null,
createdAt: a.createdAt.toISOString(),
geojson: a.geojson ?? null,
ownerUsername: a.ownerUsername,
ownerDisplayName: a.ownerDisplayName,
})),
};
}

View file

@ -1,41 +1,13 @@
import { data, redirect } from "react-router";
import { data } from "react-router";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/feed";
import { getSessionUser } from "~/lib/auth/session.server";
import { listSocialFeed, listRecentPublicActivities } from "~/lib/activities.server";
import { ClientDate } from "~/components/ClientDate";
import { ClientMap } from "~/components/ClientMap";
type View = "followed" | "public";
import { loadFeed } from "./feed.server";
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
const url = new URL(request.url);
const view: View = url.searchParams.get("view") === "public" ? "public" : "followed";
const rows =
view === "public"
? await listRecentPublicActivities(50)
: await listSocialFeed(user.id, 50);
return data({
view,
activities: rows.map((a) => ({
id: a.id,
name: a.name,
distance: a.distance,
elevationGain: a.elevationGain,
duration: a.duration,
startedAt: a.startedAt?.toISOString() ?? null,
createdAt: a.createdAt.toISOString(),
geojson: a.geojson ?? null,
ownerUsername: a.ownerUsername,
ownerDisplayName: a.ownerDisplayName,
})),
});
return data(await loadFeed(request));
}
export function meta({ data: loaderData }: Route.MetaArgs) {

View file

@ -0,0 +1,111 @@
// Server-only loader for /notifications. See `home.server.ts`.
import { redirect } from "react-router";
import { inArray, eq, and } from "drizzle-orm";
import { getDb } from "~/lib/db";
import { getSessionUser } from "~/lib/auth/session.server";
import { listForUser } from "~/lib/notifications.server";
import { linkFor } from "~/lib/notifications/link-for";
import { readPayload } from "~/lib/notifications/payload";
import {
countPendingFollowRequests,
listPendingFollowRequests,
} from "~/lib/follow.server";
import { activities } from "@trails-cool/db/schema/journal";
type Tab = "activity" | "requests";
export interface NotificationRow {
id: string;
type: string;
readAt: string | null;
createdAt: string;
link: string;
payload: Record<string, unknown> | null;
}
export interface RequestRow {
id: string;
followerUsername: string;
followerDisplayName: string | null;
followerDomain: string;
createdAt: string;
}
export async function loadNotifications(request: Request) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
const url = new URL(request.url);
const tab: Tab = url.searchParams.get("tab") === "requests" ? "requests" : "activity";
// Pending count drives the Requests tab dot regardless of which tab is
// currently active, so we always fetch it. It's a single COUNT(*) query.
const pendingCount = await countPendingFollowRequests(user.id);
if (tab === "requests") {
const requests = await listPendingFollowRequests(user.id);
return {
tab: "requests" as const,
pendingCount,
requests: requests.map((r) => ({
id: r.id,
followerUsername: r.followerUsername,
followerDisplayName: r.followerDisplayName,
followerDomain: r.followerDomain,
createdAt: r.createdAt.toISOString(),
})),
notifications: [] as NotificationRow[],
nextCursor: null as string | null,
};
}
const before = url.searchParams.get("before") ?? undefined;
const { rows, nextCursor } = await listForUser(user.id, { before });
// Renderer guard: drop activity_published rows whose subject is gone
// or no longer public (visibility flipped from public → private/unlisted).
// Fetch the still-public subject IDs in one query, then filter.
const activitySubjectIds = rows
.filter((r) => r.type === "activity_published" && r.subjectId)
.map((r) => r.subjectId as string);
let publicActivityIds = new Set<string>();
if (activitySubjectIds.length > 0) {
const db = getDb();
const visible = await db
.select({ id: activities.id })
.from(activities)
.where(and(inArray(activities.id, activitySubjectIds), eq(activities.visibility, "public")));
publicActivityIds = new Set(visible.map((v) => v.id));
}
const visibleRows = rows.filter((r) => {
if (r.type !== "activity_published") return true;
if (!r.subjectId) return false;
return publicActivityIds.has(r.subjectId);
});
return {
tab: "activity" as const,
pendingCount,
requests: [] as RequestRow[],
notifications: visibleRows.map((r) => {
const link = linkFor({
type: r.type,
subjectId: r.subjectId,
payload: r.payload,
payloadVersion: r.payloadVersion,
});
const payload = readPayload(r.type, r.payloadVersion, r.payload);
return {
id: r.id,
type: r.type,
readAt: r.readAt?.toISOString() ?? null,
createdAt: r.createdAt.toISOString(),
link: link.web,
payload,
};
}),
nextCursor,
};
}

View file

@ -1,98 +1,12 @@
import { data, redirect, useFetcher } from "react-router";
import { data, useFetcher } from "react-router";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { inArray, eq, and } from "drizzle-orm";
import type { Route } from "./+types/notifications";
import { getDb } from "~/lib/db";
import { getSessionUser } from "~/lib/auth/session.server";
import { listForUser } from "~/lib/notifications.server";
import { linkFor } from "~/lib/notifications/link-for";
import { readPayload } from "~/lib/notifications/payload";
import {
countPendingFollowRequests,
listPendingFollowRequests,
} from "~/lib/follow.server";
import { ClientDate } from "~/components/ClientDate";
import { activities } from "@trails-cool/db/schema/journal";
type Tab = "activity" | "requests";
import { loadNotifications } from "./notifications.server";
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
const url = new URL(request.url);
const tab: Tab = url.searchParams.get("tab") === "requests" ? "requests" : "activity";
// Pending count drives the Requests tab dot regardless of which tab is
// currently active, so we always fetch it. It's a single COUNT(*) query.
const pendingCount = await countPendingFollowRequests(user.id);
if (tab === "requests") {
const requests = await listPendingFollowRequests(user.id);
return data({
tab: "requests" as const,
pendingCount,
requests: requests.map((r) => ({
id: r.id,
followerUsername: r.followerUsername,
followerDisplayName: r.followerDisplayName,
followerDomain: r.followerDomain,
createdAt: r.createdAt.toISOString(),
})),
notifications: [] as NotificationRow[],
nextCursor: null as string | null,
});
}
const before = url.searchParams.get("before") ?? undefined;
const { rows, nextCursor } = await listForUser(user.id, { before });
// Renderer guard: drop activity_published rows whose subject is gone
// or no longer public (visibility flipped from public → private/unlisted).
// Fetch the still-public subject IDs in one query, then filter.
const activitySubjectIds = rows
.filter((r) => r.type === "activity_published" && r.subjectId)
.map((r) => r.subjectId as string);
let publicActivityIds = new Set<string>();
if (activitySubjectIds.length > 0) {
const db = getDb();
const visible = await db
.select({ id: activities.id })
.from(activities)
.where(and(inArray(activities.id, activitySubjectIds), eq(activities.visibility, "public")));
publicActivityIds = new Set(visible.map((v) => v.id));
}
const visibleRows = rows.filter((r) => {
if (r.type !== "activity_published") return true;
if (!r.subjectId) return false;
return publicActivityIds.has(r.subjectId);
});
return data({
tab: "activity" as const,
pendingCount,
requests: [] as RequestRow[],
notifications: visibleRows.map((r) => {
const link = linkFor({
type: r.type,
subjectId: r.subjectId,
payload: r.payload,
payloadVersion: r.payloadVersion,
});
const payload = readPayload(r.type, r.payloadVersion, r.payload);
return {
id: r.id,
type: r.type,
readAt: r.readAt?.toISOString() ?? null,
createdAt: r.createdAt.toISOString(),
link: link.web,
payload,
};
}),
nextCursor,
});
return data(await loadNotifications(request));
}
export function meta(_args: Route.MetaArgs) {

View file

@ -0,0 +1,49 @@
// Server-only loader/action for /routes/:id/edit. See `home.server.ts`.
import { data, redirect } from "react-router";
import { requireSessionUser } from "~/lib/auth/session.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 loadRouteEdit(request: Request, id: string | undefined) {
const user = await requireSessionUser(request);
const route = await getRoute(id ?? "");
if (!route) throw data({ error: "Route not found" }, { status: 404 });
if (route.ownerId !== user.id) throw data({ error: "Not authorized" }, { status: 403 });
return {
route: {
id: route.id,
name: route.name,
description: route.description,
visibility: route.visibility,
},
};
}
export async function routeEditAction(request: Request, id: string | undefined) {
const user = await requireSessionUser(request);
const routeId = id ?? "";
const formData = await request.formData();
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; 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(routeId, user.id, input);
return redirect(`/routes/${routeId}`);
}

View file

@ -1,50 +1,14 @@
import { data, redirect } from "react-router";
import { data } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/routes.$id.edit";
import { requireSessionUser } from "~/lib/auth/session.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"]);
import { loadRouteEdit, routeEditAction } from "./routes.$id.edit.server";
export async function loader({ params, request }: Route.LoaderArgs) {
const user = await requireSessionUser(request);
const route = await getRoute(params.id);
if (!route) throw data({ error: "Route not found" }, { status: 404 });
if (route.ownerId !== user.id) throw data({ error: "Not authorized" }, { status: 403 });
return data({
route: {
id: route.id,
name: route.name,
description: route.description,
visibility: route.visibility,
},
});
return data(await loadRouteEdit(request, params.id));
}
export async function action({ params, request }: Route.ActionArgs) {
const user = await requireSessionUser(request);
const formData = await request.formData();
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; 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}`);
return await routeEditAction(request, params.id);
}
export function meta(_args: Route.MetaArgs) {

View file

@ -0,0 +1,170 @@
// Server-only loader/action for /routes/:id. See `home.server.ts`.
import { data, redirect } from "react-router";
import { and, eq } from "drizzle-orm";
import { canView } from "~/lib/auth.server";
import { getSessionUser, requireSessionUser } from "~/lib/auth/session.server";
import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
import { getDb } from "~/lib/db";
import { syncPushes } from "@trails-cool/db/schema/journal";
import { getService } from "~/lib/connected-services";
export async function loadRouteDetail(request: Request, id: string | undefined) {
const routeId = id ?? "";
const [routeWithVersions, routeWithGeojson] = await Promise.all([
getRouteWithVersions(routeId),
getRoute(routeId),
]);
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 });
}
// Parse GPX once for day stats and waypoint POI data
let dayStats: Array<{ dayNumber: number; startName?: string; endName?: string; distance: number; ascent: number; descent: number }> = [];
let waypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean; note?: string; osmId?: number; poiTags?: Record<string, string> }> = [];
if (route.gpx) {
try {
const { computeDays, parseGpxAsync } = await import("@trails-cool/gpx");
const gpxData = await parseGpxAsync(route.gpx);
waypoints = gpxData.waypoints.map((w) => ({
lat: w.lat,
lon: w.lon,
name: w.name,
isDayBreak: w.isDayBreak,
note: w.note,
osmId: w.osmId,
poiTags: w.poiTags as Record<string, string> | undefined,
}));
if (route.dayBreaks && route.dayBreaks.length > 0) {
dayStats = computeDays(gpxData.waypoints, gpxData.tracks);
}
} catch {
// Fall back to empty
}
}
const currentVersion = route.versions[0]?.version ?? 1;
// Wahoo push state — only meaningful for the owner. The single
// sync_pushes row per (user, route, wahoo) carries `lastPushedVersion`,
// which we compare against the current local version to render one of
// three states: matches, local newer, or last attempt failed.
let wahooPush:
| {
canPush: boolean;
needsReauth: boolean;
currentVersion: number;
latest: {
pushedAt: string | null;
remoteId: string | null;
lastPushedVersion: number | null;
error: string | null;
} | null;
}
| null = null;
if (isOwner && user && !!route.gpx) {
const connection = await getService(user.id, "wahoo");
let latest:
| {
pushedAt: string | null;
remoteId: string | null;
lastPushedVersion: number | null;
error: string | null;
}
| null = null;
if (connection) {
const db = getDb();
const [row] = await db
.select()
.from(syncPushes)
.where(
and(
eq(syncPushes.userId, user.id),
eq(syncPushes.routeId, route.id),
eq(syncPushes.provider, "wahoo"),
),
)
.limit(1);
latest = row
? {
pushedAt: row.pushedAt ? row.pushedAt.toISOString() : null,
remoteId: row.remoteId,
lastPushedVersion: row.lastPushedVersion,
error: row.error,
}
: null;
}
wahooPush = {
canPush: !!connection,
needsReauth: !!connection && !connection.grantedScopes.includes("routes_write"),
currentVersion,
latest,
};
}
return {
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,
waypoints,
versions: route.versions.map((v) => ({
version: v.version,
changeDescription: v.changeDescription,
createdAt: v.createdAt.toISOString(),
})),
isOwner,
wahooPush,
};
}
export async function routeDetailAction(request: Request, id: string | undefined) {
const user = await requireSessionUser(request);
const routeId = id ?? "";
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "delete") {
await deleteRoute(routeId, 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(routeId, user.id, input as { name?: string; description?: string; gpx?: string });
return redirect(`/routes/${routeId}`);
}
return data({ error: "Unknown action" }, { status: 400 });
}

View file

@ -1,174 +1,17 @@
import { useState, useCallback } from "react";
import { data, redirect } from "react-router";
import { data } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/routes.$id";
import { and, eq } from "drizzle-orm";
import { canView } from "~/lib/auth.server";
import { getSessionUser, requireSessionUser } from "~/lib/auth/session.server";
import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
import { getDb } from "~/lib/db";
import { syncPushes } from "@trails-cool/db/schema/journal";
import { getService } from "~/lib/connected-services";
import { ClientDate } from "~/components/ClientDate";
import { ClientMap } from "~/components/ClientMap";
import { loadRouteDetail, routeDetailAction } from "./routes.$id.server";
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 });
}
// Parse GPX once for day stats and waypoint POI data
let dayStats: Array<{ dayNumber: number; startName?: string; endName?: string; distance: number; ascent: number; descent: number }> = [];
let waypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean; note?: string; osmId?: number; poiTags?: Record<string, string> }> = [];
if (route.gpx) {
try {
const { computeDays, parseGpxAsync } = await import("@trails-cool/gpx");
const gpxData = await parseGpxAsync(route.gpx);
waypoints = gpxData.waypoints.map((w) => ({
lat: w.lat,
lon: w.lon,
name: w.name,
isDayBreak: w.isDayBreak,
note: w.note,
osmId: w.osmId,
poiTags: w.poiTags as Record<string, string> | undefined,
}));
if (route.dayBreaks && route.dayBreaks.length > 0) {
dayStats = computeDays(gpxData.waypoints, gpxData.tracks);
}
} catch {
// Fall back to empty
}
}
const currentVersion = route.versions[0]?.version ?? 1;
// Wahoo push state — only meaningful for the owner. The single
// sync_pushes row per (user, route, wahoo) carries `lastPushedVersion`,
// which we compare against the current local version to render one of
// three states: matches, local newer, or last attempt failed.
let wahooPush:
| {
canPush: boolean;
needsReauth: boolean;
currentVersion: number;
latest: {
pushedAt: string | null;
remoteId: string | null;
lastPushedVersion: number | null;
error: string | null;
} | null;
}
| null = null;
if (isOwner && user && !!route.gpx) {
const connection = await getService(user.id, "wahoo");
let latest:
| {
pushedAt: string | null;
remoteId: string | null;
lastPushedVersion: number | null;
error: string | null;
}
| null = null;
if (connection) {
const db = getDb();
const [row] = await db
.select()
.from(syncPushes)
.where(
and(
eq(syncPushes.userId, user.id),
eq(syncPushes.routeId, route.id),
eq(syncPushes.provider, "wahoo"),
),
)
.limit(1);
latest = row
? {
pushedAt: row.pushedAt ? row.pushedAt.toISOString() : null,
remoteId: row.remoteId,
lastPushedVersion: row.lastPushedVersion,
error: row.error,
}
: null;
}
wahooPush = {
canPush: !!connection,
needsReauth: !!connection && !connection.grantedScopes.includes("routes_write"),
currentVersion,
latest,
};
}
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,
waypoints,
versions: route.versions.map((v) => ({
version: v.version,
changeDescription: v.changeDescription,
createdAt: v.createdAt.toISOString(),
})),
isOwner,
wahooPush,
});
return data(await loadRouteDetail(request, params.id));
}
export async function action({ params, request }: Route.ActionArgs) {
const user = await requireSessionUser(request);
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 });
return routeDetailAction(request, params.id);
}
export function meta({ data: loaderData }: Route.MetaArgs) {

View file

@ -0,0 +1,20 @@
// Server-only loader for /routes index. See `home.server.ts`.
import { requireSessionUser } from "~/lib/auth/session.server";
import { listRoutes } from "~/lib/routes.server";
export async function loadRoutesIndex(request: Request) {
const user = await requireSessionUser(request);
const userRoutes = await listRoutes(user.id);
return {
routes: userRoutes.map((r) => ({
id: r.id,
name: r.name,
distance: r.distance,
elevationGain: r.elevationGain,
updatedAt: r.updatedAt.toISOString(),
geojson: r.geojson ?? null,
})),
};
}

View file

@ -1,25 +1,12 @@
import { data } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/routes._index";
import { requireSessionUser } from "~/lib/auth/session.server";
import { listRoutes } from "~/lib/routes.server";
import { ClientDate } from "~/components/ClientDate";
import { ClientMap } from "~/components/ClientMap";
import { loadRoutesIndex } from "./routes._index.server";
export async function loader({ request }: Route.LoaderArgs) {
const user = await requireSessionUser(request);
const userRoutes = await listRoutes(user.id);
return data({
routes: userRoutes.map((r) => ({
id: r.id,
name: r.name,
distance: r.distance,
elevationGain: r.elevationGain,
updatedAt: r.updatedAt.toISOString(),
geojson: r.geojson ?? null,
})),
});
return data(await loadRoutesIndex(request));
}
export function meta(_args: Route.MetaArgs) {

View file

@ -0,0 +1,29 @@
// Server-only loader/action for /routes/new. See `home.server.ts`.
import { data, redirect } from "react-router";
import { requireSessionUser } from "~/lib/auth/session.server";
import { createRoute } from "~/lib/routes.server";
export async function loadRoutesNew(request: Request) {
await requireSessionUser(request);
return {};
}
export async function routesNewAction(request: Request) {
const user = await requireSessionUser(request);
const formData = await request.formData();
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const gpxFile = formData.get("gpx") as File | null;
if (!name) return data({ error: "Name is required" }, { status: 400 });
let gpx: string | undefined;
if (gpxFile && gpxFile.size > 0) {
gpx = await gpxFile.text();
}
const routeId = await createRoute(user.id, { name, description, gpx });
return redirect(`/routes/${routeId}`);
}

View file

@ -1,31 +1,14 @@
import { data, redirect } from "react-router";
import { data } from "react-router";
import type { Route } from "./+types/routes.new";
import { requireSessionUser } from "~/lib/auth/session.server";
import { createRoute } from "~/lib/routes.server";
import { loadRoutesNew, routesNewAction } from "./routes.new.server";
export async function loader({ request }: Route.LoaderArgs) {
await requireSessionUser(request);
return data({});
return data(await loadRoutesNew(request));
}
export async function action({ request }: Route.ActionArgs) {
const user = await requireSessionUser(request);
const formData = await request.formData();
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const gpxFile = formData.get("gpx") as File | null;
if (!name) return data({ error: "Name is required" }, { status: 400 });
let gpx: string | undefined;
if (gpxFile && gpxFile.size > 0) {
gpx = await gpxFile.text();
}
const routeId = await createRoute(user.id, { name, description, gpx });
return redirect(`/routes/${routeId}`);
return await routesNewAction(request);
}
export function meta(_args: Route.MetaArgs) {

View file

@ -0,0 +1,15 @@
// Server-only loader for /settings/account. See `home.server.ts`.
import { redirect } from "react-router";
import { getSessionUser } from "~/lib/auth/session.server";
export async function loadAccountSettings(request: Request) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
return {
user: {
username: user.username,
email: user.email,
},
};
}

View file

@ -1,22 +1,15 @@
import { useState, useEffect } from "react";
import { data, redirect, useFetcher } from "react-router";
import { data, useFetcher } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/settings.account";
import { getSessionUser } from "~/lib/auth/session.server";
import { loadAccountSettings } from "./settings.account.server";
export function meta() {
return [{ title: "Account — Settings — trails.cool" }];
}
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
return data({
user: {
username: user.username,
email: user.email,
},
});
return data(await loadAccountSettings(request));
}
export default function AccountSettings({ loaderData }: Route.ComponentProps) {

View file

@ -0,0 +1,23 @@
// Server-only loader for /settings/connections/komoot. See `home.server.ts`.
import { redirect } from "react-router";
import { getOrigin } from "~/lib/config.server";
import { getSessionUser } from "~/lib/auth/session.server";
import { getService } from "~/lib/connected-services/manager";
export async function loadKomootConnection(request: Request) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
const service = await getService(user.id, "komoot");
const origin = getOrigin();
const trailsProfileUrl = `${origin}/users/${user.username}`;
return {
connected: !!service,
mode: service ? (service.credentials as { mode?: string }).mode ?? null : null,
providerUserId: service?.providerUserId ?? null,
serviceId: service?.id ?? null,
trailsProfileUrl,
};
}

View file

@ -3,32 +3,17 @@
// Authenticated — email + password (password encrypted at rest)
import { useState } from "react";
import { data, redirect, useFetcher } from "react-router";
import { getOrigin } from "~/lib/config.server";
import { data, useFetcher } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/settings.connections.komoot";
import { getSessionUser } from "~/lib/auth/session.server";
import { getService } from "~/lib/connected-services/manager";
import { loadKomootConnection } from "./settings.connections.komoot.server";
export function meta() {
return [{ title: "Connect Komoot — Settings — trails.cool" }];
}
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
const service = await getService(user.id, "komoot");
const origin = getOrigin();
const trailsProfileUrl = `${origin}/users/${user.username}`;
return data({
connected: !!service,
mode: service ? (service.credentials as { mode?: string }).mode ?? null : null,
providerUserId: service?.providerUserId ?? null,
serviceId: service?.id ?? null,
trailsProfileUrl,
});
return data(await loadKomootConnection(request));
}
type VerifyResponse = { success?: boolean; error?: string };

View file

@ -0,0 +1,17 @@
// Server-only loader for /settings/profile. See `home.server.ts`.
import { redirect } from "react-router";
import { getSessionUser } from "~/lib/auth/session.server";
export async function loadProfileSettings(request: Request) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
return {
user: {
username: user.username,
displayName: user.displayName,
bio: user.bio,
profileVisibility: user.profileVisibility,
},
};
}

View file

@ -1,24 +1,15 @@
import { useState, useEffect } from "react";
import { data, redirect, useFetcher } from "react-router";
import { data, useFetcher } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/settings.profile";
import { getSessionUser } from "~/lib/auth/session.server";
import { loadProfileSettings } from "./settings.profile.server";
export function meta() {
return [{ title: "Profile — Settings — trails.cool" }];
}
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
return data({
user: {
username: user.username,
displayName: user.displayName,
bio: user.bio,
profileVisibility: user.profileVisibility,
},
});
return data(await loadProfileSettings(request));
}
export default function ProfileSettings({ loaderData }: Route.ComponentProps) {

View file

@ -0,0 +1,33 @@
// Server-only loader for /settings/security. See `home.server.ts`.
import { redirect } from "react-router";
import { eq } from "drizzle-orm";
import { getSessionUser } from "~/lib/auth/session.server";
import { getDb } from "~/lib/db";
import { credentials } from "@trails-cool/db/schema/journal";
export async function loadSecuritySettings(request: Request) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
const db = getDb();
const passkeys = await db
.select({
id: credentials.id,
deviceType: credentials.deviceType,
transports: credentials.transports,
createdAt: credentials.createdAt,
})
.from(credentials)
.where(eq(credentials.userId, user.id));
return {
userId: user.id,
passkeys: passkeys.map((p) => ({
id: p.id,
deviceType: p.deviceType,
transports: p.transports as string[] | null,
createdAt: p.createdAt.toISOString(),
})),
};
}

View file

@ -1,41 +1,16 @@
import { useState, useEffect, useCallback } from "react";
import { data, redirect, useFetcher } from "react-router";
import { data, useFetcher } from "react-router";
import { useTranslation } from "react-i18next";
import { eq } from "drizzle-orm";
import type { Route } from "./+types/settings.security";
import { getSessionUser } from "~/lib/auth/session.server";
import { getDb } from "~/lib/db";
import { credentials } from "@trails-cool/db/schema/journal";
import { ClientDate } from "~/components/ClientDate";
import { loadSecuritySettings } from "./settings.security.server";
export function meta() {
return [{ title: "Security — Settings — trails.cool" }];
}
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
const db = getDb();
const passkeys = await db
.select({
id: credentials.id,
deviceType: credentials.deviceType,
transports: credentials.transports,
createdAt: credentials.createdAt,
})
.from(credentials)
.where(eq(credentials.userId, user.id));
return data({
userId: user.id,
passkeys: passkeys.map((p) => ({
id: p.id,
deviceType: p.deviceType,
transports: p.transports as string[] | null,
createdAt: p.createdAt.toISOString(),
})),
});
return data(await loadSecuritySettings(request));
}
function transportLabel(transports: string[] | null, t: (key: string) => string): string {

View file

@ -0,0 +1,10 @@
// Server-only loader for /settings layout. See `home.server.ts`.
import { redirect } from "react-router";
import { getSessionUser } from "~/lib/auth/session.server";
export async function loadSettingsLayout(request: Request) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
return { username: user.username };
}

View file

@ -1,17 +1,15 @@
import { Outlet, redirect } from "react-router";
import { Outlet } from "react-router";
import { Link, useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/settings";
import { getSessionUser } from "~/lib/auth/session.server";
import { loadSettingsLayout } from "./settings.server";
export function meta() {
return [{ title: "Settings — trails.cool" }];
}
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
return { username: user.username };
return await loadSettingsLayout(request);
}
interface NavItem {

View file

@ -0,0 +1,118 @@
// Server-only loader/action for /sync/import/:provider. See `home.server.ts`.
import { data, redirect } from "react-router";
import { requireSessionUser } from "~/lib/auth/session.server";
import {
getManifest,
getService,
capabilityContextFor,
} from "~/lib/connected-services";
import { getImportedIds, recordImport } from "~/lib/sync/imports.server";
import { createActivity } from "~/lib/activities.server";
export async function loadSyncImportProvider(request: Request, provider: string | undefined) {
const user = await requireSessionUser(request);
const manifest = getManifest(provider ?? "");
if (!manifest || !manifest.importer) {
throw data({ error: "Unknown provider" }, { status: 404 });
}
const service = await getService(user.id, manifest.id);
if (!service) throw redirect("/settings");
const url = new URL(request.url);
const page = parseInt(url.searchParams.get("page") ?? "1");
const ctx = capabilityContextFor(service.id);
const workoutList = await manifest.importer.listImportable(ctx, page);
const importedIds = await getImportedIds(
user.id,
manifest.id,
workoutList.workouts.map((w) => w.id),
);
return {
provider: { id: manifest.id, name: manifest.displayName },
workouts: workoutList.workouts.map((w) => ({
...w,
imported: importedIds.has(w.id),
})),
page: workoutList.page,
totalPages: Math.ceil(workoutList.total / workoutList.perPage),
};
}
export async function syncImportProviderAction(request: Request, provider: string | undefined) {
const user = await requireSessionUser(request);
const manifest = getManifest(provider ?? "");
if (!manifest || !manifest.importer) {
throw data({ error: "Unknown provider" }, { status: 404 });
}
const service = await getService(user.id, manifest.id);
if (!service) return redirect("/settings");
const formData = await request.formData();
const workoutId = formData.get("workoutId") as string;
const workoutName = formData.get("workoutName") as string;
const fileUrl = formData.get("fileUrl") as string;
const startedAt = formData.get("startedAt") as string;
const distance = formData.get("distance") as string;
const duration = formData.get("duration") as string;
// Inline import here (not via Importer.importOne) so we can pass through
// the form-supplied metadata (started_at, distance, duration) the
// capability seam doesn't carry. The seam-shape importer is reserved for
// automatic / webhook-driven imports.
let gpx: string | null = null;
if (fileUrl) {
const ctx = capabilityContextFor(service.id);
// Wahoo CDN URLs are pre-signed; we still go through withFreshCredentials
// so the manager handles refresh of any near-expired token.
const buffer = await ctx.withFreshCredentials(async () => {
const resp = await fetch(fileUrl);
if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
return Buffer.from(await resp.arrayBuffer());
});
// Lazy-load to avoid bundling fit-file-parser into all routes.
const { default: FitParser } = await import("fit-file-parser");
const { generateGpx } = await import("@trails-cool/gpx");
const parsed = await new Promise<Record<string, unknown>>((resolve, reject) => {
const parser = new FitParser({ force: true });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parser.parse(buffer as any, (err: unknown, d: any) => (err ? reject(err) : resolve(d ?? {})));
});
const records = (parsed.records ?? []) as Array<{
position_lat?: number;
position_long?: number;
altitude?: number;
timestamp?: string | Date;
}>;
const trackPoints = records
.filter((r) => r.position_lat != null && r.position_long != null)
.map((r) => ({
lat: r.position_lat!,
lon: r.position_long!,
ele: r.altitude,
time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp,
}));
if (trackPoints.length >= 2) {
gpx = generateGpx({ name: workoutName, tracks: [trackPoints] });
}
}
const activityId = await createActivity(user.id, {
name: workoutName || `${manifest.displayName} workout`,
gpx: gpx ?? undefined,
distance: distance ? parseFloat(distance) : null,
duration: duration ? parseInt(duration) : null,
startedAt: startedAt ? new Date(startedAt) : null,
});
await recordImport(user.id, manifest.id, workoutId, activityId);
return { imported: workoutId };
}

View file

@ -1,122 +1,16 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { data, redirect, useFetcher } from "react-router";
import { data, useFetcher } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/sync.import.$provider";
import { requireSessionUser } from "~/lib/auth/session.server";
import {
getManifest,
getService,
capabilityContextFor,
} from "~/lib/connected-services";
import { getImportedIds, recordImport } from "~/lib/sync/imports.server";
import { createActivity } from "~/lib/activities.server";
import { ClientDate } from "~/components/ClientDate";
import { loadSyncImportProvider, syncImportProviderAction } from "./sync.import.$provider.server";
export async function loader({ params, request }: Route.LoaderArgs) {
const user = await requireSessionUser(request);
const manifest = getManifest(params.provider);
if (!manifest || !manifest.importer) {
throw data({ error: "Unknown provider" }, { status: 404 });
}
const service = await getService(user.id, manifest.id);
if (!service) return redirect("/settings");
const url = new URL(request.url);
const page = parseInt(url.searchParams.get("page") ?? "1");
const ctx = capabilityContextFor(service.id);
const workoutList = await manifest.importer.listImportable(ctx, page);
const importedIds = await getImportedIds(
user.id,
manifest.id,
workoutList.workouts.map((w) => w.id),
);
return data({
provider: { id: manifest.id, name: manifest.displayName },
workouts: workoutList.workouts.map((w) => ({
...w,
imported: importedIds.has(w.id),
})),
page: workoutList.page,
totalPages: Math.ceil(workoutList.total / workoutList.perPage),
});
return data(await loadSyncImportProvider(request, params.provider));
}
export async function action({ params, request }: Route.ActionArgs) {
const user = await requireSessionUser(request);
const manifest = getManifest(params.provider);
if (!manifest || !manifest.importer) {
throw data({ error: "Unknown provider" }, { status: 404 });
}
const service = await getService(user.id, manifest.id);
if (!service) return redirect("/settings");
const formData = await request.formData();
const workoutId = formData.get("workoutId") as string;
const workoutName = formData.get("workoutName") as string;
const fileUrl = formData.get("fileUrl") as string;
const startedAt = formData.get("startedAt") as string;
const distance = formData.get("distance") as string;
const duration = formData.get("duration") as string;
// Inline import here (not via Importer.importOne) so we can pass through
// the form-supplied metadata (started_at, distance, duration) the
// capability seam doesn't carry. The seam-shape importer is reserved for
// automatic / webhook-driven imports.
let gpx: string | null = null;
if (fileUrl) {
const ctx = capabilityContextFor(service.id);
// Wahoo CDN URLs are pre-signed; we still go through withFreshCredentials
// so the manager handles refresh of any near-expired token.
const buffer = await ctx.withFreshCredentials(async () => {
const resp = await fetch(fileUrl);
if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
return Buffer.from(await resp.arrayBuffer());
});
// Lazy-load to avoid bundling fit-file-parser into all routes.
const { default: FitParser } = await import("fit-file-parser");
const { generateGpx } = await import("@trails-cool/gpx");
const parsed = await new Promise<Record<string, unknown>>((resolve, reject) => {
const parser = new FitParser({ force: true });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parser.parse(buffer as any, (err: unknown, d: any) => (err ? reject(err) : resolve(d ?? {})));
});
const records = (parsed.records ?? []) as Array<{
position_lat?: number;
position_long?: number;
altitude?: number;
timestamp?: string | Date;
}>;
const trackPoints = records
.filter((r) => r.position_lat != null && r.position_long != null)
.map((r) => ({
lat: r.position_lat!,
lon: r.position_long!,
ele: r.altitude,
time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp,
}));
if (trackPoints.length >= 2) {
gpx = generateGpx({ name: workoutName, tracks: [trackPoints] });
}
}
const activityId = await createActivity(user.id, {
name: workoutName || `${manifest.displayName} workout`,
gpx: gpx ?? undefined,
distance: distance ? parseFloat(distance) : null,
duration: duration ? parseInt(duration) : null,
startedAt: startedAt ? new Date(startedAt) : null,
});
await recordImport(user.id, manifest.id, workoutId, activityId);
return data({ imported: workoutId });
return data(await syncImportProviderAction(request, params.provider));
}
export function meta({ data: loaderData }: Route.MetaArgs) {

View file

@ -0,0 +1,54 @@
// Server-only loader/action for /sync/import/komoot. See `home.server.ts`.
import { data, redirect } from "react-router";
import { desc, eq, and } from "drizzle-orm";
import { requireSessionUser } from "~/lib/auth/session.server";
import { getService } from "~/lib/connected-services";
import { getDb } from "~/lib/db";
import { importBatches } from "@trails-cool/db/schema/journal";
export async function loadKomootImport(request: Request) {
const user = await requireSessionUser(request);
const service = await getService(user.id, "komoot");
if (!service) throw redirect("/settings/connections/komoot");
const db = getDb();
const [batch] = await db
.select()
.from(importBatches)
.where(and(eq(importBatches.userId, user.id), eq(importBatches.connectionId, service.id)))
.orderBy(desc(importBatches.startedAt))
.limit(1);
return {
batch: batch
? {
id: batch.id,
status: batch.status,
totalFound: batch.totalFound,
importedCount: batch.importedCount,
duplicateCount: batch.duplicateCount,
errorMessage: batch.errorMessage,
startedAt: batch.startedAt.toISOString(),
completedAt: batch.completedAt?.toISOString() ?? null,
}
: null,
};
}
export async function komootImportAction(request: Request) {
await requireSessionUser(request);
// Delegate to the API route — just redirect so the page reloads with
// the new batch after the POST.
const resp = await fetch(
new URL("/api/sync/komoot/import", new URL(request.url).origin),
{ method: "POST", headers: { cookie: request.headers.get("cookie") ?? "" } },
);
if (!resp.ok) {
const body = (await resp.json()) as { error?: string };
return data({ error: body.error ?? "failed" }, { status: resp.status });
}
return redirect("/sync/import/komoot");
}

View file

@ -2,63 +2,21 @@
// and lets the user trigger a new import run.
import { useEffect, useRef } from "react";
import { data, redirect, useFetcher, useRevalidator } from "react-router";
import { data, useFetcher, useRevalidator } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/sync.import.komoot";
import { requireSessionUser } from "~/lib/auth/session.server";
import { getService } from "~/lib/connected-services";
import { getDb } from "~/lib/db";
import { importBatches } from "@trails-cool/db/schema/journal";
import { desc, eq, and } from "drizzle-orm";
import { loadKomootImport, komootImportAction } from "./sync.import.komoot.server";
export function meta() {
return [{ title: "Import from Komoot — trails.cool" }];
}
export async function loader({ request }: Route.LoaderArgs) {
const user = await requireSessionUser(request);
const service = await getService(user.id, "komoot");
if (!service) return redirect("/settings/connections/komoot");
const db = getDb();
const [batch] = await db
.select()
.from(importBatches)
.where(and(eq(importBatches.userId, user.id), eq(importBatches.connectionId, service.id)))
.orderBy(desc(importBatches.startedAt))
.limit(1);
return data({
batch: batch
? {
id: batch.id,
status: batch.status,
totalFound: batch.totalFound,
importedCount: batch.importedCount,
duplicateCount: batch.duplicateCount,
errorMessage: batch.errorMessage,
startedAt: batch.startedAt.toISOString(),
completedAt: batch.completedAt?.toISOString() ?? null,
}
: null,
});
return data(await loadKomootImport(request));
}
export async function action({ request }: Route.ActionArgs) {
await requireSessionUser(request);
// Delegate to the API route — just redirect so the page reloads with
// the new batch after the POST.
const resp = await fetch(
new URL("/api/sync/komoot/import", new URL(request.url).origin),
{ method: "POST", headers: { cookie: request.headers.get("cookie") ?? "" } },
);
if (!resp.ok) {
const body = (await resp.json()) as { error?: string };
return data({ error: body.error ?? "failed" }, { status: resp.status });
}
return redirect("/sync/import/komoot");
return await komootImportAction(request);
}
function formatDuration(seconds: number): string {

View file

@ -0,0 +1,46 @@
// Server-only loader for /users/:username/followers. See `home.server.ts`.
import { data } from "react-router";
import { eq } from "drizzle-orm";
import { getDb } from "~/lib/db";
import { users } from "@trails-cool/db/schema/journal";
import { listFollowers, countFollowers, getFollowState } from "~/lib/follow.server";
import { getSessionUser } from "~/lib/auth/session.server";
export async function loadUserFollowers(request: Request, username: string) {
const db = getDb();
const [user] = await db.select().from(users).where(eq(users.username, username));
if (!user) {
throw data({ error: "User not found" }, { status: 404 });
}
// Locked-account model: only the owner and accepted followers can see
// a private user's followers list. Non-followers (anonymous or signed-in)
// get the same 404 a stranger sees, mirroring the profile-route policy.
const currentUser = await getSessionUser(request);
const isOwn = currentUser?.id === user.id;
const followState = !isOwn && currentUser
? await getFollowState(currentUser.id, user.username)
: null;
const canSee =
isOwn ||
user.profileVisibility === "public" ||
(followState !== null && followState.following === true);
if (!canSee) {
throw data({ error: "User not found" }, { status: 404 });
}
const url = new URL(request.url);
const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10) || 1);
const [entries, total] = await Promise.all([
listFollowers(user.id, page),
countFollowers(user.id),
]);
return {
user: { username: user.username, displayName: user.displayName },
page,
total,
entries,
};
}

View file

@ -1,48 +1,10 @@
import { data } from "react-router";
import { eq } from "drizzle-orm";
import type { Route } from "./+types/users.$username.followers";
import { getDb } from "~/lib/db";
import { users } from "@trails-cool/db/schema/journal";
import { listFollowers, countFollowers, getFollowState } from "~/lib/follow.server";
import { getSessionUser } from "~/lib/auth/session.server";
import { CollectionPage } from "~/components/CollectionPage";
import { loadUserFollowers } from "./users.$username.followers.server";
export async function loader({ params, request }: Route.LoaderArgs) {
const db = getDb();
const [user] = await db.select().from(users).where(eq(users.username, params.username));
if (!user) {
throw data({ error: "User not found" }, { status: 404 });
}
// Locked-account model: only the owner and accepted followers can see
// a private user's followers list. Non-followers (anonymous or signed-in)
// get the same 404 a stranger sees, mirroring the profile-route policy.
const currentUser = await getSessionUser(request);
const isOwn = currentUser?.id === user.id;
const followState = !isOwn && currentUser
? await getFollowState(currentUser.id, user.username)
: null;
const canSee =
isOwn ||
user.profileVisibility === "public" ||
(followState !== null && followState.following === true);
if (!canSee) {
throw data({ error: "User not found" }, { status: 404 });
}
const url = new URL(request.url);
const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10) || 1);
const [entries, total] = await Promise.all([
listFollowers(user.id, page),
countFollowers(user.id),
]);
return data({
user: { username: user.username, displayName: user.displayName },
page,
total,
entries,
});
return data(await loadUserFollowers(request, params.username));
}
export function meta({ data: d }: Route.MetaArgs) {

View file

@ -0,0 +1,45 @@
// Server-only loader for /users/:username/following. See `home.server.ts`.
import { data } from "react-router";
import { eq } from "drizzle-orm";
import { getDb } from "~/lib/db";
import { users } from "@trails-cool/db/schema/journal";
import { listFollowing, countFollowing, getFollowState } from "~/lib/follow.server";
import { getSessionUser } from "~/lib/auth/session.server";
export async function loadUserFollowing(request: Request, username: string) {
const db = getDb();
const [user] = await db.select().from(users).where(eq(users.username, username));
if (!user) {
throw data({ error: "User not found" }, { status: 404 });
}
// Locked-account model — see users.$username.followers.tsx for the
// policy. Same canSee rule applies to the following list.
const currentUser = await getSessionUser(request);
const isOwn = currentUser?.id === user.id;
const followState = !isOwn && currentUser
? await getFollowState(currentUser.id, user.username)
: null;
const canSee =
isOwn ||
user.profileVisibility === "public" ||
(followState !== null && followState.following === true);
if (!canSee) {
throw data({ error: "User not found" }, { status: 404 });
}
const url = new URL(request.url);
const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10) || 1);
const [entries, total] = await Promise.all([
listFollowing(user.id, page),
countFollowing(user.id),
]);
return {
user: { username: user.username, displayName: user.displayName },
page,
total,
entries,
};
}

View file

@ -1,47 +1,10 @@
import { data } from "react-router";
import { eq } from "drizzle-orm";
import type { Route } from "./+types/users.$username.following";
import { getDb } from "~/lib/db";
import { users } from "@trails-cool/db/schema/journal";
import { listFollowing, countFollowing, getFollowState } from "~/lib/follow.server";
import { getSessionUser } from "~/lib/auth/session.server";
import { CollectionPage } from "~/components/CollectionPage";
import { loadUserFollowing } from "./users.$username.following.server";
export async function loader({ params, request }: Route.LoaderArgs) {
const db = getDb();
const [user] = await db.select().from(users).where(eq(users.username, params.username));
if (!user) {
throw data({ error: "User not found" }, { status: 404 });
}
// Locked-account model — see users.$username.followers.tsx for the
// policy. Same canSee rule applies to the following list.
const currentUser = await getSessionUser(request);
const isOwn = currentUser?.id === user.id;
const followState = !isOwn && currentUser
? await getFollowState(currentUser.id, user.username)
: null;
const canSee =
isOwn ||
user.profileVisibility === "public" ||
(followState !== null && followState.following === true);
if (!canSee) {
throw data({ error: "User not found" }, { status: 404 });
}
const url = new URL(request.url);
const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10) || 1);
const [entries, total] = await Promise.all([
listFollowing(user.id, page),
countFollowing(user.id),
]);
return data({
user: { username: user.username, displayName: user.displayName },
page,
total,
entries,
});
return data(await loadUserFollowing(request, params.username));
}
export function meta({ data: d }: Route.MetaArgs) {

View file

@ -31,7 +31,7 @@
"drizzle-orm": "catalog:",
"isbot": "^5.1.40",
"jose": "^6.2.3",
"nodemailer": "^8.0.7",
"nodemailer": "^8.0.8",
"pino": "^10.3.1",
"prom-client": "^15.1.3",
"react": "catalog:",

View file

@ -32,8 +32,8 @@
"@expo/metro-runtime": "^55.0.11",
"@gorhom/bottom-sheet": "^5.2.14",
"@maplibre/maplibre-react-native": "^11.2.1",
"@sentry/cli": "^3.4.2",
"@sentry/react-native": "~8.11.1",
"@sentry/cli": "^3.4.3",
"@sentry/react-native": "~8.12.0",
"@trails-cool/api": "workspace:*",
"@trails-cool/gpx": "workspace:*",
"@trails-cool/i18n": "workspace:*",
@ -63,15 +63,15 @@
"react-native": "0.83.4",
"react-native-gesture-handler": "^2.31.2",
"react-native-reanimated": "^4.3.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.25.0",
"react-native-safe-area-context": "~5.8.0",
"react-native-screens": "~4.25.2",
"use-latest-callback": "^0.3.4",
"zod": "^4.4.3"
},
"devDependencies": {
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^29.5.14",
"@types/react": "~19.2.14",
"@types/react": "~19.2.15",
"jest-expo": "^55.0.17",
"react-test-renderer": "^19.2.6",
"typescript": "~5.9.2"

View file

@ -41,7 +41,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"react-router": "catalog:",
"ws": "^8.20.1",
"ws": "^8.21.0",
"y-codemirror.next": "^0.3.5",
"y-protocols": "^1.0.7",
"y-websocket": "^3.0.0",
@ -54,8 +54,8 @@
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.6",
"@vitest/browser-playwright": "^4.1.6",
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"leaflet.markercluster": "^1.5.3",
"pino-pretty": "^13.1.3",
"tailwindcss": "catalog:",

View file

@ -44,7 +44,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@expo/fingerprint": "^0.16.7",
"@expo/fingerprint": "^0.19.2",
"@fission-ai/openspec": "^1.3.1",
"@playwright/test": "^1.60.0",
"@react-router/dev": "catalog:",
@ -80,9 +80,9 @@
"tailwindcss": "catalog:",
"turbo": "^2.9.14",
"typescript": "catalog:",
"typescript-eslint": "^8.59.3",
"typescript-eslint": "^8.59.4",
"vite": "catalog:",
"vitest": "^4.1.6"
"vitest": "^4.1.7"
},
"version": "1.0.0",
"dependencies": {

View file

@ -13,7 +13,7 @@
"typecheck": "tsc"
},
"dependencies": {
"@garmin/fitsdk": "^21.202.0",
"@garmin/fitsdk": "^21.205.0",
"@trails-cool/gpx": "workspace:*"
},
"devDependencies": {

1990
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@ catalog:
"@sentry/react": ^10.53.1
"@tailwindcss/vite": ^4.3.0
"@types/node": ^22.19.19
"@types/react": ^19.2.14
"@types/react": ^19.2.15
"@types/react-dom": ^19.2.3
drizzle-kit: ^0.31.10
drizzle-orm: ^0.45.2