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:
Ullrich Schäfer 2026-03-25 02:08:45 +01:00 committed by GitHub
parent 69ba510530
commit b96b32cb53
14 changed files with 172 additions and 76 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">