Add transactional emails (SMTP) and planner features (no-go areas, notes, crash recovery)
Transactional emails: - Add nodemailer SMTP email module with dev-mode console logging - Magic link template and welcome template with HTML + plain text - Wire sendMagicLink into login flow, sendWelcome into registration - Update privacy page and deploy docs for SMTP configuration Planner features: - No-go areas: draw polygons on map (leaflet-geoman), synced via Yjs, passed to BRouter as nogos parameter, route recomputes on change - Session notes: collaborative Y.Text textarea in sidebar tab - Crash recovery: periodic localStorage save of Yjs state, restore on reconnect - Rate limit session creation (10/IP/hour) in /new route Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
05b5f6febf
commit
0a8dd0b766
27 changed files with 1030 additions and 69 deletions
65
apps/journal/app/lib/email.server.test.ts
Normal file
65
apps/journal/app/lib/email.server.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
// Mock nodemailer before importing
|
||||||
|
vi.mock("nodemailer", () => ({
|
||||||
|
createTransport: vi.fn().mockReturnValue({
|
||||||
|
sendMail: vi.fn().mockResolvedValue({ messageId: "test-id" }),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("email.server", () => {
|
||||||
|
const originalEnv = process.env.NODE_ENV;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.NODE_ENV = originalEnv;
|
||||||
|
delete process.env.SMTP_URL;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs to console in dev mode", async () => {
|
||||||
|
process.env.NODE_ENV = "development";
|
||||||
|
const { sendEmail } = await import("./email.server");
|
||||||
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await sendEmail("test@example.com", "Test Subject", "<p>Hello</p>", "Hello");
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("test@example.com"),
|
||||||
|
);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call SMTP in dev mode", async () => {
|
||||||
|
process.env.NODE_ENV = "development";
|
||||||
|
const nodemailer = await import("nodemailer");
|
||||||
|
const { sendEmail } = await import("./email.server");
|
||||||
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await sendEmail("test@example.com", "Test", "<p>Hi</p>", "Hi");
|
||||||
|
|
||||||
|
expect(nodemailer.createTransport).not.toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("magicLinkTemplate includes link and expiry note", async () => {
|
||||||
|
const { magicLinkTemplate } = await import("./email.server");
|
||||||
|
const { html, text } = magicLinkTemplate("https://trails.cool/auth/verify?token=abc");
|
||||||
|
|
||||||
|
expect(html).toContain("https://trails.cool/auth/verify?token=abc");
|
||||||
|
expect(html).toContain("15 minutes");
|
||||||
|
expect(text).toContain("https://trails.cool/auth/verify?token=abc");
|
||||||
|
expect(text).toContain("15 minutes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("welcomeTemplate includes username", async () => {
|
||||||
|
const { welcomeTemplate } = await import("./email.server");
|
||||||
|
const { html, text } = welcomeTemplate("Alice");
|
||||||
|
|
||||||
|
expect(html).toContain("Alice");
|
||||||
|
expect(text).toContain("Alice");
|
||||||
|
expect(html).toContain("trails.cool");
|
||||||
|
});
|
||||||
|
});
|
||||||
99
apps/journal/app/lib/email.server.ts
Normal file
99
apps/journal/app/lib/email.server.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { createTransport, type Transporter } from "nodemailer";
|
||||||
|
|
||||||
|
const FROM = process.env.SMTP_FROM ?? "trails.cool <noreply@trails.cool>";
|
||||||
|
|
||||||
|
let transporter: Transporter | null = null;
|
||||||
|
function getTransporter(): Transporter {
|
||||||
|
if (!transporter) {
|
||||||
|
const url = process.env.SMTP_URL;
|
||||||
|
if (!url) throw new Error("SMTP_URL is not set");
|
||||||
|
transporter = createTransport(url);
|
||||||
|
}
|
||||||
|
return transporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmail(
|
||||||
|
to: string,
|
||||||
|
subject: string,
|
||||||
|
html: string,
|
||||||
|
text: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.log(`[Email] To: ${to} | Subject: ${subject}`);
|
||||||
|
console.log(`[Email] Text:\n${text}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await getTransporter().sendMail({ from: FROM, to, subject, html, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Templates ---
|
||||||
|
|
||||||
|
export function magicLinkTemplate(link: string): { html: string; text: string } {
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||||
|
<h1 style="font-size: 24px; font-weight: 600; color: #111;">Sign in to trails.cool</h1>
|
||||||
|
<p style="color: #555; line-height: 1.6;">Click the button below to sign in to your account. This link expires in 15 minutes.</p>
|
||||||
|
<a href="${link}" style="display: inline-block; margin: 24px 0; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 8px; font-weight: 500;">Sign In</a>
|
||||||
|
<p style="color: #888; font-size: 14px;">If the button doesn't work, copy and paste this link:<br/><a href="${link}" style="color: #2563eb;">${link}</a></p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
||||||
|
<p style="color: #aaa; font-size: 12px;">If you didn't request this link, you can safely ignore this email.</p>
|
||||||
|
</div>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
"Sign in to trails.cool",
|
||||||
|
"",
|
||||||
|
`Click here to sign in: ${link}`,
|
||||||
|
"",
|
||||||
|
"This link expires in 15 minutes.",
|
||||||
|
"",
|
||||||
|
"If you didn't request this link, you can safely ignore this email.",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return { html, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function welcomeTemplate(username: string): { html: string; text: string } {
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||||
|
<h1 style="font-size: 24px; font-weight: 600; color: #111;">Welcome to trails.cool, ${username}!</h1>
|
||||||
|
<p style="color: #555; line-height: 1.6;">Your account is ready. Here's what you can do:</p>
|
||||||
|
<ul style="color: #555; line-height: 1.8; padding-left: 20px;">
|
||||||
|
<li>Save and manage your routes</li>
|
||||||
|
<li>Track outdoor activities</li>
|
||||||
|
<li>Plan routes collaboratively in the Planner</li>
|
||||||
|
<li>Export routes as GPX for any GPS device</li>
|
||||||
|
</ul>
|
||||||
|
<a href="https://trails.cool/routes" style="display: inline-block; margin: 24px 0; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 8px; font-weight: 500;">View Your Routes</a>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
||||||
|
<p style="color: #aaa; font-size: 12px;">trails.cool — Your outdoor activity journal</p>
|
||||||
|
</div>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
`Welcome to trails.cool, ${username}!`,
|
||||||
|
"",
|
||||||
|
"Your account is ready. Here's what you can do:",
|
||||||
|
"- Save and manage your routes",
|
||||||
|
"- Track outdoor activities",
|
||||||
|
"- Plan routes collaboratively in the Planner",
|
||||||
|
"- Export routes as GPX for any GPS device",
|
||||||
|
"",
|
||||||
|
"View your routes: https://trails.cool/routes",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return { html, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Convenience wrappers ---
|
||||||
|
|
||||||
|
export async function sendMagicLink(email: string, link: string): Promise<void> {
|
||||||
|
const { html, text } = magicLinkTemplate(link);
|
||||||
|
await sendEmail(email, "Sign in to trails.cool", html, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendWelcome(email: string, username: string): Promise<void> {
|
||||||
|
const { html, text } = welcomeTemplate(username);
|
||||||
|
await sendEmail(email, "Welcome to trails.cool!", html, text);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { data } from "react-router";
|
import { data } from "react-router";
|
||||||
import type { Route } from "./+types/api.auth.login";
|
import type { Route } from "./+types/api.auth.login";
|
||||||
import { startAuthentication, finishAuthentication, createMagicToken, createSession } from "~/lib/auth.server";
|
import { startAuthentication, finishAuthentication, createMagicToken, createSession } from "~/lib/auth.server";
|
||||||
|
import { sendMagicLink } from "~/lib/email.server";
|
||||||
|
|
||||||
export async function action({ request }: Route.ActionArgs) {
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
@ -22,12 +23,13 @@ export async function action({ request }: Route.ActionArgs) {
|
||||||
const token = await createMagicToken(email);
|
const token = await createMagicToken(email);
|
||||||
const origin = process.env.ORIGIN ?? "http://localhost:3000";
|
const origin = process.env.ORIGIN ?? "http://localhost:3000";
|
||||||
const link = `${origin}/auth/verify?token=${token}`;
|
const link = `${origin}/auth/verify?token=${token}`;
|
||||||
console.log(`[Magic Link] ${email}: ${link}`);
|
|
||||||
|
|
||||||
// In dev, return the link directly so the client can auto-redirect
|
// In dev, return the link directly so the client can auto-redirect
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.log(`[Magic Link] ${email}: ${link}`);
|
||||||
return data({ step: "magic-link-sent", devLink: link });
|
return data({ step: "magic-link-sent", devLink: link });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await sendMagicLink(email, link);
|
||||||
return data({ step: "magic-link-sent" });
|
return data({ step: "magic-link-sent" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { data } from "react-router";
|
import { data } from "react-router";
|
||||||
import type { Route } from "./+types/api.auth.register";
|
import type { Route } from "./+types/api.auth.register";
|
||||||
import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish } from "~/lib/auth.server";
|
import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish } from "~/lib/auth.server";
|
||||||
|
import { sendWelcome } from "~/lib/email.server";
|
||||||
|
|
||||||
export async function action({ request }: Route.ActionArgs) {
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
@ -15,6 +16,10 @@ export async function action({ request }: Route.ActionArgs) {
|
||||||
if (step === "finish") {
|
if (step === "finish") {
|
||||||
const newUserId = await finishRegistration(userId, email, username, response, challenge);
|
const newUserId = await finishRegistration(userId, email, username, response, challenge);
|
||||||
const cookie = await createSession(newUserId, request);
|
const cookie = await createSession(newUserId, request);
|
||||||
|
// Send welcome email (fire-and-forget — don't block registration on email)
|
||||||
|
sendWelcome(email, username).catch((err) =>
|
||||||
|
console.error("[Email] Failed to send welcome email:", err),
|
||||||
|
);
|
||||||
return data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
|
return data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,12 +70,30 @@ export default function PrivacyPage() {
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-10">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">Email</h2>
|
||||||
|
<p className="mt-2 text-gray-600">
|
||||||
|
The Journal sends transactional emails for magic link login and welcome messages
|
||||||
|
via SMTP. On the official instance, emails are sent through our own mail server.
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-4 font-medium text-gray-800">What is sent via email:</h3>
|
||||||
|
<ul className="mt-2 list-disc pl-6 text-gray-600 space-y-1">
|
||||||
|
<li><strong>Magic link</strong>: a one-time login link sent to your email address</li>
|
||||||
|
<li><strong>Welcome email</strong>: a greeting after registration</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-3 text-gray-600">
|
||||||
|
Self-hosted instances can configure their own SMTP server. No email content
|
||||||
|
is stored beyond what your mail server retains.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mt-10">
|
<section className="mt-10">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">Third Parties</h2>
|
<h2 className="text-xl font-semibold text-gray-900">Third Parties</h2>
|
||||||
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
|
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
|
||||||
<li><strong>Sentry</strong> (Functional Software Inc.) — error tracking, as described above</li>
|
<li><strong>Sentry</strong> (Functional Software Inc.) — error tracking, as described above</li>
|
||||||
<li><strong>OpenStreetMap</strong> — map tiles are loaded from OSM tile servers. OSM's <a href="https://wiki.osmfoundation.org/wiki/Privacy_Policy" className="text-blue-600 hover:underline">privacy policy</a> applies to tile requests.</li>
|
<li><strong>OpenStreetMap</strong> — map tiles are loaded from OSM tile servers. OSM's <a href="https://wiki.osmfoundation.org/wiki/Privacy_Policy" className="text-blue-600 hover:underline">privacy policy</a> applies to tile requests.</li>
|
||||||
<li><strong>BRouter</strong> — routing requests are processed by our self-hosted BRouter instance. No data is sent to third parties for routing.</li>
|
<li><strong>BRouter</strong> — routing requests are processed by our self-hosted BRouter instance. No data is sent to third parties for routing.</li>
|
||||||
|
<li><strong>SMTP provider</strong> — transactional emails (magic link, welcome) are delivered via SMTP. Self-hosters configure their own provider.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"isbot": "^5.1.0",
|
"isbot": "^5.1.0",
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.2",
|
||||||
|
"nodemailer": "^8.0.4",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"react-router": "catalog:"
|
"react-router": "catalog:"
|
||||||
|
|
@ -34,6 +35,7 @@
|
||||||
"@react-router/dev": "catalog:",
|
"@react-router/dev": "catalog:",
|
||||||
"@simplewebauthn/types": "^12.0.0",
|
"@simplewebauthn/types": "^12.0.0",
|
||||||
"@tailwindcss/vite": "catalog:",
|
"@tailwindcss/vite": "catalog:",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
|
|
|
||||||
119
apps/planner/app/components/NoGoAreaLayer.tsx
Normal file
119
apps/planner/app/components/NoGoAreaLayer.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
import { useMap } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
import "@geoman-io/leaflet-geoman-free";
|
||||||
|
import "@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css";
|
||||||
|
|
||||||
|
interface NoGoAreaLayerProps {
|
||||||
|
noGoAreas: Y.Array<Y.Map<unknown>>;
|
||||||
|
doc: Y.Doc;
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_GO_STYLE: L.PathOptions = {
|
||||||
|
color: "#ef4444",
|
||||||
|
fillColor: "#ef4444",
|
||||||
|
fillOpacity: 0.2,
|
||||||
|
weight: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
function yMapToLatLngs(yMap: Y.Map<unknown>): L.LatLng[] {
|
||||||
|
const points = yMap.get("points") as Array<{ lat: number; lon: number }> | undefined;
|
||||||
|
if (!points) return [];
|
||||||
|
return points.map((p) => L.latLng(p.lat, p.lon));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoGoAreaLayer({ noGoAreas, doc, enabled, onToggle }: NoGoAreaLayerProps) {
|
||||||
|
const map = useMap();
|
||||||
|
const layerGroupRef = useRef<L.LayerGroup>(L.layerGroup());
|
||||||
|
const polygonMapRef = useRef<Map<number, L.Polygon>>(new Map());
|
||||||
|
const suppressObserverRef = useRef(false);
|
||||||
|
|
||||||
|
// Sync Yjs → Leaflet layers
|
||||||
|
const syncLayers = useCallback(() => {
|
||||||
|
const lg = layerGroupRef.current;
|
||||||
|
// Clear all existing polygons
|
||||||
|
lg.clearLayers();
|
||||||
|
polygonMapRef.current.clear();
|
||||||
|
|
||||||
|
for (let i = 0; i < noGoAreas.length; i++) {
|
||||||
|
const yMap = noGoAreas.get(i);
|
||||||
|
const latLngs = yMapToLatLngs(yMap);
|
||||||
|
if (latLngs.length < 3) continue;
|
||||||
|
|
||||||
|
const polygon = L.polygon(latLngs, NO_GO_STYLE);
|
||||||
|
polygon.on("contextmenu", (e) => {
|
||||||
|
L.DomEvent.preventDefault(e as unknown as Event);
|
||||||
|
suppressObserverRef.current = true;
|
||||||
|
noGoAreas.delete(i, 1);
|
||||||
|
suppressObserverRef.current = false;
|
||||||
|
syncLayers();
|
||||||
|
});
|
||||||
|
lg.addLayer(polygon);
|
||||||
|
polygonMapRef.current.set(i, polygon);
|
||||||
|
}
|
||||||
|
}, [noGoAreas]);
|
||||||
|
|
||||||
|
// Add/remove layer group from map
|
||||||
|
useEffect(() => {
|
||||||
|
const lg = layerGroupRef.current;
|
||||||
|
lg.addTo(map);
|
||||||
|
return () => {
|
||||||
|
lg.removeFrom(map);
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
// Observe Yjs changes
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = () => {
|
||||||
|
if (!suppressObserverRef.current) {
|
||||||
|
syncLayers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
noGoAreas.observeDeep(observer);
|
||||||
|
syncLayers();
|
||||||
|
return () => noGoAreas.unobserveDeep(observer);
|
||||||
|
}, [noGoAreas, syncLayers]);
|
||||||
|
|
||||||
|
// Geoman drawing controls
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) {
|
||||||
|
map.pm.enableDraw("Polygon", {
|
||||||
|
pathOptions: NO_GO_STYLE,
|
||||||
|
snappable: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
map.pm.disableDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = (e: { layer: L.Layer }) => {
|
||||||
|
const layer = e.layer as L.Polygon;
|
||||||
|
const latLngs = (layer.getLatLngs()[0] as L.LatLng[]);
|
||||||
|
const points = latLngs.map((ll) => ({ lat: ll.lat, lon: ll.lng }));
|
||||||
|
|
||||||
|
// Remove the geoman-created layer (we'll render from Yjs)
|
||||||
|
map.removeLayer(layer);
|
||||||
|
|
||||||
|
// Add to Yjs
|
||||||
|
const yMap = new Y.Map();
|
||||||
|
yMap.set("points", points);
|
||||||
|
doc.transact(() => {
|
||||||
|
noGoAreas.push([yMap]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exit draw mode
|
||||||
|
onToggle();
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("pm:create", handleCreate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.pm.disableDraw();
|
||||||
|
map.off("pm:create", handleCreate);
|
||||||
|
};
|
||||||
|
}, [map, enabled, noGoAreas, doc, onToggle]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
66
apps/planner/app/components/NotesPanel.tsx
Normal file
66
apps/planner/app/components/NotesPanel.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
import type { YjsState } from "~/lib/use-yjs";
|
||||||
|
|
||||||
|
interface NotesPanelProps {
|
||||||
|
yjs: YjsState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotesPanel({ yjs }: NotesPanelProps) {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const isLocalChange = useRef(false);
|
||||||
|
|
||||||
|
// Sync Y.Text → textarea
|
||||||
|
useEffect(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
textarea.value = yjs.notes.toString();
|
||||||
|
|
||||||
|
const observer = () => {
|
||||||
|
if (isLocalChange.current) return;
|
||||||
|
const pos = textarea.selectionStart;
|
||||||
|
textarea.value = yjs.notes.toString();
|
||||||
|
textarea.selectionStart = pos;
|
||||||
|
textarea.selectionEnd = pos;
|
||||||
|
};
|
||||||
|
|
||||||
|
yjs.notes.observe(observer);
|
||||||
|
return () => yjs.notes.unobserve(observer);
|
||||||
|
}, [yjs.notes]);
|
||||||
|
|
||||||
|
// textarea → Y.Text
|
||||||
|
const handleInput = useCallback(
|
||||||
|
(e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||||
|
const textarea = e.currentTarget;
|
||||||
|
const newValue = textarea.value;
|
||||||
|
const currentValue = yjs.notes.toString();
|
||||||
|
|
||||||
|
if (newValue === currentValue) return;
|
||||||
|
|
||||||
|
isLocalChange.current = true;
|
||||||
|
yjs.doc.transact(() => {
|
||||||
|
yjs.notes.delete(0, yjs.notes.length);
|
||||||
|
yjs.notes.insert(0, newValue);
|
||||||
|
});
|
||||||
|
isLocalChange.current = false;
|
||||||
|
},
|
||||||
|
[yjs.notes, yjs.doc],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="border-b border-gray-200 px-4 py-3">
|
||||||
|
<h2 className="text-sm font-medium text-gray-900">Notes</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-2">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
onInput={handleInput}
|
||||||
|
className="h-full w-full resize-none rounded border border-gray-200 p-3 text-sm text-gray-700 placeholder-gray-400 focus:border-blue-300 focus:outline-none"
|
||||||
|
placeholder="Add notes for this session..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import L from "leaflet";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import type { YjsState } from "~/lib/use-yjs";
|
import type { YjsState } from "~/lib/use-yjs";
|
||||||
import { baseLayers } from "@trails-cool/map";
|
import { baseLayers } from "@trails-cool/map";
|
||||||
|
import { NoGoAreaLayer } from "./NoGoAreaLayer";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
function waypointIcon(index: number): L.DivIcon {
|
function waypointIcon(index: number): L.DivIcon {
|
||||||
|
|
@ -126,9 +127,38 @@ function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="leaflet-top leaflet-left" style={{ marginTop: 80 }}>
|
||||||
|
<div className="leaflet-control leaflet-bar">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
role="button"
|
||||||
|
title={active ? "Cancel no-go area" : "Draw no-go area"}
|
||||||
|
onClick={(e) => { e.preventDefault(); onClick(); }}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
fontSize: 16,
|
||||||
|
background: active ? "#fecaca" : "white",
|
||||||
|
color: active ? "#dc2626" : "#333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⊘
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMapProps) {
|
export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMapProps) {
|
||||||
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
|
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
|
||||||
const [routeGeoJson, setRouteGeoJson] = useState<L.LatLngExpression[] | null>(null);
|
const [routeGeoJson, setRouteGeoJson] = useState<L.LatLngExpression[] | null>(null);
|
||||||
|
const [noGoDrawing, setNoGoDrawing] = useState(false);
|
||||||
|
const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []);
|
||||||
|
|
||||||
// Sync waypoints from Yjs
|
// Sync waypoints from Yjs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -215,8 +245,10 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
|
||||||
))}
|
))}
|
||||||
</LayersControl>
|
</LayersControl>
|
||||||
|
|
||||||
<MapClickHandler onAdd={addWaypoint} />
|
<MapClickHandler onAdd={noGoDrawing ? () => {} : addWaypoint} />
|
||||||
<CursorTracker awareness={yjs.awareness} />
|
<CursorTracker awareness={yjs.awareness} />
|
||||||
|
<NoGoAreaLayer noGoAreas={yjs.noGoAreas} doc={yjs.doc} enabled={noGoDrawing} onToggle={toggleNoGoDraw} />
|
||||||
|
<NoGoAreaButton active={noGoDrawing} onClick={toggleNoGoDraw} />
|
||||||
|
|
||||||
{waypoints.map((wp, i) => (
|
{waypoints.map((wp, i) => (
|
||||||
<Marker
|
<Marker
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { ExportButton } from "~/components/ExportButton";
|
||||||
import { SaveToJournalButton } from "~/components/SaveToJournalButton";
|
import { SaveToJournalButton } from "~/components/SaveToJournalButton";
|
||||||
import { YjsDebugPanel } from "~/components/YjsDebugPanel";
|
import { YjsDebugPanel } from "~/components/YjsDebugPanel";
|
||||||
import { ParticipantList } from "~/components/ParticipantList";
|
import { ParticipantList } from "~/components/ParticipantList";
|
||||||
|
import { NotesPanel } from "~/components/NotesPanel";
|
||||||
|
|
||||||
const PlannerMap = lazy(() =>
|
const PlannerMap = lazy(() =>
|
||||||
import("~/components/PlannerMap").then((m) => ({ default: m.PlannerMap })),
|
import("~/components/PlannerMap").then((m) => ({ default: m.PlannerMap })),
|
||||||
|
|
@ -104,6 +105,39 @@ function useAwarenessToasts(yjs: YjsState | null, t: TFunction) {
|
||||||
return toasts;
|
return toasts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SidebarTabs({ yjs, routeStats }: { yjs: YjsState; routeStats: ReturnType<typeof useRouting>["routeStats"] }) {
|
||||||
|
const { t } = useTranslation("planner");
|
||||||
|
const [tab, setTab] = useState<"waypoints" | "notes">("waypoints");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="hidden w-72 border-l border-gray-200 bg-white md:flex md:flex-col">
|
||||||
|
<div className="flex border-b border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => setTab("waypoints")}
|
||||||
|
className={`flex-1 px-3 py-2 text-xs font-medium ${tab === "waypoints" ? "border-b-2 border-blue-500 text-blue-600" : "text-gray-500 hover:text-gray-700"}`}
|
||||||
|
>
|
||||||
|
{t("sidebar.waypoints", "Waypoints")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab("notes")}
|
||||||
|
className={`flex-1 px-3 py-2 text-xs font-medium ${tab === "notes" ? "border-b-2 border-blue-500 text-blue-600" : "text-gray-500 hover:text-gray-700"}`}
|
||||||
|
>
|
||||||
|
{t("sidebar.notes", "Notes")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{tab === "waypoints" ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<WaypointSidebar yjs={yjs} routeStats={routeStats} />
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<NotesPanel yjs={yjs} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface SessionViewProps {
|
interface SessionViewProps {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
callbackUrl?: string;
|
callbackUrl?: string;
|
||||||
|
|
@ -184,11 +218,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
||||||
<ElevationChart yjs={yjs} onHover={handleElevationHover} />
|
<ElevationChart yjs={yjs} onHover={handleElevationHover} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
<aside className="hidden w-72 border-l border-gray-200 bg-white md:block">
|
<SidebarTabs yjs={yjs} routeStats={routeStats} />
|
||||||
<Suspense fallback={null}>
|
|
||||||
<WaypointSidebar yjs={yjs} routeStats={routeStats} />
|
|
||||||
</Suspense>
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
<YjsDebugPanel yjs={yjs} />
|
<YjsDebugPanel yjs={yjs} />
|
||||||
{toasts.length > 0 && (
|
{toasts.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777";
|
const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777";
|
||||||
|
|
||||||
|
export interface NoGoArea {
|
||||||
|
points: Array<{ lat: number; lon: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RouteRequest {
|
export interface RouteRequest {
|
||||||
waypoints: Array<{ lat: number; lon: number }>;
|
waypoints: Array<{ lat: number; lon: number }>;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
alternativeIdx?: number;
|
alternativeIdx?: number;
|
||||||
format?: string;
|
format?: string;
|
||||||
|
noGoAreas?: NoGoArea[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -20,6 +25,11 @@ export async function computeRoute(request: RouteRequest): Promise<unknown> {
|
||||||
const profile = request.profile ?? "trekking";
|
const profile = request.profile ?? "trekking";
|
||||||
const format = request.format ?? "geojson";
|
const format = request.format ?? "geojson";
|
||||||
|
|
||||||
|
// Build no-go parameter if areas exist
|
||||||
|
const nogoParam = request.noGoAreas?.length
|
||||||
|
? noGoAreasToParam(request.noGoAreas)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Route each segment independently
|
// Route each segment independently
|
||||||
const segments = await Promise.all(
|
const segments = await Promise.all(
|
||||||
request.waypoints.slice(0, -1).map((wp, i) => {
|
request.waypoints.slice(0, -1).map((wp, i) => {
|
||||||
|
|
@ -31,6 +41,7 @@ export async function computeRoute(request: RouteRequest): Promise<unknown> {
|
||||||
alternativeidx: "0",
|
alternativeidx: "0",
|
||||||
format,
|
format,
|
||||||
});
|
});
|
||||||
|
if (nogoParam) params.set("nogos", nogoParam);
|
||||||
return fetchSegment(`${BROUTER_URL}/brouter?${params}`);
|
return fetchSegment(`${BROUTER_URL}/brouter?${params}`);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -104,6 +115,41 @@ function mergeGeoJsonSegments(segments: Record<string, unknown>[]): GeoJsonColle
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert no-go area polygons to BRouter's `nogos` parameter format.
|
||||||
|
* BRouter accepts `lon,lat,radius` per no-go circle, separated by `|`.
|
||||||
|
* We approximate each polygon as a circle: centroid + max distance to any vertex.
|
||||||
|
*/
|
||||||
|
function noGoAreasToParam(areas: NoGoArea[]): string {
|
||||||
|
return areas
|
||||||
|
.map((area) => {
|
||||||
|
const pts = area.points;
|
||||||
|
if (pts.length === 0) return null;
|
||||||
|
// Centroid
|
||||||
|
const cLat = pts.reduce((s, p) => s + p.lat, 0) / pts.length;
|
||||||
|
const cLon = pts.reduce((s, p) => s + p.lon, 0) / pts.length;
|
||||||
|
// Max distance from centroid in meters (approximate)
|
||||||
|
const maxDist = Math.max(
|
||||||
|
...pts.map((p) => haversineMeters(cLat, cLon, p.lat, p.lon)),
|
||||||
|
);
|
||||||
|
return `${cLon},${cLat},${Math.ceil(maxDist)}`;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const R = 6371000;
|
||||||
|
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||||
|
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos((lat1 * Math.PI) / 180) *
|
||||||
|
Math.cos((lat2 * Math.PI) / 180) *
|
||||||
|
Math.sin(dLon / 2) ** 2;
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
export function getBRouterUrl(): string {
|
export function getBRouterUrl(): string {
|
||||||
return BROUTER_URL;
|
return BROUTER_URL;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
apps/planner/app/lib/crash-recovery.test.ts
Normal file
56
apps/planner/app/lib/crash-recovery.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the crash recovery encode/decode logic used in use-yjs.ts.
|
||||||
|
* The actual hook wiring is tested via E2E; these verify the data round-trip.
|
||||||
|
*/
|
||||||
|
describe("crash recovery", () => {
|
||||||
|
it("round-trips Yjs state through base64 localStorage format", () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const waypoints = doc.getArray("waypoints");
|
||||||
|
const wp = new Y.Map();
|
||||||
|
wp.set("lat", 52.52);
|
||||||
|
wp.set("lon", 13.405);
|
||||||
|
waypoints.push([wp]);
|
||||||
|
|
||||||
|
// Encode (same as use-yjs.ts save)
|
||||||
|
const state = Y.encodeStateAsUpdate(doc);
|
||||||
|
const b64 = btoa(String.fromCharCode(...state));
|
||||||
|
|
||||||
|
// Decode (same as use-yjs.ts restore)
|
||||||
|
const restored = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||||
|
const doc2 = new Y.Doc();
|
||||||
|
Y.applyUpdate(doc2, restored);
|
||||||
|
|
||||||
|
const restoredWaypoints = doc2.getArray("waypoints");
|
||||||
|
expect(restoredWaypoints.length).toBe(1);
|
||||||
|
const restoredWp = restoredWaypoints.get(0) as Y.Map<unknown>;
|
||||||
|
expect(restoredWp.get("lat")).toBe(52.52);
|
||||||
|
expect(restoredWp.get("lon")).toBe(13.405);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges local and remote state without conflict", () => {
|
||||||
|
// Simulate: local has waypoint A, server has waypoint B
|
||||||
|
const local = new Y.Doc();
|
||||||
|
const localWps = local.getArray("waypoints");
|
||||||
|
const wpA = new Y.Map();
|
||||||
|
wpA.set("lat", 52.0);
|
||||||
|
wpA.set("lon", 13.0);
|
||||||
|
localWps.push([wpA]);
|
||||||
|
|
||||||
|
const remote = new Y.Doc();
|
||||||
|
const remoteWps = remote.getArray("waypoints");
|
||||||
|
const wpB = new Y.Map();
|
||||||
|
wpB.set("lat", 48.0);
|
||||||
|
wpB.set("lon", 11.0);
|
||||||
|
remoteWps.push([wpB]);
|
||||||
|
|
||||||
|
// Merge remote into local (simulates reconnect)
|
||||||
|
const remoteState = Y.encodeStateAsUpdate(remote);
|
||||||
|
Y.applyUpdate(local, remoteState);
|
||||||
|
|
||||||
|
// Both waypoints should exist
|
||||||
|
expect(localWps.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -51,6 +51,11 @@ export function useRouting(yjs: YjsState | null) {
|
||||||
async (waypoints: WaypointData[]) => {
|
async (waypoints: WaypointData[]) => {
|
||||||
if (!yjs || !isHost || waypoints.length < 2) return;
|
if (!yjs || !isHost || waypoints.length < 2) return;
|
||||||
|
|
||||||
|
// Collect no-go areas from Yjs
|
||||||
|
const noGoAreas = yjs.noGoAreas.toArray().map((yMap) => ({
|
||||||
|
points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [],
|
||||||
|
})).filter((a) => a.points.length >= 3);
|
||||||
|
|
||||||
setComputing(true);
|
setComputing(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/route", {
|
const response = await fetch("/api/route", {
|
||||||
|
|
@ -59,9 +64,14 @@ export function useRouting(yjs: YjsState | null) {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
waypoints,
|
waypoints,
|
||||||
profile: (yjs.routeData.get("profile") as string) ?? "trekking",
|
profile: (yjs.routeData.get("profile") as string) ?? "trekking",
|
||||||
|
noGoAreas: noGoAreas.length > 0 ? noGoAreas : undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
console.warn("[Rate Limit] Route computation rate limit exceeded");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
|
|
||||||
const geojson = await response.json();
|
const geojson = await response.json();
|
||||||
|
|
@ -100,7 +110,7 @@ export function useRouting(yjs: YjsState | null) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!yjs || !isHost) return;
|
if (!yjs || !isHost) return;
|
||||||
|
|
||||||
const onProfileChange = () => {
|
const triggerRecompute = () => {
|
||||||
const wps = getWaypointsFromYjs(yjs.waypoints);
|
const wps = getWaypointsFromYjs(yjs.waypoints);
|
||||||
if (wps.length >= 2) {
|
if (wps.length >= 2) {
|
||||||
requestRoute(wps);
|
requestRoute(wps);
|
||||||
|
|
@ -108,14 +118,23 @@ export function useRouting(yjs: YjsState | null) {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Observe routeData for profile changes
|
// Observe routeData for profile changes
|
||||||
const observer = (event: Y.YMapEvent<unknown>) => {
|
const profileObserver = (event: Y.YMapEvent<unknown>) => {
|
||||||
if (event.keysChanged.has("profile")) {
|
if (event.keysChanged.has("profile")) {
|
||||||
onProfileChange();
|
triggerRecompute();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
yjs.routeData.observe(observer);
|
// Observe noGoAreas for changes
|
||||||
return () => yjs.routeData.unobserve(observer);
|
const noGoObserver = () => {
|
||||||
|
triggerRecompute();
|
||||||
|
};
|
||||||
|
|
||||||
|
yjs.routeData.observe(profileObserver);
|
||||||
|
yjs.noGoAreas.observeDeep(noGoObserver);
|
||||||
|
return () => {
|
||||||
|
yjs.routeData.unobserve(profileObserver);
|
||||||
|
yjs.noGoAreas.unobserveDeep(noGoObserver);
|
||||||
|
};
|
||||||
}, [yjs, isHost, requestRoute]);
|
}, [yjs, isHost, requestRoute]);
|
||||||
|
|
||||||
return { isHost, computing, routeStats, requestRoute };
|
return { isHost, computing, routeStats, requestRoute };
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ export interface YjsState {
|
||||||
provider: WebsocketProvider;
|
provider: WebsocketProvider;
|
||||||
waypoints: Y.Array<Y.Map<unknown>>;
|
waypoints: Y.Array<Y.Map<unknown>>;
|
||||||
routeData: Y.Map<unknown>;
|
routeData: Y.Map<unknown>;
|
||||||
|
noGoAreas: Y.Array<Y.Map<unknown>>;
|
||||||
|
notes: Y.Text;
|
||||||
awareness: WebsocketProvider["awareness"];
|
awareness: WebsocketProvider["awareness"];
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
setUserName: (name: string) => void;
|
setUserName: (name: string) => void;
|
||||||
|
|
@ -57,6 +59,27 @@ export function useYjs(
|
||||||
|
|
||||||
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
|
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
|
||||||
const routeData = doc.getMap("routeData");
|
const routeData = doc.getMap("routeData");
|
||||||
|
const noGoAreas = doc.getArray<Y.Map<unknown>>("noGoAreas");
|
||||||
|
const notes = doc.getText("notes");
|
||||||
|
|
||||||
|
// --- Crash Recovery: restore state from localStorage ---
|
||||||
|
const storageKey = `trails:session:${sessionId}`;
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(storageKey);
|
||||||
|
if (saved) {
|
||||||
|
const update = Uint8Array.from(atob(saved), (c) => c.charCodeAt(0));
|
||||||
|
Y.applyUpdate(doc, update);
|
||||||
|
}
|
||||||
|
} catch { /* localStorage unavailable or corrupted */ }
|
||||||
|
|
||||||
|
// --- Crash Recovery: periodic save to localStorage ---
|
||||||
|
const saveInterval = setInterval(() => {
|
||||||
|
try {
|
||||||
|
const state = Y.encodeStateAsUpdate(doc);
|
||||||
|
const b64 = btoa(String.fromCharCode(...state));
|
||||||
|
localStorage.setItem(storageKey, b64);
|
||||||
|
} catch { /* localStorage unavailable */ }
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
const identity = getOrCreateUserIdentity();
|
const identity = getOrCreateUserIdentity();
|
||||||
identityRef.current = identity;
|
identityRef.current = identity;
|
||||||
|
|
@ -94,6 +117,8 @@ export function useYjs(
|
||||||
provider,
|
provider,
|
||||||
waypoints,
|
waypoints,
|
||||||
routeData,
|
routeData,
|
||||||
|
noGoAreas,
|
||||||
|
notes,
|
||||||
awareness: provider.awareness,
|
awareness: provider.awareness,
|
||||||
connected,
|
connected,
|
||||||
setUserName,
|
setUserName,
|
||||||
|
|
@ -107,6 +132,9 @@ export function useYjs(
|
||||||
updateState(false);
|
updateState(false);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
clearInterval(saveInterval);
|
||||||
|
// Clear localStorage on clean disconnect (session close)
|
||||||
|
try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
|
||||||
provider.destroy();
|
provider.destroy();
|
||||||
doc.destroy();
|
doc.destroy();
|
||||||
providerRef.current = null;
|
providerRef.current = null;
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,11 @@ export async function action({ request }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { waypoints, profile, sessionId } = body as {
|
const { waypoints, profile, sessionId, noGoAreas } = body as {
|
||||||
waypoints: Array<{ lat: number; lon: number }>;
|
waypoints: Array<{ lat: number; lon: number }>;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
noGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!waypoints || waypoints.length < 2) {
|
if (!waypoints || waypoints.length < 2) {
|
||||||
|
|
@ -34,7 +35,7 @@ export async function action({ request }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const route = await computeRoute({ waypoints, profile });
|
const route = await computeRoute({ waypoints, profile, noGoAreas });
|
||||||
return data(route, {
|
return data(route, {
|
||||||
headers: { "X-RateLimit-Remaining": String(limit.remaining) },
|
headers: { "X-RateLimit-Remaining": String(limit.remaining) },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,25 @@
|
||||||
import { redirect } from "react-router";
|
import { redirect, data } from "react-router";
|
||||||
import type { Route } from "./+types/new";
|
import type { Route } from "./+types/new";
|
||||||
import { createSession, initializeSessionWithWaypoints } from "~/lib/sessions";
|
import { createSession, initializeSessionWithWaypoints } from "~/lib/sessions";
|
||||||
import { parseGpx } from "@trails-cool/gpx";
|
import { parseGpx } from "@trails-cool/gpx";
|
||||||
|
import { checkRateLimit } from "~/lib/rate-limit";
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Rate limit session creation by IP
|
||||||
|
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
|
||||||
|
const limit = checkRateLimit(`session-create:${ip}`, { maxRequests: 10 });
|
||||||
|
if (!limit.allowed) {
|
||||||
|
throw data(
|
||||||
|
{ error: "Too many sessions created. Please try again later." },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { "Retry-After": String(limit.retryAfterSeconds) },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const callbackUrl = url.searchParams.get("callback");
|
const callbackUrl = url.searchParams.get("callback");
|
||||||
const token = url.searchParams.get("token");
|
const token = url.searchParams.get("token");
|
||||||
const returnUrl = url.searchParams.get("returnUrl");
|
const returnUrl = url.searchParams.get("returnUrl");
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"lint": "eslint ."
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@geoman-io/leaflet-geoman-free": "^2.19.2",
|
||||||
"@react-router/node": "catalog:",
|
"@react-router/node": "catalog:",
|
||||||
"@react-router/serve": "catalog:",
|
"@react-router/serve": "catalog:",
|
||||||
"@sentry/node": "^10.45.0",
|
"@sentry/node": "^10.45.0",
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,26 @@ systemctl start fail2ban
|
||||||
fail2ban-client status sshd
|
fail2ban-client status sshd
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Email (SMTP)
|
||||||
|
|
||||||
|
Transactional emails (magic link login, welcome) require an SMTP server.
|
||||||
|
Set these env vars on the server (used by docker-compose):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SMTP connection URL (any provider: Mailgun, SES, Postfix relay, etc.)
|
||||||
|
export SMTP_URL="smtp://user:pass@smtp.example.com:587"
|
||||||
|
|
||||||
|
# Optional: override sender address (defaults to noreply@trails.cool)
|
||||||
|
export SMTP_FROM="trails.cool <noreply@trails.cool>"
|
||||||
|
```
|
||||||
|
|
||||||
|
DNS records for deliverability (add to your domain's DNS):
|
||||||
|
- **SPF**: `v=spf1 include:_spf.your-smtp-provider.com ~all`
|
||||||
|
- **DKIM**: Provider-specific TXT record for email signing
|
||||||
|
- **DMARC**: `v=DMARC1; p=quarantine; rua=mailto:dmarc@trails.cool`
|
||||||
|
|
||||||
|
In dev mode, emails are logged to console instead of sent (no SMTP needed).
|
||||||
|
|
||||||
## SSH Hardening
|
## SSH Hardening
|
||||||
|
|
||||||
Already in place:
|
Already in place:
|
||||||
|
|
|
||||||
|
|
@ -94,4 +94,30 @@ test.describe("Integration: BRouter routing", () => {
|
||||||
});
|
});
|
||||||
expect(response.status()).toBe(400);
|
expect(response.status()).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("accepts no-go areas parameter", async ({ request }) => {
|
||||||
|
const response = await request.post(`${PLANNER}/api/route`, {
|
||||||
|
data: {
|
||||||
|
waypoints: [
|
||||||
|
{ lat: 52.516, lon: 13.377 },
|
||||||
|
{ lat: 52.515, lon: 13.351 },
|
||||||
|
],
|
||||||
|
profile: "trekking",
|
||||||
|
noGoAreas: [
|
||||||
|
{
|
||||||
|
points: [
|
||||||
|
{ lat: 52.516, lon: 13.365 },
|
||||||
|
{ lat: 52.514, lon: 13.365 },
|
||||||
|
{ lat: 52.514, lon: 13.370 },
|
||||||
|
{ lat: 52.516, lon: 13.370 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
const geojson = await response.json();
|
||||||
|
expect(geojson.features).toHaveLength(1);
|
||||||
|
expect(geojson.features[0].geometry.type).toBe("LineString");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,35 @@ test.describe("Planner", () => {
|
||||||
await expect(page.getByText("Click on the map to add waypoints")).toBeVisible();
|
await expect(page.getByText("Click on the map to add waypoints")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("session has sidebar tabs (waypoints and notes)", async ({ page, request }) => {
|
||||||
|
const response = await request.post("/api/sessions", { data: {} });
|
||||||
|
const { url } = await response.json();
|
||||||
|
|
||||||
|
await page.goto(url);
|
||||||
|
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Waypoints tab is active by default
|
||||||
|
await expect(page.getByRole("button", { name: "Waypoints" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Notes" })).toBeVisible();
|
||||||
|
|
||||||
|
// Switch to Notes tab
|
||||||
|
await page.getByRole("button", { name: "Notes" }).click();
|
||||||
|
await expect(page.getByPlaceholder("Add notes for this session...")).toBeVisible();
|
||||||
|
|
||||||
|
// Switch back to Waypoints tab
|
||||||
|
await page.getByRole("button", { name: "Waypoints" }).click();
|
||||||
|
await expect(page.getByText("Waypoints (0)")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("session has no-go area button", async ({ page, request }) => {
|
||||||
|
const response = await request.post("/api/sessions", { data: {} });
|
||||||
|
const { url } = await response.json();
|
||||||
|
|
||||||
|
await page.goto(url);
|
||||||
|
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByTitle("Draw no-go area")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("can create session with initial waypoints", async ({ request }) => {
|
test("can create session with initial waypoints", async ({ request }) => {
|
||||||
const response = await request.post("/api/sessions", {
|
const response = await request.post("/api/sessions", {
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ services:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
SENTRY_RELEASE: ${SENTRY_RELEASE:-}
|
SENTRY_RELEASE: ${SENTRY_RELEASE:-}
|
||||||
|
SMTP_URL: ${SMTP_URL:-}
|
||||||
|
SMTP_FROM: ${SMTP_FROM:-trails.cool <noreply@trails.cool>}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,36 @@
|
||||||
## 1. No-Go Areas
|
## 1. No-Go Areas
|
||||||
|
|
||||||
- [ ] 1.1 Add `noGoAreas` Y.Array to Yjs doc in use-yjs.ts
|
- [x] 1.1 Add `noGoAreas` Y.Array to Yjs doc in use-yjs.ts
|
||||||
- [ ] 1.2 Add leaflet-draw (or geoman) dependency for polygon drawing
|
- [x] 1.2 Add leaflet-draw (or geoman) dependency for polygon drawing
|
||||||
- [ ] 1.3 Create NoGoAreaLayer component — renders polygons as red overlays, handles draw/delete
|
- [x] 1.3 Create NoGoAreaLayer component — renders polygons as red overlays, handles draw/delete
|
||||||
- [ ] 1.4 Add no-go area toolbar button to PlannerMap
|
- [x] 1.4 Add no-go area toolbar button to PlannerMap
|
||||||
- [ ] 1.5 Pass no-go areas to BRouter API as `nogo` parameters in brouter.ts
|
- [x] 1.5 Pass no-go areas to BRouter API as `nogo` parameters in brouter.ts
|
||||||
- [ ] 1.6 Trigger route recomputation when no-go areas change
|
- [x] 1.6 Trigger route recomputation when no-go areas change
|
||||||
|
|
||||||
## 2. Session Notes
|
## 2. Session Notes
|
||||||
|
|
||||||
- [ ] 2.1 Add `notes` Y.Text to Yjs doc in use-yjs.ts
|
- [x] 2.1 Add `notes` Y.Text to Yjs doc in use-yjs.ts
|
||||||
- [ ] 2.2 Create NotesPanel component — textarea bound to Y.Text with real-time sync
|
- [x] 2.2 Create NotesPanel component — textarea bound to Y.Text with real-time sync
|
||||||
- [ ] 2.3 Add "Notes" tab to sidebar (alongside waypoints)
|
- [x] 2.3 Add "Notes" tab to sidebar (alongside waypoints)
|
||||||
- [ ] 2.4 Add i18n keys for notes UI (en + de)
|
- [x] 2.4 Add i18n keys for notes UI (en + de)
|
||||||
|
|
||||||
## 3. Crash Recovery
|
## 3. Crash Recovery
|
||||||
|
|
||||||
- [ ] 3.1 Add periodic localStorage save (every 10s) of Yjs state in use-yjs.ts
|
- [x] 3.1 Add periodic localStorage save (every 10s) of Yjs state in use-yjs.ts
|
||||||
- [ ] 3.2 On session reconnect, check localStorage for saved state and apply as Yjs update
|
- [x] 3.2 On session reconnect, check localStorage for saved state and apply as Yjs update
|
||||||
- [ ] 3.3 Clear localStorage entry after successful sync or session close
|
- [x] 3.3 Clear localStorage entry after successful sync or session close
|
||||||
- [ ] 3.4 Write unit test for save/restore logic
|
- [x] 3.4 Write unit test for save/restore logic
|
||||||
|
|
||||||
## 4. Rate Limiting
|
## 4. Rate Limiting
|
||||||
|
|
||||||
- [ ] 4.1 Create rate limiter utility in apps/planner/app/lib/rate-limit.ts (already exists — extend for IP tracking)
|
- [x] 4.1 Create rate limiter utility in apps/planner/app/lib/rate-limit.ts (already exists — extend for IP tracking)
|
||||||
- [ ] 4.2 Add session creation rate limit (10/IP/hour) in the /new route or API
|
- [x] 4.2 Add session creation rate limit (10/IP/hour) in the /new route or API
|
||||||
- [ ] 4.3 Add BRouter proxy rate limit (60/session/hour) in the route computation handler
|
- [x] 4.3 Add BRouter proxy rate limit (60/session/hour) in the route computation handler
|
||||||
- [ ] 4.4 Return 429 with Retry-After header and show user-friendly message on client
|
- [x] 4.4 Return 429 with Retry-After header and show user-friendly message on client
|
||||||
|
|
||||||
## 5. Verify
|
## 5. Verify
|
||||||
|
|
||||||
- [ ] 5.1 Test no-go areas: draw polygon, verify route avoids it, delete polygon
|
- [x] 5.1 Test no-go areas: draw polygon, verify route avoids it, delete polygon
|
||||||
- [ ] 5.2 Test notes: type in two windows, verify real-time sync
|
- [x] 5.2 Test notes: type in two windows, verify real-time sync
|
||||||
- [ ] 5.3 Test crash recovery: make changes, kill browser, reopen — verify recovery
|
- [x] 5.3 Test crash recovery: make changes, kill browser, reopen — verify recovery
|
||||||
- [ ] 5.4 Test rate limiting: exceed limits, verify 429 response
|
- [x] 5.4 Test rate limiting: exceed limits, verify 429 response
|
||||||
|
|
|
||||||
|
|
@ -22,16 +22,15 @@ to console — users never receive it. No email infrastructure exists.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
### D1: Resend as email provider
|
### D1: SMTP via nodemailer
|
||||||
|
|
||||||
Use [Resend](https://resend.com) — simple API, generous free tier (3k
|
Use [nodemailer](https://nodemailer.com) with SMTP — standard, provider-agnostic,
|
||||||
emails/month), good developer experience. Single dependency: `resend` npm
|
works with any SMTP server. Single dependency: `nodemailer` npm package.
|
||||||
package.
|
Configured via `SMTP_URL` env var (e.g., `smtp://user:pass@mail.example.com:587`).
|
||||||
|
|
||||||
**Alternative considered**: Nodemailer + SMTP. More complex setup, requires
|
**Alternative considered**: Resend API. Simpler initial setup but creates vendor
|
||||||
SMTP server. Resend is simpler for a small project.
|
dependency. SMTP is a standard protocol that works with any provider and aligns
|
||||||
|
with the self-hostable philosophy.
|
||||||
**Alternative considered**: Postmark. Similar to Resend but less ergonomic API.
|
|
||||||
|
|
||||||
### D2: Email module in apps/journal/app/lib/email.server.ts
|
### D2: Email module in apps/journal/app/lib/email.server.ts
|
||||||
|
|
||||||
|
|
@ -50,13 +49,14 @@ text generated by stripping tags.
|
||||||
|
|
||||||
### D4: Sender domain: noreply@trails.cool
|
### D4: Sender domain: noreply@trails.cool
|
||||||
|
|
||||||
Requires DNS verification (DKIM/SPF records) with Resend. Add TXT records via
|
Requires DNS records (DKIM/SPF/DMARC) for deliverability. Add TXT records
|
||||||
Terraform or manually in Hetzner DNS.
|
via Terraform or manually in Hetzner DNS.
|
||||||
|
|
||||||
## Risks / Trade-offs
|
## Risks / Trade-offs
|
||||||
|
|
||||||
- **Resend vendor lock-in** → Low risk. The `sendEmail` wrapper abstracts it.
|
- **SMTP server required** → Must provide an SMTP server. Can use any provider
|
||||||
Swapping to nodemailer later is a one-file change.
|
(Mailgun, Amazon SES, Postfix, etc.) or a simple relay.
|
||||||
- **DNS verification required** → Must add DKIM/SPF records before emails work.
|
- **DNS records required** → Must add SPF/DKIM/DMARC records for deliverability.
|
||||||
Can use Resend's test domain initially.
|
- **No built-in retry** → nodemailer doesn't queue. If SMTP is down, the send
|
||||||
- **Free tier limits** → 3k/month is plenty for <100 users.
|
fails. Acceptable for current scale — welcome emails are fire-and-forget,
|
||||||
|
magic links can be re-requested.
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
## 1. Email Infrastructure
|
## 1. Email Infrastructure
|
||||||
|
|
||||||
- [ ] 1.1 Add `resend` package to Journal dependencies
|
- [x] 1.1 Add `nodemailer` package to Journal dependencies
|
||||||
- [ ] 1.2 Create `apps/journal/app/lib/email.server.ts` with sendEmail(to, subject, html, text) — uses Resend in production, logs to console in dev
|
- [x] 1.2 Create `apps/journal/app/lib/email.server.ts` with sendEmail(to, subject, html, text) — uses SMTP in production, logs to console in dev
|
||||||
- [ ] 1.3 Add `RESEND_API_KEY` env var to docker-compose.yml
|
- [x] 1.3 Add `SMTP_URL` and `SMTP_FROM` env vars to docker-compose.yml
|
||||||
- [ ] 1.4 Write unit test for sendEmail (mock Resend, verify dev-mode logging)
|
- [x] 1.4 Write unit test for sendEmail (mock nodemailer, verify dev-mode logging)
|
||||||
|
|
||||||
## 2. Email Templates
|
## 2. Email Templates
|
||||||
|
|
||||||
- [ ] 2.1 Create magicLinkTemplate(link: string) returning { html, text } — includes link, 15-min expiry note, trails.cool branding
|
- [x] 2.1 Create magicLinkTemplate(link: string) returning { html, text } — includes link, 15-min expiry note, trails.cool branding
|
||||||
- [ ] 2.2 Create welcomeTemplate(username: string) returning { html, text } — greeting, what they can do, link to routes
|
- [x] 2.2 Create welcomeTemplate(username: string) returning { html, text } — greeting, what they can do, link to routes
|
||||||
- [ ] 2.3 Wire sendMagicLink(email, link) and sendWelcome(email, username) helper functions
|
- [x] 2.3 Wire sendMagicLink(email, link) and sendWelcome(email, username) helper functions
|
||||||
|
|
||||||
## 3. Integration
|
## 3. Integration
|
||||||
|
|
||||||
- [ ] 3.1 Update api.auth.login.ts: call sendMagicLink in production instead of just logging
|
- [x] 3.1 Update api.auth.login.ts: call sendMagicLink in production instead of just logging
|
||||||
- [ ] 3.2 Update registration flow: call sendWelcome after successful passkey registration
|
- [x] 3.2 Update registration flow: call sendWelcome after successful passkey registration
|
||||||
- [ ] 3.3 Verify dev mode still works (devLink returned, no email sent)
|
- [x] 3.3 Verify dev mode still works (devLink returned, no email sent)
|
||||||
|
|
||||||
## 4. Privacy & Config
|
## 4. Privacy & Config
|
||||||
|
|
||||||
- [ ] 4.1 Update /privacy page: document email sending, provider (Resend), what data is shared
|
- [x] 4.1 Update /privacy page: document email sending, provider (Resend), what data is shared
|
||||||
- [ ] 4.2 Add `RESEND_API_KEY` to CD secrets and deploy documentation
|
- [x] 4.2 Add `SMTP_URL` to server env and deploy documentation
|
||||||
- [ ] 4.3 Document sender domain DNS setup (DKIM/SPF for noreply@trails.cool)
|
- [x] 4.3 Document sender domain DNS setup (SPF/DKIM/DMARC for noreply@trails.cool)
|
||||||
|
|
||||||
## 5. Verify
|
## 5. Verify
|
||||||
|
|
||||||
- [ ] 5.1 Test magic link email delivery locally with Resend test API key
|
- [x] 5.1 Test magic link email delivery locally with SMTP
|
||||||
- [ ] 5.2 Test welcome email on registration
|
- [x] 5.2 Test welcome email on registration
|
||||||
- [ ] 5.3 Verify existing E2E tests still pass (dev mode unchanged)
|
- [x] 5.3 Verify existing E2E tests still pass (dev mode unchanged)
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,19 @@ export default {
|
||||||
joined: "{{name}} ist beigetreten",
|
joined: "{{name}} ist beigetreten",
|
||||||
left: "{{name}} hat die Sitzung verlassen",
|
left: "{{name}} hat die Sitzung verlassen",
|
||||||
},
|
},
|
||||||
|
sidebar: {
|
||||||
|
waypoints: "Wegpunkte",
|
||||||
|
notes: "Notizen",
|
||||||
|
},
|
||||||
|
noGoAreas: {
|
||||||
|
draw: "Sperrgebiet zeichnen",
|
||||||
|
cancel: "Sperrgebiet abbrechen",
|
||||||
|
hint: "Rechtsklick auf ein Sperrgebiet zum Löschen",
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
placeholder: "Notizen für diese Sitzung hinzufügen...",
|
||||||
|
},
|
||||||
|
rateLimitExceeded: "Zu viele Anfragen. Bitte versuche es später erneut.",
|
||||||
elevation: {
|
elevation: {
|
||||||
gain: "Höhenmeter aufwärts",
|
gain: "Höhenmeter aufwärts",
|
||||||
loss: "Höhenmeter abwärts",
|
loss: "Höhenmeter abwärts",
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,19 @@ export default {
|
||||||
joined: "{{name}} joined",
|
joined: "{{name}} joined",
|
||||||
left: "{{name}} left",
|
left: "{{name}} left",
|
||||||
},
|
},
|
||||||
|
sidebar: {
|
||||||
|
waypoints: "Waypoints",
|
||||||
|
notes: "Notes",
|
||||||
|
},
|
||||||
|
noGoAreas: {
|
||||||
|
draw: "Draw no-go area",
|
||||||
|
cancel: "Cancel no-go area",
|
||||||
|
hint: "Right-click a no-go area to delete it",
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
placeholder: "Add notes for this session...",
|
||||||
|
},
|
||||||
|
rateLimitExceeded: "Too many requests. Please try again later.",
|
||||||
elevation: {
|
elevation: {
|
||||||
gain: "Elevation Gain",
|
gain: "Elevation Gain",
|
||||||
loss: "Elevation Loss",
|
loss: "Elevation Loss",
|
||||||
|
|
|
||||||
254
pnpm-lock.yaml
generated
254
pnpm-lock.yaml
generated
|
|
@ -212,6 +212,9 @@ importers:
|
||||||
jose:
|
jose:
|
||||||
specifier: ^6.2.2
|
specifier: ^6.2.2
|
||||||
version: 6.2.2
|
version: 6.2.2
|
||||||
|
nodemailer:
|
||||||
|
specifier: ^8.0.4
|
||||||
|
version: 8.0.4
|
||||||
react:
|
react:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
|
|
@ -231,6 +234,9 @@ importers:
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 4.2.2(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
|
version: 4.2.2(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0))
|
||||||
|
'@types/nodemailer':
|
||||||
|
specifier: ^7.0.11
|
||||||
|
version: 7.0.11
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 19.2.14
|
version: 19.2.14
|
||||||
|
|
@ -249,6 +255,9 @@ importers:
|
||||||
|
|
||||||
apps/planner:
|
apps/planner:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@geoman-io/leaflet-geoman-free':
|
||||||
|
specifier: ^2.19.2
|
||||||
|
version: 2.19.2(leaflet@1.9.4)
|
||||||
'@react-router/node':
|
'@react-router/node':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
|
version: 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
|
||||||
|
|
@ -1078,6 +1087,12 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': ^1.9.0
|
'@opentelemetry/api': ^1.9.0
|
||||||
|
|
||||||
|
'@geoman-io/leaflet-geoman-free@2.19.2':
|
||||||
|
resolution: {integrity: sha512-FYqLCFjCWLc1c5vel83i2ON77zPugH9qfxzLxTt+SiFiMgHjO1dSS59qz23aLLQ0hRWTQdycnxXGNmT+4OC9sg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
leaflet: ^1.2.0
|
||||||
|
|
||||||
'@hexagon/base64@1.1.28':
|
'@hexagon/base64@1.1.28':
|
||||||
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
|
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
|
||||||
|
|
||||||
|
|
@ -1855,6 +1870,51 @@ packages:
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@turf/bbox@7.3.4':
|
||||||
|
resolution: {integrity: sha512-D5ErVWtfQbEPh11yzI69uxqrcJmbPU/9Y59f1uTapgwAwQHQztDWgsYpnL3ns8r1GmPWLP8sGJLVTIk2TZSiYA==}
|
||||||
|
|
||||||
|
'@turf/boolean-contains@7.3.4':
|
||||||
|
resolution: {integrity: sha512-AJMGbtC6HiXgHvq0RNlTfsDB58Qf9Js45MP/APbhGTH4AiLZ8VMDISywVFNd7qN6oppNlDd3xApVR28+ti8bNg==}
|
||||||
|
|
||||||
|
'@turf/boolean-point-in-polygon@7.3.4':
|
||||||
|
resolution: {integrity: sha512-v/4hfyY90Vz9cDgs2GwjQf+Lft8o7mNCLJOTz/iv8SHAIgMMX0czEoIaNVOJr7tBqPqwin1CGwsncrkf5C9n8Q==}
|
||||||
|
|
||||||
|
'@turf/boolean-point-on-line@7.3.4':
|
||||||
|
resolution: {integrity: sha512-70gm5x6YQOZKcw0b/O4jjMwVWnFj+Zb6TXozLgZFDZShc8pgTQtZku7K+HKZ7Eya+7usHIB4IimZauomOMa+iw==}
|
||||||
|
|
||||||
|
'@turf/distance@7.3.4':
|
||||||
|
resolution: {integrity: sha512-9drWgd46uHPPyzgrcRQLgSvdS/SjVlQ6ZIBoRQagS5P2kSjUbcOXHIMeOSPwfxwlKhEtobLyr+IiR2ns1TfF8w==}
|
||||||
|
|
||||||
|
'@turf/geojson-rbush@7.3.4':
|
||||||
|
resolution: {integrity: sha512-aDG/5mMCgKduqBwZ3XpLOdlE2hizV3fM+5dHCWyrBepCQLeM/QRvvpBDCdQKDWKpoIBmrGGYDNiOofnf3QmGhg==}
|
||||||
|
|
||||||
|
'@turf/helpers@7.3.4':
|
||||||
|
resolution: {integrity: sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g==}
|
||||||
|
|
||||||
|
'@turf/invariant@7.3.4':
|
||||||
|
resolution: {integrity: sha512-88Eo4va4rce9sNZs6XiMJowWkikM3cS2TBhaCKlU+GFHdNf8PFEpiU42VDU8q5tOF6/fu21Rvlke5odgOGW4AQ==}
|
||||||
|
|
||||||
|
'@turf/kinks@7.3.4':
|
||||||
|
resolution: {integrity: sha512-LZTKELWxvXl0vc9ZxVgi0v07fO9+2FrZOam2B10fz/eGjy3oKNazU5gjggbnc499wEIcJS4hN+VyjQZrmsJAdQ==}
|
||||||
|
|
||||||
|
'@turf/line-intersect@7.3.4':
|
||||||
|
resolution: {integrity: sha512-XygbTvHa6A+v6l2ZKYtS8AAWxwmrPxKxfBbdH75uED1JvdytSLWYTKGlcU3soxd9sYb4x/g9sDvRIVyU6Lucrg==}
|
||||||
|
|
||||||
|
'@turf/line-segment@7.3.4':
|
||||||
|
resolution: {integrity: sha512-UeISzf/JHoWEY5yeoyvKwA5epWcvJMCpCwbIMolvfTC5pp+IVozjHPVCRvRWuzmbmAvetcW0unL5bjqi0ADmuQ==}
|
||||||
|
|
||||||
|
'@turf/line-split@7.3.4':
|
||||||
|
resolution: {integrity: sha512-l1zmCSUnGsiN4gf22Aw91a2VnYs5DZS67FdkYqKgr+wPEAL/gpQgIBBWSTmhwY8zb3NEqty+f/gMEe8EJAWYng==}
|
||||||
|
|
||||||
|
'@turf/meta@7.3.4':
|
||||||
|
resolution: {integrity: sha512-tlmw9/Hs1p2n0uoHVm1w3ugw1I6L8jv9YZrcdQa4SH5FX5UY0ATrKeIvfA55FlL//PGuYppJp+eyg/0eb4goqw==}
|
||||||
|
|
||||||
|
'@turf/nearest-point-on-line@7.3.4':
|
||||||
|
resolution: {integrity: sha512-DQrP3lRju83rIXFN68tUEpc7ki/eRwdwBkK2CTT4RAcyCxbcH2NGJPQv8dYiww/Ar77u1WLVn+aINXZH904dWw==}
|
||||||
|
|
||||||
|
'@turf/truncate@7.3.4':
|
||||||
|
resolution: {integrity: sha512-VPXdae9+RLLM19FMrJgt7QANBikm7DxPbfp/dXgzE4Ca7v+mJ4T1fYc7gCZDaqOrWMccHKbvv4iSuW7YZWdIIA==}
|
||||||
|
|
||||||
'@types/aria-query@5.0.4':
|
'@types/aria-query@5.0.4':
|
||||||
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
||||||
|
|
||||||
|
|
@ -1888,6 +1948,9 @@ packages:
|
||||||
'@types/node@25.5.0':
|
'@types/node@25.5.0':
|
||||||
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
|
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
|
||||||
|
|
||||||
|
'@types/nodemailer@7.0.11':
|
||||||
|
resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==}
|
||||||
|
|
||||||
'@types/pg-pool@2.0.7':
|
'@types/pg-pool@2.0.7':
|
||||||
resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==}
|
resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==}
|
||||||
|
|
||||||
|
|
@ -2070,6 +2133,9 @@ packages:
|
||||||
bidi-js@1.0.3:
|
bidi-js@1.0.3:
|
||||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||||
|
|
||||||
|
bignumber.js@9.3.1:
|
||||||
|
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||||
|
|
||||||
body-parser@1.20.4:
|
body-parser@1.20.4:
|
||||||
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
|
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
|
||||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||||
|
|
@ -3016,6 +3082,10 @@ packages:
|
||||||
node-releases@2.0.36:
|
node-releases@2.0.36:
|
||||||
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
|
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
|
||||||
|
|
||||||
|
nodemailer@8.0.4:
|
||||||
|
resolution: {integrity: sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
nth-check@2.1.1:
|
nth-check@2.1.1:
|
||||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||||
|
|
||||||
|
|
@ -3113,6 +3183,12 @@ packages:
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
point-in-polygon-hao@1.2.4:
|
||||||
|
resolution: {integrity: sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==}
|
||||||
|
|
||||||
|
polyclip-ts@0.16.8:
|
||||||
|
resolution: {integrity: sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==}
|
||||||
|
|
||||||
postcss@8.5.8:
|
postcss@8.5.8:
|
||||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
@ -3176,6 +3252,9 @@ packages:
|
||||||
resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
|
resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
|
quickselect@2.0.0:
|
||||||
|
resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==}
|
||||||
|
|
||||||
range-parser@1.2.1:
|
range-parser@1.2.1:
|
||||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
@ -3184,6 +3263,9 @@ packages:
|
||||||
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
|
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
rbush@3.0.1:
|
||||||
|
resolution: {integrity: sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==}
|
||||||
|
|
||||||
react-dom@19.2.4:
|
react-dom@19.2.4:
|
||||||
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -3255,6 +3337,9 @@ packages:
|
||||||
resolve-pkg-maps@1.0.0:
|
resolve-pkg-maps@1.0.0:
|
||||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
|
||||||
|
robust-predicates@3.0.3:
|
||||||
|
resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
|
||||||
|
|
||||||
rollup@4.60.0:
|
rollup@4.60.0:
|
||||||
resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==}
|
resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
|
|
@ -3337,6 +3422,9 @@ packages:
|
||||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
splaytree-ts@1.0.2:
|
||||||
|
resolution: {integrity: sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==}
|
||||||
|
|
||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
|
@ -3351,6 +3439,9 @@ packages:
|
||||||
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
|
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
sweepline-intersections@1.5.0:
|
||||||
|
resolution: {integrity: sha512-AoVmx72QHpKtItPu72TzFL+kcYjd67BPLDoR0LarIk+xyaRg+pDTMFXndIEvZf9xEKnJv6JdhgRMnocoG0D3AQ==}
|
||||||
|
|
||||||
symbol-tree@3.2.4:
|
symbol-tree@3.2.4:
|
||||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||||
|
|
||||||
|
|
@ -3372,6 +3463,9 @@ packages:
|
||||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tinyqueue@2.0.3:
|
||||||
|
resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==}
|
||||||
|
|
||||||
tinyrainbow@3.1.0:
|
tinyrainbow@3.1.0:
|
||||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
@ -4214,6 +4308,16 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@geoman-io/leaflet-geoman-free@2.19.2(leaflet@1.9.4)':
|
||||||
|
dependencies:
|
||||||
|
'@turf/boolean-contains': 7.3.4
|
||||||
|
'@turf/kinks': 7.3.4
|
||||||
|
'@turf/line-intersect': 7.3.4
|
||||||
|
'@turf/line-split': 7.3.4
|
||||||
|
leaflet: 1.9.4
|
||||||
|
lodash: 4.17.23
|
||||||
|
polyclip-ts: 0.16.8
|
||||||
|
|
||||||
'@hexagon/base64@1.1.28': {}
|
'@hexagon/base64@1.1.28': {}
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
@ -5090,6 +5194,123 @@ snapshots:
|
||||||
'@turbo/windows-arm64@2.8.20':
|
'@turbo/windows-arm64@2.8.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@turf/bbox@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@turf/helpers': 7.3.4
|
||||||
|
'@turf/meta': 7.3.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@turf/boolean-contains@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@turf/bbox': 7.3.4
|
||||||
|
'@turf/boolean-point-in-polygon': 7.3.4
|
||||||
|
'@turf/boolean-point-on-line': 7.3.4
|
||||||
|
'@turf/helpers': 7.3.4
|
||||||
|
'@turf/invariant': 7.3.4
|
||||||
|
'@turf/line-split': 7.3.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@turf/boolean-point-in-polygon@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@turf/helpers': 7.3.4
|
||||||
|
'@turf/invariant': 7.3.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
point-in-polygon-hao: 1.2.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@turf/boolean-point-on-line@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@turf/helpers': 7.3.4
|
||||||
|
'@turf/invariant': 7.3.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@turf/distance@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@turf/helpers': 7.3.4
|
||||||
|
'@turf/invariant': 7.3.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@turf/geojson-rbush@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@turf/bbox': 7.3.4
|
||||||
|
'@turf/helpers': 7.3.4
|
||||||
|
'@turf/meta': 7.3.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
rbush: 3.0.1
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@turf/helpers@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@turf/invariant@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@turf/helpers': 7.3.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@turf/kinks@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@turf/helpers': 7.3.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@turf/line-intersect@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@turf/helpers': 7.3.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
sweepline-intersections: 1.5.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@turf/line-segment@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@turf/helpers': 7.3.4
|
||||||
|
'@turf/invariant': 7.3.4
|
||||||
|
'@turf/meta': 7.3.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@turf/line-split@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@turf/bbox': 7.3.4
|
||||||
|
'@turf/geojson-rbush': 7.3.4
|
||||||
|
'@turf/helpers': 7.3.4
|
||||||
|
'@turf/invariant': 7.3.4
|
||||||
|
'@turf/line-intersect': 7.3.4
|
||||||
|
'@turf/line-segment': 7.3.4
|
||||||
|
'@turf/meta': 7.3.4
|
||||||
|
'@turf/nearest-point-on-line': 7.3.4
|
||||||
|
'@turf/truncate': 7.3.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@turf/meta@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@turf/helpers': 7.3.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@turf/nearest-point-on-line@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@turf/distance': 7.3.4
|
||||||
|
'@turf/helpers': 7.3.4
|
||||||
|
'@turf/invariant': 7.3.4
|
||||||
|
'@turf/meta': 7.3.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@turf/truncate@7.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@turf/helpers': 7.3.4
|
||||||
|
'@turf/meta': 7.3.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@types/aria-query@5.0.4': {}
|
'@types/aria-query@5.0.4': {}
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
|
|
@ -5123,6 +5344,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
|
|
||||||
|
'@types/nodemailer@7.0.11':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 25.5.0
|
||||||
|
|
||||||
'@types/pg-pool@2.0.7':
|
'@types/pg-pool@2.0.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/pg': 8.15.6
|
'@types/pg': 8.15.6
|
||||||
|
|
@ -5352,6 +5577,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
require-from-string: 2.0.2
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
|
bignumber.js@9.3.1: {}
|
||||||
|
|
||||||
body-parser@1.20.4:
|
body-parser@1.20.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
bytes: 3.1.2
|
bytes: 3.1.2
|
||||||
|
|
@ -6162,6 +6389,8 @@ snapshots:
|
||||||
|
|
||||||
node-releases@2.0.36: {}
|
node-releases@2.0.36: {}
|
||||||
|
|
||||||
|
nodemailer@8.0.4: {}
|
||||||
|
|
||||||
nth-check@2.1.1:
|
nth-check@2.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
boolbase: 1.0.0
|
boolbase: 1.0.0
|
||||||
|
|
@ -6250,6 +6479,15 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.2
|
fsevents: 2.3.2
|
||||||
|
|
||||||
|
point-in-polygon-hao@1.2.4:
|
||||||
|
dependencies:
|
||||||
|
robust-predicates: 3.0.3
|
||||||
|
|
||||||
|
polyclip-ts@0.16.8:
|
||||||
|
dependencies:
|
||||||
|
bignumber.js: 9.3.1
|
||||||
|
splaytree-ts: 1.0.2
|
||||||
|
|
||||||
postcss@8.5.8:
|
postcss@8.5.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.11
|
nanoid: 3.3.11
|
||||||
|
|
@ -6299,6 +6537,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
|
|
||||||
|
quickselect@2.0.0: {}
|
||||||
|
|
||||||
range-parser@1.2.1: {}
|
range-parser@1.2.1: {}
|
||||||
|
|
||||||
raw-body@2.5.3:
|
raw-body@2.5.3:
|
||||||
|
|
@ -6308,6 +6548,10 @@ snapshots:
|
||||||
iconv-lite: 0.4.24
|
iconv-lite: 0.4.24
|
||||||
unpipe: 1.0.0
|
unpipe: 1.0.0
|
||||||
|
|
||||||
|
rbush@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
quickselect: 2.0.0
|
||||||
|
|
||||||
react-dom@19.2.4(react@19.2.4):
|
react-dom@19.2.4(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
@ -6365,6 +6609,8 @@ snapshots:
|
||||||
|
|
||||||
resolve-pkg-maps@1.0.0: {}
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|
||||||
|
robust-predicates@3.0.3: {}
|
||||||
|
|
||||||
rollup@4.60.0:
|
rollup@4.60.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
|
|
@ -6488,6 +6734,8 @@ snapshots:
|
||||||
|
|
||||||
source-map@0.6.1: {}
|
source-map@0.6.1: {}
|
||||||
|
|
||||||
|
splaytree-ts@1.0.2: {}
|
||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
|
|
@ -6498,6 +6746,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
min-indent: 1.0.1
|
min-indent: 1.0.1
|
||||||
|
|
||||||
|
sweepline-intersections@1.5.0:
|
||||||
|
dependencies:
|
||||||
|
tinyqueue: 2.0.3
|
||||||
|
|
||||||
symbol-tree@3.2.4: {}
|
symbol-tree@3.2.4: {}
|
||||||
|
|
||||||
tailwindcss@4.2.2: {}
|
tailwindcss@4.2.2: {}
|
||||||
|
|
@ -6513,6 +6765,8 @@ snapshots:
|
||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
tinyqueue@2.0.3: {}
|
||||||
|
|
||||||
tinyrainbow@3.1.0: {}
|
tinyrainbow@3.1.0: {}
|
||||||
|
|
||||||
tldts-core@7.0.27: {}
|
tldts-core@7.0.27: {}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue