Merge pull request #419 from trails-cool/fix/journal-audit-server-split-all
fix(journal): extract loaders/actions for the remaining 21 mixed routes
This commit is contained in:
commit
ebf77b9b17
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 { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/activities.$id";
|
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 { ClientDate } from "~/components/ClientDate";
|
||||||
import { ClientMap } from "~/components/ClientMap";
|
import { ClientMap } from "~/components/ClientMap";
|
||||||
import type { Visibility } from "@trails-cool/db/schema/journal";
|
import { loadActivityDetail, activityDetailAction } from "./activities.$id.server";
|
||||||
|
|
||||||
const VISIBILITY_VALUES = new Set<Visibility>(["private", "unlisted", "public"]);
|
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
const activity = await getActivity(params.id);
|
return data(await loadActivityDetail(request, params.id));
|
||||||
if (!activity) throw data({ error: "Activity not found" }, { status: 404 });
|
|
||||||
|
|
||||||
const user = await getSessionUser(request);
|
|
||||||
const isOwner = user?.id === activity.ownerId;
|
|
||||||
|
|
||||||
// Visibility gate — public always, unlisted on direct link, private owner-only.
|
|
||||||
// 404 (not 403) to avoid leaking existence.
|
|
||||||
if (!canView(activity, user, { asDirectLink: true })) {
|
|
||||||
throw data({ error: "Activity not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const userRoutes = isOwner && user ? await listRoutes(user.id) : [];
|
|
||||||
|
|
||||||
return data({
|
|
||||||
activity: {
|
|
||||||
id: activity.id,
|
|
||||||
name: activity.name,
|
|
||||||
description: activity.description,
|
|
||||||
distance: activity.distance,
|
|
||||||
elevationGain: activity.elevationGain,
|
|
||||||
elevationLoss: activity.elevationLoss,
|
|
||||||
duration: activity.duration,
|
|
||||||
routeId: activity.routeId,
|
|
||||||
hasGpx: !!activity.gpx,
|
|
||||||
geojson: activity.geojson ?? null,
|
|
||||||
startedAt: activity.startedAt?.toISOString() ?? null,
|
|
||||||
visibility: activity.visibility,
|
|
||||||
createdAt: activity.createdAt.toISOString(),
|
|
||||||
importSource: activity.importSource,
|
|
||||||
},
|
|
||||||
isOwner,
|
|
||||||
routes: userRoutes.map((r) => ({ id: r.id, name: r.name })),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ params, request }: Route.ActionArgs) {
|
export async function action({ params, request }: Route.ActionArgs) {
|
||||||
const user = await requireSessionUser(request);
|
return await activityDetailAction(request, params.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(params.id, routeId, user.id);
|
|
||||||
}
|
|
||||||
return redirect(`/activities/${params.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "create-route") {
|
|
||||||
const routeId = await createRouteFromActivity(params.id, user.id);
|
|
||||||
if (routeId) return redirect(`/routes/${routeId}`);
|
|
||||||
return data({ error: "No GPX data to create route from" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "delete") {
|
|
||||||
await deleteImportByActivity(params.id);
|
|
||||||
const deleted = await deleteActivity(params.id, user.id);
|
|
||||||
if (deleted) return redirect("/activities");
|
|
||||||
return data({ error: "Activity not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "set-visibility") {
|
|
||||||
const raw = formData.get("visibility") as string | null;
|
|
||||||
if (!raw || !VISIBILITY_VALUES.has(raw as Visibility)) {
|
|
||||||
return data({ error: "Invalid visibility" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const ok = await updateActivityVisibility(params.id, user.id, raw as Visibility);
|
|
||||||
if (!ok) return data({ error: "Activity not found" }, { status: 404 });
|
|
||||||
return redirect(`/activities/${params.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data({ error: "Unknown action" }, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function meta({ data: loaderData }: Route.MetaArgs) {
|
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 { data } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/activities._index";
|
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 { ClientDate } from "~/components/ClientDate";
|
||||||
import { ClientMap } from "~/components/ClientMap";
|
import { ClientMap } from "~/components/ClientMap";
|
||||||
|
import { loadActivitiesIndex } from "./activities._index.server";
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const user = await requireSessionUser(request);
|
return data(await loadActivitiesIndex(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,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function meta(_args: Route.MetaArgs) {
|
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 type { Route } from "./+types/activities.new";
|
||||||
import { requireSessionUser } from "~/lib/auth/session.server";
|
import { loadActivitiesNew, activitiesNewAction } from "./activities.new.server";
|
||||||
import { createActivity } from "~/lib/activities.server";
|
|
||||||
import { listRoutes } from "~/lib/routes.server";
|
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const user = await requireSessionUser(request);
|
return data(await loadActivitiesNew(request));
|
||||||
|
|
||||||
const userRoutes = await listRoutes(user.id);
|
|
||||||
return data({
|
|
||||||
routes: userRoutes.map((r) => ({ id: r.id, name: r.name })),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request }: Route.ActionArgs) {
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
const user = await requireSessionUser(request);
|
return await activitiesNewAction(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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function meta(_args: Route.MetaArgs) {
|
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 { 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 { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/auth.accept-terms";
|
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 { TERMS_VERSION } from "~/lib/legal";
|
||||||
|
import { loadAcceptTerms, acceptTermsAction } from "./auth.accept-terms.server";
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [
|
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) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const user = await getSessionUser(request);
|
return await loadAcceptTerms(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 action({ request }: Route.ActionArgs) {
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
const user = await getSessionUser(request);
|
return await acceptTermsAction(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AcceptTermsPage() {
|
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 type { Route } from "./+types/auth.verify";
|
||||||
import { verifyMagicToken, verifyEmailChange } from "~/lib/auth.server";
|
import { loadAuthVerify } from "./auth.verify.server";
|
||||||
import { requireSessionUser } from "~/lib/auth/session.server";
|
|
||||||
import { completeAuth } from "~/lib/auth/completion.server";
|
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const url = new URL(request.url);
|
return await loadAuthVerify(request);
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VerifyPage() {
|
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 { Link } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/explore";
|
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";
|
import { FollowButton } from "~/components/FollowButton";
|
||||||
|
import { loadExplore } from "./explore.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 loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const viewer = await getSessionUser(request);
|
return data(await loadExplore(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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function meta(_args: Route.MetaArgs) {
|
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 { Link } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/feed";
|
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 { ClientDate } from "~/components/ClientDate";
|
||||||
import { ClientMap } from "~/components/ClientMap";
|
import { ClientMap } from "~/components/ClientMap";
|
||||||
|
import { loadFeed } from "./feed.server";
|
||||||
type View = "followed" | "public";
|
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const user = await getSessionUser(request);
|
return data(await loadFeed(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,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function meta({ data: loaderData }: Route.MetaArgs) {
|
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 { Link } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { inArray, eq, and } from "drizzle-orm";
|
|
||||||
import type { Route } from "./+types/notifications";
|
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 { ClientDate } from "~/components/ClientDate";
|
||||||
import { activities } from "@trails-cool/db/schema/journal";
|
import { loadNotifications } from "./notifications.server";
|
||||||
|
|
||||||
type Tab = "activity" | "requests";
|
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const user = await getSessionUser(request);
|
return data(await loadNotifications(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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function meta(_args: Route.MetaArgs) {
|
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 { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/routes.$id.edit";
|
import type { Route } from "./+types/routes.$id.edit";
|
||||||
import { requireSessionUser } from "~/lib/auth/session.server";
|
import { loadRouteEdit, routeEditAction } from "./routes.$id.edit.server";
|
||||||
import { getRoute, updateRoute } from "~/lib/routes.server";
|
|
||||||
import type { Visibility } from "@trails-cool/db/schema/journal";
|
|
||||||
|
|
||||||
const VISIBILITY_VALUES = new Set<Visibility>(["private", "unlisted", "public"]);
|
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
const user = await requireSessionUser(request);
|
return data(await loadRouteEdit(request, params.id));
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ params, request }: Route.ActionArgs) {
|
export async function action({ params, request }: Route.ActionArgs) {
|
||||||
const user = await requireSessionUser(request);
|
return await routeEditAction(request, params.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(params.id, user.id, input);
|
|
||||||
return redirect(`/routes/${params.id}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function meta(_args: Route.MetaArgs) {
|
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 { useState, useCallback } from "react";
|
||||||
import { data, redirect } from "react-router";
|
import { data } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/routes.$id";
|
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 { ClientDate } from "~/components/ClientDate";
|
||||||
import { ClientMap } from "~/components/ClientMap";
|
import { ClientMap } from "~/components/ClientMap";
|
||||||
|
import { loadRouteDetail, routeDetailAction } from "./routes.$id.server";
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
const [routeWithVersions, routeWithGeojson] = await Promise.all([
|
return data(await loadRouteDetail(request, params.id));
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ params, request }: Route.ActionArgs) {
|
export async function action({ params, request }: Route.ActionArgs) {
|
||||||
const user = await requireSessionUser(request);
|
return routeDetailAction(request, params.id);
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function meta({ data: loaderData }: Route.MetaArgs) {
|
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 { data } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/routes._index";
|
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 { ClientDate } from "~/components/ClientDate";
|
||||||
import { ClientMap } from "~/components/ClientMap";
|
import { ClientMap } from "~/components/ClientMap";
|
||||||
|
import { loadRoutesIndex } from "./routes._index.server";
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const user = await requireSessionUser(request);
|
return data(await loadRoutesIndex(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,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function meta(_args: Route.MetaArgs) {
|
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 type { Route } from "./+types/routes.new";
|
||||||
import { requireSessionUser } from "~/lib/auth/session.server";
|
import { loadRoutesNew, routesNewAction } from "./routes.new.server";
|
||||||
import { createRoute } from "~/lib/routes.server";
|
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
await requireSessionUser(request);
|
return data(await loadRoutesNew(request));
|
||||||
return data({});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request }: Route.ActionArgs) {
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
const user = await requireSessionUser(request);
|
return await routesNewAction(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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function meta(_args: Route.MetaArgs) {
|
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 { useState, useEffect } from "react";
|
||||||
import { data, redirect, useFetcher } from "react-router";
|
import { data, useFetcher } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/settings.account";
|
import type { Route } from "./+types/settings.account";
|
||||||
import { getSessionUser } from "~/lib/auth/session.server";
|
import { loadAccountSettings } from "./settings.account.server";
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [{ title: "Account — Settings — trails.cool" }];
|
return [{ title: "Account — Settings — trails.cool" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const user = await getSessionUser(request);
|
return data(await loadAccountSettings(request));
|
||||||
if (!user) throw redirect("/auth/login");
|
|
||||||
return data({
|
|
||||||
user: {
|
|
||||||
username: user.username,
|
|
||||||
email: user.email,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AccountSettings({ loaderData }: Route.ComponentProps) {
|
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)
|
// Authenticated — email + password (password encrypted at rest)
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { data, redirect, useFetcher } from "react-router";
|
import { data, useFetcher } from "react-router";
|
||||||
import { getOrigin } from "~/lib/config.server";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/settings.connections.komoot";
|
import type { Route } from "./+types/settings.connections.komoot";
|
||||||
import { getSessionUser } from "~/lib/auth/session.server";
|
import { loadKomootConnection } from "./settings.connections.komoot.server";
|
||||||
import { getService } from "~/lib/connected-services/manager";
|
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [{ title: "Connect Komoot — Settings — trails.cool" }];
|
return [{ title: "Connect Komoot — Settings — trails.cool" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const user = await getSessionUser(request);
|
return data(await loadKomootConnection(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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type VerifyResponse = { success?: boolean; error?: string };
|
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 { useState, useEffect } from "react";
|
||||||
import { data, redirect, useFetcher } from "react-router";
|
import { data, useFetcher } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/settings.profile";
|
import type { Route } from "./+types/settings.profile";
|
||||||
import { getSessionUser } from "~/lib/auth/session.server";
|
import { loadProfileSettings } from "./settings.profile.server";
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [{ title: "Profile — Settings — trails.cool" }];
|
return [{ title: "Profile — Settings — trails.cool" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const user = await getSessionUser(request);
|
return data(await loadProfileSettings(request));
|
||||||
if (!user) throw redirect("/auth/login");
|
|
||||||
return data({
|
|
||||||
user: {
|
|
||||||
username: user.username,
|
|
||||||
displayName: user.displayName,
|
|
||||||
bio: user.bio,
|
|
||||||
profileVisibility: user.profileVisibility,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfileSettings({ loaderData }: Route.ComponentProps) {
|
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 { useState, useEffect, useCallback } from "react";
|
||||||
import { data, redirect, useFetcher } from "react-router";
|
import { data, useFetcher } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type { Route } from "./+types/settings.security";
|
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 { ClientDate } from "~/components/ClientDate";
|
||||||
|
import { loadSecuritySettings } from "./settings.security.server";
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [{ title: "Security — Settings — trails.cool" }];
|
return [{ title: "Security — Settings — trails.cool" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const user = await getSessionUser(request);
|
return data(await loadSecuritySettings(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(),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function transportLabel(transports: string[] | null, t: (key: string) => string): string {
|
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 { Link, useLocation } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/settings";
|
import type { Route } from "./+types/settings";
|
||||||
import { getSessionUser } from "~/lib/auth/session.server";
|
import { loadSettingsLayout } from "./settings.server";
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [{ title: "Settings — trails.cool" }];
|
return [{ title: "Settings — trails.cool" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const user = await getSessionUser(request);
|
return await loadSettingsLayout(request);
|
||||||
if (!user) throw redirect("/auth/login");
|
|
||||||
return { username: user.username };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavItem {
|
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 { 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 { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/sync.import.$provider";
|
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 { ClientDate } from "~/components/ClientDate";
|
||||||
|
import { loadSyncImportProvider, syncImportProviderAction } from "./sync.import.$provider.server";
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
const user = await requireSessionUser(request);
|
return data(await loadSyncImportProvider(request, params.provider));
|
||||||
|
|
||||||
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),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ params, request }: Route.ActionArgs) {
|
export async function action({ params, request }: Route.ActionArgs) {
|
||||||
const user = await requireSessionUser(request);
|
return data(await syncImportProviderAction(request, params.provider));
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function meta({ data: loaderData }: Route.MetaArgs) {
|
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.
|
// and lets the user trigger a new import run.
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
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 { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/sync.import.komoot";
|
import type { Route } from "./+types/sync.import.komoot";
|
||||||
import { requireSessionUser } from "~/lib/auth/session.server";
|
import { loadKomootImport, komootImportAction } from "./sync.import.komoot.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";
|
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [{ title: "Import from Komoot — trails.cool" }];
|
return [{ title: "Import from Komoot — trails.cool" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const user = await requireSessionUser(request);
|
return data(await loadKomootImport(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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request }: Route.ActionArgs) {
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
await requireSessionUser(request);
|
return await komootImportAction(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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
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 { data } from "react-router";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type { Route } from "./+types/users.$username.followers";
|
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 { CollectionPage } from "~/components/CollectionPage";
|
||||||
|
import { loadUserFollowers } from "./users.$username.followers.server";
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
const db = getDb();
|
return data(await loadUserFollowers(request, params.username));
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function meta({ data: d }: Route.MetaArgs) {
|
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 { data } from "react-router";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type { Route } from "./+types/users.$username.following";
|
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 { CollectionPage } from "~/components/CollectionPage";
|
||||||
|
import { loadUserFollowing } from "./users.$username.following.server";
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
const db = getDb();
|
return data(await loadUserFollowing(request, params.username));
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function meta({ data: d }: Route.MetaArgs) {
|
export function meta({ data: d }: Route.MetaArgs) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue