Merge pull request #44 from trails-cool/wire-up-i18n

Wire up i18n in both apps
This commit is contained in:
Ullrich Schäfer 2026-03-25 02:51:41 +01:00 committed by GitHub
commit 48c74f16f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 173 additions and 77 deletions

View file

@ -2,8 +2,12 @@ import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse }
import type { LinksFunction } from "react-router";
import type { Route } from "./+types/root";
import * as Sentry from "@sentry/react";
import { useTranslation } from "react-i18next";
import { initI18n } from "@trails-cool/i18n";
import stylesheet from "@trails-cool/ui/styles.css?url";
initI18n();
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
export function Layout({ children }: { children: React.ReactNode }) {
@ -30,18 +34,19 @@ export default function App() {
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
Sentry.captureException(error);
const { t } = useTranslation();
if (isRouteErrorResponse(error)) {
return (
<div className="mx-auto max-w-md px-4 py-16 text-center">
<h1 className="text-4xl font-bold text-gray-900">{error.status}</h1>
<p className="mt-2 text-gray-600">
{error.status === 404 && "Page not found"}
{error.status === 503 && "Service temporarily unavailable. Please try again later."}
{error.status !== 404 && error.status !== 503 && (error.statusText || "Something went wrong")}
{error.status === 404 && t("pageNotFound")}
{error.status === 503 && t("serviceUnavailable")}
{error.status !== 404 && error.status !== 503 && (error.statusText || t("error"))}
</p>
<a href="/" className="mt-6 inline-block text-blue-600 hover:underline">
Go home
{t("goHome")}
</a>
</div>
);
@ -49,12 +54,12 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div className="mx-auto max-w-md px-4 py-16 text-center">
<h1 className="text-4xl font-bold text-gray-900">Error</h1>
<h1 className="text-4xl font-bold text-gray-900">{t("error")}</h1>
<p className="mt-2 text-gray-600">
{error instanceof Error ? error.message : "An unexpected error occurred"}
{error instanceof Error ? error.message : t("error")}
</p>
<a href="/" className="mt-6 inline-block text-blue-600 hover:underline">
Go home
{t("goHome")}
</a>
</div>
);

View file

@ -1,6 +1,8 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
export default function LoginPage() {
const { t } = useTranslation("journal");
const [mode, setMode] = useState<"passkey" | "magic-link">("passkey");
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
@ -81,7 +83,7 @@ export default function LoginPage() {
return (
<div className="mx-auto max-w-md px-4 py-16">
<h1 className="text-2xl font-bold text-gray-900">Sign In</h1>
<h1 className="text-2xl font-bold text-gray-900">{t("auth.login")}</h1>
{mode === "passkey" && (
<div className="mt-8">
@ -90,7 +92,7 @@ export default function LoginPage() {
disabled={loading}
className="w-full rounded-md bg-blue-600 px-4 py-3 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Authenticating..." : "Sign in with Passkey"}
{loading ? t("auth.authenticating") : t("auth.signInWithPasskey")}
</button>
<div className="mt-6 text-center">
@ -98,7 +100,7 @@ export default function LoginPage() {
onClick={() => setMode("magic-link")}
className="text-sm text-gray-500 hover:text-gray-700"
>
No passkey on this device? Use a magic link instead
{t("auth.useMagicLink")}
</button>
</div>
</div>
@ -107,11 +109,11 @@ export default function LoginPage() {
{mode === "magic-link" && !magicLinkSent && (
<form onSubmit={handleMagicLink} className="mt-8 space-y-4">
<p className="text-sm text-gray-600">
We'll send a login link to your email.
{t("auth.magicLinkHelp")}
</p>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
{t("auth.email")}
</label>
<input
id="email"
@ -128,7 +130,7 @@ export default function LoginPage() {
disabled={loading}
className="w-full rounded-md bg-gray-800 px-4 py-2 text-white hover:bg-gray-900 disabled:opacity-50"
>
{loading ? "Sending..." : "Send Magic Link"}
{loading ? t("auth.sending") : t("auth.sendMagicLink")}
</button>
<div className="text-center">
@ -137,7 +139,7 @@ export default function LoginPage() {
onClick={() => setMode("passkey")}
className="text-sm text-gray-500 hover:text-gray-700"
>
Back to passkey login
{t("auth.backToPasskey")}
</button>
</div>
</form>
@ -146,10 +148,10 @@ export default function LoginPage() {
{magicLinkSent && (
<div className="mt-8 rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
Check your email! We sent a login link to <strong>{email}</strong>.
{t("auth.checkEmail")} <strong>{email}</strong>.
</p>
<p className="mt-2 text-xs text-green-600">
The link expires in 15 minutes.
{t("auth.linkExpires")}
</p>
</div>
)}
@ -159,9 +161,9 @@ export default function LoginPage() {
)}
<p className="mt-6 text-center text-sm text-gray-500">
Don't have an account?{" "}
{t("auth.noAccount")}{" "}
<a href="/auth/register" className="text-blue-600 hover:underline">
Register
{t("auth.register")}
</a>
</p>
</div>

View file

@ -1,6 +1,8 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
export default function RegisterPage() {
const { t } = useTranslation("journal");
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [error, setError] = useState<string | null>(null);
@ -59,15 +61,15 @@ export default function RegisterPage() {
return (
<div className="mx-auto max-w-md px-4 py-16">
<h1 className="text-2xl font-bold text-gray-900">Create Account</h1>
<h1 className="text-2xl font-bold text-gray-900">{t("auth.register")}</h1>
<p className="mt-2 text-sm text-gray-600">
Register with a passkey no password needed.
{t("auth.registerDescription")}
</p>
<form onSubmit={handleRegister} className="mt-8 space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
{t("auth.email")}
</label>
<input
id="email"
@ -81,7 +83,7 @@ export default function RegisterPage() {
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
{t("auth.username")}
</label>
<input
id="username"
@ -106,14 +108,14 @@ export default function RegisterPage() {
disabled={loading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Creating passkey..." : "Register with Passkey"}
{loading ? t("auth.creatingPasskey") : t("auth.registerWithPasskey")}
</button>
</form>
<p className="mt-6 text-center text-sm text-gray-500">
Already have an account?{" "}
<a href="/auth/login" className="text-blue-600 hover:underline">
Sign in
{t("auth.login")}
</a>
</p>
</div>

View file

@ -1,5 +1,6 @@
import { useState, useCallback } from "react";
import { data } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/home";
import { getSessionUser } from "~/lib/auth.server";
@ -22,6 +23,7 @@ export async function loader({ request }: Route.LoaderArgs) {
export default function Home({ loaderData }: Route.ComponentProps) {
const { user, showAddPasskey } = loaderData;
const { t } = useTranslation("journal");
const [addingPasskey, setAddingPasskey] = useState(false);
const [passkeyDone, setPasskeyDone] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -74,19 +76,19 @@ export default function Home({ loaderData }: Route.ComponentProps) {
return (
<div className="mx-auto max-w-4xl px-4 py-16">
<h1 className="text-4xl font-bold text-gray-900">trails.cool</h1>
<p className="mt-4 text-lg text-gray-600">Your outdoor activity journal</p>
<h1 className="text-4xl font-bold text-gray-900">{t("title")}</h1>
<p className="mt-4 text-lg text-gray-600">{t("subtitle")}</p>
{user ? (
<div className="mt-8">
<p className="text-gray-700">
Welcome, <a href={`/users/${user.username}`} className="text-blue-600 hover:underline">{user.displayName ?? user.username}</a>
{t("welcome")} <a href={`/users/${user.username}`} className="text-blue-600 hover:underline">{user.displayName ?? user.username}</a>
</p>
{showAddPasskey && !passkeyDone && (
<div className="mt-6 rounded-md bg-blue-50 p-4">
<p className="text-sm text-blue-800">
Add a passkey for faster sign-in on this device.
{t("addPasskeyPrompt")}
</p>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
<button
@ -94,7 +96,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
disabled={addingPasskey}
className="mt-3 rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{addingPasskey ? "Setting up..." : "Add Passkey"}
{addingPasskey ? t("settingUp") : t("addPasskey")}
</button>
</div>
)}
@ -102,7 +104,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
{passkeyDone && (
<div className="mt-6 rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
Passkey added! You can now sign in instantly on this device.
{t("passkeyAdded")}
</p>
</div>
)}
@ -113,13 +115,13 @@ export default function Home({ loaderData }: Route.ComponentProps) {
href="/auth/register"
className="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
>
Register
{t("auth.register")}
</a>
<a
href="/auth/login"
className="rounded-md border border-gray-300 px-6 py-2 text-gray-700 hover:bg-gray-50"
>
Sign in
{t("auth.login")}
</a>
</div>
)}

View file

@ -1,5 +1,6 @@
import { useState, useCallback } from "react";
import { data, redirect } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/routes.$id";
import { getSessionUser } from "~/lib/auth.server";
import { getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
@ -73,6 +74,7 @@ export function meta({ data: loaderData }: Route.MetaArgs) {
export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
const { route, versions, isOwner } = loaderData;
const { t } = useTranslation("journal");
const [editLoading, setEditLoading] = useState(false);
const handleEditInPlanner = useCallback(async () => {
@ -104,13 +106,13 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
disabled={editLoading}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{editLoading ? "Opening..." : "Edit in Planner"}
{editLoading ? t("routes.opening") : t("routes.editInPlanner")}
</button>
<a
href={`/routes/${route.id}/edit`}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
>
Edit
{t("routes.edit")}
</a>
{route.hasGpx && (
<a
@ -118,7 +120,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
download
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
>
Export GPX
{t("routes.exportGpx")}
</a>
)}
</div>
@ -131,7 +133,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
<p className="text-2xl font-bold text-gray-900">
{(route.distance / 1000).toFixed(1)} km
</p>
<p className="text-sm text-gray-500">Distance</p>
<p className="text-sm text-gray-500">{t("routes.distance")}</p>
</div>
)}
{route.elevationGain != null && (
@ -181,7 +183,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
type="submit"
className="rounded-md bg-red-50 px-4 py-2 text-sm text-red-700 hover:bg-red-100"
>
Delete Route
{t("routes.delete")}
</button>
</form>
</div>

View file

@ -1,4 +1,5 @@
import { data, redirect } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/routes._index";
import { getSessionUser } from "~/lib/auth.server";
import { listRoutes } from "~/lib/routes.server";
@ -26,22 +27,23 @@ export function meta(_args: Route.MetaArgs) {
export default function RoutesListPage({ loaderData }: Route.ComponentProps) {
const { routes } = loaderData;
const { t } = useTranslation("journal");
return (
<div className="mx-auto max-w-4xl px-4 py-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">My Routes</h1>
<h1 className="text-2xl font-bold text-gray-900">{t("routes.myRoutes")}</h1>
<a
href="/routes/new"
className="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
>
New Route
{t("routes.new")}
</a>
</div>
{routes.length === 0 ? (
<p className="mt-8 text-center text-gray-500">
No routes yet. Create your first route!
{t("routes.noRoutesYet")}
</p>
) : (
<ul className="mt-6 divide-y divide-gray-200">

View file

@ -1,10 +1,13 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import * as Y from "yjs";
import type { YjsState } from "~/lib/use-yjs";
import { generateGpx } from "@trails-cool/gpx";
import type { TrackPoint } from "@trails-cool/gpx";
export function ExportButton({ yjs }: { yjs: YjsState }) {
const { t } = useTranslation("planner");
const handleExport = useCallback(() => {
// Get waypoints from Yjs
const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map<unknown>) => ({
@ -51,7 +54,7 @@ export function ExportButton({ yjs }: { yjs: YjsState }) {
onClick={handleExport}
className="rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
>
Export GPX
{t("exportGpx")}
</button>
);
}

View file

@ -1,19 +1,15 @@
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import type { YjsState } from "~/lib/use-yjs";
const PROFILES = [
{ id: "trekking", label: "Hiking" },
{ id: "fastbike", label: "Cycling (fast)" },
{ id: "safety", label: "Cycling (safe)" },
{ id: "shortest", label: "Shortest" },
{ id: "car-eco", label: "Car" },
];
const PROFILE_IDS = ["trekking", "fastbike", "safety", "shortest", "car"] as const;
interface ProfileSelectorProps {
yjs: YjsState;
}
export function ProfileSelector({ yjs }: ProfileSelectorProps) {
const { t } = useTranslation("planner");
const [profile, setProfile] = useState("trekking");
useEffect(() => {
@ -38,7 +34,7 @@ export function ProfileSelector({ yjs }: ProfileSelectorProps) {
return (
<div className="flex items-center gap-2">
<label htmlFor="profile" className="text-sm text-gray-600">
Profile:
{t("profile")}:
</label>
<select
id="profile"
@ -46,9 +42,9 @@ export function ProfileSelector({ yjs }: ProfileSelectorProps) {
onChange={handleChange}
className="rounded border border-gray-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{PROFILES.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
{PROFILE_IDS.map((id) => (
<option key={id} value={id}>
{t(`profiles.${id}`)}
</option>
))}
</select>

View file

@ -1,4 +1,5 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import * as Y from "yjs";
import type { YjsState } from "~/lib/use-yjs";
import { generateGpx } from "@trails-cool/gpx";
@ -12,6 +13,7 @@ interface SaveToJournalButtonProps {
}
export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl }: SaveToJournalButtonProps) {
const { t } = useTranslation("planner");
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -72,13 +74,13 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl
disabled={saving}
className="rounded bg-green-600 px-3 py-1 text-sm text-white hover:bg-green-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save to Journal"}
{saving ? t("saving") : t("saveToJournal")}
</button>
{saved && <span className="text-xs text-green-600">Saved!</span>}
{saved && <span className="text-xs text-green-600">{t("saved")}</span>}
{error && <span className="text-xs text-red-600">{error}</span>}
{saved && returnUrl && (
<a href={returnUrl} className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200">
Return to Journal
{t("returnToJournal")}
</a>
)}
</div>

View file

@ -1,4 +1,5 @@
import { Suspense, lazy, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useYjs } from "~/lib/use-yjs";
import { useRouting } from "~/lib/use-routing";
import { ProfileSelector } from "~/components/ProfileSelector";
@ -25,6 +26,7 @@ interface SessionViewProps {
}
export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, initialWaypoints }: SessionViewProps) {
const { t } = useTranslation("planner");
const yjs = useYjs(sessionId, initialWaypoints);
const { isHost, computing, routeStats, requestRoute } = useRouting(yjs);
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
@ -36,7 +38,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
if (!yjs) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-gray-500">Connecting...</p>
<p className="text-gray-500">{t("connecting")}</p>
</div>
);
}
@ -45,7 +47,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
<>
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-gray-200 px-4 py-2">
<div className="flex items-center gap-2 md:gap-4">
<h1 className="hidden text-lg font-semibold text-gray-900 sm:block">trails.cool Planner</h1>
<h1 className="hidden text-lg font-semibold text-gray-900 sm:block">{t("title")}</h1>
<ProfileSelector yjs={yjs} />
</div>
<div className="flex items-center gap-3">
@ -59,15 +61,15 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
)}
<ExportButton yjs={yjs} />
{computing && (
<span className="text-xs text-blue-600">Computing route...</span>
<span className="text-xs text-blue-600">{t("computingRoute")}</span>
)}
{isHost && (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
Host
{t("host")}
</span>
)}
<span className="text-sm text-gray-500">
{yjs.connected ? "Connected" : "Connecting..."} · {sessionId.slice(0, 8)}
{yjs.connected ? t("connected") : t("connecting")} · {sessionId.slice(0, 8)}
</span>
</div>
</header>
@ -84,7 +86,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
<Suspense
fallback={
<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">
Loading map...
{t("loadingMap")}
</div>
}
>

View file

@ -2,8 +2,12 @@ import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse }
import type { LinksFunction } from "react-router";
import type { Route } from "./+types/root";
import * as Sentry from "@sentry/react";
import { useTranslation } from "react-i18next";
import { initI18n } from "@trails-cool/i18n";
import stylesheet from "@trails-cool/ui/styles.css?url";
initI18n();
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
export function Layout({ children }: { children: React.ReactNode }) {
@ -30,6 +34,7 @@ export default function App() {
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
Sentry.captureException(error);
const { t } = useTranslation();
if (isRouteErrorResponse(error)) {
return (
@ -37,9 +42,9 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900">{error.status}</h1>
<p className="mt-2 text-gray-600">
{error.status === 404 && "Page not found"}
{error.status === 503 && "Service temporarily unavailable"}
{error.status !== 404 && error.status !== 503 && (error.statusText || "Something went wrong")}
{error.status === 404 && t("pageNotFound")}
{error.status === 503 && t("serviceUnavailable")}
{error.status !== 404 && error.status !== 503 && (error.statusText || t("error"))}
</p>
</div>
</div>
@ -49,9 +54,9 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900">Error</h1>
<h1 className="text-4xl font-bold text-gray-900">{t("error")}</h1>
<p className="mt-2 text-gray-600">
{error instanceof Error ? error.message : "An unexpected error occurred"}
{error instanceof Error ? error.message : t("error")}
</p>
</div>
</div>

View file

@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/home";
export function meta(_args: Route.MetaArgs) {
@ -8,11 +9,13 @@ export function meta(_args: Route.MetaArgs) {
}
export default function Home() {
const { t } = useTranslation("planner");
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900">trails.cool Planner</h1>
<p className="mt-4 text-lg text-gray-600">Collaborative route planning</p>
<h1 className="text-4xl font-bold text-gray-900">{t("title")}</h1>
<p className="mt-4 text-lg text-gray-600">{t("subtitle")}</p>
</div>
</div>
);

View file

@ -15,7 +15,7 @@ test.describe("Journal", () => {
test("registration page renders correctly", async ({ page }) => {
await page.goto("/auth/register");
await expect(page.getByText("Create Account")).toBeVisible();
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
await expect(page.getByLabel("Email")).toBeVisible();
await expect(page.getByLabel("Username")).toBeVisible();
await expect(page.getByRole("button", { name: /Register with Passkey/ })).toBeVisible();

View file

@ -7,6 +7,9 @@ export default {
close: "Schließen",
loading: "Wird geladen...",
error: "Etwas ist schiefgelaufen",
goHome: "Zur Startseite",
pageNotFound: "Seite nicht gefunden",
serviceUnavailable: "Dienst vorübergehend nicht verfügbar. Bitte versuche es später erneut.",
},
planner: {
title: "trails.cool Planer",
@ -14,11 +17,22 @@ export default {
newSession: "Neue Sitzung",
saveRoute: "Route speichern",
exportGpx: "GPX exportieren",
profile: "Routing-Profil",
profile: "Profil",
connecting: "Verbinde...",
loadingMap: "Karte wird geladen...",
computingRoute: "Route wird berechnet...",
host: "Host",
connected: "Verbunden",
saveToJournal: "Im Journal speichern",
saving: "Speichert...",
saved: "Gespeichert!",
returnToJournal: "Zurück zum Journal",
profiles: {
trekking: "Wandern",
bike: "Radfahren",
fastbike: "Radfahren (schnell)",
safety: "Radfahren (sicher)",
shortest: "Kürzeste",
car: "Auto",
},
elevation: {
gain: "Höhenmeter aufwärts",
@ -29,16 +43,25 @@ export default {
journal: {
title: "trails.cool",
subtitle: "Dein Outdoor-Aktivitäten-Tagebuch",
welcome: "Willkommen,",
addPasskeyPrompt: "Füge einen Passkey hinzu, um dich schneller auf diesem Gerät anzumelden.",
addPasskey: "Passkey hinzufügen",
settingUp: "Wird eingerichtet...",
passkeyAdded: "Passkey hinzugefügt! Du kannst dich jetzt sofort auf diesem Gerät anmelden.",
routes: {
title: "Routen",
myRoutes: "Meine Routen",
new: "Neue Route",
edit: "Route bearbeiten",
editInPlanner: "Im Planer bearbeiten",
opening: "Öffne...",
importGpx: "GPX importieren",
exportGpx: "GPX exportieren",
delete: "Route löschen",
distance: "Strecke",
elevationGain: "Höhenmeter",
noRoutesYet: "Noch keine Routen. Erstelle deine erste Route!",
saveChanges: "Änderungen speichern",
},
activities: {
title: "Aktivitäten",
@ -52,8 +75,20 @@ export default {
register: "Registrieren",
logout: "Abmelden",
email: "E-Mail",
password: "Passwort",
username: "Benutzername",
signInWithPasskey: "Mit Passkey anmelden",
authenticating: "Authentifiziere...",
useMagicLink: "Kein Passkey auf diesem Gerät? Nutze stattdessen einen Magic Link",
magicLinkHelp: "Wir senden dir einen Anmeldelink per E-Mail.",
sendMagicLink: "Magic Link senden",
sending: "Wird gesendet...",
backToPasskey: "Zurück zur Passkey-Anmeldung",
checkEmail: "Prüfe deine E-Mails! Wir haben einen Anmeldelink gesendet an",
linkExpires: "Der Link ist 15 Minuten gültig.",
noAccount: "Noch kein Konto?",
registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.",
registerWithPasskey: "Mit Passkey registrieren",
creatingPasskey: "Erstelle Passkey...",
},
},
} as const;

View file

@ -7,6 +7,9 @@ export default {
close: "Close",
loading: "Loading...",
error: "Something went wrong",
goHome: "Go home",
pageNotFound: "Page not found",
serviceUnavailable: "Service temporarily unavailable. Please try again later.",
},
planner: {
title: "trails.cool Planner",
@ -14,11 +17,22 @@ export default {
newSession: "New Session",
saveRoute: "Save Route",
exportGpx: "Export GPX",
profile: "Routing Profile",
profile: "Profile",
connecting: "Connecting...",
loadingMap: "Loading map...",
computingRoute: "Computing route...",
host: "Host",
connected: "Connected",
saveToJournal: "Save to Journal",
saving: "Saving...",
saved: "Saved!",
returnToJournal: "Return to Journal",
profiles: {
trekking: "Hiking",
bike: "Cycling",
fastbike: "Cycling (fast)",
safety: "Cycling (safe)",
shortest: "Shortest",
car: "Car",
},
elevation: {
gain: "Elevation Gain",
@ -29,16 +43,25 @@ export default {
journal: {
title: "trails.cool",
subtitle: "Your outdoor activity journal",
welcome: "Welcome,",
addPasskeyPrompt: "Add a passkey for faster sign-in on this device.",
addPasskey: "Add Passkey",
settingUp: "Setting up...",
passkeyAdded: "Passkey added! You can now sign in instantly on this device.",
routes: {
title: "Routes",
myRoutes: "My Routes",
new: "New Route",
edit: "Edit Route",
editInPlanner: "Edit in Planner",
opening: "Opening...",
importGpx: "Import GPX",
exportGpx: "Export GPX",
delete: "Delete Route",
distance: "Distance",
elevationGain: "Elevation Gain",
noRoutesYet: "No routes yet. Create your first route!",
saveChanges: "Save Changes",
},
activities: {
title: "Activities",
@ -48,12 +71,24 @@ export default {
createRouteFromActivity: "Create Route from Activity",
},
auth: {
login: "Log In",
register: "Sign Up",
login: "Sign In",
register: "Register",
logout: "Log Out",
email: "Email",
password: "Password",
username: "Username",
signInWithPasskey: "Sign in with Passkey",
authenticating: "Authenticating...",
useMagicLink: "No passkey on this device? Use a magic link instead",
magicLinkHelp: "We'll send a login link to your email.",
sendMagicLink: "Send Magic Link",
sending: "Sending...",
backToPasskey: "Back to passkey login",
checkEmail: "Check your email! We sent a login link to",
linkExpires: "The link expires in 15 minutes.",
noAccount: "Don't have an account?",
registerDescription: "Register with a passkey — no password needed.",
registerWithPasskey: "Register with Passkey",
creatingPasskey: "Creating passkey...",
},
},
} as const;