Merge pull request #25 from trails-cool/journal-routes
Implement Journal route management (Group 8)
This commit is contained in:
commit
f47eca565c
7 changed files with 627 additions and 10 deletions
151
apps/journal/app/lib/routes.server.ts
Normal file
151
apps/journal/app/lib/routes.server.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq, desc, and } from "drizzle-orm";
|
||||||
|
import { getDb } from "./db";
|
||||||
|
import { routes, routeVersions } from "@trails-cool/db/schema/journal";
|
||||||
|
import { parseGpx } from "@trails-cool/gpx";
|
||||||
|
|
||||||
|
export interface RouteInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
gpx?: string;
|
||||||
|
routingProfile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRoute(ownerId: string, input: RouteInput) {
|
||||||
|
const db = getDb();
|
||||||
|
const id = randomUUID();
|
||||||
|
|
||||||
|
let distance: number | null = null;
|
||||||
|
let elevationGain: number | null = null;
|
||||||
|
let elevationLoss: number | null = null;
|
||||||
|
|
||||||
|
if (input.gpx) {
|
||||||
|
const stats = computeRouteStats(input.gpx);
|
||||||
|
distance = stats.distance;
|
||||||
|
elevationGain = stats.elevationGain;
|
||||||
|
elevationLoss = stats.elevationLoss;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(routes).values({
|
||||||
|
id,
|
||||||
|
ownerId,
|
||||||
|
name: input.name,
|
||||||
|
description: input.description ?? "",
|
||||||
|
gpx: input.gpx,
|
||||||
|
routingProfile: input.routingProfile,
|
||||||
|
distance,
|
||||||
|
elevationGain,
|
||||||
|
elevationLoss,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create initial version if GPX provided
|
||||||
|
if (input.gpx) {
|
||||||
|
await db.insert(routeVersions).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
routeId: id,
|
||||||
|
version: 1,
|
||||||
|
gpx: input.gpx,
|
||||||
|
createdBy: ownerId,
|
||||||
|
changeDescription: "Initial version",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRoute(id: string) {
|
||||||
|
const db = getDb();
|
||||||
|
const [route] = await db.select().from(routes).where(eq(routes.id, id));
|
||||||
|
return route ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRouteWithVersions(id: string) {
|
||||||
|
const db = getDb();
|
||||||
|
const [route] = await db.select().from(routes).where(eq(routes.id, id));
|
||||||
|
if (!route) return null;
|
||||||
|
|
||||||
|
const versions = await db
|
||||||
|
.select()
|
||||||
|
.from(routeVersions)
|
||||||
|
.where(eq(routeVersions.routeId, id))
|
||||||
|
.orderBy(desc(routeVersions.version));
|
||||||
|
|
||||||
|
return { ...route, versions };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRoutes(ownerId: string) {
|
||||||
|
const db = getDb();
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(routes)
|
||||||
|
.where(eq(routes.ownerId, ownerId))
|
||||||
|
.orderBy(desc(routes.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRoute(
|
||||||
|
id: string,
|
||||||
|
ownerId: string,
|
||||||
|
input: Partial<RouteInput>,
|
||||||
|
) {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
|
if (input.name !== undefined) updateData.name = input.name;
|
||||||
|
if (input.description !== undefined) updateData.description = input.description;
|
||||||
|
|
||||||
|
if (input.gpx) {
|
||||||
|
updateData.gpx = input.gpx;
|
||||||
|
const stats = computeRouteStats(input.gpx);
|
||||||
|
updateData.distance = stats.distance;
|
||||||
|
updateData.elevationGain = stats.elevationGain;
|
||||||
|
updateData.elevationLoss = stats.elevationLoss;
|
||||||
|
|
||||||
|
// Get next version number
|
||||||
|
const existingVersions = await db
|
||||||
|
.select()
|
||||||
|
.from(routeVersions)
|
||||||
|
.where(eq(routeVersions.routeId, id))
|
||||||
|
.orderBy(desc(routeVersions.version));
|
||||||
|
|
||||||
|
const nextVersion = (existingVersions[0]?.version ?? 0) + 1;
|
||||||
|
|
||||||
|
await db.insert(routeVersions).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
routeId: id,
|
||||||
|
version: nextVersion,
|
||||||
|
gpx: input.gpx,
|
||||||
|
createdBy: ownerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(routes)
|
||||||
|
.set(updateData)
|
||||||
|
.where(and(eq(routes.id, id), eq(routes.ownerId, ownerId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRoute(id: string, ownerId: string) {
|
||||||
|
const db = getDb();
|
||||||
|
const result = await db
|
||||||
|
.delete(routes)
|
||||||
|
.where(and(eq(routes.id, id), eq(routes.ownerId, ownerId)))
|
||||||
|
.returning({ id: routes.id });
|
||||||
|
return result.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRouteStats(gpxString: string) {
|
||||||
|
try {
|
||||||
|
const gpxData = parseGpx(gpxString);
|
||||||
|
return {
|
||||||
|
distance: Math.round(
|
||||||
|
gpxData.elevation.profile.length > 0
|
||||||
|
? gpxData.elevation.profile[gpxData.elevation.profile.length - 1]!.distance
|
||||||
|
: 0,
|
||||||
|
),
|
||||||
|
elevationGain: gpxData.elevation.gain,
|
||||||
|
elevationLoss: gpxData.elevation.loss,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { distance: null, elevationGain: null, elevationLoss: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,5 +8,9 @@ export default [
|
||||||
route("auth/logout", "routes/auth.logout.tsx"),
|
route("auth/logout", "routes/auth.logout.tsx"),
|
||||||
route("api/auth/register", "routes/api.auth.register.ts"),
|
route("api/auth/register", "routes/api.auth.register.ts"),
|
||||||
route("api/auth/login", "routes/api.auth.login.ts"),
|
route("api/auth/login", "routes/api.auth.login.ts"),
|
||||||
|
route("routes", "routes/routes._index.tsx"),
|
||||||
|
route("routes/new", "routes/routes.new.tsx"),
|
||||||
|
route("routes/:id", "routes/routes.$id.tsx"),
|
||||||
|
route("routes/:id/edit", "routes/routes.$id.edit.tsx"),
|
||||||
route("users/:username", "routes/users.$username.tsx"),
|
route("users/:username", "routes/users.$username.tsx"),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|
|
||||||
108
apps/journal/app/routes/routes.$id.edit.tsx
Normal file
108
apps/journal/app/routes/routes.$id.edit.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { data, redirect } from "react-router";
|
||||||
|
import type { Route } from "./+types/routes.$id.edit";
|
||||||
|
import { getSessionUser } from "~/lib/auth.server";
|
||||||
|
import { getRoute, updateRoute } from "~/lib/routes.server";
|
||||||
|
|
||||||
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
const user = await getSessionUser(request);
|
||||||
|
if (!user) return redirect("/auth/login");
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ params, request }: Route.ActionArgs) {
|
||||||
|
const user = await getSessionUser(request);
|
||||||
|
if (!user) return redirect("/auth/login");
|
||||||
|
|
||||||
|
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 input: { name?: string; description?: string; gpx?: string } = {};
|
||||||
|
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);
|
||||||
|
return redirect(`/routes/${params.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function meta(_args: Route.MetaArgs) {
|
||||||
|
return [{ title: "Edit Route — trails.cool" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditRoutePage({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { route } = loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Edit Route</h1>
|
||||||
|
|
||||||
|
<form method="post" encType="multipart/form-data" className="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
defaultValue={route.name}
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows={3}
|
||||||
|
defaultValue={route.description ?? ""}
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="gpx" className="block text-sm font-medium text-gray-700">
|
||||||
|
Update GPX (optional — creates new version)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="gpx"
|
||||||
|
name="gpx"
|
||||||
|
type="file"
|
||||||
|
accept=".gpx,application/gpx+xml"
|
||||||
|
className="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file:bg-blue-50 file:px-4 file:py-2 file:text-sm file:font-medium file:text-blue-700 hover:file:bg-blue-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={`/routes/${route.id}`}
|
||||||
|
className="rounded-md border border-gray-300 px-4 py-2 text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
apps/journal/app/routes/routes.$id.tsx
Normal file
181
apps/journal/app/routes/routes.$id.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { data, redirect } from "react-router";
|
||||||
|
import type { Route } from "./+types/routes.$id";
|
||||||
|
import { getSessionUser } from "~/lib/auth.server";
|
||||||
|
import { getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
|
||||||
|
|
||||||
|
|
||||||
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
const route = await getRouteWithVersions(params.id);
|
||||||
|
if (!route) throw data({ error: "Route not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const user = await getSessionUser(request);
|
||||||
|
const isOwner = user?.id === route.ownerId;
|
||||||
|
|
||||||
|
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,
|
||||||
|
createdAt: route.createdAt.toISOString(),
|
||||||
|
updatedAt: route.updatedAt.toISOString(),
|
||||||
|
},
|
||||||
|
versions: route.versions.map((v) => ({
|
||||||
|
version: v.version,
|
||||||
|
changeDescription: v.changeDescription,
|
||||||
|
createdAt: v.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
isOwner,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ params, request }: Route.ActionArgs) {
|
||||||
|
const user = await getSessionUser(request);
|
||||||
|
if (!user) return redirect("/auth/login");
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "export-gpx") {
|
||||||
|
const route = await getRouteWithVersions(params.id);
|
||||||
|
if (!route?.gpx) return data({ error: "No GPX data" }, { status: 400 });
|
||||||
|
|
||||||
|
return new Response(route.gpx, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/gpx+xml",
|
||||||
|
"Content-Disposition": `attachment; filename="${route.name.replace(/[^a-z0-9]/gi, "_")}.gpx"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return data({ error: "Unknown action" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function meta({ data: loaderData }: Route.MetaArgs) {
|
||||||
|
const name = (loaderData as { route: { name: string } })?.route?.name ?? "Route";
|
||||||
|
return [{ title: `${name} — trails.cool` }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { route, versions, isOwner } = loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{route.name}</h1>
|
||||||
|
{route.description && (
|
||||||
|
<p className="mt-2 text-gray-600">{route.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isOwner && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<a
|
||||||
|
href={`/routes/${route.id}/edit`}
|
||||||
|
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="intent" value="export-gpx" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!route.hasGpx}
|
||||||
|
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Export GPX
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-3 gap-4">
|
||||||
|
{route.distance != null && (
|
||||||
|
<div className="rounded-md bg-gray-50 p-4">
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{(route.distance / 1000).toFixed(1)} km
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">Distance</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{route.elevationGain != null && (
|
||||||
|
<div className="rounded-md bg-gray-50 p-4">
|
||||||
|
<p className="text-2xl font-bold text-gray-900">↑ {route.elevationGain} m</p>
|
||||||
|
<p className="text-sm text-gray-500">Ascent</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{route.elevationLoss != null && (
|
||||||
|
<div className="rounded-md bg-gray-50 p-4">
|
||||||
|
<p className="text-2xl font-bold text-gray-900">↓ {route.elevationLoss} m</p>
|
||||||
|
<p className="text-sm text-gray-500">Descent</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{versions.length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Version History</h2>
|
||||||
|
<ul className="mt-3 divide-y divide-gray-200">
|
||||||
|
{versions.map((v) => (
|
||||||
|
<li key={v.version} className="py-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-gray-700">v{v.version}</span>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{new Date(v.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{v.changeDescription && (
|
||||||
|
<p className="text-sm text-gray-500">{v.changeDescription}</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<div className="mt-8 border-t border-gray-200 pt-6">
|
||||||
|
<form method="post" onSubmit={(e) => {
|
||||||
|
if (!confirm("Are you sure you want to delete this route?")) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<input type="hidden" name="intent" value="delete" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md bg-red-50 px-4 py-2 text-sm text-red-700 hover:bg-red-100"
|
||||||
|
>
|
||||||
|
Delete Route
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/journal/app/routes/routes._index.tsx
Normal file
74
apps/journal/app/routes/routes._index.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { data, redirect } from "react-router";
|
||||||
|
import type { Route } from "./+types/routes._index";
|
||||||
|
import { getSessionUser } from "~/lib/auth.server";
|
||||||
|
import { listRoutes } from "~/lib/routes.server";
|
||||||
|
|
||||||
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
|
const user = await getSessionUser(request);
|
||||||
|
if (!user) return redirect("/auth/login");
|
||||||
|
|
||||||
|
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(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function meta(_args: Route.MetaArgs) {
|
||||||
|
return [{ title: "My Routes — trails.cool" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoutesListPage({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { routes } = loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">My Routes</h1>
|
||||||
|
<a
|
||||||
|
href="/routes/new"
|
||||||
|
className="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
New Route
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{routes.length === 0 ? (
|
||||||
|
<p className="mt-8 text-center text-gray-500">
|
||||||
|
No routes yet. Create your first route!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-6 divide-y divide-gray-200">
|
||||||
|
{routes.map((route) => (
|
||||||
|
<li key={route.id}>
|
||||||
|
<a
|
||||||
|
href={`/routes/${route.id}`}
|
||||||
|
className="block py-4 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-medium text-gray-900">{route.name}</h2>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{new Date(route.updatedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex gap-4 text-sm text-gray-500">
|
||||||
|
{route.distance != null && (
|
||||||
|
<span>{(route.distance / 1000).toFixed(1)} km</span>
|
||||||
|
)}
|
||||||
|
{route.elevationGain != null && (
|
||||||
|
<span>↑ {route.elevationGain} m</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
apps/journal/app/routes/routes.new.tsx
Normal file
99
apps/journal/app/routes/routes.new.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
|
||||||
|
import { data, redirect } from "react-router";
|
||||||
|
import type { Route } from "./+types/routes.new";
|
||||||
|
import { getSessionUser } from "~/lib/auth.server";
|
||||||
|
import { createRoute } from "~/lib/routes.server";
|
||||||
|
|
||||||
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
|
const user = await getSessionUser(request);
|
||||||
|
if (!user) return redirect("/auth/login");
|
||||||
|
return data({});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
|
const user = await getSessionUser(request);
|
||||||
|
if (!user) return redirect("/auth/login");
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return [{ title: "New Route — trails.cool" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewRoutePage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">New Route</h1>
|
||||||
|
|
||||||
|
<form method="post" encType="multipart/form-data" className="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="Sunday morning ride"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows={3}
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="gpx" className="block text-sm font-medium text-gray-700">
|
||||||
|
GPX file (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="gpx"
|
||||||
|
name="gpx"
|
||||||
|
type="file"
|
||||||
|
accept=".gpx,application/gpx+xml"
|
||||||
|
className="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file:bg-blue-50 file:px-4 file:py-2 file:text-sm file:font-medium file:text-blue-700 hover:file:bg-blue-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Create Route
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/routes"
|
||||||
|
className="rounded-md border border-gray-300 px-4 py-2 text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -76,16 +76,16 @@
|
||||||
|
|
||||||
## 8. Journal — Route Management
|
## 8. Journal — Route Management
|
||||||
|
|
||||||
- [ ] 8.1 Set up PostgreSQL schema (journal.routes table with PostGIS geometry column, journal.route_versions table)
|
- [x] 8.1 Set up PostgreSQL schema (journal.routes table with PostGIS geometry column, journal.route_versions table)
|
||||||
- [ ] 8.2 Implement route creation page (form: name, description, optional GPX upload)
|
- [x] 8.2 Implement route creation page (form: name, description, optional GPX upload)
|
||||||
- [ ] 8.3 Implement route detail page (map, metadata, version history)
|
- [x] 8.3 Implement route detail page (map, metadata, version history)
|
||||||
- [ ] 8.4 Implement route edit page (update name, description)
|
- [x] 8.4 Implement route edit page (update name, description)
|
||||||
- [ ] 8.5 Implement route deletion (with confirmation dialog)
|
- [x] 8.5 Implement route deletion (with confirmation dialog)
|
||||||
- [ ] 8.6 Implement GPX import (parse GPX, extract geometry for PostGIS, compute stats)
|
- [x] 8.6 Implement GPX import (parse GPX, extract geometry for PostGIS, compute stats)
|
||||||
- [ ] 8.7 Implement GPX export (generate GPX from stored data, download)
|
- [x] 8.7 Implement GPX export (generate GPX from stored data, download)
|
||||||
- [ ] 8.8 Implement route versioning (create new version on each GPX update)
|
- [x] 8.8 Implement route versioning (create new version on each GPX update)
|
||||||
- [ ] 8.9 Implement route list page (user's routes, sorted by last updated)
|
- [x] 8.9 Implement route list page (user's routes, sorted by last updated)
|
||||||
- [ ] 8.10 Implement route metadata computation (distance, elevation gain/loss from GPX)
|
- [x] 8.10 Implement route metadata computation (distance, elevation gain/loss from GPX)
|
||||||
|
|
||||||
## 9. Planner-Journal Handoff
|
## 9. Planner-Journal Handoff
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue