Merge pull request #44 from trails-cool/wire-up-i18n
Wire up i18n in both apps
This commit is contained in:
commit
48c74f16f8
15 changed files with 173 additions and 77 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue