diff --git a/openspec/changes/phase-1-mvp/tasks.md b/openspec/changes/phase-1-mvp/tasks.md
index 3115f8d..46e770c 100644
--- a/openspec/changes/phase-1-mvp/tasks.md
+++ b/openspec/changes/phase-1-mvp/tasks.md
@@ -10,12 +10,12 @@
## 2. Shared Packages
-- [ ] 2.1 Implement `@trails-cool/types` — Route, Activity, Waypoint, RouteVersion, RouteMetadata interfaces
-- [ ] 2.2 Implement `@trails-cool/gpx` — GPX parser (XML → waypoints/tracks/elevation) and GPX generator (waypoints/tracks → XML)
-- [ ] 2.3 Implement `@trails-cool/map` — MapView React component (Leaflet + OSM), RouteLayer component (GeoJSON polyline), layer switcher (OSM/OpenTopoMap/CyclOSM)
-- [ ] 2.4 Implement `@trails-cool/ui` — Button, Input, Card, Layout components with Tailwind styling
-- [ ] 2.5 Implement `@trails-cool/i18n` — react-i18next config, English + German translation files, LanguageSwitcher component
-- [ ] 2.6 Verify all packages are importable from both apps
+- [x] 2.1 Implement `@trails-cool/types` — Route, Activity, Waypoint, RouteVersion, RouteMetadata interfaces
+- [x] 2.2 Implement `@trails-cool/gpx` — GPX parser (XML → waypoints/tracks/elevation) and GPX generator (waypoints/tracks → XML)
+- [x] 2.3 Implement `@trails-cool/map` — MapView React component (Leaflet + OSM), RouteLayer component (GeoJSON polyline), layer switcher (OSM/OpenTopoMap/CyclOSM)
+- [x] 2.4 Implement `@trails-cool/ui` — Button, Input, Card, Layout components with Tailwind styling
+- [x] 2.5 Implement `@trails-cool/i18n` — react-i18next config, English + German translation files, LanguageSwitcher component
+- [x] 2.6 Verify all packages are importable from both apps
## 3. Infrastructure
diff --git a/package.json b/package.json
index b0e3430..738b560 100644
--- a/package.json
+++ b/package.json
@@ -27,15 +27,21 @@
"@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
+ "@types/leaflet": "^1.9.21",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^10.1.0",
"eslint-config-prettier": "^10.1.8",
+ "i18next": "^25.10.4",
+ "i18next-browser-languagedetector": "^8.2.1",
"jsdom": "^29.0.1",
+ "leaflet": "^1.9.4",
"playwright": "^1.58.2",
"prettier": "^3.8.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
+ "react-i18next": "^16.6.1",
+ "react-leaflet": "^5.0.0",
"react-router": "^7.13.1",
"tailwindcss": "^4.2.2",
"turbo": "^2.8.20",
diff --git a/packages/gpx/package.json b/packages/gpx/package.json
index 367f9dc..f4a6df1 100644
--- a/packages/gpx/package.json
+++ b/packages/gpx/package.json
@@ -2,6 +2,12 @@
"name": "@trails-cool/gpx",
"version": "0.0.1",
"type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
"main": "./src/index.ts",
- "types": "./src/index.ts"
+ "types": "./src/index.ts",
+ "dependencies": {
+ "@trails-cool/types": "workspace:*"
+ }
}
diff --git a/packages/gpx/src/generate.test.ts b/packages/gpx/src/generate.test.ts
new file mode 100644
index 0000000..c4e1a5b
--- /dev/null
+++ b/packages/gpx/src/generate.test.ts
@@ -0,0 +1,51 @@
+import { describe, it, expect } from "vitest";
+import { generateGpx } from "./generate";
+import { parseGpx } from "./parse";
+
+describe("generateGpx", () => {
+ it("generates valid GPX with name", () => {
+ const gpx = generateGpx({ name: "Test Route" });
+ expect(gpx).toContain('Test Route");
+ expect(gpx).toContain("");
+ });
+
+ it("generates waypoints", () => {
+ const gpx = generateGpx({
+ waypoints: [
+ { lat: 52.52, lon: 13.405, name: "Berlin" },
+ { lat: 48.137, lon: 11.576 },
+ ],
+ });
+ expect(gpx).toContain('wpt lat="52.52" lon="13.405"');
+ expect(gpx).toContain("Berlin");
+ expect(gpx).toContain('wpt lat="48.137" lon="11.576"');
+ });
+
+ it("generates track points with elevation", () => {
+ const gpx = generateGpx({
+ tracks: [[{ lat: 52.52, lon: 13.405, ele: 34 }, { lat: 48.137, lon: 11.576, ele: 519 }]],
+ });
+ expect(gpx).toContain("");
+ expect(gpx).toContain('');
+ expect(gpx).toContain("34");
+ });
+
+ it("escapes XML special characters in names", () => {
+ const gpx = generateGpx({ name: "Route & B" });
+ expect(gpx).toContain("Route <A> & B");
+ });
+
+ it("produces round-trippable GPX", () => {
+ const original = generateGpx({
+ name: "Round Trip",
+ waypoints: [{ lat: 52.52, lon: 13.405, name: "Start" }],
+ tracks: [[{ lat: 52.52, lon: 13.405, ele: 34 }, { lat: 48.137, lon: 11.576, ele: 519 }]],
+ });
+ const parsed = parseGpx(original);
+ expect(parsed.name).toBe("Round Trip");
+ expect(parsed.waypoints).toHaveLength(1);
+ expect(parsed.waypoints[0]!.name).toBe("Start");
+ expect(parsed.tracks[0]).toHaveLength(2);
+ });
+});
diff --git a/packages/gpx/src/generate.ts b/packages/gpx/src/generate.ts
new file mode 100644
index 0000000..1bfcd8c
--- /dev/null
+++ b/packages/gpx/src/generate.ts
@@ -0,0 +1,60 @@
+import type { Waypoint } from "@trails-cool/types";
+import type { TrackPoint } from "./types";
+
+/**
+ * Generate a GPX XML string from waypoints and track points.
+ */
+export function generateGpx(options: {
+ name?: string;
+ waypoints?: Waypoint[];
+ tracks?: TrackPoint[][];
+}): string {
+ const lines: string[] = [
+ '',
+ '',
+ ];
+
+ if (options.name) {
+ lines.push(" ", ` ${escapeXml(options.name)}`, " ");
+ }
+
+ if (options.waypoints) {
+ for (const wpt of options.waypoints) {
+ lines.push(` `);
+ if (wpt.name) {
+ lines.push(` ${escapeXml(wpt.name)}`);
+ }
+ lines.push(" ");
+ }
+ }
+
+ if (options.tracks) {
+ for (const track of options.tracks) {
+ lines.push(" ", " ");
+ for (const pt of track) {
+ lines.push(` `);
+ if (pt.ele !== undefined) {
+ lines.push(` ${pt.ele}`);
+ }
+ if (pt.time) {
+ lines.push(` `);
+ }
+ lines.push(" ");
+ }
+ lines.push(" ", " ");
+ }
+ }
+
+ lines.push("");
+ return lines.join("\n");
+}
+
+function escapeXml(str: string): string {
+ return str
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
diff --git a/packages/gpx/src/index.ts b/packages/gpx/src/index.ts
index 3a76bbc..6dc0a8d 100644
--- a/packages/gpx/src/index.ts
+++ b/packages/gpx/src/index.ts
@@ -1,4 +1,3 @@
-/**
- * GPX parsing, generation, and validation for trails.cool
- */
-export {};
+export { parseGpx } from "./parse";
+export { generateGpx } from "./generate";
+export type { GpxData, TrackPoint, ElevationProfile } from "./types";
diff --git a/packages/gpx/src/parse.test.ts b/packages/gpx/src/parse.test.ts
new file mode 100644
index 0000000..dd31b56
--- /dev/null
+++ b/packages/gpx/src/parse.test.ts
@@ -0,0 +1,56 @@
+import { describe, it, expect } from "vitest";
+import { parseGpx } from "./parse";
+
+const sampleGpx = `
+
+ Test Route
+ Berlin
+ Munich
+
+
+ 34
+ 113
+ 519
+
+
+`;
+
+describe("parseGpx", () => {
+ it("parses route name", () => {
+ const result = parseGpx(sampleGpx);
+ expect(result.name).toBe("Test Route");
+ });
+
+ it("parses waypoints with lat, lon, and name", () => {
+ const result = parseGpx(sampleGpx);
+ expect(result.waypoints).toHaveLength(2);
+ expect(result.waypoints[0]).toEqual({ lat: 52.52, lon: 13.405, name: "Berlin" });
+ expect(result.waypoints[1]).toEqual({ lat: 48.137, lon: 11.576, name: "Munich" });
+ });
+
+ it("parses track points with elevation", () => {
+ const result = parseGpx(sampleGpx);
+ expect(result.tracks).toHaveLength(1);
+ expect(result.tracks[0]).toHaveLength(3);
+ expect(result.tracks[0]![0]).toEqual({ lat: 52.52, lon: 13.405, ele: 34, time: undefined });
+ });
+
+ it("computes elevation gain and loss", () => {
+ const result = parseGpx(sampleGpx);
+ expect(result.elevation.gain).toBeGreaterThan(0);
+ expect(result.elevation.loss).toBe(0); // monotonically increasing elevation
+ expect(result.elevation.gain).toBe(485); // 113-34 + 519-113
+ });
+
+ it("builds elevation profile", () => {
+ const result = parseGpx(sampleGpx);
+ expect(result.elevation.profile).toHaveLength(3);
+ expect(result.elevation.profile[0]!.distance).toBe(0);
+ expect(result.elevation.profile[0]!.elevation).toBe(34);
+ expect(result.elevation.profile[2]!.distance).toBeGreaterThan(0);
+ });
+
+ it("throws on invalid XML", () => {
+ expect(() => parseGpx("not xml at all <<<<")).toThrow("Invalid GPX XML");
+ });
+});
diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts
new file mode 100644
index 0000000..d1069ea
--- /dev/null
+++ b/packages/gpx/src/parse.ts
@@ -0,0 +1,103 @@
+import type { Waypoint } from "@trails-cool/types";
+import type { GpxData, TrackPoint, ElevationProfile } from "./types";
+
+/**
+ * Parse a GPX XML string into structured data.
+ */
+export function parseGpx(xml: string): GpxData {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(xml, "application/xml");
+
+ const parserError = doc.querySelector("parsererror");
+ if (parserError) {
+ throw new Error(`Invalid GPX XML: ${parserError.textContent}`);
+ }
+
+ const name = doc.querySelector("metadata > name")?.textContent ?? undefined;
+ const waypoints = parseWaypoints(doc);
+ const tracks = parseTracks(doc);
+ const elevation = computeElevation(tracks);
+
+ return { name, waypoints, tracks, elevation };
+}
+
+function parseWaypoints(doc: Document): Waypoint[] {
+ const wpts = doc.querySelectorAll("wpt");
+ return Array.from(wpts).map((wpt) => {
+ const lat = parseFloat(wpt.getAttribute("lat") ?? "0");
+ const lon = parseFloat(wpt.getAttribute("lon") ?? "0");
+ const name = wpt.querySelector("name")?.textContent ?? undefined;
+ return { lat, lon, name };
+ });
+}
+
+function parseTracks(doc: Document): TrackPoint[][] {
+ const tracks: TrackPoint[][] = [];
+ const trksegs = doc.querySelectorAll("trk > trkseg");
+
+ for (const seg of trksegs) {
+ const points: TrackPoint[] = [];
+ for (const pt of seg.querySelectorAll("trkpt")) {
+ const lat = parseFloat(pt.getAttribute("lat") ?? "0");
+ const lon = parseFloat(pt.getAttribute("lon") ?? "0");
+ const eleText = pt.querySelector("ele")?.textContent;
+ const time = pt.querySelector("time")?.textContent ?? undefined;
+ points.push({
+ lat,
+ lon,
+ ele: eleText ? parseFloat(eleText) : undefined,
+ time,
+ });
+ }
+ tracks.push(points);
+ }
+
+ return tracks;
+}
+
+function computeElevation(tracks: TrackPoint[][]): GpxData["elevation"] {
+ let gain = 0;
+ let loss = 0;
+ const profile: ElevationProfile[] = [];
+ let totalDistance = 0;
+
+ for (const track of tracks) {
+ for (let i = 0; i < track.length; i++) {
+ const pt = track[i]!;
+
+ if (i > 0) {
+ const prev = track[i - 1]!;
+ totalDistance += haversineDistance(prev.lat, prev.lon, pt.lat, pt.lon);
+
+ if (pt.ele !== undefined && prev.ele !== undefined) {
+ const diff = pt.ele - prev.ele;
+ if (diff > 0) gain += diff;
+ else loss += Math.abs(diff);
+ }
+ }
+
+ if (pt.ele !== undefined) {
+ profile.push({ distance: totalDistance, elevation: pt.ele });
+ }
+ }
+ }
+
+ return { gain: Math.round(gain), loss: Math.round(loss), profile };
+}
+
+/** Haversine distance between two points in meters */
+function haversineDistance(
+ lat1: number,
+ lon1: number,
+ lat2: number,
+ lon2: number,
+): number {
+ const R = 6371000;
+ const toRad = (deg: number) => (deg * Math.PI) / 180;
+ const dLat = toRad(lat2 - lat1);
+ const dLon = toRad(lon2 - lon1);
+ const a =
+ Math.sin(dLat / 2) ** 2 +
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+}
diff --git a/packages/gpx/src/types.ts b/packages/gpx/src/types.ts
new file mode 100644
index 0000000..d7861cc
--- /dev/null
+++ b/packages/gpx/src/types.ts
@@ -0,0 +1,26 @@
+import type { Waypoint } from "@trails-cool/types";
+
+export interface TrackPoint {
+ lat: number;
+ lon: number;
+ ele?: number;
+ time?: string;
+}
+
+export interface ElevationProfile {
+ /** Distance from start in meters */
+ distance: number;
+ /** Elevation in meters */
+ elevation: number;
+}
+
+export interface GpxData {
+ name?: string;
+ waypoints: Waypoint[];
+ tracks: TrackPoint[][];
+ elevation: {
+ gain: number;
+ loss: number;
+ profile: ElevationProfile[];
+ };
+}
diff --git a/packages/i18n/package.json b/packages/i18n/package.json
index 471ff28..f55bf10 100644
--- a/packages/i18n/package.json
+++ b/packages/i18n/package.json
@@ -2,6 +2,15 @@
"name": "@trails-cool/i18n",
"version": "0.0.1",
"type": "module",
+ "exports": {
+ ".": "./src/index.ts",
+ "./locales/*": "./src/locales/*"
+ },
"main": "./src/index.ts",
- "types": "./src/index.ts"
+ "types": "./src/index.ts",
+ "peerDependencies": {
+ "react": ">=18",
+ "i18next": ">=23",
+ "react-i18next": ">=14"
+ }
}
diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts
index bc8a73d..8b069b6 100644
--- a/packages/i18n/src/index.ts
+++ b/packages/i18n/src/index.ts
@@ -1,5 +1,29 @@
-/**
- * Shared i18n configuration and translation strings for trails.cool
- * Uses react-i18next. Start with English + German.
- */
-export {};
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import LanguageDetector from "i18next-browser-languagedetector";
+import en from "./locales/en";
+import de from "./locales/de";
+
+export const defaultNS = "common";
+
+export const resources = {
+ en: { common: en.common, planner: en.planner, journal: en.journal },
+ de: { common: de.common, planner: de.planner, journal: de.journal },
+} as const;
+
+export function initI18n() {
+ return i18n
+ .use(LanguageDetector)
+ .use(initReactI18next)
+ .init({
+ resources,
+ defaultNS,
+ fallbackLng: "en",
+ supportedLngs: ["en", "de"],
+ interpolation: {
+ escapeValue: false,
+ },
+ });
+}
+
+export { i18n };
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
new file mode 100644
index 0000000..63d6037
--- /dev/null
+++ b/packages/i18n/src/locales/de.ts
@@ -0,0 +1,59 @@
+export default {
+ common: {
+ save: "Speichern",
+ cancel: "Abbrechen",
+ delete: "Löschen",
+ edit: "Bearbeiten",
+ close: "Schließen",
+ loading: "Wird geladen...",
+ error: "Etwas ist schiefgelaufen",
+ },
+ planner: {
+ title: "trails.cool Planer",
+ subtitle: "Gemeinsame Routenplanung",
+ newSession: "Neue Sitzung",
+ saveRoute: "Route speichern",
+ exportGpx: "GPX exportieren",
+ profile: "Routing-Profil",
+ profiles: {
+ trekking: "Wandern",
+ bike: "Radfahren",
+ shortest: "Kürzeste",
+ },
+ elevation: {
+ gain: "Höhenmeter aufwärts",
+ loss: "Höhenmeter abwärts",
+ profile: "Höhenprofil",
+ },
+ },
+ journal: {
+ title: "trails.cool",
+ subtitle: "Dein Outdoor-Aktivitäten-Tagebuch",
+ routes: {
+ title: "Routen",
+ new: "Neue Route",
+ edit: "Route bearbeiten",
+ editInPlanner: "Im Planer bearbeiten",
+ importGpx: "GPX importieren",
+ exportGpx: "GPX exportieren",
+ delete: "Route löschen",
+ distance: "Strecke",
+ elevationGain: "Höhenmeter",
+ },
+ activities: {
+ title: "Aktivitäten",
+ new: "Neue Aktivität",
+ duration: "Dauer",
+ linkToRoute: "Mit Route verknüpfen",
+ createRouteFromActivity: "Route aus Aktivität erstellen",
+ },
+ auth: {
+ login: "Anmelden",
+ register: "Registrieren",
+ logout: "Abmelden",
+ email: "E-Mail",
+ password: "Passwort",
+ username: "Benutzername",
+ },
+ },
+} as const;
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
new file mode 100644
index 0000000..bffca1f
--- /dev/null
+++ b/packages/i18n/src/locales/en.ts
@@ -0,0 +1,59 @@
+export default {
+ common: {
+ save: "Save",
+ cancel: "Cancel",
+ delete: "Delete",
+ edit: "Edit",
+ close: "Close",
+ loading: "Loading...",
+ error: "Something went wrong",
+ },
+ planner: {
+ title: "trails.cool Planner",
+ subtitle: "Collaborative route planning",
+ newSession: "New Session",
+ saveRoute: "Save Route",
+ exportGpx: "Export GPX",
+ profile: "Routing Profile",
+ profiles: {
+ trekking: "Hiking",
+ bike: "Cycling",
+ shortest: "Shortest",
+ },
+ elevation: {
+ gain: "Elevation Gain",
+ loss: "Elevation Loss",
+ profile: "Elevation Profile",
+ },
+ },
+ journal: {
+ title: "trails.cool",
+ subtitle: "Your outdoor activity journal",
+ routes: {
+ title: "Routes",
+ new: "New Route",
+ edit: "Edit Route",
+ editInPlanner: "Edit in Planner",
+ importGpx: "Import GPX",
+ exportGpx: "Export GPX",
+ delete: "Delete Route",
+ distance: "Distance",
+ elevationGain: "Elevation Gain",
+ },
+ activities: {
+ title: "Activities",
+ new: "New Activity",
+ duration: "Duration",
+ linkToRoute: "Link to Route",
+ createRouteFromActivity: "Create Route from Activity",
+ },
+ auth: {
+ login: "Log In",
+ register: "Sign Up",
+ logout: "Log Out",
+ email: "Email",
+ password: "Password",
+ username: "Username",
+ },
+ },
+} as const;
diff --git a/packages/map/package.json b/packages/map/package.json
index c7b6eb9..ad780b9 100644
--- a/packages/map/package.json
+++ b/packages/map/package.json
@@ -2,6 +2,15 @@
"name": "@trails-cool/map",
"version": "0.0.1",
"type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
"main": "./src/index.ts",
- "types": "./src/index.ts"
+ "types": "./src/index.ts",
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18",
+ "leaflet": ">=1.9",
+ "react-leaflet": ">=5"
+ }
}
diff --git a/packages/map/src/MapView.tsx b/packages/map/src/MapView.tsx
new file mode 100644
index 0000000..30c3347
--- /dev/null
+++ b/packages/map/src/MapView.tsx
@@ -0,0 +1,34 @@
+import { MapContainer, TileLayer, LayersControl } from "react-leaflet";
+import { baseLayers } from "./layers";
+import "leaflet/dist/leaflet.css";
+
+export interface MapViewProps {
+ center?: [number, number];
+ zoom?: number;
+ className?: string;
+ children?: React.ReactNode;
+}
+
+export function MapView({
+ center = [50.1, 10.0],
+ zoom = 6,
+ className = "h-full w-full",
+ children,
+}: MapViewProps) {
+ return (
+
+
+ {baseLayers.map((layer, i) => (
+
+
+
+ ))}
+
+ {children}
+
+ );
+}
diff --git a/packages/map/src/RouteLayer.tsx b/packages/map/src/RouteLayer.tsx
new file mode 100644
index 0000000..7c16389
--- /dev/null
+++ b/packages/map/src/RouteLayer.tsx
@@ -0,0 +1,18 @@
+import { GeoJSON } from "react-leaflet";
+import type { GeoJsonObject } from "geojson";
+
+export interface RouteLayerProps {
+ data: GeoJsonObject;
+ color?: string;
+ weight?: number;
+}
+
+export function RouteLayer({ data, color = "#2563eb", weight = 4 }: RouteLayerProps) {
+ return (
+
+ );
+}
diff --git a/packages/map/src/index.ts b/packages/map/src/index.ts
index 1905d55..a297f36 100644
--- a/packages/map/src/index.ts
+++ b/packages/map/src/index.ts
@@ -1,5 +1,6 @@
-/**
- * Map rendering utilities for trails.cool
- * Leaflet wrappers, tile layer configs, overlay management
- */
-export {};
+export { MapView } from "./MapView";
+export type { MapViewProps } from "./MapView";
+export { RouteLayer } from "./RouteLayer";
+export type { RouteLayerProps } from "./RouteLayer";
+export { baseLayers } from "./layers";
+export type { TileLayerConfig } from "./layers";
diff --git a/packages/map/src/layers.ts b/packages/map/src/layers.ts
new file mode 100644
index 0000000..afa6e36
--- /dev/null
+++ b/packages/map/src/layers.ts
@@ -0,0 +1,29 @@
+export interface TileLayerConfig {
+ name: string;
+ url: string;
+ attribution: string;
+ maxZoom?: number;
+}
+
+export const baseLayers: TileLayerConfig[] = [
+ {
+ name: "OpenStreetMap",
+ url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
+ attribution: '© OpenStreetMap',
+ maxZoom: 19,
+ },
+ {
+ name: "OpenTopoMap",
+ url: "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png",
+ attribution:
+ '© OpenTopoMap (CC-BY-SA)',
+ maxZoom: 17,
+ },
+ {
+ name: "CyclOSM",
+ url: "https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png",
+ attribution:
+ '© OpenStreetMap © CyclOSM',
+ maxZoom: 20,
+ },
+];
diff --git a/packages/ui/src/Button.tsx b/packages/ui/src/Button.tsx
new file mode 100644
index 0000000..0a436b7
--- /dev/null
+++ b/packages/ui/src/Button.tsx
@@ -0,0 +1,32 @@
+import type { ButtonHTMLAttributes } from "react";
+
+export interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: "primary" | "secondary" | "ghost";
+ size?: "sm" | "md" | "lg";
+}
+
+const variantStyles = {
+ primary: "bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800",
+ secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 active:bg-gray-300",
+ ghost: "text-gray-700 hover:bg-gray-100 active:bg-gray-200",
+};
+
+const sizeStyles = {
+ sm: "px-2.5 py-1.5 text-sm",
+ md: "px-4 py-2 text-sm",
+ lg: "px-6 py-3 text-base",
+};
+
+export function Button({
+ variant = "primary",
+ size = "md",
+ className = "",
+ ...props
+}: ButtonProps) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Card.tsx b/packages/ui/src/Card.tsx
new file mode 100644
index 0000000..41934c9
--- /dev/null
+++ b/packages/ui/src/Card.tsx
@@ -0,0 +1,12 @@
+export interface CardProps {
+ className?: string;
+ children: React.ReactNode;
+}
+
+export function Card({ className = "", children }: CardProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/ui/src/Input.tsx b/packages/ui/src/Input.tsx
new file mode 100644
index 0000000..ae07294
--- /dev/null
+++ b/packages/ui/src/Input.tsx
@@ -0,0 +1,25 @@
+import type { InputHTMLAttributes } from "react";
+
+export interface InputProps extends InputHTMLAttributes {
+ label?: string;
+ error?: string;
+}
+
+export function Input({ label, error, className = "", id, ...props }: InputProps) {
+ const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
+ return (
+
+ {label && (
+
+ )}
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index ae7542b..e75bcca 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -1,4 +1,6 @@
-/**
- * Shared UI components for trails.cool
- */
-export {};
+export { Button } from "./Button";
+export type { ButtonProps } from "./Button";
+export { Input } from "./Input";
+export type { InputProps } from "./Input";
+export { Card } from "./Card";
+export type { CardProps } from "./Card";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 39e714a..70f5a2f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -71,6 +71,9 @@ importers:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@types/leaflet':
+ specifier: ^1.9.21
+ version: 1.9.21
'@types/react':
specifier: ^19.2.14
version: 19.2.14
@@ -83,9 +86,18 @@ importers:
eslint-config-prettier:
specifier: ^10.1.8
version: 10.1.8(eslint@10.1.0(jiti@2.6.1))
+ i18next:
+ specifier: ^25.10.4
+ version: 25.10.4(typescript@5.9.3)
+ i18next-browser-languagedetector:
+ specifier: ^8.2.1
+ version: 8.2.1
jsdom:
specifier: ^29.0.1
version: 29.0.1
+ leaflet:
+ specifier: ^1.9.4
+ version: 1.9.4
playwright:
specifier: ^1.58.2
version: 1.58.2
@@ -98,6 +110,12 @@ importers:
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
+ react-i18next:
+ specifier: ^16.6.1
+ version: 16.6.1(i18next@25.10.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
+ react-leaflet:
+ specifier: ^5.0.0
+ version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-router:
specifier: ^7.13.1
version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -233,11 +251,38 @@ importers:
specifier: 'catalog:'
version: 6.4.1(jiti@2.6.1)(lightningcss@1.32.0)
- packages/gpx: {}
+ packages/gpx:
+ dependencies:
+ '@trails-cool/types':
+ specifier: workspace:*
+ version: link:../types
- packages/i18n: {}
+ packages/i18n:
+ dependencies:
+ i18next:
+ specifier: '>=23'
+ version: 25.10.4(typescript@5.9.3)
+ react:
+ specifier: '>=18'
+ version: 19.2.4
+ react-i18next:
+ specifier: '>=14'
+ version: 16.6.1(i18next@25.10.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
- packages/map: {}
+ packages/map:
+ dependencies:
+ leaflet:
+ specifier: '>=1.9'
+ version: 1.9.4
+ react:
+ specifier: '>=18'
+ version: 19.2.4
+ react-dom:
+ specifier: '>=18'
+ version: 19.2.4(react@19.2.4)
+ react-leaflet:
+ specifier: '>=5'
+ version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
packages/types: {}
@@ -832,6 +877,13 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ '@react-leaflet/core@3.0.0':
+ resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
+ peerDependencies:
+ leaflet: ^1.9.0
+ react: ^19.0.0
+ react-dom: ^19.0.0
+
'@react-router/dev@7.13.1':
resolution: {integrity: sha512-H+kEvbbOaWGaitOyL6CgqPsHqRUh66HuVRvIEaZEqdoAY/1xChdhmmq6ZumMHzcFHgHlfOcoXgNHlz6ZO4NWcg==}
engines: {node: '>=20.0.0'}
@@ -1173,9 +1225,15 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+ '@types/geojson@7946.0.16':
+ resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
+
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+ '@types/leaflet@1.9.21':
+ resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
+
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -1708,10 +1766,24 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ html-parse-stringify@3.0.1:
+ resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
+
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
+ i18next-browser-languagedetector@8.2.1:
+ resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==}
+
+ i18next@25.10.4:
+ resolution: {integrity: sha512-XsE/6eawy090meuFU0BTY9BtmWr1m9NSwLr0NK7/A04LA58wdAvDsi9WNOJ40Qb1E9NIPbvnVLZEN2fWDd3/3Q==}
+ peerDependencies:
+ typescript: ^5
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@@ -1795,6 +1867,9 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+ leaflet@1.9.4:
+ resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
+
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -2083,9 +2158,32 @@ packages:
peerDependencies:
react: ^19.2.4
+ react-i18next@16.6.1:
+ resolution: {integrity: sha512-izjXh+AkBLy3h3xe3sh6Gg1flhFHc3UyzsMftMKYJr2Z7WvAZQIdjjpHypctN41zFoeLdJUNGDgP1+Qich2fYg==}
+ peerDependencies:
+ i18next: '>= 25.6.2'
+ react: '>= 16.8.0'
+ react-dom: '*'
+ react-native: '*'
+ typescript: ^5
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+ react-native:
+ optional: true
+ typescript:
+ optional: true
+
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+ react-leaflet@5.0.0:
+ resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
+ peerDependencies:
+ leaflet: ^1.9.0
+ react: ^19.0.0
+ react-dom: ^19.0.0
+
react-refresh@0.14.2:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'}
@@ -2303,6 +2401,11 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ use-sync-external-store@1.6.0:
+ resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
@@ -2439,6 +2542,10 @@ packages:
jsdom:
optional: true
+ void-elements@3.1.0:
+ resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
+ engines: {node: '>=0.10.0'}
+
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
@@ -2949,6 +3056,12 @@ snapshots:
dependencies:
playwright: 1.58.2
+ '@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ leaflet: 1.9.4
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
'@react-router/dev@7.13.1(@react-router/serve@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))(jiti@2.6.1)(lightningcss@1.32.0)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@6.4.1(jiti@2.6.1)(lightningcss@1.32.0))':
dependencies:
'@babel/core': 7.29.0
@@ -3237,8 +3350,14 @@ snapshots:
'@types/estree@1.0.8': {}
+ '@types/geojson@7946.0.16': {}
+
'@types/json-schema@7.0.15': {}
+ '@types/leaflet@1.9.21':
+ dependencies:
+ '@types/geojson': 7946.0.16
+
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
@@ -3872,6 +3991,10 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
+ html-parse-stringify@3.0.1:
+ dependencies:
+ void-elements: 3.1.0
+
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -3880,6 +4003,16 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
+ i18next-browser-languagedetector@8.2.1:
+ dependencies:
+ '@babel/runtime': 7.29.2
+
+ i18next@25.10.4(typescript@5.9.3):
+ dependencies:
+ '@babel/runtime': 7.29.2
+ optionalDependencies:
+ typescript: 5.9.3
+
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@@ -3952,6 +4085,8 @@ snapshots:
dependencies:
json-buffer: 3.0.1
+ leaflet@1.9.4: {}
+
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -4182,8 +4317,26 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
+ react-i18next@16.6.1(i18next@25.10.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
+ dependencies:
+ '@babel/runtime': 7.29.2
+ html-parse-stringify: 3.0.1
+ i18next: 25.10.4(typescript@5.9.3)
+ react: 19.2.4
+ use-sync-external-store: 1.6.0(react@19.2.4)
+ optionalDependencies:
+ react-dom: 19.2.4(react@19.2.4)
+ typescript: 5.9.3
+
react-is@17.0.2: {}
+ react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ dependencies:
+ '@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ leaflet: 1.9.4
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
react-refresh@0.14.2: {}
react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
@@ -4420,6 +4573,10 @@ snapshots:
dependencies:
punycode: 2.3.1
+ use-sync-external-store@1.6.0(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+
utils-merge@1.0.1: {}
valibot@1.3.1(typescript@5.9.3):
@@ -4502,6 +4659,8 @@ snapshots:
transitivePeerDependencies:
- msw
+ void-elements@3.1.0: {}
+
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0