fix(journal): extract loaders/actions for the remaining 21 mixed routes
Completes the .server.ts split started in #418. Every route that mixes a default-export component with a server-only loader/action now has a sibling <route>.server.ts holding the data-fetching helpers; the route .tsx is a thin delegator. Routes converted (21): activities._index, activities.$id, activities.new, auth.accept-terms, auth.verify, explore, feed, notifications, routes._index, routes.$id, routes.$id.edit, routes.new, settings, settings.account, settings.connections.komoot, settings.profile, settings.security, sync.import.$provider, sync.import.komoot, users.$username.followers, users.$username.following Pattern (same as home.tsx / users.$username.tsx / settings.connections.tsx): - loader → `return data(await loadX(request, params?))` - action → `return await xAction(request, params?)` - All `getDb` / Drizzle schema / `~/lib/*.server` imports move to the .server.ts sibling. - `throw redirect(...)` and `throw data(...)` propagate through the delegator unchanged. No behavior changes — pure module-graph cleanup. Component modules no longer transitively import the DB client; Vite's tree-shake of server-only code is now backed by an explicit, file-local contract. Verified: - pnpm typecheck — green - pnpm lint — green - pnpm test — 181 passed, 31 integration-gated skipped - pnpm --filter @trails-cool/journal build — succeeds Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a57637868b
commit
df562742e1
42 changed files with 1160 additions and 937 deletions
95
apps/journal/app/routes/activities.$id.server.ts
Normal file
95
apps/journal/app/routes/activities.$id.server.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
27
apps/journal/app/routes/activities._index.server.ts
Normal file
27
apps/journal/app/routes/activities._index.server.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
41
apps/journal/app/routes/activities.new.server.ts
Normal file
41
apps/journal/app/routes/activities.new.server.ts
Normal 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}`);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
47
apps/journal/app/routes/auth.accept-terms.server.ts
Normal file
47
apps/journal/app/routes/auth.accept-terms.server.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
35
apps/journal/app/routes/auth.verify.server.ts
Normal file
35
apps/journal/app/routes/auth.verify.server.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
74
apps/journal/app/routes/explore.server.ts
Normal file
74
apps/journal/app/routes/explore.server.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
36
apps/journal/app/routes/feed.server.ts
Normal file
36
apps/journal/app/routes/feed.server.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
111
apps/journal/app/routes/notifications.server.ts
Normal file
111
apps/journal/app/routes/notifications.server.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
49
apps/journal/app/routes/routes.$id.edit.server.ts
Normal file
49
apps/journal/app/routes/routes.$id.edit.server.ts
Normal 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}`);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
170
apps/journal/app/routes/routes.$id.server.ts
Normal file
170
apps/journal/app/routes/routes.$id.server.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
20
apps/journal/app/routes/routes._index.server.ts
Normal file
20
apps/journal/app/routes/routes._index.server.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
29
apps/journal/app/routes/routes.new.server.ts
Normal file
29
apps/journal/app/routes/routes.new.server.ts
Normal 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}`);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
15
apps/journal/app/routes/settings.account.server.ts
Normal file
15
apps/journal/app/routes/settings.account.server.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
17
apps/journal/app/routes/settings.profile.server.ts
Normal file
17
apps/journal/app/routes/settings.profile.server.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
33
apps/journal/app/routes/settings.security.server.ts
Normal file
33
apps/journal/app/routes/settings.security.server.ts
Normal 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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
10
apps/journal/app/routes/settings.server.ts
Normal file
10
apps/journal/app/routes/settings.server.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
118
apps/journal/app/routes/sync.import.$provider.server.ts
Normal file
118
apps/journal/app/routes/sync.import.$provider.server.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
54
apps/journal/app/routes/sync.import.komoot.server.ts
Normal file
54
apps/journal/app/routes/sync.import.komoot.server.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
46
apps/journal/app/routes/users.$username.followers.server.ts
Normal file
46
apps/journal/app/routes/users.$username.followers.server.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
45
apps/journal/app/routes/users.$username.following.server.ts
Normal file
45
apps/journal/app/routes/users.$username.following.server.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue