Wire up i18n in both apps
Initialize i18n via initI18n() in both root.tsx files and replace all hardcoded user-facing strings with useTranslation() calls. Adds missing translation keys for planner (profiles, connection states, save flow) and journal (auth pages, route management, passkey prompts). Both English and German translations are complete. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
69ba510530
commit
b96b32cb53
14 changed files with 172 additions and 76 deletions
|
|
@ -2,8 +2,12 @@ import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse }
|
||||||
import type { LinksFunction } from "react-router";
|
import type { LinksFunction } from "react-router";
|
||||||
import type { Route } from "./+types/root";
|
import type { Route } from "./+types/root";
|
||||||
import * as Sentry from "@sentry/react";
|
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";
|
import stylesheet from "@trails-cool/ui/styles.css?url";
|
||||||
|
|
||||||
|
initI18n();
|
||||||
|
|
||||||
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
|
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
|
@ -30,18 +34,19 @@ export default function App() {
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (isRouteErrorResponse(error)) {
|
if (isRouteErrorResponse(error)) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-md px-4 py-16 text-center">
|
<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>
|
<h1 className="text-4xl font-bold text-gray-900">{error.status}</h1>
|
||||||
<p className="mt-2 text-gray-600">
|
<p className="mt-2 text-gray-600">
|
||||||
{error.status === 404 && "Page not found"}
|
{error.status === 404 && t("pageNotFound")}
|
||||||
{error.status === 503 && "Service temporarily unavailable. Please try again later."}
|
{error.status === 503 && t("serviceUnavailable")}
|
||||||
{error.status !== 404 && error.status !== 503 && (error.statusText || "Something went wrong")}
|
{error.status !== 404 && error.status !== 503 && (error.statusText || t("error"))}
|
||||||
</p>
|
</p>
|
||||||
<a href="/" className="mt-6 inline-block text-blue-600 hover:underline">
|
<a href="/" className="mt-6 inline-block text-blue-600 hover:underline">
|
||||||
Go home
|
{t("goHome")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -49,12 +54,12 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-md px-4 py-16 text-center">
|
<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">
|
<p className="mt-2 text-gray-600">
|
||||||
{error instanceof Error ? error.message : "An unexpected error occurred"}
|
{error instanceof Error ? error.message : t("error")}
|
||||||
</p>
|
</p>
|
||||||
<a href="/" className="mt-6 inline-block text-blue-600 hover:underline">
|
<a href="/" className="mt-6 inline-block text-blue-600 hover:underline">
|
||||||
Go home
|
{t("goHome")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const { t } = useTranslation("journal");
|
||||||
const [mode, setMode] = useState<"passkey" | "magic-link">("passkey");
|
const [mode, setMode] = useState<"passkey" | "magic-link">("passkey");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -81,7 +83,7 @@ export default function LoginPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-md px-4 py-16">
|
<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" && (
|
{mode === "passkey" && (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
|
|
@ -90,7 +92,7 @@ export default function LoginPage() {
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full rounded-md bg-blue-600 px-4 py-3 text-white hover:bg-blue-700 disabled:opacity-50"
|
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>
|
</button>
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
|
|
@ -98,7 +100,7 @@ export default function LoginPage() {
|
||||||
onClick={() => setMode("magic-link")}
|
onClick={() => setMode("magic-link")}
|
||||||
className="text-sm text-gray-500 hover:text-gray-700"
|
className="text-sm text-gray-500 hover:text-gray-700"
|
||||||
>
|
>
|
||||||
No passkey on this device? Use a magic link instead
|
{t("auth.useMagicLink")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -107,11 +109,11 @@ export default function LoginPage() {
|
||||||
{mode === "magic-link" && !magicLinkSent && (
|
{mode === "magic-link" && !magicLinkSent && (
|
||||||
<form onSubmit={handleMagicLink} className="mt-8 space-y-4">
|
<form onSubmit={handleMagicLink} className="mt-8 space-y-4">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
We'll send a login link to your email.
|
{t("auth.magicLinkHelp")}
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
Email
|
{t("auth.email")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
|
|
@ -128,7 +130,7 @@ export default function LoginPage() {
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full rounded-md bg-gray-800 px-4 py-2 text-white hover:bg-gray-900 disabled:opacity-50"
|
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>
|
</button>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -137,7 +139,7 @@ export default function LoginPage() {
|
||||||
onClick={() => setMode("passkey")}
|
onClick={() => setMode("passkey")}
|
||||||
className="text-sm text-gray-500 hover:text-gray-700"
|
className="text-sm text-gray-500 hover:text-gray-700"
|
||||||
>
|
>
|
||||||
Back to passkey login
|
{t("auth.backToPasskey")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -146,10 +148,10 @@ export default function LoginPage() {
|
||||||
{magicLinkSent && (
|
{magicLinkSent && (
|
||||||
<div className="mt-8 rounded-md bg-green-50 p-4">
|
<div className="mt-8 rounded-md bg-green-50 p-4">
|
||||||
<p className="text-sm text-green-800">
|
<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>
|
||||||
<p className="mt-2 text-xs text-green-600">
|
<p className="mt-2 text-xs text-green-600">
|
||||||
The link expires in 15 minutes.
|
{t("auth.linkExpires")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -159,9 +161,9 @@ export default function LoginPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-gray-500">
|
<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">
|
<a href="/auth/register" className="text-blue-600 hover:underline">
|
||||||
Register
|
{t("auth.register")}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
|
const { t } = useTranslation("journal");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -59,15 +61,15 @@ export default function RegisterPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-md px-4 py-16">
|
<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">
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
Register with a passkey — no password needed.
|
{t("auth.registerDescription")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleRegister} className="mt-8 space-y-4">
|
<form onSubmit={handleRegister} className="mt-8 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
Email
|
{t("auth.email")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
|
|
@ -81,7 +83,7 @@ export default function RegisterPage() {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||||
Username
|
{t("auth.username")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
|
|
@ -106,14 +108,14 @@ export default function RegisterPage() {
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-gray-500">
|
<p className="mt-6 text-center text-sm text-gray-500">
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<a href="/auth/login" className="text-blue-600 hover:underline">
|
<a href="/auth/login" className="text-blue-600 hover:underline">
|
||||||
Sign in
|
{t("auth.login")}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { data } from "react-router";
|
import { data } from "react-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/home";
|
import type { Route } from "./+types/home";
|
||||||
import { getSessionUser } from "~/lib/auth.server";
|
import { getSessionUser } from "~/lib/auth.server";
|
||||||
|
|
||||||
|
|
@ -22,6 +23,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||||
|
|
||||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
const { user, showAddPasskey } = loaderData;
|
const { user, showAddPasskey } = loaderData;
|
||||||
|
const { t } = useTranslation("journal");
|
||||||
const [addingPasskey, setAddingPasskey] = useState(false);
|
const [addingPasskey, setAddingPasskey] = useState(false);
|
||||||
const [passkeyDone, setPasskeyDone] = useState(false);
|
const [passkeyDone, setPasskeyDone] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -74,19 +76,19 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl px-4 py-16">
|
<div className="mx-auto max-w-4xl px-4 py-16">
|
||||||
<h1 className="text-4xl font-bold text-gray-900">trails.cool</h1>
|
<h1 className="text-4xl font-bold text-gray-900">{t("title")}</h1>
|
||||||
<p className="mt-4 text-lg text-gray-600">Your outdoor activity journal</p>
|
<p className="mt-4 text-lg text-gray-600">{t("subtitle")}</p>
|
||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<p className="text-gray-700">
|
<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>
|
</p>
|
||||||
|
|
||||||
{showAddPasskey && !passkeyDone && (
|
{showAddPasskey && !passkeyDone && (
|
||||||
<div className="mt-6 rounded-md bg-blue-50 p-4">
|
<div className="mt-6 rounded-md bg-blue-50 p-4">
|
||||||
<p className="text-sm text-blue-800">
|
<p className="text-sm text-blue-800">
|
||||||
Add a passkey for faster sign-in on this device.
|
{t("addPasskeyPrompt")}
|
||||||
</p>
|
</p>
|
||||||
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
|
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
|
||||||
<button
|
<button
|
||||||
|
|
@ -94,7 +96,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
disabled={addingPasskey}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -102,7 +104,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
{passkeyDone && (
|
{passkeyDone && (
|
||||||
<div className="mt-6 rounded-md bg-green-50 p-4">
|
<div className="mt-6 rounded-md bg-green-50 p-4">
|
||||||
<p className="text-sm text-green-800">
|
<p className="text-sm text-green-800">
|
||||||
Passkey added! You can now sign in instantly on this device.
|
{t("passkeyAdded")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -113,13 +115,13 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
href="/auth/register"
|
href="/auth/register"
|
||||||
className="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
|
className="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
Register
|
{t("auth.register")}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/auth/login"
|
href="/auth/login"
|
||||||
className="rounded-md border border-gray-300 px-6 py-2 text-gray-700 hover:bg-gray-50"
|
className="rounded-md border border-gray-300 px-6 py-2 text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
Sign in
|
{t("auth.login")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { data, redirect } from "react-router";
|
import { data, redirect } from "react-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/routes.$id";
|
import type { Route } from "./+types/routes.$id";
|
||||||
import { getSessionUser } from "~/lib/auth.server";
|
import { getSessionUser } from "~/lib/auth.server";
|
||||||
import { getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.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) {
|
export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { route, versions, isOwner } = loaderData;
|
const { route, versions, isOwner } = loaderData;
|
||||||
|
const { t } = useTranslation("journal");
|
||||||
const [editLoading, setEditLoading] = useState(false);
|
const [editLoading, setEditLoading] = useState(false);
|
||||||
|
|
||||||
const handleEditInPlanner = useCallback(async () => {
|
const handleEditInPlanner = useCallback(async () => {
|
||||||
|
|
@ -104,13 +106,13 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
||||||
disabled={editLoading}
|
disabled={editLoading}
|
||||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
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>
|
</button>
|
||||||
<a
|
<a
|
||||||
href={`/routes/${route.id}/edit`}
|
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"
|
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>
|
</a>
|
||||||
{route.hasGpx && (
|
{route.hasGpx && (
|
||||||
<a
|
<a
|
||||||
|
|
@ -118,7 +120,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
||||||
download
|
download
|
||||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
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>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -131,7 +133,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{(route.distance / 1000).toFixed(1)} km
|
{(route.distance / 1000).toFixed(1)} km
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500">Distance</p>
|
<p className="text-sm text-gray-500">{t("routes.distance")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{route.elevationGain != null && (
|
{route.elevationGain != null && (
|
||||||
|
|
@ -181,7 +183,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
||||||
type="submit"
|
type="submit"
|
||||||
className="rounded-md bg-red-50 px-4 py-2 text-sm text-red-700 hover:bg-red-100"
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { data, redirect } from "react-router";
|
import { data, redirect } from "react-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/routes._index";
|
import type { Route } from "./+types/routes._index";
|
||||||
import { getSessionUser } from "~/lib/auth.server";
|
import { getSessionUser } from "~/lib/auth.server";
|
||||||
import { listRoutes } from "~/lib/routes.server";
|
import { listRoutes } from "~/lib/routes.server";
|
||||||
|
|
@ -26,22 +27,23 @@ export function meta(_args: Route.MetaArgs) {
|
||||||
|
|
||||||
export default function RoutesListPage({ loaderData }: Route.ComponentProps) {
|
export default function RoutesListPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { routes } = loaderData;
|
const { routes } = loaderData;
|
||||||
|
const { t } = useTranslation("journal");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl px-4 py-8">
|
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||||
<div className="flex items-center justify-between">
|
<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
|
<a
|
||||||
href="/routes/new"
|
href="/routes/new"
|
||||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
|
className="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
New Route
|
{t("routes.new")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{routes.length === 0 ? (
|
{routes.length === 0 ? (
|
||||||
<p className="mt-8 text-center text-gray-500">
|
<p className="mt-8 text-center text-gray-500">
|
||||||
No routes yet. Create your first route!
|
{t("routes.noRoutesYet")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-6 divide-y divide-gray-200">
|
<ul className="mt-6 divide-y divide-gray-200">
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
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 { generateGpx } from "@trails-cool/gpx";
|
import { generateGpx } from "@trails-cool/gpx";
|
||||||
import type { TrackPoint } from "@trails-cool/gpx";
|
import type { TrackPoint } from "@trails-cool/gpx";
|
||||||
|
|
||||||
export function ExportButton({ yjs }: { yjs: YjsState }) {
|
export function ExportButton({ yjs }: { yjs: YjsState }) {
|
||||||
|
const { t } = useTranslation("planner");
|
||||||
|
|
||||||
const handleExport = useCallback(() => {
|
const handleExport = useCallback(() => {
|
||||||
// Get waypoints from Yjs
|
// Get waypoints from Yjs
|
||||||
const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map<unknown>) => ({
|
const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map<unknown>) => ({
|
||||||
|
|
@ -51,7 +54,7 @@ export function ExportButton({ yjs }: { yjs: YjsState }) {
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
className="rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
|
className="rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
|
||||||
>
|
>
|
||||||
Export GPX
|
{t("exportGpx")}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,15 @@
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { YjsState } from "~/lib/use-yjs";
|
import type { YjsState } from "~/lib/use-yjs";
|
||||||
|
|
||||||
const PROFILES = [
|
const PROFILE_IDS = ["trekking", "fastbike", "safety", "shortest", "car"] as const;
|
||||||
{ id: "trekking", label: "Hiking" },
|
|
||||||
{ id: "fastbike", label: "Cycling (fast)" },
|
|
||||||
{ id: "safety", label: "Cycling (safe)" },
|
|
||||||
{ id: "shortest", label: "Shortest" },
|
|
||||||
{ id: "car-eco", label: "Car" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ProfileSelectorProps {
|
interface ProfileSelectorProps {
|
||||||
yjs: YjsState;
|
yjs: YjsState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileSelector({ yjs }: ProfileSelectorProps) {
|
export function ProfileSelector({ yjs }: ProfileSelectorProps) {
|
||||||
|
const { t } = useTranslation("planner");
|
||||||
const [profile, setProfile] = useState("trekking");
|
const [profile, setProfile] = useState("trekking");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -38,7 +34,7 @@ export function ProfileSelector({ yjs }: ProfileSelectorProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label htmlFor="profile" className="text-sm text-gray-600">
|
<label htmlFor="profile" className="text-sm text-gray-600">
|
||||||
Profile:
|
{t("profile")}:
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="profile"
|
id="profile"
|
||||||
|
|
@ -46,9 +42,9 @@ export function ProfileSelector({ yjs }: ProfileSelectorProps) {
|
||||||
onChange={handleChange}
|
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"
|
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) => (
|
{PROFILE_IDS.map((id) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={id} value={id}>
|
||||||
{p.label}
|
{t(`profiles.${id}`)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
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 { generateGpx } from "@trails-cool/gpx";
|
import { generateGpx } from "@trails-cool/gpx";
|
||||||
|
|
@ -12,6 +13,7 @@ interface SaveToJournalButtonProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl }: SaveToJournalButtonProps) {
|
export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl }: SaveToJournalButtonProps) {
|
||||||
|
const { t } = useTranslation("planner");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -72,13 +74,13 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="rounded bg-green-600 px-3 py-1 text-sm text-white hover:bg-green-700 disabled:opacity-50"
|
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>
|
</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>}
|
{error && <span className="text-xs text-red-600">{error}</span>}
|
||||||
{saved && returnUrl && (
|
{saved && returnUrl && (
|
||||||
<a href={returnUrl} className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200">
|
<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>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Suspense, lazy, useState, useCallback } from "react";
|
import { Suspense, lazy, useState, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useYjs } from "~/lib/use-yjs";
|
import { useYjs } from "~/lib/use-yjs";
|
||||||
import { useRouting } from "~/lib/use-routing";
|
import { useRouting } from "~/lib/use-routing";
|
||||||
import { ProfileSelector } from "~/components/ProfileSelector";
|
import { ProfileSelector } from "~/components/ProfileSelector";
|
||||||
|
|
@ -25,6 +26,7 @@ interface SessionViewProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, initialWaypoints }: SessionViewProps) {
|
export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, initialWaypoints }: SessionViewProps) {
|
||||||
|
const { t } = useTranslation("planner");
|
||||||
const yjs = useYjs(sessionId, initialWaypoints);
|
const yjs = useYjs(sessionId, initialWaypoints);
|
||||||
const { isHost, computing, routeStats, requestRoute } = useRouting(yjs);
|
const { isHost, computing, routeStats, requestRoute } = useRouting(yjs);
|
||||||
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
|
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
|
||||||
|
|
@ -36,7 +38,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
||||||
if (!yjs) {
|
if (!yjs) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<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>
|
</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">
|
<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">
|
<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} />
|
<ProfileSelector yjs={yjs} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -59,15 +61,15 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
||||||
)}
|
)}
|
||||||
<ExportButton yjs={yjs} />
|
<ExportButton yjs={yjs} />
|
||||||
{computing && (
|
{computing && (
|
||||||
<span className="text-xs text-blue-600">Computing route...</span>
|
<span className="text-xs text-blue-600">{t("computingRoute")}</span>
|
||||||
)}
|
)}
|
||||||
{isHost && (
|
{isHost && (
|
||||||
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
|
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
|
||||||
Host
|
{t("host")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-sm text-gray-500">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -84,7 +86,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">
|
<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">
|
||||||
Loading map...
|
{t("loadingMap")}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,12 @@ import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse }
|
||||||
import type { LinksFunction } from "react-router";
|
import type { LinksFunction } from "react-router";
|
||||||
import type { Route } from "./+types/root";
|
import type { Route } from "./+types/root";
|
||||||
import * as Sentry from "@sentry/react";
|
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";
|
import stylesheet from "@trails-cool/ui/styles.css?url";
|
||||||
|
|
||||||
|
initI18n();
|
||||||
|
|
||||||
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
|
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
|
@ -30,6 +34,7 @@ export default function App() {
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (isRouteErrorResponse(error)) {
|
if (isRouteErrorResponse(error)) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -37,9 +42,9 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-4xl font-bold text-gray-900">{error.status}</h1>
|
<h1 className="text-4xl font-bold text-gray-900">{error.status}</h1>
|
||||||
<p className="mt-2 text-gray-600">
|
<p className="mt-2 text-gray-600">
|
||||||
{error.status === 404 && "Page not found"}
|
{error.status === 404 && t("pageNotFound")}
|
||||||
{error.status === 503 && "Service temporarily unavailable"}
|
{error.status === 503 && t("serviceUnavailable")}
|
||||||
{error.status !== 404 && error.status !== 503 && (error.statusText || "Something went wrong")}
|
{error.status !== 404 && error.status !== 503 && (error.statusText || t("error"))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -49,9 +54,9 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-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">
|
<p className="mt-2 text-gray-600">
|
||||||
{error instanceof Error ? error.message : "An unexpected error occurred"}
|
{error instanceof Error ? error.message : t("error")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/home";
|
import type { Route } from "./+types/home";
|
||||||
|
|
||||||
export function meta(_args: Route.MetaArgs) {
|
export function meta(_args: Route.MetaArgs) {
|
||||||
|
|
@ -8,11 +9,13 @@ export function meta(_args: Route.MetaArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const { t } = useTranslation("planner");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-4xl font-bold text-gray-900">trails.cool Planner</h1>
|
<h1 className="text-4xl font-bold text-gray-900">{t("title")}</h1>
|
||||||
<p className="mt-4 text-lg text-gray-600">Collaborative route planning</p>
|
<p className="mt-4 text-lg text-gray-600">{t("subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ export default {
|
||||||
close: "Schließen",
|
close: "Schließen",
|
||||||
loading: "Wird geladen...",
|
loading: "Wird geladen...",
|
||||||
error: "Etwas ist schiefgelaufen",
|
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: {
|
planner: {
|
||||||
title: "trails.cool Planer",
|
title: "trails.cool Planer",
|
||||||
|
|
@ -14,11 +17,22 @@ export default {
|
||||||
newSession: "Neue Sitzung",
|
newSession: "Neue Sitzung",
|
||||||
saveRoute: "Route speichern",
|
saveRoute: "Route speichern",
|
||||||
exportGpx: "GPX exportieren",
|
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: {
|
profiles: {
|
||||||
trekking: "Wandern",
|
trekking: "Wandern",
|
||||||
bike: "Radfahren",
|
fastbike: "Radfahren (schnell)",
|
||||||
|
safety: "Radfahren (sicher)",
|
||||||
shortest: "Kürzeste",
|
shortest: "Kürzeste",
|
||||||
|
car: "Auto",
|
||||||
},
|
},
|
||||||
elevation: {
|
elevation: {
|
||||||
gain: "Höhenmeter aufwärts",
|
gain: "Höhenmeter aufwärts",
|
||||||
|
|
@ -29,16 +43,25 @@ export default {
|
||||||
journal: {
|
journal: {
|
||||||
title: "trails.cool",
|
title: "trails.cool",
|
||||||
subtitle: "Dein Outdoor-Aktivitäten-Tagebuch",
|
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: {
|
routes: {
|
||||||
title: "Routen",
|
title: "Routen",
|
||||||
|
myRoutes: "Meine Routen",
|
||||||
new: "Neue Route",
|
new: "Neue Route",
|
||||||
edit: "Route bearbeiten",
|
edit: "Route bearbeiten",
|
||||||
editInPlanner: "Im Planer bearbeiten",
|
editInPlanner: "Im Planer bearbeiten",
|
||||||
|
opening: "Öffne...",
|
||||||
importGpx: "GPX importieren",
|
importGpx: "GPX importieren",
|
||||||
exportGpx: "GPX exportieren",
|
exportGpx: "GPX exportieren",
|
||||||
delete: "Route löschen",
|
delete: "Route löschen",
|
||||||
distance: "Strecke",
|
distance: "Strecke",
|
||||||
elevationGain: "Höhenmeter",
|
elevationGain: "Höhenmeter",
|
||||||
|
noRoutesYet: "Noch keine Routen. Erstelle deine erste Route!",
|
||||||
|
saveChanges: "Änderungen speichern",
|
||||||
},
|
},
|
||||||
activities: {
|
activities: {
|
||||||
title: "Aktivitäten",
|
title: "Aktivitäten",
|
||||||
|
|
@ -52,8 +75,20 @@ export default {
|
||||||
register: "Registrieren",
|
register: "Registrieren",
|
||||||
logout: "Abmelden",
|
logout: "Abmelden",
|
||||||
email: "E-Mail",
|
email: "E-Mail",
|
||||||
password: "Passwort",
|
|
||||||
username: "Benutzername",
|
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;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ export default {
|
||||||
close: "Close",
|
close: "Close",
|
||||||
loading: "Loading...",
|
loading: "Loading...",
|
||||||
error: "Something went wrong",
|
error: "Something went wrong",
|
||||||
|
goHome: "Go home",
|
||||||
|
pageNotFound: "Page not found",
|
||||||
|
serviceUnavailable: "Service temporarily unavailable. Please try again later.",
|
||||||
},
|
},
|
||||||
planner: {
|
planner: {
|
||||||
title: "trails.cool Planner",
|
title: "trails.cool Planner",
|
||||||
|
|
@ -14,11 +17,22 @@ export default {
|
||||||
newSession: "New Session",
|
newSession: "New Session",
|
||||||
saveRoute: "Save Route",
|
saveRoute: "Save Route",
|
||||||
exportGpx: "Export GPX",
|
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: {
|
profiles: {
|
||||||
trekking: "Hiking",
|
trekking: "Hiking",
|
||||||
bike: "Cycling",
|
fastbike: "Cycling (fast)",
|
||||||
|
safety: "Cycling (safe)",
|
||||||
shortest: "Shortest",
|
shortest: "Shortest",
|
||||||
|
car: "Car",
|
||||||
},
|
},
|
||||||
elevation: {
|
elevation: {
|
||||||
gain: "Elevation Gain",
|
gain: "Elevation Gain",
|
||||||
|
|
@ -29,16 +43,25 @@ export default {
|
||||||
journal: {
|
journal: {
|
||||||
title: "trails.cool",
|
title: "trails.cool",
|
||||||
subtitle: "Your outdoor activity journal",
|
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: {
|
routes: {
|
||||||
title: "Routes",
|
title: "Routes",
|
||||||
|
myRoutes: "My Routes",
|
||||||
new: "New Route",
|
new: "New Route",
|
||||||
edit: "Edit Route",
|
edit: "Edit Route",
|
||||||
editInPlanner: "Edit in Planner",
|
editInPlanner: "Edit in Planner",
|
||||||
|
opening: "Opening...",
|
||||||
importGpx: "Import GPX",
|
importGpx: "Import GPX",
|
||||||
exportGpx: "Export GPX",
|
exportGpx: "Export GPX",
|
||||||
delete: "Delete Route",
|
delete: "Delete Route",
|
||||||
distance: "Distance",
|
distance: "Distance",
|
||||||
elevationGain: "Elevation Gain",
|
elevationGain: "Elevation Gain",
|
||||||
|
noRoutesYet: "No routes yet. Create your first route!",
|
||||||
|
saveChanges: "Save Changes",
|
||||||
},
|
},
|
||||||
activities: {
|
activities: {
|
||||||
title: "Activities",
|
title: "Activities",
|
||||||
|
|
@ -48,12 +71,24 @@ export default {
|
||||||
createRouteFromActivity: "Create Route from Activity",
|
createRouteFromActivity: "Create Route from Activity",
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
login: "Log In",
|
login: "Sign In",
|
||||||
register: "Sign Up",
|
register: "Register",
|
||||||
logout: "Log Out",
|
logout: "Log Out",
|
||||||
email: "Email",
|
email: "Email",
|
||||||
password: "Password",
|
|
||||||
username: "Username",
|
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;
|
} as const;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue