From c8979cb73b6712387527bab7538770970d390263 Mon Sep 17 00:00:00 2001 From: AlxcBTW <187763517+AlxcBTW@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:18:02 +0100 Subject: [PATCH 1/7] i18n foundation: next-intl without locale routing, language from ?lang, cookie, Accept-Language; translated API error texts --- apps/web/messages/de/account.json | 1 + apps/web/messages/de/auth.json | 1 + apps/web/messages/de/common.json | 20 ++ apps/web/messages/en/account.json | 1 + apps/web/messages/en/auth.json | 1 + apps/web/messages/en/common.json | 20 ++ apps/web/messages/es/account.json | 1 + apps/web/messages/es/auth.json | 1 + apps/web/messages/es/common.json | 20 ++ apps/web/messages/fr/account.json | 1 + apps/web/messages/fr/auth.json | 1 + apps/web/messages/fr/common.json | 20 ++ apps/web/messages/pt/account.json | 1 + apps/web/messages/pt/auth.json | 1 + apps/web/messages/pt/common.json | 20 ++ apps/web/messages/tr/account.json | 1 + apps/web/messages/tr/auth.json | 1 + apps/web/messages/tr/common.json | 20 ++ apps/web/next.config.ts | 6 +- apps/web/package.json | 1 + apps/web/src/app/(auth)/layout.tsx | 10 + apps/web/src/app/layout.tsx | 11 +- apps/web/src/i18n/config.test.ts | 28 ++ apps/web/src/i18n/config.ts | 56 ++++ apps/web/src/i18n/errors.ts | 42 +++ apps/web/src/i18n/messages.test.ts | 41 +++ apps/web/src/i18n/messages.ts | 16 ++ apps/web/src/i18n/request.ts | 18 ++ apps/web/src/i18n/test-utils.tsx | 15 + apps/web/src/proxy.ts | 10 + pnpm-lock.yaml | 448 +++++++++++++++++++++++++++++ 31 files changed, 831 insertions(+), 3 deletions(-) create mode 100644 apps/web/messages/de/account.json create mode 100644 apps/web/messages/de/auth.json create mode 100644 apps/web/messages/de/common.json create mode 100644 apps/web/messages/en/account.json create mode 100644 apps/web/messages/en/auth.json create mode 100644 apps/web/messages/en/common.json create mode 100644 apps/web/messages/es/account.json create mode 100644 apps/web/messages/es/auth.json create mode 100644 apps/web/messages/es/common.json create mode 100644 apps/web/messages/fr/account.json create mode 100644 apps/web/messages/fr/auth.json create mode 100644 apps/web/messages/fr/common.json create mode 100644 apps/web/messages/pt/account.json create mode 100644 apps/web/messages/pt/auth.json create mode 100644 apps/web/messages/pt/common.json create mode 100644 apps/web/messages/tr/account.json create mode 100644 apps/web/messages/tr/auth.json create mode 100644 apps/web/messages/tr/common.json create mode 100644 apps/web/src/app/(auth)/layout.tsx create mode 100644 apps/web/src/i18n/config.test.ts create mode 100644 apps/web/src/i18n/config.ts create mode 100644 apps/web/src/i18n/errors.ts create mode 100644 apps/web/src/i18n/messages.test.ts create mode 100644 apps/web/src/i18n/messages.ts create mode 100644 apps/web/src/i18n/request.ts create mode 100644 apps/web/src/i18n/test-utils.tsx diff --git a/apps/web/messages/de/account.json b/apps/web/messages/de/account.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/apps/web/messages/de/account.json @@ -0,0 +1 @@ +{} diff --git a/apps/web/messages/de/auth.json b/apps/web/messages/de/auth.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/apps/web/messages/de/auth.json @@ -0,0 +1 @@ +{} diff --git a/apps/web/messages/de/common.json b/apps/web/messages/de/common.json new file mode 100644 index 0000000..e689a5c --- /dev/null +++ b/apps/web/messages/de/common.json @@ -0,0 +1,20 @@ +{ + "errors": { + "generic": "Etwas ist schiefgelaufen. Versuche es erneut.", + "network": "AnyPlay ist nicht erreichbar. Prüfe deine Verbindung und versuche es erneut.", + "internal": "Bei AnyPlay ist ein Problem aufgetreten. Versuche es in einer Minute erneut.", + "unauthorized": "Deine Sitzung ist abgelaufen. Melde dich erneut an.", + "forbidden": "Das kannst du mit diesem Konto nicht tun.", + "banned": "Dieses Konto ist gesperrt.", + "not_found": "Das gibt es nicht mehr.", + "limit": "Du hast das Limit dafür erreicht. Entferne zuerst eines.", + "too_large": "Das ist zu groß zum Senden.", + "code_wrong": "Dieser Code ist falsch. Prüfe ihn und versuche es erneut.", + "code_expired": "Dieser Code ist abgelaufen. Fordere einen neuen an.", + "handle_taken": "Dieser Name ist vergeben. Versuche einen anderen.", + "handle_invalid": "Verwende 3 bis 16 Buchstaben, Ziffern, Bindestriche oder Unterstriche. Beginne mit einem Buchstaben.", + "rate_limited": "Zu viele Versuche. Warte einen Moment und versuche es erneut.", + "rate_limited_wait": "Zu viele Versuche. Warte {seconds, plural, one {# Sekunde} other {# Sekunden}} und versuche es erneut.", + "passkey_unsupported": "Dieser Browser unterstützt keine Passkeys. Verwende stattdessen den E-Mail-Code." + } +} diff --git a/apps/web/messages/en/account.json b/apps/web/messages/en/account.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/apps/web/messages/en/account.json @@ -0,0 +1 @@ +{} diff --git a/apps/web/messages/en/auth.json b/apps/web/messages/en/auth.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/apps/web/messages/en/auth.json @@ -0,0 +1 @@ +{} diff --git a/apps/web/messages/en/common.json b/apps/web/messages/en/common.json new file mode 100644 index 0000000..e7c6bd5 --- /dev/null +++ b/apps/web/messages/en/common.json @@ -0,0 +1,20 @@ +{ + "errors": { + "generic": "Something went wrong. Try again.", + "network": "Can’t reach AnyPlay. Check your connection and try again.", + "internal": "AnyPlay had a problem on its side. Try again in a minute.", + "unauthorized": "Your session ended. Sign in again.", + "forbidden": "You can’t do that with this account.", + "banned": "This account is banned.", + "not_found": "That doesn’t exist any more.", + "limit": "You’ve reached the limit for this. Remove one first.", + "too_large": "That is too large to send.", + "code_wrong": "That code is wrong. Check it and try again.", + "code_expired": "That code has expired. Request a new one.", + "handle_taken": "That handle is taken. Try another.", + "handle_invalid": "Use 3 to 16 letters, digits, hyphens or underscores. Start with a letter.", + "rate_limited": "Too many tries. Wait a moment and try again.", + "rate_limited_wait": "Too many tries. Wait {seconds, plural, one {# second} other {# seconds}} and try again.", + "passkey_unsupported": "This browser can’t use passkeys. Use the e-mail code instead." + } +} diff --git a/apps/web/messages/es/account.json b/apps/web/messages/es/account.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/apps/web/messages/es/account.json @@ -0,0 +1 @@ +{} diff --git a/apps/web/messages/es/auth.json b/apps/web/messages/es/auth.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/apps/web/messages/es/auth.json @@ -0,0 +1 @@ +{} diff --git a/apps/web/messages/es/common.json b/apps/web/messages/es/common.json new file mode 100644 index 0000000..8fad371 --- /dev/null +++ b/apps/web/messages/es/common.json @@ -0,0 +1,20 @@ +{ + "errors": { + "generic": "Algo salió mal. Inténtalo de nuevo.", + "network": "No se puede conectar con AnyPlay. Comprueba tu conexión e inténtalo de nuevo.", + "internal": "AnyPlay tuvo un problema. Inténtalo de nuevo en un minuto.", + "unauthorized": "Tu sesión ha terminado. Inicia sesión de nuevo.", + "forbidden": "No puedes hacer eso con esta cuenta.", + "banned": "Esta cuenta está bloqueada.", + "not_found": "Eso ya no existe.", + "limit": "Has llegado al límite. Quita uno primero.", + "too_large": "Eso es demasiado grande para enviarlo.", + "code_wrong": "Ese código es incorrecto. Revísalo e inténtalo de nuevo.", + "code_expired": "Ese código ha caducado. Solicita uno nuevo.", + "handle_taken": "Ese nombre de usuario ya está en uso. Prueba con otro.", + "handle_invalid": "Usa de 3 a 16 letras, dígitos, guiones o guiones bajos. Empieza con una letra.", + "rate_limited": "Demasiados intentos. Espera un momento e inténtalo de nuevo.", + "rate_limited_wait": "Demasiados intentos. Espera {seconds, plural, one {# segundo} other {# segundos}} e inténtalo de nuevo.", + "passkey_unsupported": "Este navegador no admite passkeys. Usa el código por correo electrónico." + } +} diff --git a/apps/web/messages/fr/account.json b/apps/web/messages/fr/account.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/apps/web/messages/fr/account.json @@ -0,0 +1 @@ +{} diff --git a/apps/web/messages/fr/auth.json b/apps/web/messages/fr/auth.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/apps/web/messages/fr/auth.json @@ -0,0 +1 @@ +{} diff --git a/apps/web/messages/fr/common.json b/apps/web/messages/fr/common.json new file mode 100644 index 0000000..214b826 --- /dev/null +++ b/apps/web/messages/fr/common.json @@ -0,0 +1,20 @@ +{ + "errors": { + "generic": "Une erreur s’est produite. Réessaie.", + "network": "Impossible de joindre AnyPlay. Vérifie ta connexion et réessaie.", + "internal": "AnyPlay a rencontré un problème. Réessaie dans une minute.", + "unauthorized": "Ta session est terminée. Reconnecte-toi.", + "forbidden": "Tu ne peux pas faire cela avec ce compte.", + "banned": "Ce compte est banni.", + "not_found": "Cela n’existe plus.", + "limit": "Tu as atteint la limite. Supprime-en un d’abord.", + "too_large": "C’est trop volumineux pour être envoyé.", + "code_wrong": "Ce code est incorrect. Vérifie-le et réessaie.", + "code_expired": "Ce code a expiré. Demande-en un nouveau.", + "handle_taken": "Ce pseudo est déjà pris. Essaie-en un autre.", + "handle_invalid": "Utilise de 3 à 16 lettres, chiffres, tirets ou tirets bas. Commence par une lettre.", + "rate_limited": "Trop d’essais. Attends un instant et réessaie.", + "rate_limited_wait": "Trop d’essais. Attends {seconds, plural, one {# seconde} other {# secondes}} et réessaie.", + "passkey_unsupported": "Ce navigateur ne gère pas les clés d’accès. Utilise plutôt le code reçu par e-mail." + } +} diff --git a/apps/web/messages/pt/account.json b/apps/web/messages/pt/account.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/apps/web/messages/pt/account.json @@ -0,0 +1 @@ +{} diff --git a/apps/web/messages/pt/auth.json b/apps/web/messages/pt/auth.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/apps/web/messages/pt/auth.json @@ -0,0 +1 @@ +{} diff --git a/apps/web/messages/pt/common.json b/apps/web/messages/pt/common.json new file mode 100644 index 0000000..d4ab3b6 --- /dev/null +++ b/apps/web/messages/pt/common.json @@ -0,0 +1,20 @@ +{ + "errors": { + "generic": "Algo deu errado. Tente de novo.", + "network": "Não foi possível conectar ao AnyPlay. Verifique sua conexão e tente de novo.", + "internal": "O AnyPlay teve um problema. Tente de novo em um minuto.", + "unauthorized": "Sua sessão terminou. Entre de novo.", + "forbidden": "Você não pode fazer isso com esta conta.", + "banned": "Esta conta está banida.", + "not_found": "Isso não existe mais.", + "limit": "Você atingiu o limite. Remova um primeiro.", + "too_large": "Isso é grande demais para enviar.", + "code_wrong": "Esse código está errado. Confira e tente de novo.", + "code_expired": "Esse código expirou. Peça um novo.", + "handle_taken": "Esse nome de usuário já está em uso. Tente outro.", + "handle_invalid": "Use de 3 a 16 letras, dígitos, hífens ou sublinhados. Comece com uma letra.", + "rate_limited": "Muitas tentativas. Aguarde um momento e tente de novo.", + "rate_limited_wait": "Muitas tentativas. Aguarde {seconds, plural, one {# segundo} other {# segundos}} e tente de novo.", + "passkey_unsupported": "Este navegador não oferece suporte a passkeys. Use o código por e-mail." + } +} diff --git a/apps/web/messages/tr/account.json b/apps/web/messages/tr/account.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/apps/web/messages/tr/account.json @@ -0,0 +1 @@ +{} diff --git a/apps/web/messages/tr/auth.json b/apps/web/messages/tr/auth.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/apps/web/messages/tr/auth.json @@ -0,0 +1 @@ +{} diff --git a/apps/web/messages/tr/common.json b/apps/web/messages/tr/common.json new file mode 100644 index 0000000..3418d30 --- /dev/null +++ b/apps/web/messages/tr/common.json @@ -0,0 +1,20 @@ +{ + "errors": { + "generic": "Bir şeyler ters gitti. Yeniden dene.", + "network": "AnyPlay’e ulaşılamıyor. Bağlantını kontrol edip yeniden dene.", + "internal": "AnyPlay’in tarafında bir sorun oldu. Bir dakika sonra yeniden dene.", + "unauthorized": "Oturumun sona erdi. Yeniden giriş yap.", + "forbidden": "Bunu bu hesapla yapamazsın.", + "banned": "Bu hesap engellendi.", + "not_found": "Bu artık yok.", + "limit": "Bunun için sınıra ulaştın. Önce birini kaldır.", + "too_large": "Bu göndermek için fazla büyük.", + "code_wrong": "Bu kod yanlış. Kontrol edip yeniden dene.", + "code_expired": "Bu kodun süresi doldu. Yeni bir kod iste.", + "handle_taken": "Bu kullanıcı adı alınmış. Başka bir tane dene.", + "handle_invalid": "3 ile 16 arasında harf, rakam, tire veya alt çizgi kullan. Bir harfle başla.", + "rate_limited": "Çok fazla deneme. Biraz bekleyip yeniden dene.", + "rate_limited_wait": "Çok fazla deneme. {seconds, plural, one {# saniye} other {# saniye}} bekleyip yeniden dene.", + "passkey_unsupported": "Bu tarayıcı geçiş anahtarlarını kullanamıyor. Bunun yerine e-posta kodunu kullan." + } +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 5e33406..8f0c33d 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { NextConfig } from "next"; +import createNextIntlPlugin from "next-intl/plugin"; /** * Hardening, because the 2025-26 Next.js advisories are real and every self-hoster would have to @@ -47,4 +48,7 @@ const config: NextConfig = { }, }; -export default config; +// The words come from messages// (src/i18n); no language in the path, the proxy picks one. +const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); + +export default withNextIntl(config); diff --git a/apps/web/package.json b/apps/web/package.json index 9c294ad..2b54515 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "@tanstack/react-query": "5.104.4", "lenis": "1.3.26", "next": "16.4.2", + "next-intl": "4.14.7", "react": "19.3.1", "react-dom": "19.3.1" }, diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..eda9809 --- /dev/null +++ b/apps/web/src/app/(auth)/layout.tsx @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { NextIntlClientProvider } from "next-intl"; +import { getMessages } from "next-intl/server"; +import type { ReactNode } from "react"; + +/** Sign-in, sign-up, device link and join: the shared words and the auth words, in the visitor's language. */ +export default async function AuthLayout({ children }: { children: ReactNode }) { + const { common, auth } = (await getMessages()) as Record>; + return {children}; +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index ad689c2..d696311 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,4 +1,6 @@ import type { Metadata, Viewport } from "next"; +import { NextIntlClientProvider } from "next-intl"; +import { getLocale, getMessages } from "next-intl/server"; import { connection } from "next/server"; import { Monitoring } from "@/components/monitoring"; import { SiteProvider } from "@/components/site-context"; @@ -27,10 +29,15 @@ export const viewport: Viewport = { themeColor: "#0b0a09", colorScheme: "dark li export default async function RootLayout({ children }: { children: React.ReactNode }) { const site = readSite(); + const locale = await getLocale(); + // Only the shared words go to every page; the account flows add theirs in their own layouts. + const { common } = (await getMessages()) as { common: Record }; return ( - + - {children} + + {children} + diff --git a/apps/web/src/i18n/config.test.ts b/apps/web/src/i18n/config.test.ts new file mode 100644 index 0000000..14d79dc --- /dev/null +++ b/apps/web/src/i18n/config.test.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { describe, expect, it } from "vitest"; +import { fromAcceptLanguage, parseLocale, resolveLocale } from "./config"; + +describe("locale resolution", () => { + it("reads region and case variants", () => { + expect(parseLocale("de-AT")).toBe("de"); + expect(parseLocale(" TR ")).toBe("tr"); + expect(parseLocale("pt_BR")).toBe("pt"); + expect(parseLocale("ja")).toBeNull(); + expect(parseLocale("")).toBeNull(); + expect(parseLocale(undefined)).toBeNull(); + }); + it("takes the best of Accept-Language by weight, then by order", () => { + expect(fromAcceptLanguage("fr-CH, fr;q=0.9, en;q=0.8")).toBe("fr"); + expect(fromAcceptLanguage("ja, es;q=0.4, de;q=0.7")).toBe("de"); + expect(fromAcceptLanguage("de;q=0, en")).toBe("en"); + expect(fromAcceptLanguage("ja, zh")).toBeNull(); + expect(fromAcceptLanguage("de;q=abc")).toBeNull(); + }); + it("prefers the explicit parameter, then the cookie, then the browser, then English", () => { + expect(resolveLocale({ param: "tr", cookie: "de", acceptLanguage: "fr" })).toBe("tr"); + expect(resolveLocale({ param: "xx", cookie: "de", acceptLanguage: "fr" })).toBe("de"); + expect(resolveLocale({ cookie: "xx", acceptLanguage: "fr" })).toBe("fr"); + expect(resolveLocale({ acceptLanguage: "ja" })).toBe("en"); + expect(resolveLocale({})).toBe("en"); + }); +}); diff --git a/apps/web/src/i18n/config.ts b/apps/web/src/i18n/config.ts new file mode 100644 index 0000000..8b16905 --- /dev/null +++ b/apps/web/src/i18n/config.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** + * The languages of the account flows: the launcher's six, with the launcher's codes, so the launcher + * can pass its own language as `?lang=`. English is the fallback for everything else. + */ +export const LOCALES = ["en", "tr", "de", "es", "fr", "pt"] as const; +export type Locale = (typeof LOCALES)[number]; +export const DEFAULT_LOCALE: Locale = "en"; + +/** The cookie that remembers the choice, and the header the proxy hands to the render. */ +export const LOCALE_COOKIE = "lang"; +export const LOCALE_HEADER = "x-locale"; +export const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; + +/** Each language's own name, for the one place that lists them. */ +export const LOCALE_NAMES: Record = { + en: "English", + tr: "Türkçe", + de: "Deutsch", + es: "Español", + fr: "Français", + pt: "Português", +}; + +const known = new Set(LOCALES); + +/** `de`, `de-AT`, `DE_at` and ` de ` all mean German; anything else is no answer. */ +export function parseLocale(value: string | null | undefined): Locale | null { + const base = value?.trim().toLowerCase().split(/[-_]/)[0]; + return base && known.has(base) ? (base as Locale) : null; +} + +/** The best language in an Accept-Language header, honouring q values; null when none is one of ours. */ +export function fromAcceptLanguage(header: string | null | undefined): Locale | null { + if (!header) return null; + const ranked = header + .split(",") + .map((part, index) => { + const [tag, ...params] = part.trim().split(";"); + const q = params.map((p) => p.trim()).find((p) => p.startsWith("q=")); + const weight = q ? Number.parseFloat(q.slice(2)) : 1; + return { tag: tag ?? "", weight: Number.isFinite(weight) ? weight : 0, index }; + }) + .filter((e) => e.weight > 0) + .sort((a, b) => b.weight - a.weight || a.index - b.index); + for (const { tag } of ranked) { + const locale = parseLocale(tag); + if (locale) return locale; + } + return null; +} + +/** Explicit `?lang=` first, then the cookie, then Accept-Language, then English. */ +export function resolveLocale(input: { param?: string | null; cookie?: string | null; acceptLanguage?: string | null }): Locale { + return parseLocale(input.param) ?? parseLocale(input.cookie) ?? fromAcceptLanguage(input.acceptLanguage) ?? DEFAULT_LOCALE; +} diff --git a/apps/web/src/i18n/errors.ts b/apps/web/src/i18n/errors.ts new file mode 100644 index 0000000..76f6660 --- /dev/null +++ b/apps/web/src/i18n/errors.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-only +"use client"; + +import { isApiError } from "@anyplay/api-client"; +import { useTranslations } from "next-intl"; +import { useCallback } from "react"; + +const KNOWN = new Set([ + "network", + "internal", + "unauthorized", + "forbidden", + "banned", + "not_found", + "limit", + "too_large", + "code_wrong", + "code_expired", + "handle_taken", + "handle_invalid", + "rate_limited", + "passkey_unsupported", +]); + +/** + * The words for a failure, in the visitor's language: the translated twin of the API client's + * `describeError`. A lockout the cloud words itself (how long, and that a passkey still works) is + * shown as the cloud wrote it, which is English; every other failure is ours. + */ +export function useDescribeError(): (e: unknown) => string { + const t = useTranslations("common.errors"); + return useCallback( + (e: unknown) => { + if (!isApiError(e)) return t("generic"); + if (e.code === "rate_limited" && e.message && !e.message.startsWith("Too many requests")) return e.message; + if (e.code === "rate_limited" && e.retryAfter) return t("rate_limited_wait", { seconds: e.retryAfter }); + if (KNOWN.has(e.code)) return t(e.code as "network"); + return e.message || t("generic"); + }, + [t], + ); +} diff --git a/apps/web/src/i18n/messages.test.ts b/apps/web/src/i18n/messages.test.ts new file mode 100644 index 0000000..d7e4da8 --- /dev/null +++ b/apps/web/src/i18n/messages.test.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { describe, expect, it } from "vitest"; +import { LOCALES } from "./config"; +import { AREAS, loadMessages } from "./messages"; + +type Tree = { [key: string]: string | Tree }; + +/** Every leaf as `path -> text`. */ +function flatten(tree: Tree, prefix = ""): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(tree)) { + if (typeof v === "string") out[prefix + k] = v; + else Object.assign(out, flatten(v, `${prefix}${k}.`)); + } + return out; +} + +/** The ICU arguments of a message, `{name}` and `{n, plural, ...}` alike, and the rich tags. */ +function placeholders(text: string): string[] { + const args = [...text.matchAll(/\{\s*([A-Za-z_][\w]*)\s*[,}]/g)].map((m) => m[1]!); + const tags = [...text.matchAll(/<([A-Za-z]+)>/g)].map((m) => `<${m[1]}>`); + return [...new Set([...args, ...tags])].sort(); +} + +describe("messages", () => { + for (const area of AREAS) { + describe(area, () => { + for (const locale of LOCALES.filter((l) => l !== "en")) { + it(`${locale} has exactly the keys and placeholders of English`, async () => { + const en = flatten(((await loadMessages("en", [area]))[area] ?? {}) as Tree); + const other = flatten(((await loadMessages(locale, [area]))[area] ?? {}) as Tree); + expect(Object.keys(other).sort()).toEqual(Object.keys(en).sort()); + for (const key of Object.keys(en)) { + expect(other[key], `${locale} ${area}.${key} is empty`).not.toBe(""); + expect(placeholders(other[key]!), `${locale} ${area}.${key}`).toEqual(placeholders(en[key]!)); + } + }); + } + }); + } +}); diff --git a/apps/web/src/i18n/messages.ts b/apps/web/src/i18n/messages.ts new file mode 100644 index 0000000..51085ce --- /dev/null +++ b/apps/web/src/i18n/messages.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import type { Locale } from "./config"; + +/** + * The words, one JSON file per area and language under `messages//`. English is the + * source: every other language must have exactly the same keys (messages.test.ts), and the ICU + * placeholders must match, so a missing or mistyped string fails a test, not a visitor. + */ +export const AREAS = ["common", "auth", "account"] as const; +export type Area = (typeof AREAS)[number]; +export type Messages = Record; + +export async function loadMessages(locale: Locale, areas: readonly Area[] = AREAS): Promise { + const entries = await Promise.all(areas.map(async (area) => [area, (await import(`../../messages/${locale}/${area}.json`)).default] as const)); + return Object.fromEntries(entries); +} diff --git a/apps/web/src/i18n/request.ts b/apps/web/src/i18n/request.ts new file mode 100644 index 0000000..bf50688 --- /dev/null +++ b/apps/web/src/i18n/request.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { cookies, headers } from "next/headers"; +import { getRequestConfig } from "next-intl/server"; +import { LOCALE_COOKIE, LOCALE_HEADER, parseLocale, resolveLocale } from "./config"; +import { loadMessages } from "./messages"; + +/** + * The language of this request. The proxy (src/proxy.ts) has already weighed `?lang=`, the cookie + * and Accept-Language and says the result in a header; when it did not run (a test), the same + * weighing is done here from the cookie and Accept-Language. + */ +export default getRequestConfig(async () => { + const h = await headers(); + const locale = + parseLocale(h.get(LOCALE_HEADER)) ?? + resolveLocale({ cookie: (await cookies()).get(LOCALE_COOKIE)?.value, acceptLanguage: h.get("accept-language") }); + return { locale, messages: await loadMessages(locale) }; +}); diff --git a/apps/web/src/i18n/test-utils.tsx b/apps/web/src/i18n/test-utils.tsx new file mode 100644 index 0000000..cf579f3 --- /dev/null +++ b/apps/web/src/i18n/test-utils.tsx @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { NextIntlClientProvider } from "next-intl"; +import type { ReactNode } from "react"; +import type { Locale } from "./config"; +import { AREAS, loadMessages } from "./messages"; + +/** Wraps a component under test in the real words of one language (English by default). */ +export async function withIntl(ui: ReactNode, locale: Locale = "en"): Promise { + const messages = await loadMessages(locale, AREAS); + return ( + + {ui} + + ); +} diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 0ede213..a252192 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import { NextResponse, type NextRequest } from "next/server"; +import { LOCALE_COOKIE, LOCALE_COOKIE_MAX_AGE, LOCALE_HEADER, parseLocale, resolveLocale } from "@/i18n/config"; import { monitoringOrigin } from "@/lib/monitoring"; /** @@ -45,8 +46,17 @@ export function proxy(request: NextRequest) { const headers = new Headers(request.headers); headers.set("x-nonce", nonce); headers.set("content-security-policy", csp); + // The language: an explicit ?lang= (the launcher passes its own), else the cookie, else the browser. + const param = request.nextUrl.searchParams.get("lang"); + const cookie = request.cookies.get(LOCALE_COOKIE)?.value; + const locale = resolveLocale({ param, cookie, acceptLanguage: request.headers.get("accept-language") }); + headers.set(LOCALE_HEADER, locale); const response = NextResponse.next({ request: { headers } }); response.headers.set("content-security-policy", csp); + // An explicit choice is remembered, so the next page opened without it keeps the language. + if (parseLocale(param) && parseLocale(cookie) !== locale) { + response.cookies.set(LOCALE_COOKIE, locale, { path: "/", maxAge: LOCALE_COOKIE_MAX_AGE, sameSite: "lax", secure: process.env.NODE_ENV === "production" }); + } return response; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ad77b0..c195096 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,6 +208,9 @@ importers: next: specifier: 16.4.2 version: 16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.64.1)(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + next-intl: + specifier: 4.14.7 + version: 4.14.7(@swc/helpers@0.5.23)(@types/react@19.3.0)(next@16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.64.1)(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react@19.3.1) react: specifier: 19.3.1 version: 19.3.1 @@ -500,6 +503,15 @@ packages: resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} engines: {node: '>=20.19.0'} + '@eloqnt/config@0.1.1': + resolution: {integrity: sha512-YRDigSaiJEzKDfzTth5kG+L/PKfB3mo8h7yNzpXExPfvQmcD05WoZT57PdhJE0jI+Vva6eb0OCU8hnHHgdME3g==} + + '@eloqnt/format-json@0.1.0': + resolution: {integrity: sha512-4Pkb+HyzgWNJXoU48aJhUyNZSjZvMGooPCNG1ZFmBm55xTiNNPQCNXH66Ob6sXS6RvbWZrRpCf+Uo/Jz9K9g+A==} + + '@eloqnt/format-po@0.1.1': + resolution: {integrity: sha512-aLtmIoqXGEaeTT0RcsLN8sZ3aIrsT/c/dVs28zrLhDjO3+vA3KNP+r98N0xq8w/k36+/GIUt4uZNbqcA71GVWw==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -580,6 +592,24 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@formatjs/fast-memoize@3.1.7': + resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==} + + '@formatjs/icu-messageformat-parser@3.5.18': + resolution: {integrity: sha512-wX1efcL8d7K5QapOTRsP/OLTA/kpwe4LwqWyNxDNg5iRs8i9jJbCyxB5P8jc4KOW5oiLzz2DJr+BRY5xMV9+bA==} + + '@formatjs/icu-messageformat-parser@3.5.22': + resolution: {integrity: sha512-YuwN+dDKgssfuYwtOyGQD0CSn7K46g9cv1Fp0VgKpjZDDFvv8b8crzP990IcJhRPWHhKzCGsbUSJVIguyyFA5A==} + + '@formatjs/icu-skeleton-parser@2.1.11': + resolution: {integrity: sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==} + + '@formatjs/icu-skeleton-parser@2.1.12': + resolution: {integrity: sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==} + + '@formatjs/intl-localematcher@0.8.14': + resolution: {integrity: sha512-8O7V38QmbB11+ryJ3KM/znrzXtiqgpXzqNoqDsndHZZ5/zDKTTL+cf5F0mzXkQEwhx8IlWnqYvEovf2S5EwLTw==} + '@heroicons/react@2.2.0': resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==} peerDependencies: @@ -887,6 +917,88 @@ packages: '@paper-design/shaders@0.0.81': resolution: {integrity: sha512-jeGdyjMscZ2bxmQdo8lhwUufHUf4iq3s1ayjh8PXU7Go42Rw8E3I0It1FZdc3HuWt5J82m//DRWaxsRqRfA4Gg==} + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} + engines: {node: '>= 10.0.0'} + '@playwright/test@1.64.1': resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} engines: {node: '>=20'} @@ -994,6 +1106,9 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@schummar/icu-type-parser@1.21.5': + resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@sentry/browser-utils@11.8.0': resolution: {integrity: sha512-tTqJmnfuJaQy9LyInCWJtJ0L+VnOq+L9+lTMkQH+pptXBAuHfdJyEl2IhGqO4QmsfKyZpHa2GVd3HKpEQsVYeg==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} @@ -1022,9 +1137,102 @@ packages: resolution: {integrity: sha512-0jXMLd9XCBEZ5tu0VJa1uj1HYCpsjuNjbEBhF5pUw4FHyIH5hz01WuI20SH2Ct0pP1mykTzJLPGjZ8sUiAmMnQ==} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0'} + '@swc/core-darwin-arm64@1.16.18': + resolution: {integrity: sha512-ozgiCHyFDCwfXzTnaLR+i8WxEyt06GTudn9rPh08vj+u3Z8vMvt5Pi8T+BikyNXm7y++h6UMdqsnJuWrV+tk2A==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.16.18': + resolution: {integrity: sha512-tuwxXi+sjxxTAi7GP3NeQXQxOf4SXSiFxKAMYLBdoy7JqGYby6uSDXojcFoLOhSp7oiNGstnaFXxfbP0Hpro9w==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.16.18': + resolution: {integrity: sha512-SMdhmY1P9Kveh0MRj6vqQF98bsjnDa0lBBvkd/lcpxmjj5QHJOQAU0N3g6h7GPWpBMs3h2g3lHu/QTDn+uMsXg==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.16.18': + resolution: {integrity: sha512-ep/mQyhblQYZ6tkfq0IJspcVSFl5jdywks3kejanrbB31uuujRD/QWOoGNp2L3lLRdAdQvgl/M3pA7g3hSuQug==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.16.18': + resolution: {integrity: sha512-pkzGVxrAQu5+febW1a/Aiph3YJk0UiJKXoWbxnDVEIOZWkk5aETHTuyVsOQ3kOgn2dzNMvJVeqcoEQdH+ntqWQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.16.18': + resolution: {integrity: sha512-v9kiu4as1pJyQ8ESFTtMNydZ99m86QXLfAtm+XiKPKS93pXL80BCaXokAs9ebioGkIXgmwfbzUCdUC62H3JWkw==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.16.18': + resolution: {integrity: sha512-vU7TKRJ26i86YQr4QY/5bdcH72KzkBbSSUfPlErW6d6KOR47BcVGlYZFTcMMYlmd3CVSXFHW18tBSila66YfoA==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.16.18': + resolution: {integrity: sha512-Ml6kbe0BoVJkSgmYPAEUudGcu84jaQeqDN8xeMumbwNpT0vlmm7241Ddp9XKCjQABvjomnXF0OiNOAfjBhNDNw==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.16.18': + resolution: {integrity: sha512-TGU24+QmmS8y/hg0BLKgOU3W5TCcQC9bgGl8AB0R61gTNQdWdcmCehRLAJwlZfxtAml66xMJ6PWrX8NKrABOsA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.16.18': + resolution: {integrity: sha512-S53hm7Amc3YV8t2BUT6R7J+zSnwiGarG+GnxMca4W0uMkxBZ3Gl6jBwEgb6YqrYjf2DzOuRfauztqptCO76a/w==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.16.18': + resolution: {integrity: sha512-+6/10+OJmU12zWkrPVG0QeQEr8VsWeF8gyXrOfHZMIfMOfz8/ULXVZPhV9GhGMKSWake1xEJj2IYCiFRS/xMpQ==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.16.18': + resolution: {integrity: sha512-7LsVrIICAZWSajXT6DwO7PYSTnW9maamOCyQylaOh13Viptrgi/7hl9fP75SiHe+kscaJahGXcorgt4MOOh/1w==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.16.18': + resolution: {integrity: sha512-0cYQLkpcwUngg55TDwpwrrVOU+B6zh1NFbp+oS7dVFhmYkzaARelwugo/bzPJh1FrcnppmF+KTqioCGwyqbcEw==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@swc/types@0.1.28': + resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} + '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -1578,6 +1786,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2017,6 +2229,9 @@ packages: resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} engines: {node: ^22.13.0 || >=24.0.0} + icu-minify@4.14.9: + resolution: {integrity: sha512-PmO+fNnC3K3mfPKTnm3RrPERrhQZnjm1AlpyZQ0ShbXi8Y/kYZ3YOde8TEjCLz9k3P2DmzkBn8lykGrCAi1Ajw==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2037,6 +2252,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + intl-messageformat@11.2.15: + resolution: {integrity: sha512-W4VNytsXyWgW5lvVchmsb9xgA5rSKqoflu3/IQ9UCtB0z4R+Bx9MoEkwFT/+30RCl/KzUoH0QGGe9lppF5SuvQ==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2466,6 +2684,23 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + next-intl-swc-plugin-extractor@4.14.7: + resolution: {integrity: sha512-0KUc8helXuJfyXVUQDolz0wnRnaW+dkJf6ozX6A+O9x1dd1+cb4PpZqj6FfcDdJagMuIxtnDmeRT7yUhllMYKA==} + + next-intl@4.14.7: + resolution: {integrity: sha512-VQJu34ga2pG8EO7aQO7mzinoso/XpvQdMPfifBZIeWAoyGlKU3//nt7QVwWBZuOMBne4g6onsEFk+IA5ueF61g==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + next@16.4.2: resolution: {integrity: sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==} engines: {node: '>=20.9.0'} @@ -2487,6 +2722,9 @@ packages: sass: optional: true + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-exports-info@1.6.2: resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} engines: {node: '>= 0.4'} @@ -2589,6 +2827,9 @@ packages: engines: {node: '>=20'} hasBin: true + po-parser@2.2.0: + resolution: {integrity: sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2936,6 +3177,15 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-intl@4.14.9: + resolution: {integrity: sha512-37ktvdCCz+6JY2VFPl/GV69Ds4ahiUT2c2xEZZRZYxdgGLxFsUbdxzjQVRm3YMIOUU0VPs6zvlifb4Yjk4IxIg==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.7.0: resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} peerDependencies: @@ -3280,6 +3530,17 @@ snapshots: '@csstools/css-tokenizer@4.0.2': {} + '@eloqnt/config@0.1.1': {} + + '@eloqnt/format-json@0.1.0': + dependencies: + '@eloqnt/config': 0.1.1 + + '@eloqnt/format-po@0.1.1': + dependencies: + '@eloqnt/config': 0.1.1 + po-parser: 2.2.0 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -3371,6 +3632,24 @@ snapshots: '@floating-ui/utils@0.2.12': {} + '@formatjs/fast-memoize@3.1.7': {} + + '@formatjs/icu-messageformat-parser@3.5.18': + dependencies: + '@formatjs/icu-skeleton-parser': 2.1.11 + + '@formatjs/icu-messageformat-parser@3.5.22': + dependencies: + '@formatjs/icu-skeleton-parser': 2.1.12 + + '@formatjs/icu-skeleton-parser@2.1.11': {} + + '@formatjs/icu-skeleton-parser@2.1.12': {} + + '@formatjs/intl-localematcher@0.8.14': + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@heroicons/react@2.2.0(react@19.3.1)': dependencies: react: 19.3.1 @@ -3588,6 +3867,62 @@ snapshots: '@paper-design/shaders@0.0.81': {} + '@parcel/watcher-android-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-x64@2.6.0': + optional: true + + '@parcel/watcher-freebsd-x64@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-musl@2.6.0': + optional: true + + '@parcel/watcher-win32-arm64@2.6.0': + optional: true + + '@parcel/watcher-win32-x64@2.6.0': + optional: true + + '@parcel/watcher@2.6.0': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.7 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 + '@playwright/test@1.64.1': dependencies: playwright: 1.64.1 @@ -3641,6 +3976,8 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@schummar/icu-type-parser@1.21.5': {} + '@sentry/browser-utils@11.8.0': dependencies: '@sentry/conventions': 0.27.0 @@ -3676,10 +4013,71 @@ snapshots: '@sentry/browser-utils': 11.8.0 '@sentry/core': 11.8.0 + '@swc/core-darwin-arm64@1.16.18': + optional: true + + '@swc/core-darwin-x64@1.16.18': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.16.18': + optional: true + + '@swc/core-linux-arm64-gnu@1.16.18': + optional: true + + '@swc/core-linux-arm64-musl@1.16.18': + optional: true + + '@swc/core-linux-ppc64-gnu@1.16.18': + optional: true + + '@swc/core-linux-s390x-gnu@1.16.18': + optional: true + + '@swc/core-linux-x64-gnu@1.16.18': + optional: true + + '@swc/core-linux-x64-musl@1.16.18': + optional: true + + '@swc/core-win32-arm64-msvc@1.16.18': + optional: true + + '@swc/core-win32-ia32-msvc@1.16.18': + optional: true + + '@swc/core-win32-x64-msvc@1.16.18': + optional: true + + '@swc/core@1.16.18(@swc/helpers@0.5.23)': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.28 + optionalDependencies: + '@swc/core-darwin-arm64': 1.16.18 + '@swc/core-darwin-x64': 1.16.18 + '@swc/core-linux-arm-gnueabihf': 1.16.18 + '@swc/core-linux-arm64-gnu': 1.16.18 + '@swc/core-linux-arm64-musl': 1.16.18 + '@swc/core-linux-ppc64-gnu': 1.16.18 + '@swc/core-linux-s390x-gnu': 1.16.18 + '@swc/core-linux-x64-gnu': 1.16.18 + '@swc/core-linux-x64-musl': 1.16.18 + '@swc/core-win32-arm64-msvc': 1.16.18 + '@swc/core-win32-ia32-msvc': 1.16.18 + '@swc/core-win32-x64-msvc': 1.16.18 + '@swc/helpers': 0.5.23 + + '@swc/counter@0.1.3': {} + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 + '@swc/types@0.1.28': + dependencies: + '@swc/counter': 0.1.3 + '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 @@ -4190,6 +4588,8 @@ snapshots: concat-map@0.0.1: {} + content-type@2.1.0: {} + convert-source-map@2.0.0: {} cross-spawn@7.0.6: @@ -4790,6 +5190,10 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + icu-minify@4.14.9: + dependencies: + '@formatjs/icu-messageformat-parser': 3.5.22 + ignore@5.3.2: {} ignore@7.0.12: {} @@ -4807,6 +5211,11 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.1 + intl-messageformat@11.2.15: + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@formatjs/icu-messageformat-parser': 3.5.18 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -5178,6 +5587,31 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + next-intl-swc-plugin-extractor@4.14.7: {} + + next-intl@4.14.7(@swc/helpers@0.5.23)(@types/react@19.3.0)(next@16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.64.1)(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react@19.3.1): + dependencies: + '@eloqnt/config': 0.1.1 + '@eloqnt/format-json': 0.1.0 + '@eloqnt/format-po': 0.1.1 + '@formatjs/intl-localematcher': 0.8.14 + '@parcel/watcher': 2.6.0 + '@swc/core': 1.16.18(@swc/helpers@0.5.23) + icu-minify: 4.14.9 + negotiator: 1.1.0 + next: 16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.64.1)(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + next-intl-swc-plugin-extractor: 4.14.7 + react: 19.3.1 + use-intl: 4.14.9(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + transitivePeerDependencies: + - '@swc/helpers' + next@16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.64.1)(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): dependencies: '@next/env': 16.4.2 @@ -5204,6 +5638,8 @@ snapshots: - '@types/node' - babel-plugin-macros + node-addon-api@7.1.1: {} + node-exports-info@1.6.2: dependencies: array.prototype.flatmap: 1.3.3 @@ -5309,6 +5745,8 @@ snapshots: dependencies: playwright-core: 1.64.1 + po-parser@2.2.0: {} + possible-typed-array-names@1.1.0: {} postcss@8.5.23: @@ -5780,6 +6218,16 @@ snapshots: dependencies: punycode: 2.3.1 + use-intl@4.14.9(@types/react@19.3.0)(react@19.3.1): + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@schummar/icu-type-parser': 1.21.5 + icu-minify: 4.14.9 + intl-messageformat: 11.2.15 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + use-sync-external-store@1.7.0(react@19.3.1): dependencies: react: 19.3.1 From 83ed1cf518f4f6bcad798dd2ed61be214fc2a8ff Mon Sep 17 00:00:00 2001 From: AlxcBTW <187763517+AlxcBTW@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:18:29 +0100 Subject: [PATCH 2/7] i18n test wrapper --- apps/web/src/i18n/test-utils.tsx | 37 ++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/apps/web/src/i18n/test-utils.tsx b/apps/web/src/i18n/test-utils.tsx index cf579f3..d608feb 100644 --- a/apps/web/src/i18n/test-utils.tsx +++ b/apps/web/src/i18n/test-utils.tsx @@ -2,14 +2,39 @@ import { NextIntlClientProvider } from "next-intl"; import type { ReactNode } from "react"; import type { Locale } from "./config"; -import { AREAS, loadMessages } from "./messages"; +import en_common from "../../messages/en/common.json"; +import en_auth from "../../messages/en/auth.json"; +import en_account from "../../messages/en/account.json"; +import tr_common from "../../messages/tr/common.json"; +import tr_auth from "../../messages/tr/auth.json"; +import tr_account from "../../messages/tr/account.json"; +import de_common from "../../messages/de/common.json"; +import de_auth from "../../messages/de/auth.json"; +import de_account from "../../messages/de/account.json"; +import es_common from "../../messages/es/common.json"; +import es_auth from "../../messages/es/auth.json"; +import es_account from "../../messages/es/account.json"; +import fr_common from "../../messages/fr/common.json"; +import fr_auth from "../../messages/fr/auth.json"; +import fr_account from "../../messages/fr/account.json"; +import pt_common from "../../messages/pt/common.json"; +import pt_auth from "../../messages/pt/auth.json"; +import pt_account from "../../messages/pt/account.json"; -/** Wraps a component under test in the real words of one language (English by default). */ -export async function withIntl(ui: ReactNode, locale: Locale = "en"): Promise { - const messages = await loadMessages(locale, AREAS); +/** Wraps a component under test in the real words of one language (English by default): `render(...)`. */ +const ALL: Record> = { + en: { common: en_common, auth: en_auth, account: en_account }, + tr: { common: tr_common, auth: tr_auth, account: tr_account }, + de: { common: de_common, auth: de_auth, account: de_account }, + es: { common: es_common, auth: es_auth, account: es_account }, + fr: { common: fr_common, auth: fr_auth, account: fr_account }, + pt: { common: pt_common, auth: pt_auth, account: pt_account }, +}; + +export function Intl({ children, locale = "en" }: { children: ReactNode; locale?: Locale }) { return ( - - {ui} + + {children} ); } From 1ac28a85dee9faef6664b418e03673d76a3dffef Mon Sep 17 00:00:00 2001 From: AlxcBTW <187763517+AlxcBTW@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:45:17 +0100 Subject: [PATCH 3/7] Localize the account flows into EN/TR/DE/ES/FR/PT: auth and account strings, per-area providers, proxy language tests --- apps/web/messages/de/account.json | 462 +++++++++++++++++- apps/web/messages/de/auth.json | 144 +++++- apps/web/messages/en/account.json | 462 +++++++++++++++++- apps/web/messages/en/auth.json | 144 +++++- apps/web/messages/es/account.json | 462 +++++++++++++++++- apps/web/messages/es/auth.json | 144 +++++- apps/web/messages/fr/account.json | 462 +++++++++++++++++- apps/web/messages/fr/auth.json | 144 +++++- apps/web/messages/pt/account.json | 462 +++++++++++++++++- apps/web/messages/pt/auth.json | 144 +++++- apps/web/messages/tr/account.json | 462 +++++++++++++++++- apps/web/messages/tr/auth.json | 144 +++++- .../(account)/account/connections/page.tsx | 6 +- .../src/app/(account)/account/data/page.tsx | 6 +- .../app/(account)/account/friends/page.tsx | 6 +- apps/web/src/app/(account)/account/layout.tsx | 21 +- .../(account)/account/notifications/page.tsx | 6 +- apps/web/src/app/(account)/account/page.tsx | 6 +- .../app/(account)/account/privacy/page.tsx | 6 +- .../app/(account)/account/profile/page.tsx | 6 +- .../app/(account)/account/security/page.tsx | 6 +- .../app/(account)/account/trophies/page.tsx | 6 +- apps/web/src/app/(auth)/create/page.tsx | 14 +- apps/web/src/app/(auth)/join/[code]/page.tsx | 16 +- apps/web/src/app/(auth)/link/page.tsx | 14 +- apps/web/src/app/(auth)/sign-in/page.tsx | 14 +- apps/web/src/app/u/layout.tsx | 10 + apps/web/src/components/account/compare.tsx | 85 ++-- apps/web/src/components/account/level.tsx | 12 +- .../src/components/account/notifications.tsx | 70 +-- apps/web/src/components/account/pages.tsx | 371 ++++++++------ apps/web/src/components/account/profile.tsx | 71 +-- apps/web/src/components/account/shell.tsx | 48 +- apps/web/src/components/account/social.tsx | 105 ++-- apps/web/src/components/account/ui.tsx | 92 ++-- apps/web/src/components/auth/auth-flow.tsx | 201 ++++---- apps/web/src/components/auth/auth-shell.tsx | 49 +- apps/web/src/components/auth/fields.test.tsx | 17 +- apps/web/src/components/auth/fields.tsx | 10 +- apps/web/src/components/auth/join-flow.tsx | 45 +- apps/web/src/components/auth/link-flow.tsx | 32 +- apps/web/src/proxy.test.ts | 21 +- 42 files changed, 4417 insertions(+), 591 deletions(-) create mode 100644 apps/web/src/app/u/layout.tsx diff --git a/apps/web/messages/de/account.json b/apps/web/messages/de/account.json index 0967ef4..28319a8 100644 --- a/apps/web/messages/de/account.json +++ b/apps/web/messages/de/account.json @@ -1 +1,461 @@ -{} +{ + "ui": { + "never": "nie", + "on": "An", + "off": "Aus", + "cancel": "Abbrechen", + "working": "Läuft …", + "typeToConfirm": "Gib zum Bestätigen {word} ein", + "audience": { + "everyone": "Alle", + "friends": "Freunde", + "nobody": "Nur ich" + }, + "stepUp": { + "title": "Bestätige, dass du es bist", + "needed": "Für diese Änderung ist eine aktuelle Anmeldung nötig. Sie gilt 15 Minuten.", + "sent": "Wir haben einen 6-stelligen Code an {hint} gesendet.", + "passkey": "Passkey verwenden", + "sending": "Wird gesendet …", + "sendCode": "Code per E-Mail senden" + } + }, + "shell": { + "home": "AnyPlay, Startseite", + "signOut": "Abmelden", + "nav": "Konto", + "nav_overview": "Übersicht", + "nav_profile": "Profil", + "nav_friends": "Freunde", + "nav_trophies": "Trophäen", + "nav_security": "Sicherheit", + "nav_notifications": "Benachrichtigungen", + "nav_privacy": "Datenschutz", + "nav_connections": "Verbindungen", + "nav_data": "Deine Daten", + "unreachable": "AnyPlay ist nicht erreichbar. Prüfe deine Verbindung und versuche es erneut.", + "loading": "Lädt" + }, + "level": { + "top": "Höchstes Level erreicht", + "toNext": "Noch {count, plural, one {# Punkt} other {# Punkte}} bis Level {level}", + "level": "Level {level}", + "points": "{count, plural, one {# Punkt} other {# Punkte}}", + "progress": "Level-Fortschritt", + "progressTo": "Fortschritt zu Level {level}" + }, + "meta": { + "title": "Dein Konto", + "template": "%s · Dein Konto · AnyPlay" + }, + "state": { + "loading": "Wird geladen", + "tryAgain": "Erneut versuchen" + }, + "overview": { + "label": "Dein Konto", + "memberSince": "Mitglied seit {date}", + "appearingOffline": "Du erscheinst offline", + "deletion": "Dein Konto wird am {date} gelöscht. Mein Konto behalten", + "passkeys": "{count, plural, one {# Passkey} other {# Passkeys}}", + "noPasskey": "Noch kein Passkey", + "launchersBrowsers": "{launchers, plural, one {# Launcher} other {# Launcher}} · {browsers, plural, one {# Browser} other {# Browser}}", + "trophiesAudience": "Trophäen: {audience}", + "pictureAudience": "Profilbild: {audience}", + "audience": { + "everyone": "Alle", + "friends": "Freunde", + "nobody": "Nur du" + }, + "audienceInline": { + "everyone": "alle", + "friends": "Freunde", + "nobody": "nur du" + }, + "linked": "{count} verknüpft", + "nothingLinked": "Nichts verknüpft", + "connectionsDetail": "Discord, Steam", + "dataValue": "Exportieren oder löschen", + "dataDetail": "Alles, was wir über dich speichern", + "launcher": { + "title": "Hol dir den Launcher", + "description": "Melde dich mit diesem Konto im Launcher an, und deine Freunde, Partys und Trophäen sind da." + } + }, + "profile": { + "lead": "Das sehen deine Freunde, wenn sie dein Profil öffnen. Wer welchen Teil sieht, bestimmst du auf der Seite Privatsphäre.", + "avatar": { + "title": "Bild", + "description": "PNG oder JPEG, bis zu 1 MB. Es wird rund angezeigt, halte das Gesicht also in der Mitte.", + "uploading": "Wird hochgeladen…", + "change": "Bild ändern", + "upload": "Bild hochladen", + "remove": "Entfernen" + }, + "about": { + "title": "Über mich", + "description": "Ein bis zwei Zeilen über dich: was du spielst, wann du da bist.", + "placeholder": "Koop, wenn es geht, solo, wenn nicht.", + "saved": "Gespeichert", + "saving": "Wird gespeichert…", + "save": "Speichern" + }, + "favourites": { + "title": "Lieblingsspiele", + "description": "Bis zu {max}, per Titel-ID. Diese Seite zeigt die ID. Der Launcher zeigt den Namen jedes Spiels aus deiner eigenen Kopie.", + "empty": "Noch keine Lieblingsspiele. Füge unten eine Titel-ID hinzu.", + "listLabel": "Deine Lieblingsspiele", + "remove": "{id} entfernen", + "fieldLabel": "Titel-ID", + "full": "Du hast {max} Spiele. Entferne eins, um ein weiteres hinzuzufügen.", + "add": "Spiel hinzufügen", + "saved": "Gespeichert", + "saving": "Wird gespeichert…", + "save": "Speichern", + "badId": "Verwende nur Buchstaben, Zahlen, Bindestriche und Unterstriche.", + "duplicate": "{id} steht schon auf der Liste." + } + }, + "security": { + "lead": "Wie du dich anmeldest, und jeder Launcher und Browser, der bei deinem Konto angemeldet ist.", + "email": { + "title": "E-Mail", + "description": "Dorthin gehen deine Anmeldecodes. Zum Ändern brauchst du einen Code, der an die neue Adresse geschickt wird.", + "change": "Ändern", + "invalid": "Gib die neue Adresse ein, zum Beispiel name@beispiel.de.", + "done": "Deine Anmeldeadresse ist jetzt {email}.", + "newAddress": "Neue Adresse", + "sending": "Wird gesendet…", + "send": "Code senden", + "cancel": "Abbrechen", + "sentTo": "Wir haben einen 6-stelligen Code an {email} gesendet." + }, + "passkeys": { + "title": "Passkeys", + "description": "Melde dich mit Fingerabdruck, Gesicht oder Geräte-PIN statt mit einem Code an. Einen Passkey kann niemand abfischen.", + "add": "Passkey hinzufügen", + "waiting": "Warte auf dein Gerät…", + "detail": "Hinzugefügt {added} · Zuletzt verwendet: {used}", + "remove": "Entfernen", + "empty": "Noch kein Passkey. Füge einen hinzu, und die Anmeldung geht mit einer Berührung statt mit einem E-Mail-Code.", + "full": "Du hast die meisten Passkeys, die ein Konto behalten kann ({max}). Entferne einen, um einen weiteren hinzuzufügen.", + "confirmTitle": "Diesen Passkey entfernen?", + "confirmDescription": "Dieses Gerät kann dich damit nicht mehr anmelden. Mit einem Code per E-Mail kannst du dich immer anmelden.", + "confirmLabel": "Passkey entfernen", + "defaultName": "Passkey auf {system}", + "thisDevice": "diesem Gerät" + }, + "devices": { + "title": "Launcher", + "description": "Jeder PC, auf dem der Launcher bei deinem Konto angemeldet ist.", + "detail": "{platform} · Zuletzt verwendet: {used}", + "signOut": "Abmelden", + "empty": "Noch kein Launcher angemeldet.", + "confirmTitle": "Diesen Launcher abmelden?", + "confirmDescription": "Er geht offline, bis sich jemand auf diesem PC wieder anmeldet.", + "confirmLabel": "Launcher abmelden" + }, + "browsers": { + "title": "Browser", + "description": "Jeder Browser, der auf dieser Kontoseite angemeldet ist. Wir speichern über sie nicht mehr als den Zeitpunkt der Nutzung.", + "thisBrowser": "Dieser Browser", + "here": "Du bist hier", + "other": "Anderer Browser", + "detail": "Angemeldet {created} · Zuletzt verwendet: {used}", + "signOut": "Abmelden", + "confirmThisTitle": "Von diesem Browser abmelden?", + "confirmOtherTitle": "Diesen anderen Browser abmelden?", + "confirmThisDescription": "Du kommst zurück zum Start und meldest dich beim nächsten Mal neu an.", + "confirmOtherDescription": "Er muss sich neu anmelden, um dein Konto zu öffnen.", + "confirmLabel": "Abmelden" + }, + "everywhere": { + "title": "Überall abmelden", + "description": "Jeder Launcher und jeder Browser, auch dieser. Nutze das, wenn ein Gerät verloren ging oder sich jemand anderes angemeldet haben könnte.", + "button": "Überall abmelden", + "confirmTitle": "Überall abmelden?", + "confirmDescription": "Jeder Launcher und Browser wird sofort abgemeldet, auch dieser.", + "confirmLabel": "Überall abmelden" + } + }, + "privacy": { + "lead": "Wer dein Profil und deine Aktivität sieht. Dein Name ist immer sichtbar, damit Freunde dich finden.", + "profile": { + "title": "Dein Profil", + "description": "Änderungen gelten sofort, auch im Launcher." + }, + "parts": { + "trophies": { + "title": "Trophäen", + "detail": "Deine Trophäenzahlen, Punkte und dein Level" + }, + "playtime": { + "title": "Spielzeit", + "detail": "Wie lange du gespielt hast, pro Spiel" + }, + "about": { + "title": "Über mich", + "detail": "Die Zeile in deinem Profil" + }, + "favourites": { + "title": "Lieblingsspiele", + "detail": "Bis zu drei Spiele, die du anheftest" + }, + "avatar": { + "title": "Bild", + "detail": "Dein Profilbild" + } + }, + "whoSees": "Wer sieht: {part}", + "activity": { + "title": "Deine Aktivität", + "description": "Was du auf AnyPlay tust, so wie deine Freunde es in ihrem Feed sehen.", + "row": "Aktivität", + "detail": "Freunde sehen sie in ihrem Feed. Fremde sehen keinen Feed. {nobody} verbirgt, was dort steht, und stoppt neue Einträge." + }, + "appearOffline": { + "title": "Online-Status", + "description": "Gilt für jeden Launcher und Browser, der bei deinem Konto angemeldet ist.", + "label": "Offline erscheinen", + "detail": "Freunde sehen dich als offline. Du kannst trotzdem spielen, chatten und beitreten." + }, + "publicProfile": { + "title": "Öffentliches Profil", + "description": "Aus, bis du es einschaltest. Gilt für jeden Launcher und Browser, der bei deinem Konto angemeldet ist.", + "label": "Öffentliches Profil anzeigen", + "detail": "Jeder mit dem Link sieht dein Level, deine Trophäen und gespielte Spiele. Ob du online bist, zeigt es nie.", + "page": "Deine Seite: {url}" + }, + "playedWith": { + "title": "Zuletzt gespielt mit", + "description": "Wenn du es ausschaltest, kommt niemand Neues dazu. Die Liste bleibt, bis du sie leerst.", + "label": "Mitspieler merken", + "detail": "Merkt sich die Leute, mit denen du eine Party oder Spielsitzung geteilt hast, 30 Tage lang, bis zu 50. Nur du siehst die Liste.", + "clear": "Liste leeren", + "cleared": "Die Liste ist leer.", + "confirmTitle": "Liste leeren?", + "confirmDescription": "Alle auf deiner Liste der Mitspieler werden entfernt. Die Listen anderer ändern sich nicht, und du kannst das nicht rückgängig machen." + } + }, + "connections": { + "lead": "Verknüpfe die Konten, mit denen du schon spielst, damit Freunde dich finden und sehen, wo du sonst noch bist.", + "result": { + "linked": "{name} ist mit deinem Konto verknüpft.", + "denied": "{name} wurde nicht verknüpft: Die Anfrage wurde dort abgelehnt.", + "expired": "Das Verknüpfen von {name} hat zu lange gedauert. Starte neu.", + "fresh": "Zum Verknüpfen von {name} brauchst du eine frische Anmeldung. Starte neu und bestätige, dass du es bist.", + "taken": "Dieses {name}-Konto ist schon mit einem anderen AnyPlay-Konto verknüpft.", + "exists": "Du hast schon ein {name}-Konto verknüpft. Hebe zuerst diese Verknüpfung auf.", + "failed": "Das Verknüpfen von {name} ist fehlgeschlagen. Versuche es in einer Minute erneut." + }, + "whoSees": "Wer dein {name}-Konto sieht", + "linkedAgo": "Verknüpft {ago}", + "unlink": "Verknüpfung aufheben", + "link": "{name} verknüpfen", + "opening": "Wird geöffnet…", + "soon": "Bald", + "notLinked": "Nicht verknüpft", + "arrivesSoon": "Verknüpfung kommt bald", + "confirmTitle": "Verknüpfung mit {name} aufheben?", + "confirmDescription": "Dein {name}-Konto erscheint nicht mehr in deinem Profil. Du kannst es jederzeit wieder verknüpfen.", + "confirmLabel": "Verknüpfung mit {name} aufheben" + }, + "data": { + "lead": "Alles, was AnyPlay über dich speichert, gehört dir: Hol dir eine Kopie oder lösche das Konto.", + "export": { + "title": "Deine Daten herunterladen", + "description": "Dein Konto, dein Profil, Freunde, Trophäen, Spielzeit und verknüpfte Konten als eine JSON-Datei.", + "preparing": "Wird vorbereitet…", + "button": "Herunterladen" + }, + "scheduled": { + "title": "Löschung geplant", + "description": "Dein Konto und alles darin wird am {date} gelöscht. Bis dahin kannst du es behalten.", + "keep": "Mein Konto behalten" + }, + "delete": { + "title": "Dein Konto löschen", + "description": "Dein Konto wird nach 30 Tagen gelöscht; bis dahin kannst du es dir anders überlegen. Danach ist es weg, samt Freunden, Trophäen und Spielzeit.", + "button": "Konto löschen" + }, + "confirm": { + "title": "Dein Konto löschen?", + "description": "Es wird in 30 Tagen gelöscht. Jeder Launcher und Browser wird jetzt abgemeldet." + } + }, + "compare": { + "title": "Trophäen vergleichen", + "description": "Deine Trophäen neben denen von {handle}.", + "descriptionShared": "Du und {handle}, in einem Spiel, das ihr beide habt.", + "signInDescription": "Sieh, wie deine Trophäen im Vergleich zu denen von {handle} aussehen.", + "signIn": "Anmelden, um Trophäen zu vergleichen", + "hidden": "Die Trophäen von {handle} sind nicht zum Vergleichen verfügbar.", + "loading": "Wird geladen", + "loadingComparison": "Der Vergleich wird geladen", + "empty": "Du und {handle} habt noch kein gemeinsames Spiel. Spiel ein Spiel, das {handle} gespielt hat, dann erscheint es hier.", + "game": "Spiel", + "gameNamed": "Spiel {title}", + "verdictNone": "Keiner von euch hat in diesem Spiel eine Trophäe erhalten.", + "verdictLevel": "In diesem Spiel steht ihr punktgleich.", + "verdictAhead": "Du liegst in diesem Spiel {count, plural, one {# Punkt} other {# Punkte}} vorn.", + "verdictBehind": "{handle} liegt in diesem Spiel {count, plural, one {# Punkt} other {# Punkte}} vorn.", + "caption": "Trophäen in {titleId}: du neben {handle}", + "stat": "Kategorie", + "you": "Du", + "ahead": "(vorn)", + "rowTrophies": "Trophäen", + "rowPoints": "Punkte in diesem Spiel", + "rowLevel": "Level insgesamt", + "grade": { + "platinum": "Platin", + "gold": "Gold", + "silver": "Silber", + "bronze": "Bronze" + }, + "both": "Von beiden erhalten", + "onlyYou": "Nur du", + "onlyThem": "Nur {handle}" + }, + "publicProfile": { + "home": "AnyPlay, Startseite", + "yourAccount": "Dein Konto", + "signIn": "Anmelden", + "loading": "Wird geladen", + "missingTitle": "Kein Spieler namens {handle}", + "missingBody": "Prüfe die Schreibweise des Namens oder bitte den Spieler, ihn noch einmal zu schicken.", + "goHome": "Zu AnyPlay", + "player": "Spieler", + "trophies": "Trophäen", + "trophiesEarned": "{count, number} erhalten.", + "noTrophies": "{handle} hat noch keine Trophäe erhalten.", + "favourites": "Lieblingsspiele", + "favouritesNote": "Angezeigt wird die Titel-ID.", + "game": "Spiel", + "connections": "Verknüpfungen", + "playtime": "Gespielte Spiele", + "playtimeNote": "{count, plural, one {# Spiel} other {# Spiele}}, insgesamt {total}.", + "nothing": "Von {handle} gibt es noch nichts weiter zu zeigen.", + "openProfile": "Profil öffnen", + "openProfileOn": "auf {brand} (öffnet in einem neuen Tab)", + "signInToAdd": "Anmelden, um {handle} hinzuzufügen", + "editProfile": "Profil bearbeiten", + "blocked": "Blockiert", + "friends": "Freunde", + "requestSent": "Anfrage gesendet", + "accepting": "Wird angenommen…", + "accept": "Anfrage annehmen", + "sending": "Wird gesendet…", + "addFriend": "Freund hinzufügen", + "addInAnyPlay": "Freund in AnyPlay hinzufügen", + "minutes": "{n} Min.", + "hours": "{n} Std." + }, + "friends": { + "title": "Freunde", + "lead": "Füge die Leute hinzu, mit denen du spielst, beantworte Anfragen und entscheide, wer draußen bleibt.", + "add": { + "title": "Freund hinzufügen", + "description": "Gib den Namen eines Spielers ein, um ihm eine Anfrage zu senden.", + "handle": "Name", + "hint": "Sein Name, mit oder ohne @.", + "send": "Anfrage senden", + "sending": "Wird gesendet…", + "enterHandle": "Gib den Namen des Spielers ein, den du hinzufügen möchtest.", + "nowFriends": "Du und @{handle} seid jetzt Freunde.", + "sent": "Anfrage an @{handle} gesendet. Ihr seid Freunde, sobald die Anfrage angenommen wird." + }, + "requests": { + "title": "Anfragen", + "description": "Spieler, die mit dir befreundet sein möchten, und die Anfragen, die du gesendet hast.", + "empty": "Keine offenen Anfragen. Wenn dich jemand hinzufügt, erscheint die Anfrage hier.", + "received": "Erhalten", + "sent": "Gesendet", + "receivedAgo": "Erhalten: {when}", + "sentAgo": "Gesendet: {when}", + "decline": "Ablehnen", + "accept": "Annehmen", + "withdraw": "Zurückziehen" + }, + "list": { + "title": "Deine Freunde", + "description": "Sieh, wer online ist und was gespielt wird.", + "empty": "Noch keine Freunde. Füge oben jemanden über den Namen hinzu; sobald die Anfrage angenommen wird, erscheint er hier.", + "online": "Online", + "offline": "Offline", + "playing": "Spielt {title}", + "block": "Blockieren", + "remove": "Entfernen" + }, + "blocked": { + "title": "Blockiert", + "description": "Blockierte Spieler werden aus deinen Freunden und Anfragen entfernt.", + "empty": "Du hast niemanden blockiert. Blockiere einen Spieler in deiner Freundesliste, wenn du es brauchst.", + "unblock": "Blockierung aufheben" + }, + "confirm": { + "blockTitle": "@{handle} blockieren?", + "removeTitle": "@{handle} als Freund entfernen?", + "blockText": "Ihr seid dann keine Freunde mehr, und offene Anfragen werden gelöscht. Du kannst die Blockierung jederzeit aufheben.", + "removeText": "Ihr seht nicht mehr, wann der andere online ist. Später kannst du eine neue Anfrage senden.", + "blockLabel": "Spieler blockieren", + "removeLabel": "Freund entfernen" + } + }, + "trophies": { + "title": "Trophäen", + "lead": "Was du in deinen Spielen verdient hast. AnyPlay speichert nur Anzahlen pro Spiel und Stufe, nie Trophäennamen oder Grafiken.", + "totals": { + "title": "Gesamt", + "description": "{total} verdient in {games, plural, one {# Spiel} other {# Spielen}}.", + "empty": "Noch keine Trophäen. Spiele ein Spiel über AnyPlay, dann werden deine Trophäen hier gezählt." + }, + "byGame": { + "title": "Nach Spiel", + "description": "Die Spiele sind nach Titel-ID sortiert, die neuesten zuerst.", + "detail": "{earned, plural, one {# Trophäe} other {# Trophäen}} · zuletzt verdient: {when}" + }, + "grades": { + "platinum": "Platin", + "gold": "Gold", + "silver": "Silber", + "bronze": "Bronze" + } + }, + "notifications": { + "title": "Benachrichtigungen", + "lead": "Erhalte auf diesem Gerät eine Benachrichtigung, wenn dir ein Freund eine Anfrage sendet, dich in eine Party einlädt oder dir schreibt.", + "device": { + "title": "Dieses Gerät", + "description": "Jeder Browser und jedes Handy wird einzeln eingerichtet. Schalte das dort ein, wo du benachrichtigt werden möchtest.", + "notEnabled": "Diese Cloud sendet keine Benachrichtigungen.", + "checking": "Dieses Gerät wird geprüft…", + "unsupported": "Dieser Browser kann keine Benachrichtigungen empfangen. Nutze ein aktuelles Chrome, Edge, Firefox oder Safari.", + "needsInstall": "Auf dem iPhone und iPad braucht AnyPlay für Benachrichtigungen einen Platz auf dem Home-Bildschirm. Tippe auf Teilen, wähle „Zum Home-Bildschirm“ und öffne AnyPlay von dort.", + "blocked": "Benachrichtigungen sind für diese Seite blockiert. Erlaube sie in den Website-Einstellungen deines Browsers und komm dann zurück.", + "switch": "Dieses Gerät benachrichtigen", + "others": "{count, plural, one {Ein weiteres Gerät erhält ebenfalls Benachrichtigungen. Schalte sie auf diesem Gerät aus.} other {# weitere Geräte erhalten ebenfalls Benachrichtigungen. Schalte sie auf diesen Geräten aus.}}" + }, + "kinds": { + "title": "Worüber benachrichtigt werden soll", + "description": "Nur für dieses Gerät.", + "friend_request": { + "label": "Freundschaftsanfragen", + "detail": "Jemand möchte mit dir befreundet sein." + }, + "party_invite": { + "label": "Party-Einladungen", + "detail": "Ein Freund lädt dich in eine Party ein." + }, + "message": { + "label": "Nachrichten", + "detail": "Ein Freund schreibt dir. Die Nachricht selbst steht nicht in der Benachrichtigung." + } + }, + "errors": { + "timeout": "Der Benachrichtigungsdienst deines Browsers hat nicht geantwortet. Prüfe deine Verbindung und versuche es erneut.", + "noKey": "Diese Cloud hat keinen Schlüssel zum Senden von Benachrichtigungen.", + "lostSubscription": "Dieser Browser hat sein Abonnement verloren. Schalte Benachrichtigungen aus und wieder ein.", + "notAllowed": "Dieser Browser hat Benachrichtigungen nicht erlaubt. In einem privaten Fenster funktionieren sie nicht; prüfe sonst die Website-Einstellungen." + } + } +} diff --git a/apps/web/messages/de/auth.json b/apps/web/messages/de/auth.json index 0967ef4..9fe0d1f 100644 --- a/apps/web/messages/de/auth.json +++ b/apps/web/messages/de/auth.json @@ -1 +1,143 @@ -{} +{ + "meta": { + "signIn": { + "title": "Anmelden", + "description": "Melde dich bei AnyPlay mit einem Passkey oder einem Code per E-Mail an." + }, + "create": { + "title": "Erstelle dein Konto", + "description": "Erstelle dein AnyPlay-Konto: eine E-Mail-Adresse und ein Name genügen." + }, + "link": { + "title": "Öffne dein Konto", + "description": "Öffne dein AnyPlay-Konto aus dem Launcher in diesem Browser." + }, + "join": { + "title": "Einladung", + "description": "Eine Einladung, gemeinsam auf AnyPlay zu spielen." + } + }, + "shell": { + "home": "AnyPlay, Startseite", + "back": "Zurück zum Anfang", + "promise": "Spiele deine Spiele gemeinsam.", + "toastOnline": "mira ist online", + "toastParty": "kit ist deiner Party beigetreten", + "toastTrophy": "Trophäe erhalten · Into the Ember", + "help": "Brauchst du Hilfe?", + "helpLink": "Frag auf Discord", + "legalNav": "Rechtliches", + "legalNotice": "Impressum", + "privacyNotice": "Datenschutzhinweis", + "termsOfUse": "Nutzungsbedingungen" + }, + "step": { + "loading": "Wird geladen", + "unreachable": "AnyPlay ist nicht erreichbar", + "tryAgain": "Erneut versuchen", + "newTab": "(öffnet in einem neuen Tab)", + "backToStart": "Zurück zum Anfang", + "signIn": "Anmelden", + "createAccount": "Konto erstellen", + "getApp": "AnyPlay holen", + "continue": "Weiter" + }, + "fields": { + "code": "Code" + }, + "flow": { + "authorize": "AnyPlay auf {device} wartet auf diese Anmeldung." + }, + "email": { + "create": { + "title": "Erstelle dein Konto", + "lead": "Beginne mit deiner E-Mail-Adresse. Wir senden dir einen Code zur Bestätigung." + }, + "signIn": { + "title": "Anmelden", + "lead": "Nutze deinen Passkey oder lass dir einen Code per E-Mail schicken." + }, + "passkey": "Mit Passkey anmelden", + "or": "oder", + "label": "E-Mail-Adresse", + "placeholder": "name@example.com", + "invalid": "Gib deine E-Mail-Adresse ein, zum Beispiel name@example.com.", + "sending": "Wird gesendet…", + "send": "Code senden", + "haveAccount": "Du hast schon ein Konto? Anmelden", + "newHere": "Neu bei AnyPlay? Konto erstellen" + }, + "code": { + "title": "Prüfe deine E-Mails", + "lead": "Wir haben einen 6-stelligen Code an {email} gesendet. Er gilt 10 Minuten.", + "incomplete": "Gib die 6 Ziffern aus der E-Mail ein.", + "checking": "Wird geprüft…", + "resent": "Ein neuer Code ist unterwegs.", + "noMail": "Keine E-Mail da?", + "resendIn": "Neuen Code in {seconds} s senden", + "resend": "Neuen Code senden" + }, + "details": { + "title": "Wähle einen Namen", + "lead": "Deine Adresse {email} ist bestätigt. Andere Spieler sehen deinen Namen.", + "handle": "Name", + "handleHint": "Verwende 3 bis 16 Buchstaben, Ziffern, Bindestriche oder Unterstriche. Beginne mit einem Buchstaben.", + "ageHint": "Das heißt: 16 oder älter.", + "age": "Ich bin alt genug, um den Bedingungen selbst zuzustimmen.", + "terms": "Ich akzeptiere die Nutzungsbedingungen (Version {version}) und habe den Datenschutzhinweis gelesen.", + "creating": "Wird erstellt…", + "submit": "Konto erstellen" + }, + "terms": { + "title": "Bedingungen ansehen", + "lead": "Die Bedingungen haben sich geändert. Lies die Nutzungsbedingungen und akzeptiere Version {version}, um fortzufahren.", + "accept": "Ich akzeptiere die Nutzungsbedingungen (Version {version}).", + "required": "Akzeptiere die Bedingungen, um fortzufahren.", + "submit": "Akzeptieren und fortfahren" + }, + "banned": { + "title": "Dieses Konto ist gesperrt", + "leadUntil": "Die Sperre endet am {date}.", + "leadForever": "Die Sperre endet nicht von selbst.", + "rule": "Regel", + "reason": "Grund", + "appeal": "Stellungnahme lesen und Einspruch einlegen" + }, + "handoff": { + "title": "Angemeldet", + "lead": "Du wirst zu AnyPlay zurückgebracht. Passiert nichts, nutze die Schaltfläche.", + "open": "AnyPlay öffnen", + "note": "Du kannst diesen Tab schließen, sobald AnyPlay dich als angemeldet zeigt." + }, + "done": { + "title": "Du bist angemeldet", + "inviteLead": "Du wirst zur Einladung zurückgebracht.", + "welcome": "Willkommen, {handle}. Deine Freunde, Partys und Trophäen warten im Launcher.", + "account": "Zu deinem Konto" + }, + "join": { + "expiredTitle": "Diese Einladung ist abgelaufen", + "expiredLead": "Der Link ist vielleicht abgelaufen, oder die Person spielt nicht mehr. Bitte um einen neuen.", + "title": "{handle} hat dich eingeladen", + "leadSignedIn": "Öffne AnyPlay auf deinem PC, um die Einladung zu sehen. Dort entscheidest du, ob du beitrittst.", + "leadSignedOut": "Melde dich zuerst an oder erstelle ein Konto. Danach kommst du hierher zurück, um die Einladung zu öffnen.", + "party": "Party", + "game": "Spiel", + "partyName": "Eine AnyPlay-Party", + "gameFallback": "Ein Spiel", + "joined": "Beigetreten", + "players": "{members} von {max, plural, one {# Spieler} other {# Spielern}}", + "open": "In AnyPlay öffnen", + "noApp": "Noch kein AnyPlay auf diesem PC? Hol es dir zuerst und öffne diese Seite dann erneut." + }, + "link": { + "expiredTitle": "Dieser Link ist abgelaufen", + "expiredLead": "Öffne deine Kontoseite noch einmal aus AnyPlay.", + "close": "Du kannst diesen Tab schließen.", + "title": "Öffne dein Konto", + "lead": "AnyPlay auf deinem PC möchte das Konto von {handle} in diesem Browser öffnen.", + "open": "Konto öffnen", + "opening": "Wird geöffnet…", + "notYou": "Das warst nicht du? Schließe den Tab." + } +} diff --git a/apps/web/messages/en/account.json b/apps/web/messages/en/account.json index 0967ef4..4b44a0d 100644 --- a/apps/web/messages/en/account.json +++ b/apps/web/messages/en/account.json @@ -1 +1,461 @@ -{} +{ + "ui": { + "never": "never", + "on": "On", + "off": "Off", + "cancel": "Cancel", + "working": "Working…", + "typeToConfirm": "Type {word} to confirm", + "audience": { + "everyone": "Everyone", + "friends": "Friends", + "nobody": "Only me" + }, + "stepUp": { + "title": "Confirm it’s you", + "needed": "This change needs a recent sign-in. It lasts 15 minutes.", + "sent": "We sent a 6-digit code to {hint}.", + "passkey": "Use your passkey", + "sending": "Sending…", + "sendCode": "Send a code by e-mail" + } + }, + "shell": { + "home": "AnyPlay, home", + "signOut": "Sign out", + "nav": "Account", + "nav_overview": "Overview", + "nav_profile": "Profile", + "nav_friends": "Friends", + "nav_trophies": "Trophies", + "nav_security": "Security", + "nav_notifications": "Notifications", + "nav_privacy": "Privacy", + "nav_connections": "Connections", + "nav_data": "Your data", + "unreachable": "Can’t reach AnyPlay. Check your connection and try again.", + "loading": "Loading" + }, + "level": { + "top": "Top level reached", + "toNext": "{count, plural, one {# point} other {# points}} to level {level}", + "level": "Level {level}", + "points": "{count, plural, one {# point} other {# points}}", + "progress": "Level progress", + "progressTo": "Progress to level {level}" + }, + "meta": { + "title": "Your account", + "template": "%s · Your account · AnyPlay" + }, + "state": { + "loading": "Loading", + "tryAgain": "Try again" + }, + "overview": { + "label": "Your account", + "memberSince": "Member since {date}", + "appearingOffline": "Appearing offline", + "deletion": "Your account will be deleted on {date}. Keep my account", + "passkeys": "{count, plural, one {# passkey} other {# passkeys}}", + "noPasskey": "No passkey yet", + "launchersBrowsers": "{launchers, plural, one {# launcher} other {# launchers}} · {browsers, plural, one {# browser} other {# browsers}}", + "trophiesAudience": "Trophies: {audience}", + "pictureAudience": "Profile picture: {audience}", + "audience": { + "everyone": "Everyone", + "friends": "Friends", + "nobody": "Only you" + }, + "audienceInline": { + "everyone": "everyone", + "friends": "friends", + "nobody": "only you" + }, + "linked": "{count} linked", + "nothingLinked": "Nothing linked", + "connectionsDetail": "Discord, Steam", + "dataValue": "Export or delete", + "dataDetail": "Everything we keep about you", + "launcher": { + "title": "Get the launcher", + "description": "Sign in to the launcher with this account, and your friends, parties and trophies are there." + } + }, + "profile": { + "lead": "What your friends see when they open your profile. Who sees each part is up to you on the Privacy page.", + "avatar": { + "title": "Picture", + "description": "PNG or JPEG, up to 1 MB. It shows round, so keep the face in the middle.", + "uploading": "Uploading…", + "change": "Change picture", + "upload": "Upload picture", + "remove": "Remove" + }, + "about": { + "title": "About", + "description": "A line or two about you: what you play, when you're around.", + "placeholder": "Co-op when I can, solo when I can't.", + "saved": "Saved", + "saving": "Saving…", + "save": "Save" + }, + "favourites": { + "title": "Favourite games", + "description": "Up to {max}, by title ID. This site shows the ID. The launcher shows each game's name from your own copy.", + "empty": "No favourite games yet. Add a title ID below.", + "listLabel": "Your favourite games", + "remove": "Remove {id}", + "fieldLabel": "Title ID", + "full": "You have {max} games. Remove one to add another.", + "add": "Add game", + "saved": "Saved", + "saving": "Saving…", + "save": "Save", + "badId": "Use only letters, numbers, hyphens and underscores.", + "duplicate": "{id} is on the list already." + } + }, + "security": { + "lead": "How you sign in, and every launcher and browser that is signed in to your account.", + "email": { + "title": "E-mail", + "description": "Where your sign-in codes go. Changing it needs a code sent to the new address.", + "change": "Change", + "invalid": "Enter the new address, like name@example.com.", + "done": "Your sign-in address is now {email}.", + "newAddress": "New address", + "sending": "Sending…", + "send": "Send code", + "cancel": "Cancel", + "sentTo": "We sent a 6-digit code to {email}." + }, + "passkeys": { + "title": "Passkeys", + "description": "Sign in with your fingerprint, face or device PIN instead of a code. Nobody can phish a passkey.", + "add": "Add a passkey", + "waiting": "Waiting for your device…", + "detail": "Added {added} · Last used {used}", + "remove": "Remove", + "empty": "No passkey yet. Add one and signing in takes a touch instead of an e-mail code.", + "full": "You have the most passkeys an account can keep ({max}). Remove one to add another.", + "confirmTitle": "Remove this passkey?", + "confirmDescription": "That device can no longer sign you in with it. You can always sign in with a code by e-mail.", + "confirmLabel": "Remove passkey", + "defaultName": "Passkey on {system}", + "thisDevice": "this device" + }, + "devices": { + "title": "Launchers", + "description": "Every PC where the launcher is signed in to your account.", + "detail": "{platform} · Last used {used}", + "signOut": "Sign out", + "empty": "No launcher is signed in yet.", + "confirmTitle": "Sign out this launcher?", + "confirmDescription": "It goes offline until someone signs in on that PC again.", + "confirmLabel": "Sign out launcher" + }, + "browsers": { + "title": "Browsers", + "description": "Every browser signed in to this account page. We keep no more about them than when they were used.", + "thisBrowser": "This browser", + "here": "You are here", + "other": "Other browser", + "detail": "Signed in {created} · Last used {used}", + "signOut": "Sign out", + "confirmThisTitle": "Sign out of this browser?", + "confirmOtherTitle": "Sign out that browser?", + "confirmThisDescription": "You go back to the start and sign in again next time.", + "confirmOtherDescription": "It has to sign in again to open your account.", + "confirmLabel": "Sign out" + }, + "everywhere": { + "title": "Sign out everywhere", + "description": "Every launcher and every browser, this one too. Use it when a device was lost or someone else might have signed in.", + "button": "Sign out everywhere", + "confirmTitle": "Sign out everywhere?", + "confirmDescription": "Every launcher and browser is signed out at once, this one too.", + "confirmLabel": "Sign out everywhere" + } + }, + "privacy": { + "lead": "Who sees your profile and your activity. Your handle is always visible, so friends can find you.", + "profile": { + "title": "Your profile", + "description": "Changes count at once, in the launcher too." + }, + "parts": { + "trophies": { + "title": "Trophies", + "detail": "Your trophy counts, points and level" + }, + "playtime": { + "title": "Playtime", + "detail": "How long you played, per game" + }, + "about": { + "title": "About", + "detail": "The line on your profile" + }, + "favourites": { + "title": "Favourite games", + "detail": "Up to three games you pin" + }, + "avatar": { + "title": "Picture", + "detail": "Your profile picture" + } + }, + "whoSees": "Who sees: {part}", + "activity": { + "title": "Your activity", + "description": "What you do on AnyPlay, as your friends see it in their feed.", + "row": "Activity", + "detail": "Friends see it in their feed. Strangers see no feed. {nobody} hides what is there and stops new items." + }, + "appearOffline": { + "title": "Online status", + "description": "Applies to every launcher and browser signed in to your account.", + "label": "Appear offline", + "detail": "Friends see you as offline. You can still play, chat and join." + }, + "publicProfile": { + "title": "Public profile", + "description": "Off until you turn it on. Applies to every launcher and browser signed in to your account.", + "label": "Show a public profile", + "detail": "Anyone with the link sees your level, trophies and games played. It never shows whether you are online.", + "page": "Your page: {url}" + }, + "playedWith": { + "title": "People you played with", + "description": "Turning it off adds no one new. The list stays until you clear it.", + "label": "Remember people I play with", + "detail": "Keeps the people you shared a party or game session with for 30 days, up to 50. Only you see the list.", + "clear": "Clear the list", + "cleared": "The list is empty.", + "confirmTitle": "Clear the list?", + "confirmDescription": "Everyone on your list of people you played with is removed. It does not change anyone else’s list, and you can’t undo it." + } + }, + "connections": { + "lead": "Link the accounts you already play with, so friends can find you and see where else you are.", + "result": { + "linked": "{name} is linked to your account.", + "denied": "{name} was not linked: the request was declined there.", + "expired": "Linking {name} took too long. Start again.", + "fresh": "Linking {name} needs a recent sign-in. Start again and confirm it's you.", + "taken": "That {name} account is already linked to another AnyPlay account.", + "exists": "You already have a {name} account linked. Unlink it first.", + "failed": "Linking {name} failed. Try again in a minute." + }, + "whoSees": "Who sees your {name} account", + "linkedAgo": "Linked {ago}", + "unlink": "Unlink", + "link": "Link {name}", + "opening": "Opening…", + "soon": "Soon", + "notLinked": "Not linked", + "arrivesSoon": "Linking arrives soon", + "confirmTitle": "Unlink {name}?", + "confirmDescription": "Your {name} account no longer shows on your profile. You can link it again any time.", + "confirmLabel": "Unlink {name}" + }, + "data": { + "lead": "Everything AnyPlay keeps about you is yours: take a copy, or delete the account.", + "export": { + "title": "Download your data", + "description": "Your account, profile, friends, trophies, playtime and linked accounts, as one JSON file.", + "preparing": "Preparing…", + "button": "Download" + }, + "scheduled": { + "title": "Deletion scheduled", + "description": "Your account and everything in it will be deleted on {date}. Until then you can keep it.", + "keep": "Keep my account" + }, + "delete": { + "title": "Delete your account", + "description": "Your account is deleted after 30 days; until then you can change your mind. After that it is gone, with your friends, trophies and playtime.", + "button": "Delete account" + }, + "confirm": { + "title": "Delete your account?", + "description": "It is deleted in 30 days. Every launcher and browser is signed out now." + } + }, + "compare": { + "title": "Compare trophies", + "description": "Your trophies next to {handle}’s.", + "descriptionShared": "You and {handle}, in a game you both have.", + "signInDescription": "See how your trophies stack up against {handle}.", + "signIn": "Sign in to compare trophies", + "hidden": "{handle}’s trophies aren’t available to compare.", + "loading": "Loading", + "loadingComparison": "Loading the comparison", + "empty": "You and {handle} have no game in common yet. Play a game {handle} has played and it shows up here.", + "game": "Game", + "gameNamed": "Game {title}", + "verdictNone": "Neither of you has earned a trophy in this game.", + "verdictLevel": "You are level on points in this game.", + "verdictAhead": "You are ahead by {count, plural, one {# point} other {# points}} in this game.", + "verdictBehind": "{handle} is ahead by {count, plural, one {# point} other {# points}} in this game.", + "caption": "Trophies in {titleId}, you next to {handle}", + "stat": "Stat", + "you": "You", + "ahead": "(ahead)", + "rowTrophies": "Trophies", + "rowPoints": "Points in this game", + "rowLevel": "Level overall", + "grade": { + "platinum": "Platinum", + "gold": "Gold", + "silver": "Silver", + "bronze": "Bronze" + }, + "both": "Earned by both", + "onlyYou": "Only you", + "onlyThem": "Only {handle}" + }, + "publicProfile": { + "home": "AnyPlay, home", + "yourAccount": "Your account", + "signIn": "Sign in", + "loading": "Loading", + "missingTitle": "No player named {handle}", + "missingBody": "Check the spelling of the handle, or ask the player to send it again.", + "goHome": "Go to AnyPlay", + "player": "Player", + "trophies": "Trophies", + "trophiesEarned": "{count, number} earned.", + "noTrophies": "{handle} has not earned a trophy yet.", + "favourites": "Favourite games", + "favouritesNote": "Shown by title ID.", + "game": "Game", + "connections": "Connections", + "playtime": "Games played", + "playtimeNote": "{count, plural, one {# game} other {# games}}, {total} in total.", + "nothing": "There is nothing more to show of {handle} yet.", + "openProfile": "Open profile", + "openProfileOn": "on {brand} (opens in a new tab)", + "signInToAdd": "Sign in to add {handle}", + "editProfile": "Edit your profile", + "blocked": "Blocked", + "friends": "Friends", + "requestSent": "Request sent", + "accepting": "Accepting…", + "accept": "Accept request", + "sending": "Sending…", + "addFriend": "Add friend", + "addInAnyPlay": "Add friend in AnyPlay", + "minutes": "{n} min", + "hours": "{n} h" + }, + "friends": { + "title": "Friends", + "lead": "Add the people you play with, answer requests and decide who stays out.", + "add": { + "title": "Add a friend", + "description": "Enter a player’s handle to send them a request.", + "handle": "Handle", + "hint": "Their handle, with or without the @.", + "send": "Send request", + "sending": "Sending…", + "enterHandle": "Enter the handle of the player you want to add.", + "nowFriends": "You and @{handle} are now friends.", + "sent": "Request sent to @{handle}. You are friends once they accept." + }, + "requests": { + "title": "Requests", + "description": "Players who want to be your friend, and the requests you sent.", + "empty": "No open requests. When someone adds you, the request shows up here.", + "received": "Received", + "sent": "Sent", + "receivedAgo": "Received {when}", + "sentAgo": "Sent {when}", + "decline": "Decline", + "accept": "Accept", + "withdraw": "Withdraw" + }, + "list": { + "title": "Your friends", + "description": "See who is online and what they are playing.", + "empty": "No friends yet. Add someone by their handle above; they show up here once they accept.", + "online": "Online", + "offline": "Offline", + "playing": "Playing {title}", + "block": "Block", + "remove": "Remove" + }, + "blocked": { + "title": "Blocked", + "description": "Blocked players are removed from your friends and requests.", + "empty": "You have not blocked anyone. Block a player from your friends list when you need to.", + "unblock": "Unblock" + }, + "confirm": { + "blockTitle": "Block @{handle}?", + "removeTitle": "Remove @{handle} from your friends?", + "blockText": "You are no longer friends and any open request is dropped. You can unblock them any time.", + "removeText": "You stop seeing when each other is online. You can send a new request later.", + "blockLabel": "Block player", + "removeLabel": "Remove friend" + } + }, + "trophies": { + "title": "Trophies", + "lead": "What you earned in your games. AnyPlay keeps counts per game and grade, never trophy names or art.", + "totals": { + "title": "Totals", + "description": "{total} earned across {games, plural, one {# game} other {# games}}.", + "empty": "No trophies yet. Play a game through AnyPlay and the ones you earn are counted here." + }, + "byGame": { + "title": "By game", + "description": "Games are listed by title id, the most recent first.", + "detail": "{earned, plural, one {# trophy} other {# trophies}} · last earned {when}" + }, + "grades": { + "platinum": "Platinum", + "gold": "Gold", + "silver": "Silver", + "bronze": "Bronze" + } + }, + "notifications": { + "title": "Notifications", + "lead": "Get a notification on this device when a friend asks to be your friend, invites you to a party or writes to you.", + "device": { + "title": "This device", + "description": "Each browser or phone is set up on its own. Turn this on where you want to be notified.", + "notEnabled": "This cloud does not send notifications.", + "checking": "Checking this device…", + "unsupported": "This browser can’t receive notifications. Use a current Chrome, Edge, Firefox or Safari.", + "needsInstall": "On iPhone and iPad, notifications need AnyPlay on your Home Screen. Tap Share, choose Add to Home Screen, then open AnyPlay from there.", + "blocked": "Notifications are blocked for this site. Allow them in your browser’s site settings, then come back.", + "switch": "Notify this device", + "others": "{count, plural, one {One other device also gets notifications. Turn them off on that device.} other {# other devices also get notifications. Turn them off on that device.}}" + }, + "kinds": { + "title": "What to notify about", + "description": "For this device only.", + "friend_request": { + "label": "Friend requests", + "detail": "Someone asks to be your friend." + }, + "party_invite": { + "label": "Party invitations", + "detail": "A friend invites you to a party." + }, + "message": { + "label": "Messages", + "detail": "A friend writes to you. The message itself stays out of the notification." + } + }, + "errors": { + "timeout": "Your browser’s notification service did not answer. Check your connection and try again.", + "noKey": "This cloud has no key to send notifications with.", + "lostSubscription": "This browser lost its subscription. Turn notifications off and on again.", + "notAllowed": "This browser did not allow notifications. They don’t work in a private window; otherwise check the site settings." + } + } +} diff --git a/apps/web/messages/en/auth.json b/apps/web/messages/en/auth.json index 0967ef4..fb8324e 100644 --- a/apps/web/messages/en/auth.json +++ b/apps/web/messages/en/auth.json @@ -1 +1,143 @@ -{} +{ + "meta": { + "signIn": { + "title": "Sign in", + "description": "Sign in to AnyPlay with a passkey or a code by e-mail." + }, + "create": { + "title": "Create your account", + "description": "Create your AnyPlay account: an e-mail address and a handle." + }, + "link": { + "title": "Open your account", + "description": "Open your AnyPlay account in this browser, from the launcher." + }, + "join": { + "title": "Invitation", + "description": "An invitation to play together on AnyPlay." + } + }, + "shell": { + "home": "AnyPlay, home", + "back": "Back to the start", + "promise": "Play your games together.", + "toastOnline": "mira is online", + "toastParty": "kit joined your party", + "toastTrophy": "Trophy earned · Into the Ember", + "help": "Need help?", + "helpLink": "Ask on Discord", + "legalNav": "Legal", + "legalNotice": "Legal notice", + "privacyNotice": "Privacy notice", + "termsOfUse": "Terms of use" + }, + "step": { + "loading": "Loading", + "unreachable": "Can’t reach AnyPlay", + "tryAgain": "Try again", + "newTab": "(opens in a new tab)", + "backToStart": "Back to the start", + "signIn": "Sign in", + "createAccount": "Create an account", + "getApp": "Get AnyPlay", + "continue": "Continue" + }, + "fields": { + "code": "Code" + }, + "flow": { + "authorize": "AnyPlay on {device} is waiting for this sign-in." + }, + "email": { + "create": { + "title": "Create your account", + "lead": "Start with your e-mail address. We send a code to confirm it." + }, + "signIn": { + "title": "Sign in", + "lead": "Use your passkey, or get a code by e-mail." + }, + "passkey": "Sign in with a passkey", + "or": "or", + "label": "E-mail address", + "placeholder": "name@example.com", + "invalid": "Enter your e-mail address, like name@example.com.", + "sending": "Sending…", + "send": "Send code", + "haveAccount": "Already have an account? Sign in", + "newHere": "New to AnyPlay? Create an account" + }, + "code": { + "title": "Check your e-mail", + "lead": "We sent a 6-digit code to {email}. It works for 10 minutes.", + "incomplete": "Enter the 6 digits from the e-mail.", + "checking": "Checking…", + "resent": "A new code is on its way.", + "noMail": "No e-mail?", + "resendIn": "Send a new code in {seconds} s", + "resend": "Send a new code" + }, + "details": { + "title": "Choose a handle", + "lead": "Your address {email} is confirmed. Other players see your handle.", + "handle": "Handle", + "handleHint": "Use 3 to 16 letters, digits, hyphens or underscores. Start with a letter.", + "ageHint": "That means 16 or older.", + "age": "I am old enough to agree to the terms on my own.", + "terms": "I accept the terms (version {version}) and have read the privacy notice.", + "creating": "Creating…", + "submit": "Create account" + }, + "terms": { + "title": "Review the terms", + "lead": "The terms changed. Read the terms and accept version {version} to continue.", + "accept": "I accept the terms (version {version}).", + "required": "Accept the terms to continue.", + "submit": "Accept and continue" + }, + "banned": { + "title": "This account is banned", + "leadUntil": "The ban ends on {date}.", + "leadForever": "The ban does not end on its own.", + "rule": "Rule", + "reason": "Reason", + "appeal": "Read the statement and appeal" + }, + "handoff": { + "title": "Signed in", + "lead": "Taking you back to AnyPlay. If nothing happens, use the button.", + "open": "Open AnyPlay", + "note": "You can close this tab once AnyPlay shows you as signed in." + }, + "done": { + "title": "You’re signed in", + "inviteLead": "Taking you back to the invitation.", + "welcome": "Welcome, {handle}. Your friends, parties and trophies are waiting in the launcher.", + "account": "Go to your account" + }, + "join": { + "expiredTitle": "This invitation has expired", + "expiredLead": "The link may have run out, or the player has stopped playing. Ask them for a new one.", + "title": "{handle} invited you", + "leadSignedIn": "Open AnyPlay on your PC to see the invitation. You choose there whether to join.", + "leadSignedOut": "Sign in or create an account first. You come back here to open the invitation.", + "party": "Party", + "game": "Game", + "partyName": "An AnyPlay party", + "gameFallback": "A game", + "joined": "Joined", + "players": "{members} of {max, plural, one {# player} other {# players}}", + "open": "Open in AnyPlay", + "noApp": "No AnyPlay on this PC yet? Get it first, then open this page again." + }, + "link": { + "expiredTitle": "This link has expired", + "expiredLead": "Open your account page from AnyPlay again.", + "close": "You can close this tab.", + "title": "Open your account", + "lead": "AnyPlay on your PC asked to open the account of {handle} in this browser.", + "open": "Open account", + "opening": "Opening…", + "notYou": "Did not ask for this? Close the tab." + } +} diff --git a/apps/web/messages/es/account.json b/apps/web/messages/es/account.json index 0967ef4..efec935 100644 --- a/apps/web/messages/es/account.json +++ b/apps/web/messages/es/account.json @@ -1 +1,461 @@ -{} +{ + "ui": { + "never": "nunca", + "on": "Activado", + "off": "Desactivado", + "cancel": "Cancelar", + "working": "Procesando…", + "typeToConfirm": "Escribe {word} para confirmar", + "audience": { + "everyone": "Todos", + "friends": "Amigos", + "nobody": "Solo yo" + }, + "stepUp": { + "title": "Confirma que eres tú", + "needed": "Este cambio requiere un inicio de sesión reciente. Dura 15 minutos.", + "sent": "Hemos enviado un código de 6 dígitos a {hint}.", + "passkey": "Usa tu passkey", + "sending": "Enviando…", + "sendCode": "Enviar un código por correo" + } + }, + "shell": { + "home": "AnyPlay, inicio", + "signOut": "Cerrar sesión", + "nav": "Cuenta", + "nav_overview": "Resumen", + "nav_profile": "Perfil", + "nav_friends": "Amigos", + "nav_trophies": "Trofeos", + "nav_security": "Seguridad", + "nav_notifications": "Notificaciones", + "nav_privacy": "Privacidad", + "nav_connections": "Conexiones", + "nav_data": "Tus datos", + "unreachable": "No se puede acceder a AnyPlay. Revisa tu conexión e inténtalo de nuevo.", + "loading": "Cargando" + }, + "level": { + "top": "Nivel máximo alcanzado", + "toNext": "{count, plural, one {# punto} other {# puntos}} para el nivel {level}", + "level": "Nivel {level}", + "points": "{count, plural, one {# punto} other {# puntos}}", + "progress": "Progreso de nivel", + "progressTo": "Progreso hacia el nivel {level}" + }, + "meta": { + "title": "Tu cuenta", + "template": "%s · Tu cuenta · AnyPlay" + }, + "state": { + "loading": "Cargando", + "tryAgain": "Reintentar" + }, + "overview": { + "label": "Tu cuenta", + "memberSince": "Miembro desde {date}", + "appearingOffline": "Apareces sin conexión", + "deletion": "Tu cuenta se eliminará el {date}. Conservar mi cuenta", + "passkeys": "{count, plural, one {# llave de acceso} other {# llaves de acceso}}", + "noPasskey": "Aún no hay llaves de acceso", + "launchersBrowsers": "{launchers, plural, one {# launcher} other {# launchers}} · {browsers, plural, one {# navegador} other {# navegadores}}", + "trophiesAudience": "Trofeos: {audience}", + "pictureAudience": "Foto de perfil: {audience}", + "audience": { + "everyone": "Todos", + "friends": "Amigos", + "nobody": "Solo tú" + }, + "audienceInline": { + "everyone": "todos", + "friends": "amigos", + "nobody": "solo tú" + }, + "linked": "{count} vinculadas", + "nothingLinked": "Nada vinculado", + "connectionsDetail": "Discord, Steam", + "dataValue": "Exportar o eliminar", + "dataDetail": "Todo lo que guardamos sobre ti", + "launcher": { + "title": "Consigue el launcher", + "description": "Inicia sesión en el launcher con esta cuenta y tus amigos, parties y trofeos estarán ahí." + } + }, + "profile": { + "lead": "Lo que ven tus amigos al abrir tu perfil. Quién ve cada parte lo decides tú en la página de Privacidad.", + "avatar": { + "title": "Foto", + "description": "PNG o JPEG, de hasta 1 MB. Se muestra redonda, así que deja la cara en el centro.", + "uploading": "Subiendo…", + "change": "Cambiar foto", + "upload": "Subir foto", + "remove": "Quitar" + }, + "about": { + "title": "Sobre mí", + "description": "Una o dos líneas sobre ti: a qué juegas, cuándo estás por aquí.", + "placeholder": "Cooperativo cuando puedo, solo cuando no.", + "saved": "Guardado", + "saving": "Guardando…", + "save": "Guardar" + }, + "favourites": { + "title": "Juegos favoritos", + "description": "Hasta {max}, por ID de título. Esta web muestra el ID. El launcher muestra el nombre de cada juego desde tu propia copia.", + "empty": "Aún no hay juegos favoritos. Añade un ID de título abajo.", + "listLabel": "Tus juegos favoritos", + "remove": "Quitar {id}", + "fieldLabel": "ID de título", + "full": "Tienes {max} juegos. Quita uno para añadir otro.", + "add": "Añadir juego", + "saved": "Guardado", + "saving": "Guardando…", + "save": "Guardar", + "badId": "Usa solo letras, números, guiones y guiones bajos.", + "duplicate": "{id} ya está en la lista." + } + }, + "security": { + "lead": "Cómo inicias sesión y todos los launchers y navegadores con la sesión iniciada en tu cuenta.", + "email": { + "title": "Correo electrónico", + "description": "Adónde van tus códigos de inicio de sesión. Para cambiarlo hace falta un código enviado a la nueva dirección.", + "change": "Cambiar", + "invalid": "Escribe la nueva dirección, por ejemplo nombre@ejemplo.com.", + "done": "Tu dirección de inicio de sesión ahora es {email}.", + "newAddress": "Nueva dirección", + "sending": "Enviando…", + "send": "Enviar código", + "cancel": "Cancelar", + "sentTo": "Hemos enviado un código de 6 dígitos a {email}." + }, + "passkeys": { + "title": "Llaves de acceso", + "description": "Inicia sesión con tu huella, tu cara o el PIN del dispositivo en lugar de un código. Nadie puede suplantar una llave de acceso.", + "add": "Añadir una llave de acceso", + "waiting": "Esperando a tu dispositivo…", + "detail": "Añadida {added} · Último uso: {used}", + "remove": "Quitar", + "empty": "Aún no hay llaves de acceso. Añade una y iniciar sesión será un toque en lugar de un código por correo.", + "full": "Tienes el máximo de llaves de acceso que puede tener una cuenta ({max}). Quita una para añadir otra.", + "confirmTitle": "¿Quitar esta llave de acceso?", + "confirmDescription": "Ese dispositivo ya no podrá iniciar tu sesión con ella. Siempre puedes iniciar sesión con un código por correo.", + "confirmLabel": "Quitar llave de acceso", + "defaultName": "Llave de acceso en {system}", + "thisDevice": "este dispositivo" + }, + "devices": { + "title": "Launchers", + "description": "Todos los PC donde el launcher tiene la sesión iniciada en tu cuenta.", + "detail": "{platform} · Último uso: {used}", + "signOut": "Cerrar sesión", + "empty": "Aún no hay ningún launcher con la sesión iniciada.", + "confirmTitle": "¿Cerrar sesión en este launcher?", + "confirmDescription": "Se quedará sin conexión hasta que alguien inicie sesión de nuevo en ese PC.", + "confirmLabel": "Cerrar sesión del launcher" + }, + "browsers": { + "title": "Navegadores", + "description": "Todos los navegadores con la sesión iniciada en esta página de cuenta. De ellos solo guardamos cuándo se usaron.", + "thisBrowser": "Este navegador", + "here": "Estás aquí", + "other": "Otro navegador", + "detail": "Sesión iniciada {created} · Último uso: {used}", + "signOut": "Cerrar sesión", + "confirmThisTitle": "¿Cerrar sesión en este navegador?", + "confirmOtherTitle": "¿Cerrar sesión en ese navegador?", + "confirmThisDescription": "Vuelves al inicio y tendrás que iniciar sesión de nuevo la próxima vez.", + "confirmOtherDescription": "Tendrá que iniciar sesión de nuevo para abrir tu cuenta.", + "confirmLabel": "Cerrar sesión" + }, + "everywhere": { + "title": "Cerrar sesión en todas partes", + "description": "Todos los launchers y todos los navegadores, también este. Úsalo si perdiste un dispositivo o alguien más pudo iniciar sesión.", + "button": "Cerrar sesión en todas partes", + "confirmTitle": "¿Cerrar sesión en todas partes?", + "confirmDescription": "Se cierra la sesión de todos los launchers y navegadores a la vez, también este.", + "confirmLabel": "Cerrar sesión en todas partes" + } + }, + "privacy": { + "lead": "Quién ve tu perfil y tu actividad. Tu nombre siempre es visible, para que tus amigos te encuentren.", + "profile": { + "title": "Tu perfil", + "description": "Los cambios cuentan al instante, también en el launcher." + }, + "parts": { + "trophies": { + "title": "Trofeos", + "detail": "Tu número de trofeos, tus puntos y tu nivel" + }, + "playtime": { + "title": "Tiempo de juego", + "detail": "Cuánto has jugado, por juego" + }, + "about": { + "title": "Sobre mí", + "detail": "La línea de tu perfil" + }, + "favourites": { + "title": "Juegos favoritos", + "detail": "Hasta tres juegos que fijas" + }, + "avatar": { + "title": "Foto", + "detail": "Tu foto de perfil" + } + }, + "whoSees": "Quién ve: {part}", + "activity": { + "title": "Tu actividad", + "description": "Lo que haces en AnyPlay, tal como lo ven tus amigos en su feed.", + "row": "Actividad", + "detail": "Tus amigos la ven en su feed. Los desconocidos no ven ningún feed. {nobody} oculta lo que hay y detiene los elementos nuevos." + }, + "appearOffline": { + "title": "Estado en línea", + "description": "Se aplica a todos los launchers y navegadores con la sesión iniciada en tu cuenta.", + "label": "Aparecer sin conexión", + "detail": "Tus amigos te ven sin conexión. Aun así puedes jugar, chatear y unirte." + }, + "publicProfile": { + "title": "Perfil público", + "description": "Desactivado hasta que lo actives. Se aplica a todos los launchers y navegadores con la sesión iniciada en tu cuenta.", + "label": "Mostrar un perfil público", + "detail": "Cualquiera con el enlace ve tu nivel, tus trofeos y los juegos a los que has jugado. Nunca muestra si estás en línea.", + "page": "Tu página: {url}" + }, + "playedWith": { + "title": "Jugadores recientes", + "description": "Si lo desactivas, no se añade a nadie nuevo. La lista se queda hasta que la vacíes.", + "label": "Recordar con quién juego", + "detail": "Guarda durante 30 días a las personas con quienes compartiste una party o una sesión de juego, hasta 50. Solo tú ves la lista.", + "clear": "Vaciar la lista", + "cleared": "La lista está vacía.", + "confirmTitle": "¿Vaciar la lista?", + "confirmDescription": "Se quita a todos de tu lista de personas con las que has jugado. No cambia la lista de nadie más y no se puede deshacer." + } + }, + "connections": { + "lead": "Vincula las cuentas con las que ya juegas, para que tus amigos te encuentren y vean dónde más estás.", + "result": { + "linked": "{name} está vinculado a tu cuenta.", + "denied": "{name} no se vinculó: la solicitud fue rechazada allí.", + "expired": "Vincular {name} tardó demasiado. Empieza de nuevo.", + "fresh": "Vincular {name} requiere un inicio de sesión reciente. Empieza de nuevo y confirma que eres tú.", + "taken": "Esa cuenta de {name} ya está vinculada a otra cuenta de AnyPlay.", + "exists": "Ya tienes una cuenta de {name} vinculada. Desvincúlala primero.", + "failed": "No se pudo vincular {name}. Inténtalo de nuevo en un minuto." + }, + "whoSees": "Quién ve tu cuenta de {name}", + "linkedAgo": "Vinculada {ago}", + "unlink": "Desvincular", + "link": "Vincular {name}", + "opening": "Abriendo…", + "soon": "Próximamente", + "notLinked": "Sin vincular", + "arrivesSoon": "La vinculación llega pronto", + "confirmTitle": "¿Desvincular {name}?", + "confirmDescription": "Tu cuenta de {name} ya no aparece en tu perfil. Puedes volver a vincularla cuando quieras.", + "confirmLabel": "Desvincular {name}" + }, + "data": { + "lead": "Todo lo que AnyPlay guarda sobre ti es tuyo: llévate una copia o elimina la cuenta.", + "export": { + "title": "Descargar tus datos", + "description": "Tu cuenta, perfil, amigos, trofeos, tiempo de juego y cuentas vinculadas, en un solo archivo JSON.", + "preparing": "Preparando…", + "button": "Descargar" + }, + "scheduled": { + "title": "Eliminación programada", + "description": "Tu cuenta y todo lo que contiene se eliminarán el {date}. Hasta entonces puedes conservarla.", + "keep": "Conservar mi cuenta" + }, + "delete": { + "title": "Eliminar tu cuenta", + "description": "Tu cuenta se elimina a los 30 días; hasta entonces puedes cambiar de opinión. Después desaparece, junto con tus amigos, trofeos y tiempo de juego.", + "button": "Eliminar cuenta" + }, + "confirm": { + "title": "¿Eliminar tu cuenta?", + "description": "Se elimina en 30 días. Se cierra ahora la sesión de todos los launchers y navegadores." + } + }, + "compare": { + "title": "Comparar trofeos", + "description": "Tus trofeos junto a los de {handle}.", + "descriptionShared": "Tú y {handle}, en un juego que tenéis los dos.", + "signInDescription": "Mira cómo se comparan tus trofeos con los de {handle}.", + "signIn": "Inicia sesión para comparar trofeos", + "hidden": "Los trofeos de {handle} no están disponibles para comparar.", + "loading": "Cargando", + "loadingComparison": "Cargando la comparación", + "empty": "Tú y {handle} aún no tenéis ningún juego en común. Juega a un juego que {handle} haya jugado y aparecerá aquí.", + "game": "Juego", + "gameNamed": "Juego {title}", + "verdictNone": "Ninguno de los dos ha conseguido un trofeo en este juego.", + "verdictLevel": "Estáis igualados en puntos en este juego.", + "verdictAhead": "Vas {count, plural, one {# punto} other {# puntos}} por delante en este juego.", + "verdictBehind": "{handle} va {count, plural, one {# punto} other {# puntos}} por delante en este juego.", + "caption": "Trofeos en {titleId}: tú junto a {handle}", + "stat": "Categoría", + "you": "Tú", + "ahead": "(por delante)", + "rowTrophies": "Trofeos", + "rowPoints": "Puntos en este juego", + "rowLevel": "Nivel general", + "grade": { + "platinum": "Platino", + "gold": "Oro", + "silver": "Plata", + "bronze": "Bronce" + }, + "both": "Conseguidos por ambos", + "onlyYou": "Solo tú", + "onlyThem": "Solo {handle}" + }, + "publicProfile": { + "home": "AnyPlay, inicio", + "yourAccount": "Tu cuenta", + "signIn": "Iniciar sesión", + "loading": "Cargando", + "missingTitle": "No hay ningún jugador llamado {handle}", + "missingBody": "Revisa cómo está escrito el nombre o pídele al jugador que lo envíe de nuevo.", + "goHome": "Ir a AnyPlay", + "player": "Jugador", + "trophies": "Trofeos", + "trophiesEarned": "{count, number} conseguidos.", + "noTrophies": "{handle} aún no ha conseguido ningún trofeo.", + "favourites": "Juegos favoritos", + "favouritesNote": "Se muestran por ID del título.", + "game": "Juego", + "connections": "Conexiones", + "playtime": "Juegos jugados", + "playtimeNote": "{count, plural, one {# juego} other {# juegos}}, {total} en total.", + "nothing": "Aún no hay nada más que mostrar de {handle}.", + "openProfile": "Abrir perfil", + "openProfileOn": "en {brand} (se abre en una pestaña nueva)", + "signInToAdd": "Inicia sesión para añadir a {handle}", + "editProfile": "Editar tu perfil", + "blocked": "Bloqueado", + "friends": "Amigos", + "requestSent": "Solicitud enviada", + "accepting": "Aceptando…", + "accept": "Aceptar solicitud", + "sending": "Enviando…", + "addFriend": "Añadir amigo", + "addInAnyPlay": "Añadir amigo en AnyPlay", + "minutes": "{n} min", + "hours": "{n} h" + }, + "friends": { + "title": "Amigos", + "lead": "Añade a la gente con la que juegas, responde solicitudes y decide quién se queda fuera.", + "add": { + "title": "Añadir un amigo", + "description": "Escribe el nombre de un jugador para enviarle una solicitud.", + "handle": "Nombre", + "hint": "Su nombre, con o sin la @.", + "send": "Enviar solicitud", + "sending": "Enviando…", + "enterHandle": "Escribe el nombre del jugador que quieres añadir.", + "nowFriends": "Tú y @{handle} ya sois amigos.", + "sent": "Solicitud enviada a @{handle}. Seréis amigos cuando la acepte." + }, + "requests": { + "title": "Solicitudes", + "description": "Jugadores que quieren ser tus amigos y las solicitudes que has enviado.", + "empty": "No hay solicitudes abiertas. Cuando alguien te añada, la solicitud aparecerá aquí.", + "received": "Recibidas", + "sent": "Enviadas", + "receivedAgo": "Recibida {when}", + "sentAgo": "Enviada {when}", + "decline": "Rechazar", + "accept": "Aceptar", + "withdraw": "Retirar" + }, + "list": { + "title": "Tus amigos", + "description": "Mira quién está en línea y a qué juega.", + "empty": "Aún no tienes amigos. Añade a alguien por su nombre arriba; aparecerá aquí cuando acepte.", + "online": "En línea", + "offline": "Sin conexión", + "playing": "Jugando a {title}", + "block": "Bloquear", + "remove": "Quitar" + }, + "blocked": { + "title": "Bloqueados", + "description": "Los jugadores bloqueados se quitan de tus amigos y solicitudes.", + "empty": "No has bloqueado a nadie. Bloquea a un jugador desde tu lista de amigos cuando lo necesites.", + "unblock": "Desbloquear" + }, + "confirm": { + "blockTitle": "¿Bloquear a @{handle}?", + "removeTitle": "¿Quitar a @{handle} de tus amigos?", + "blockText": "Dejáis de ser amigos y se descarta cualquier solicitud abierta. Puedes desbloquearlo cuando quieras.", + "removeText": "Dejáis de ver cuándo está en línea el otro. Puedes enviar una solicitud nueva más tarde.", + "blockLabel": "Bloquear jugador", + "removeLabel": "Quitar amigo" + } + }, + "trophies": { + "title": "Trofeos", + "lead": "Lo que has conseguido en tus juegos. AnyPlay guarda recuentos por juego y categoría, nunca nombres ni imágenes de trofeos.", + "totals": { + "title": "Totales", + "description": "{total} conseguidos en {games, plural, one {# juego} other {# juegos}}.", + "empty": "Aún no hay trofeos. Juega a algo con AnyPlay y los que consigas se contarán aquí." + }, + "byGame": { + "title": "Por juego", + "description": "Los juegos se ordenan por ID de título, los más recientes primero.", + "detail": "{earned, plural, one {# trofeo} other {# trofeos}} · último conseguido: {when}" + }, + "grades": { + "platinum": "Platino", + "gold": "Oro", + "silver": "Plata", + "bronze": "Bronce" + } + }, + "notifications": { + "title": "Notificaciones", + "lead": "Recibe una notificación en este dispositivo cuando un amigo te envíe una solicitud, te invite a una party o te escriba.", + "device": { + "title": "Este dispositivo", + "description": "Cada navegador o móvil se configura por separado. Actívalo donde quieras recibir avisos.", + "notEnabled": "Esta nube no envía notificaciones.", + "checking": "Comprobando este dispositivo…", + "unsupported": "Este navegador no puede recibir notificaciones. Usa una versión actual de Chrome, Edge, Firefox o Safari.", + "needsInstall": "En iPhone y iPad, las notificaciones necesitan AnyPlay en tu pantalla de inicio. Toca Compartir, elige Añadir a pantalla de inicio y abre AnyPlay desde ahí.", + "blocked": "Las notificaciones están bloqueadas para este sitio. Permítelas en los ajustes del sitio de tu navegador y vuelve.", + "switch": "Notificar a este dispositivo", + "others": "{count, plural, one {Otro dispositivo también recibe notificaciones. Desactívalas en ese dispositivo.} other {Otros # dispositivos también reciben notificaciones. Desactívalas en esos dispositivos.}}" + }, + "kinds": { + "title": "Sobre qué avisar", + "description": "Solo para este dispositivo.", + "friend_request": { + "label": "Solicitudes de amistad", + "detail": "Alguien quiere ser tu amigo." + }, + "party_invite": { + "label": "Invitaciones a party", + "detail": "Un amigo te invita a una party." + }, + "message": { + "label": "Mensajes", + "detail": "Un amigo te escribe. El mensaje en sí no aparece en la notificación." + } + }, + "errors": { + "timeout": "El servicio de notificaciones de tu navegador no ha respondido. Comprueba tu conexión e inténtalo de nuevo.", + "noKey": "Esta nube no tiene una clave para enviar notificaciones.", + "lostSubscription": "Este navegador ha perdido su suscripción. Desactiva las notificaciones y vuelve a activarlas.", + "notAllowed": "Este navegador no ha permitido las notificaciones. No funcionan en una ventana privada; si no, revisa los ajustes del sitio." + } + } +} diff --git a/apps/web/messages/es/auth.json b/apps/web/messages/es/auth.json index 0967ef4..514cb44 100644 --- a/apps/web/messages/es/auth.json +++ b/apps/web/messages/es/auth.json @@ -1 +1,143 @@ -{} +{ + "meta": { + "signIn": { + "title": "Iniciar sesión", + "description": "Inicia sesión en AnyPlay con una llave de acceso o con un código por correo." + }, + "create": { + "title": "Crea tu cuenta", + "description": "Crea tu cuenta de AnyPlay: un correo electrónico y un nombre." + }, + "link": { + "title": "Abre tu cuenta", + "description": "Abre tu cuenta de AnyPlay en este navegador desde el launcher." + }, + "join": { + "title": "Invitación", + "description": "Una invitación para jugar juntos en AnyPlay." + } + }, + "shell": { + "home": "AnyPlay, inicio", + "back": "Volver al inicio", + "promise": "Juega tus juegos juntos.", + "toastOnline": "mira está en línea", + "toastParty": "kit se unió a tu party", + "toastTrophy": "Trofeo conseguido · Into the Ember", + "help": "¿Necesitas ayuda?", + "helpLink": "Pregunta en Discord", + "legalNav": "Legal", + "legalNotice": "Aviso legal", + "privacyNotice": "Aviso de privacidad", + "termsOfUse": "Condiciones de uso" + }, + "step": { + "loading": "Cargando", + "unreachable": "No se puede conectar con AnyPlay", + "tryAgain": "Reintentar", + "newTab": "(se abre en una pestaña nueva)", + "backToStart": "Volver al inicio", + "signIn": "Iniciar sesión", + "createAccount": "Crear cuenta", + "getApp": "Obtener AnyPlay", + "continue": "Continuar" + }, + "fields": { + "code": "Código" + }, + "flow": { + "authorize": "AnyPlay en {device} está esperando este inicio de sesión." + }, + "email": { + "create": { + "title": "Crea tu cuenta", + "lead": "Empieza con tu correo electrónico. Te enviamos un código para confirmarlo." + }, + "signIn": { + "title": "Iniciar sesión", + "lead": "Usa tu llave de acceso o recibe un código por correo." + }, + "passkey": "Iniciar sesión con una llave de acceso", + "or": "o", + "label": "Correo electrónico", + "placeholder": "name@example.com", + "invalid": "Escribe tu correo electrónico, por ejemplo name@example.com.", + "sending": "Enviando…", + "send": "Enviar código", + "haveAccount": "¿Ya tienes una cuenta? Iniciar sesión", + "newHere": "¿Eres nuevo en AnyPlay? Crear cuenta" + }, + "code": { + "title": "Revisa tu correo", + "lead": "Enviamos un código de 6 dígitos a {email}. Es válido durante 10 minutos.", + "incomplete": "Escribe los 6 dígitos del correo.", + "checking": "Comprobando…", + "resent": "Un código nuevo está en camino.", + "noMail": "¿No llega el correo?", + "resendIn": "Enviar un código nuevo en {seconds} s", + "resend": "Enviar un código nuevo" + }, + "details": { + "title": "Elige un nombre", + "lead": "Tu dirección {email} está confirmada. Los demás jugadores ven tu nombre.", + "handle": "Nombre", + "handleHint": "Usa de 3 a 16 letras, dígitos, guiones o guiones bajos. Empieza con una letra.", + "ageHint": "Es decir, 16 años o más.", + "age": "Tengo edad suficiente para aceptar las condiciones por mi cuenta.", + "terms": "Acepto las condiciones (versión {version}) y he leído el aviso de privacidad.", + "creating": "Creando…", + "submit": "Crear cuenta" + }, + "terms": { + "title": "Revisa las condiciones", + "lead": "Las condiciones han cambiado. Lee las condiciones y acepta la versión {version} para continuar.", + "accept": "Acepto las condiciones (versión {version}).", + "required": "Acepta las condiciones para continuar.", + "submit": "Aceptar y continuar" + }, + "banned": { + "title": "Esta cuenta está suspendida", + "leadUntil": "La suspensión termina el {date}.", + "leadForever": "La suspensión no termina por sí sola.", + "rule": "Norma", + "reason": "Motivo", + "appeal": "Leer la declaración y apelar" + }, + "handoff": { + "title": "Sesión iniciada", + "lead": "Te llevamos de vuelta a AnyPlay. Si no pasa nada, usa el botón.", + "open": "Abrir AnyPlay", + "note": "Puedes cerrar esta pestaña cuando AnyPlay te muestre con la sesión iniciada." + }, + "done": { + "title": "Has iniciado sesión", + "inviteLead": "Te llevamos de vuelta a la invitación.", + "welcome": "Te damos la bienvenida, {handle}. Tus amigos, partys y trofeos te esperan en el launcher.", + "account": "Ir a tu cuenta" + }, + "join": { + "expiredTitle": "Esta invitación ha caducado", + "expiredLead": "Puede que el enlace haya caducado o que el jugador haya dejado de jugar. Pide uno nuevo.", + "title": "{handle} te ha invitado", + "leadSignedIn": "Abre AnyPlay en tu PC para ver la invitación. Allí decides si te unes.", + "leadSignedOut": "Primero inicia sesión o crea una cuenta. Después vuelves aquí para abrir la invitación.", + "party": "Party", + "game": "Juego", + "partyName": "Una party de AnyPlay", + "gameFallback": "Un juego", + "joined": "Unidos", + "players": "{members} de {max, plural, one {# jugador} other {# jugadores}}", + "open": "Abrir en AnyPlay", + "noApp": "¿Aún no tienes AnyPlay en este PC? Consíguelo primero y vuelve a abrir esta página." + }, + "link": { + "expiredTitle": "Este enlace ha caducado", + "expiredLead": "Vuelve a abrir tu página de cuenta desde AnyPlay.", + "close": "Puedes cerrar esta pestaña.", + "title": "Abre tu cuenta", + "lead": "AnyPlay en tu PC ha pedido abrir la cuenta de {handle} en este navegador.", + "open": "Abrir cuenta", + "opening": "Abriendo…", + "notYou": "¿No lo has pedido tú? Cierra la pestaña." + } +} diff --git a/apps/web/messages/fr/account.json b/apps/web/messages/fr/account.json index 0967ef4..0acdd4e 100644 --- a/apps/web/messages/fr/account.json +++ b/apps/web/messages/fr/account.json @@ -1 +1,461 @@ -{} +{ + "ui": { + "never": "jamais", + "on": "Activé", + "off": "Désactivé", + "cancel": "Annuler", + "working": "En cours…", + "typeToConfirm": "Saisis {word} pour confirmer", + "audience": { + "everyone": "Tout le monde", + "friends": "Amis", + "nobody": "Moi seul" + }, + "stepUp": { + "title": "Confirme que c’est toi", + "needed": "Ce changement demande une connexion récente. Elle reste valable 15 minutes.", + "sent": "Nous avons envoyé un code à 6 chiffres à {hint}.", + "passkey": "Utiliser ta passkey", + "sending": "Envoi…", + "sendCode": "Envoyer un code par e-mail" + } + }, + "shell": { + "home": "AnyPlay, accueil", + "signOut": "Se déconnecter", + "nav": "Compte", + "nav_overview": "Aperçu", + "nav_profile": "Profil", + "nav_friends": "Amis", + "nav_trophies": "Trophées", + "nav_security": "Sécurité", + "nav_notifications": "Notifications", + "nav_privacy": "Confidentialité", + "nav_connections": "Connexions", + "nav_data": "Tes données", + "unreachable": "Impossible de joindre AnyPlay. Vérifie ta connexion et réessaie.", + "loading": "Chargement" + }, + "level": { + "top": "Niveau maximal atteint", + "toNext": "{count, plural, one {# point} other {# points}} pour le niveau {level}", + "level": "Niveau {level}", + "points": "{count, plural, one {# point} other {# points}}", + "progress": "Progression du niveau", + "progressTo": "Progression vers le niveau {level}" + }, + "meta": { + "title": "Ton compte", + "template": "%s · Ton compte · AnyPlay" + }, + "state": { + "loading": "Chargement", + "tryAgain": "Réessayer" + }, + "overview": { + "label": "Ton compte", + "memberSince": "Membre depuis le {date}", + "appearingOffline": "Tu apparais hors ligne", + "deletion": "Ton compte sera supprimé le {date}. Garder mon compte", + "passkeys": "{count, plural, one {# clé d’accès} other {# clés d’accès}}", + "noPasskey": "Pas encore de clé d’accès", + "launchersBrowsers": "{launchers, plural, one {# launcher} other {# launchers}} · {browsers, plural, one {# navigateur} other {# navigateurs}}", + "trophiesAudience": "Trophées : {audience}", + "pictureAudience": "Photo de profil : {audience}", + "audience": { + "everyone": "Tout le monde", + "friends": "Amis", + "nobody": "Toi seul" + }, + "audienceInline": { + "everyone": "tout le monde", + "friends": "amis", + "nobody": "toi seul" + }, + "linked": "{count} liés", + "nothingLinked": "Rien de lié", + "connectionsDetail": "Discord, Steam", + "dataValue": "Exporter ou supprimer", + "dataDetail": "Tout ce que nous gardons sur toi", + "launcher": { + "title": "Récupère le launcher", + "description": "Connecte-toi au launcher avec ce compte, et tes amis, ta party et tes trophées y sont." + } + }, + "profile": { + "lead": "Ce que tes amis voient quand ils ouvrent ton profil. Qui voit chaque partie, tu le choisis sur la page Confidentialité.", + "avatar": { + "title": "Photo", + "description": "PNG ou JPEG, jusqu’à 1 Mo. Elle s’affiche en rond, alors garde le visage au centre.", + "uploading": "Envoi…", + "change": "Changer la photo", + "upload": "Envoyer une photo", + "remove": "Retirer" + }, + "about": { + "title": "À propos", + "description": "Une ou deux lignes sur toi : à quoi tu joues, quand tu es là.", + "placeholder": "En coop quand je peux, en solo sinon.", + "saved": "Enregistré", + "saving": "Enregistrement…", + "save": "Enregistrer" + }, + "favourites": { + "title": "Jeux favoris", + "description": "Jusqu’à {max}, par ID de titre. Ce site affiche l’ID. Le launcher affiche le nom de chaque jeu à partir de ta propre copie.", + "empty": "Pas encore de jeux favoris. Ajoute un ID de titre ci-dessous.", + "listLabel": "Tes jeux favoris", + "remove": "Retirer {id}", + "fieldLabel": "ID de titre", + "full": "Tu as {max} jeux. Retire-en un pour en ajouter un autre.", + "add": "Ajouter un jeu", + "saved": "Enregistré", + "saving": "Enregistrement…", + "save": "Enregistrer", + "badId": "Utilise uniquement des lettres, des chiffres, des traits d’union et des tirets bas.", + "duplicate": "{id} est déjà dans la liste." + } + }, + "security": { + "lead": "Comment tu te connectes, et chaque launcher et navigateur connecté à ton compte.", + "email": { + "title": "E-mail", + "description": "L’adresse où arrivent tes codes de connexion. Pour la changer, il faut un code envoyé à la nouvelle adresse.", + "change": "Changer", + "invalid": "Saisis la nouvelle adresse, par exemple nom@exemple.com.", + "done": "Ton adresse de connexion est maintenant {email}.", + "newAddress": "Nouvelle adresse", + "sending": "Envoi…", + "send": "Envoyer le code", + "cancel": "Annuler", + "sentTo": "Nous avons envoyé un code à 6 chiffres à {email}." + }, + "passkeys": { + "title": "Clés d’accès", + "description": "Connecte-toi avec ton empreinte, ton visage ou le code PIN de l’appareil au lieu d’un code. Personne ne peut hameçonner une clé d’accès.", + "add": "Ajouter une clé d’accès", + "waiting": "En attente de ton appareil…", + "detail": "Ajoutée {added} · Dernière utilisation : {used}", + "remove": "Retirer", + "empty": "Pas encore de clé d’accès. Ajoutes-en une et te connecter ne prend qu’une touche, au lieu d’un code par e-mail.", + "full": "Tu as le maximum de clés d’accès qu’un compte peut garder ({max}). Retires-en une pour en ajouter une autre.", + "confirmTitle": "Retirer cette clé d’accès ?", + "confirmDescription": "Cet appareil ne pourra plus te connecter avec elle. Tu peux toujours te connecter avec un code par e-mail.", + "confirmLabel": "Retirer la clé d’accès", + "defaultName": "Clé d’accès sur {system}", + "thisDevice": "cet appareil" + }, + "devices": { + "title": "Launchers", + "description": "Chaque PC où le launcher est connecté à ton compte.", + "detail": "{platform} · Dernière utilisation : {used}", + "signOut": "Se déconnecter", + "empty": "Aucun launcher n’est encore connecté.", + "confirmTitle": "Déconnecter ce launcher ?", + "confirmDescription": "Il passe hors ligne jusqu’à ce que quelqu’un se reconnecte sur ce PC.", + "confirmLabel": "Déconnecter le launcher" + }, + "browsers": { + "title": "Navigateurs", + "description": "Chaque navigateur connecté à cette page de compte. Nous ne gardons rien d’autre à leur sujet que le moment où ils ont servi.", + "thisBrowser": "Ce navigateur", + "here": "Tu es ici", + "other": "Autre navigateur", + "detail": "Connecté {created} · Dernière utilisation : {used}", + "signOut": "Se déconnecter", + "confirmThisTitle": "Se déconnecter de ce navigateur ?", + "confirmOtherTitle": "Déconnecter ce navigateur ?", + "confirmThisDescription": "Tu reviens à l’accueil et tu te reconnectes la prochaine fois.", + "confirmOtherDescription": "Il doit se reconnecter pour ouvrir ton compte.", + "confirmLabel": "Se déconnecter" + }, + "everywhere": { + "title": "Se déconnecter partout", + "description": "Chaque launcher et chaque navigateur, celui-ci compris. Utilise-le si un appareil est perdu ou si quelqu’un d’autre a pu se connecter.", + "button": "Se déconnecter partout", + "confirmTitle": "Se déconnecter partout ?", + "confirmDescription": "Chaque launcher et chaque navigateur est déconnecté d’un coup, celui-ci compris.", + "confirmLabel": "Se déconnecter partout" + } + }, + "privacy": { + "lead": "Qui voit ton profil et ton activité. Ton nom est toujours visible, pour que tes amis puissent te trouver.", + "profile": { + "title": "Ton profil", + "description": "Les changements comptent tout de suite, dans le launcher aussi." + }, + "parts": { + "trophies": { + "title": "Trophées", + "detail": "Ton nombre de trophées, tes points et ton niveau" + }, + "playtime": { + "title": "Temps de jeu", + "detail": "Combien de temps tu as joué, par jeu" + }, + "about": { + "title": "À propos", + "detail": "La ligne de ton profil" + }, + "favourites": { + "title": "Jeux favoris", + "detail": "Jusqu’à trois jeux que tu épingles" + }, + "avatar": { + "title": "Photo", + "detail": "Ta photo de profil" + } + }, + "whoSees": "Qui voit : {part}", + "activity": { + "title": "Ton activité", + "description": "Ce que tu fais sur AnyPlay, tel que tes amis le voient dans leur fil.", + "row": "Activité", + "detail": "Tes amis la voient dans leur fil. Les inconnus ne voient aucun fil. {nobody} masque ce qui s’y trouve et arrête les nouveaux éléments." + }, + "appearOffline": { + "title": "Statut en ligne", + "description": "S’applique à chaque launcher et navigateur connecté à ton compte.", + "label": "Apparaître hors ligne", + "detail": "Tes amis te voient hors ligne. Tu peux quand même jouer, discuter et rejoindre." + }, + "publicProfile": { + "title": "Profil public", + "description": "Désactivé tant que tu ne l’actives pas. S’applique à chaque launcher et navigateur connecté à ton compte.", + "label": "Afficher un profil public", + "detail": "Toute personne qui a le lien voit ton niveau, tes trophées et les jeux auxquels tu as joué. Il n’indique jamais si tu es en ligne.", + "page": "Ta page : {url}" + }, + "playedWith": { + "title": "Joué récemment avec", + "description": "Si tu le désactives, personne de nouveau n’est ajouté. La liste reste jusqu’à ce que tu la vides.", + "label": "Retenir avec qui je joue", + "detail": "Garde pendant 30 jours les personnes avec qui tu as partagé une party ou une session de jeu, jusqu’à 50. Toi seul vois la liste.", + "clear": "Vider la liste", + "cleared": "La liste est vide.", + "confirmTitle": "Vider la liste ?", + "confirmDescription": "Tout le monde est retiré de ta liste des personnes avec qui tu as joué. La liste des autres ne change pas, et tu ne peux pas annuler." + } + }, + "connections": { + "lead": "Lie les comptes avec lesquels tu joues déjà, pour que tes amis te trouvent et voient où tu es aussi.", + "result": { + "linked": "{name} est lié à ton compte.", + "denied": "{name} n’a pas été lié : la demande a été refusée là-bas.", + "expired": "La liaison de {name} a pris trop de temps. Recommence.", + "fresh": "La liaison de {name} demande une connexion récente. Recommence et confirme que c’est bien toi.", + "taken": "Ce compte {name} est déjà lié à un autre compte AnyPlay.", + "exists": "Tu as déjà un compte {name} lié. Dissocie-le d’abord.", + "failed": "La liaison de {name} a échoué. Réessaie dans une minute." + }, + "whoSees": "Qui voit ton compte {name}", + "linkedAgo": "Lié {ago}", + "unlink": "Dissocier", + "link": "Lier {name}", + "opening": "Ouverture…", + "soon": "Bientôt", + "notLinked": "Non lié", + "arrivesSoon": "La liaison arrive bientôt", + "confirmTitle": "Dissocier {name} ?", + "confirmDescription": "Ton compte {name} n’apparaît plus sur ton profil. Tu peux le lier de nouveau quand tu veux.", + "confirmLabel": "Dissocier {name}" + }, + "data": { + "lead": "Tout ce qu’AnyPlay garde sur toi est à toi : prends-en une copie, ou supprime le compte.", + "export": { + "title": "Télécharger tes données", + "description": "Ton compte, ton profil, tes amis, tes trophées, ton temps de jeu et tes comptes liés, dans un seul fichier JSON.", + "preparing": "Préparation…", + "button": "Télécharger" + }, + "scheduled": { + "title": "Suppression programmée", + "description": "Ton compte et tout ce qu’il contient seront supprimés le {date}. D’ici là, tu peux le garder.", + "keep": "Garder mon compte" + }, + "delete": { + "title": "Supprimer ton compte", + "description": "Ton compte est supprimé au bout de 30 jours ; d’ici là, tu peux changer d’avis. Ensuite, il a disparu, avec tes amis, tes trophées et ton temps de jeu.", + "button": "Supprimer le compte" + }, + "confirm": { + "title": "Supprimer ton compte ?", + "description": "Il est supprimé dans 30 jours. Chaque launcher et chaque navigateur est déconnecté maintenant." + } + }, + "compare": { + "title": "Comparer les trophées", + "description": "Tes trophées à côté de ceux de {handle}.", + "descriptionShared": "Toi et {handle}, dans un jeu que vous avez tous les deux.", + "signInDescription": "Vois comment tes trophées se comparent à ceux de {handle}.", + "signIn": "Connecte-toi pour comparer les trophées", + "hidden": "Les trophées de {handle} ne sont pas disponibles pour la comparaison.", + "loading": "Chargement", + "loadingComparison": "Chargement de la comparaison", + "empty": "Toi et {handle}, vous n’avez pas encore de jeu en commun. Joue à un jeu auquel {handle} a joué et il apparaîtra ici.", + "game": "Jeu", + "gameNamed": "Jeu {title}", + "verdictNone": "Aucun de vous n’a encore obtenu de trophée dans ce jeu.", + "verdictLevel": "Vous êtes à égalité de points dans ce jeu.", + "verdictAhead": "Tu as {count, plural, one {# point} other {# points}} d’avance dans ce jeu.", + "verdictBehind": "{handle} a {count, plural, one {# point} other {# points}} d’avance dans ce jeu.", + "caption": "Trophées dans {titleId} : toi à côté de {handle}", + "stat": "Catégorie", + "you": "Toi", + "ahead": "(devant)", + "rowTrophies": "Trophées", + "rowPoints": "Points dans ce jeu", + "rowLevel": "Niveau global", + "grade": { + "platinum": "Platine", + "gold": "Or", + "silver": "Argent", + "bronze": "Bronze" + }, + "both": "Obtenus par les deux", + "onlyYou": "Toi seul", + "onlyThem": "{handle} seul" + }, + "publicProfile": { + "home": "AnyPlay, accueil", + "yourAccount": "Ton compte", + "signIn": "Se connecter", + "loading": "Chargement", + "missingTitle": "Aucun joueur nommé {handle}", + "missingBody": "Vérifie l’orthographe du pseudo ou demande au joueur de te le renvoyer.", + "goHome": "Aller sur AnyPlay", + "player": "Joueur", + "trophies": "Trophées", + "trophiesEarned": "{count, number} obtenus.", + "noTrophies": "{handle} n’a pas encore obtenu de trophée.", + "favourites": "Jeux favoris", + "favouritesNote": "Affichés par ID de titre.", + "game": "Jeu", + "connections": "Connexions", + "playtime": "Jeux joués", + "playtimeNote": "{count, plural, one {# jeu} other {# jeux}}, {total} au total.", + "nothing": "Il n’y a encore rien d’autre à montrer de {handle}.", + "openProfile": "Ouvrir le profil", + "openProfileOn": "sur {brand} (s’ouvre dans un nouvel onglet)", + "signInToAdd": "Connecte-toi pour ajouter {handle}", + "editProfile": "Modifier ton profil", + "blocked": "Bloqué", + "friends": "Amis", + "requestSent": "Demande envoyée", + "accepting": "Acceptation…", + "accept": "Accepter la demande", + "sending": "Envoi…", + "addFriend": "Ajouter un ami", + "addInAnyPlay": "Ajouter un ami dans AnyPlay", + "minutes": "{n} min", + "hours": "{n} h" + }, + "friends": { + "title": "Amis", + "lead": "Ajoute les personnes avec qui tu joues, réponds aux demandes et décide qui reste dehors.", + "add": { + "title": "Ajouter un ami", + "description": "Saisis le nom d’un joueur pour lui envoyer une demande.", + "handle": "Nom", + "hint": "Son nom, avec ou sans le @.", + "send": "Envoyer la demande", + "sending": "Envoi…", + "enterHandle": "Saisis le nom du joueur que tu veux ajouter.", + "nowFriends": "Toi et @{handle} êtes maintenant amis.", + "sent": "Demande envoyée à @{handle}. Vous serez amis dès qu’elle sera acceptée." + }, + "requests": { + "title": "Demandes", + "description": "Les joueurs qui veulent devenir tes amis et les demandes que tu as envoyées.", + "empty": "Aucune demande en cours. Quand quelqu’un t’ajoute, la demande apparaît ici.", + "received": "Reçues", + "sent": "Envoyées", + "receivedAgo": "Reçue {when}", + "sentAgo": "Envoyée {when}", + "decline": "Refuser", + "accept": "Accepter", + "withdraw": "Retirer" + }, + "list": { + "title": "Tes amis", + "description": "Vois qui est en ligne et à quoi la personne joue.", + "empty": "Pas encore d’amis. Ajoute quelqu’un par son nom ci-dessus ; la personne apparaît ici une fois la demande acceptée.", + "online": "En ligne", + "offline": "Hors ligne", + "playing": "Joue à {title}", + "block": "Bloquer", + "remove": "Retirer" + }, + "blocked": { + "title": "Bloqués", + "description": "Les joueurs bloqués sont retirés de tes amis et de tes demandes.", + "empty": "Tu n’as bloqué personne. Bloque un joueur depuis ta liste d’amis quand tu en as besoin.", + "unblock": "Débloquer" + }, + "confirm": { + "blockTitle": "Bloquer @{handle} ?", + "removeTitle": "Retirer @{handle} de tes amis ?", + "blockText": "Vous n’êtes plus amis et toute demande en cours est supprimée. Tu peux débloquer cette personne à tout moment.", + "removeText": "Vous ne verrez plus quand l’autre est en ligne. Tu pourras envoyer une nouvelle demande plus tard.", + "blockLabel": "Bloquer le joueur", + "removeLabel": "Retirer l’ami" + } + }, + "trophies": { + "title": "Trophées", + "lead": "Ce que tu as obtenu dans tes jeux. AnyPlay garde des totaux par jeu et par niveau, jamais les noms ni les visuels des trophées.", + "totals": { + "title": "Totaux", + "description": "{total} obtenus dans {games, plural, one {# jeu} other {# jeux}}.", + "empty": "Pas encore de trophées. Joue à un jeu avec AnyPlay et ceux que tu obtiens sont comptés ici." + }, + "byGame": { + "title": "Par jeu", + "description": "Les jeux sont classés par ID de titre, les plus récents d’abord.", + "detail": "{earned, plural, one {# trophée} other {# trophées}} · dernier obtenu : {when}" + }, + "grades": { + "platinum": "Platine", + "gold": "Or", + "silver": "Argent", + "bronze": "Bronze" + } + }, + "notifications": { + "title": "Notifications", + "lead": "Reçois une notification sur cet appareil quand un ami t’envoie une demande, t’invite dans une party ou t’écrit.", + "device": { + "title": "Cet appareil", + "description": "Chaque navigateur ou téléphone se règle séparément. Active l’option là où tu veux être prévenu.", + "notEnabled": "Ce cloud n’envoie pas de notifications.", + "checking": "Vérification de cet appareil…", + "unsupported": "Ce navigateur ne peut pas recevoir de notifications. Utilise une version récente de Chrome, Edge, Firefox ou Safari.", + "needsInstall": "Sur iPhone et iPad, les notifications demandent qu’AnyPlay soit sur ton écran d’accueil. Touche Partager, choisis Sur l’écran d’accueil, puis ouvre AnyPlay depuis là.", + "blocked": "Les notifications sont bloquées pour ce site. Autorise-les dans les réglages du site de ton navigateur, puis reviens.", + "switch": "Notifier cet appareil", + "others": "{count, plural, one {Un autre appareil reçoit aussi les notifications. Désactive-les sur cet appareil.} other {# autres appareils reçoivent aussi les notifications. Désactive-les sur ces appareils.}}" + }, + "kinds": { + "title": "Sujets des notifications", + "description": "Pour cet appareil uniquement.", + "friend_request": { + "label": "Demandes d’ami", + "detail": "Quelqu’un veut devenir ton ami." + }, + "party_invite": { + "label": "Invitations à une party", + "detail": "Un ami t’invite dans une party." + }, + "message": { + "label": "Messages", + "detail": "Un ami t’écrit. Le message lui-même n’apparaît pas dans la notification." + } + }, + "errors": { + "timeout": "Le service de notifications de ton navigateur n’a pas répondu. Vérifie ta connexion et réessaie.", + "noKey": "Ce cloud n’a pas de clé pour envoyer des notifications.", + "lostSubscription": "Ce navigateur a perdu son abonnement. Désactive les notifications puis réactive-les.", + "notAllowed": "Ce navigateur n’a pas autorisé les notifications. Elles ne fonctionnent pas en navigation privée ; sinon, vérifie les réglages du site." + } + } +} diff --git a/apps/web/messages/fr/auth.json b/apps/web/messages/fr/auth.json index 0967ef4..02b4f30 100644 --- a/apps/web/messages/fr/auth.json +++ b/apps/web/messages/fr/auth.json @@ -1 +1,143 @@ -{} +{ + "meta": { + "signIn": { + "title": "Se connecter", + "description": "Connecte-toi à AnyPlay avec une clé d’accès ou un code reçu par e-mail." + }, + "create": { + "title": "Crée ton compte", + "description": "Crée ton compte AnyPlay : une adresse e-mail et un nom." + }, + "link": { + "title": "Ouvre ton compte", + "description": "Ouvre ton compte AnyPlay dans ce navigateur, depuis le launcher." + }, + "join": { + "title": "Invitation", + "description": "Une invitation à jouer ensemble sur AnyPlay." + } + }, + "shell": { + "home": "AnyPlay, accueil", + "back": "Retour à l’accueil", + "promise": "Joue à tes jeux ensemble.", + "toastOnline": "mira est en ligne", + "toastParty": "kit a rejoint ta party", + "toastTrophy": "Trophée obtenu · Into the Ember", + "help": "Besoin d’aide ?", + "helpLink": "Demande sur Discord", + "legalNav": "Mentions légales", + "legalNotice": "Mentions légales", + "privacyNotice": "Avis de confidentialité", + "termsOfUse": "Conditions d’utilisation" + }, + "step": { + "loading": "Chargement", + "unreachable": "Impossible de joindre AnyPlay", + "tryAgain": "Réessayer", + "newTab": "(s’ouvre dans un nouvel onglet)", + "backToStart": "Retour à l’accueil", + "signIn": "Se connecter", + "createAccount": "Créer un compte", + "getApp": "Obtenir AnyPlay", + "continue": "Continuer" + }, + "fields": { + "code": "Code" + }, + "flow": { + "authorize": "AnyPlay sur {device} attend cette connexion." + }, + "email": { + "create": { + "title": "Crée ton compte", + "lead": "Commence par ton adresse e-mail. Nous t’envoyons un code pour la confirmer." + }, + "signIn": { + "title": "Se connecter", + "lead": "Utilise ta clé d’accès ou reçois un code par e-mail." + }, + "passkey": "Se connecter avec une clé d’accès", + "or": "ou", + "label": "Adresse e-mail", + "placeholder": "name@example.com", + "invalid": "Saisis ton adresse e-mail, par exemple name@example.com.", + "sending": "Envoi…", + "send": "Envoyer le code", + "haveAccount": "Tu as déjà un compte ? Se connecter", + "newHere": "Nouveau sur AnyPlay ? Créer un compte" + }, + "code": { + "title": "Regarde tes e-mails", + "lead": "Nous avons envoyé un code à 6 chiffres à {email}. Il est valable 10 minutes.", + "incomplete": "Saisis les 6 chiffres de l’e-mail.", + "checking": "Vérification…", + "resent": "Un nouveau code est en route.", + "noMail": "Pas d’e-mail ?", + "resendIn": "Envoyer un nouveau code dans {seconds} s", + "resend": "Envoyer un nouveau code" + }, + "details": { + "title": "Choisis un nom", + "lead": "Ton adresse {email} est confirmée. Les autres joueurs voient ton nom.", + "handle": "Nom", + "handleHint": "Utilise de 3 à 16 lettres, chiffres, tirets ou tirets bas. Commence par une lettre.", + "ageHint": "C’est-à-dire 16 ans ou plus.", + "age": "J’ai l’âge d’accepter les conditions par moi-même.", + "terms": "J’accepte les conditions (version {version}) et j’ai lu l’avis de confidentialité.", + "creating": "Création…", + "submit": "Créer un compte" + }, + "terms": { + "title": "Relis les conditions", + "lead": "Les conditions ont changé. Lis les conditions et accepte la version {version} pour continuer.", + "accept": "J’accepte les conditions (version {version}).", + "required": "Accepte les conditions pour continuer.", + "submit": "Accepter et continuer" + }, + "banned": { + "title": "Ce compte est banni", + "leadUntil": "Le bannissement prend fin le {date}.", + "leadForever": "Le bannissement ne prend pas fin de lui-même.", + "rule": "Règle", + "reason": "Motif", + "appeal": "Lire la décision et faire appel" + }, + "handoff": { + "title": "Connecté", + "lead": "Nous te ramenons vers AnyPlay. Si rien ne se passe, utilise le bouton.", + "open": "Ouvrir AnyPlay", + "note": "Tu peux fermer cet onglet dès qu’AnyPlay t’affiche comme connecté." + }, + "done": { + "title": "Tu es connecté", + "inviteLead": "Nous te ramenons à l’invitation.", + "welcome": "Bienvenue, {handle}. Tes amis, tes partys et tes trophées t’attendent dans le launcher.", + "account": "Aller à ton compte" + }, + "join": { + "expiredTitle": "Cette invitation a expiré", + "expiredLead": "Le lien a peut-être expiré, ou le joueur a arrêté de jouer. Demande-en un nouveau.", + "title": "{handle} t’a envoyé une invitation", + "leadSignedIn": "Ouvre AnyPlay sur ton PC pour voir l’invitation. C’est là que tu choisis de rejoindre ou non.", + "leadSignedOut": "Connecte-toi ou crée un compte d’abord. Tu reviens ensuite ici pour ouvrir l’invitation.", + "party": "Party", + "game": "Jeu", + "partyName": "Une party AnyPlay", + "gameFallback": "Un jeu", + "joined": "Présents", + "players": "{members} sur {max, plural, one {# joueur} other {# joueurs}}", + "open": "Ouvrir dans AnyPlay", + "noApp": "Pas encore d’AnyPlay sur ce PC ? Obtiens-le d’abord, puis rouvre cette page." + }, + "link": { + "expiredTitle": "Ce lien a expiré", + "expiredLead": "Rouvre ta page de compte depuis AnyPlay.", + "close": "Tu peux fermer cet onglet.", + "title": "Ouvre ton compte", + "lead": "AnyPlay sur ton PC demande d’ouvrir le compte de {handle} dans ce navigateur.", + "open": "Ouvrir le compte", + "opening": "Ouverture…", + "notYou": "Ce n’est pas toi ? Ferme l’onglet." + } +} diff --git a/apps/web/messages/pt/account.json b/apps/web/messages/pt/account.json index 0967ef4..63930dc 100644 --- a/apps/web/messages/pt/account.json +++ b/apps/web/messages/pt/account.json @@ -1 +1,461 @@ -{} +{ + "ui": { + "never": "nunca", + "on": "Ativado", + "off": "Desativado", + "cancel": "Cancelar", + "working": "Processando…", + "typeToConfirm": "Digite {word} para confirmar", + "audience": { + "everyone": "Todos", + "friends": "Amigos", + "nobody": "Só eu" + }, + "stepUp": { + "title": "Confirme que é você", + "needed": "Esta alteração exige um login recente. Ele vale por 15 minutos.", + "sent": "Enviamos um código de 6 dígitos para {hint}.", + "passkey": "Usar sua passkey", + "sending": "Enviando…", + "sendCode": "Enviar um código por e-mail" + } + }, + "shell": { + "home": "AnyPlay, início", + "signOut": "Sair", + "nav": "Conta", + "nav_overview": "Visão geral", + "nav_profile": "Perfil", + "nav_friends": "Amigos", + "nav_trophies": "Troféus", + "nav_security": "Segurança", + "nav_notifications": "Notificações", + "nav_privacy": "Privacidade", + "nav_connections": "Conexões", + "nav_data": "Seus dados", + "unreachable": "Não foi possível acessar o AnyPlay. Verifique sua conexão e tente de novo.", + "loading": "Carregando" + }, + "level": { + "top": "Nível máximo alcançado", + "toNext": "{count, plural, one {# ponto} other {# pontos}} para o nível {level}", + "level": "Nível {level}", + "points": "{count, plural, one {# ponto} other {# pontos}}", + "progress": "Progresso de nível", + "progressTo": "Progresso para o nível {level}" + }, + "meta": { + "title": "Sua conta", + "template": "%s · Sua conta · AnyPlay" + }, + "state": { + "loading": "Carregando", + "tryAgain": "Tentar de novo" + }, + "overview": { + "label": "Sua conta", + "memberSince": "Membro desde {date}", + "appearingOffline": "Você aparece offline", + "deletion": "Sua conta será excluída em {date}. Manter minha conta", + "passkeys": "{count, plural, one {# chave de acesso} other {# chaves de acesso}}", + "noPasskey": "Nenhuma chave de acesso ainda", + "launchersBrowsers": "{launchers, plural, one {# launcher} other {# launchers}} · {browsers, plural, one {# navegador} other {# navegadores}}", + "trophiesAudience": "Troféus: {audience}", + "pictureAudience": "Foto de perfil: {audience}", + "audience": { + "everyone": "Todos", + "friends": "Amigos", + "nobody": "Só você" + }, + "audienceInline": { + "everyone": "todos", + "friends": "amigos", + "nobody": "só você" + }, + "linked": "{count} vinculadas", + "nothingLinked": "Nada vinculado", + "connectionsDetail": "Discord, Steam", + "dataValue": "Exportar ou excluir", + "dataDetail": "Tudo o que guardamos sobre você", + "launcher": { + "title": "Baixe o launcher", + "description": "Entre no launcher com esta conta, e seus amigos, parties e troféus estarão lá." + } + }, + "profile": { + "lead": "O que seus amigos veem quando abrem seu perfil. Quem vê cada parte é você quem decide, na página Privacidade.", + "avatar": { + "title": "Foto", + "description": "PNG ou JPEG, de até 1 MB. Ela aparece redonda, então deixe o rosto no meio.", + "uploading": "Enviando…", + "change": "Alterar foto", + "upload": "Enviar foto", + "remove": "Remover" + }, + "about": { + "title": "Sobre", + "description": "Uma ou duas linhas sobre você: o que você joga, quando está por aqui.", + "placeholder": "Em co-op quando dá, sozinho quando não dá.", + "saved": "Salvo", + "saving": "Salvando…", + "save": "Salvar" + }, + "favourites": { + "title": "Jogos favoritos", + "description": "Até {max}, por ID do título. Este site mostra o ID. O launcher mostra o nome de cada jogo a partir da sua própria cópia.", + "empty": "Nenhum jogo favorito ainda. Adicione um ID de título abaixo.", + "listLabel": "Seus jogos favoritos", + "remove": "Remover {id}", + "fieldLabel": "ID do título", + "full": "Você tem {max} jogos. Remova um para adicionar outro.", + "add": "Adicionar jogo", + "saved": "Salvo", + "saving": "Salvando…", + "save": "Salvar", + "badId": "Use apenas letras, números, hifens e sublinhados.", + "duplicate": "{id} já está na lista." + } + }, + "security": { + "lead": "Como você entra e todos os launchers e navegadores conectados à sua conta.", + "email": { + "title": "E-mail", + "description": "Para onde vão seus códigos de entrada. Para mudar, é preciso um código enviado ao novo endereço.", + "change": "Alterar", + "invalid": "Digite o novo endereço, como nome@exemplo.com.", + "done": "Seu endereço de entrada agora é {email}.", + "newAddress": "Novo endereço", + "sending": "Enviando…", + "send": "Enviar código", + "cancel": "Cancelar", + "sentTo": "Enviamos um código de 6 dígitos para {email}." + }, + "passkeys": { + "title": "Chaves de acesso", + "description": "Entre com sua digital, seu rosto ou o PIN do dispositivo, em vez de um código. Ninguém consegue aplicar phishing em uma chave de acesso.", + "add": "Adicionar uma chave de acesso", + "waiting": "Aguardando seu dispositivo…", + "detail": "Adicionada {added} · Último uso: {used}", + "remove": "Remover", + "empty": "Nenhuma chave de acesso ainda. Adicione uma e entrar vira um toque, em vez de um código por e-mail.", + "full": "Você tem o máximo de chaves de acesso que uma conta pode guardar ({max}). Remova uma para adicionar outra.", + "confirmTitle": "Remover esta chave de acesso?", + "confirmDescription": "Esse dispositivo não poderá mais entrar na sua conta com ela. Você sempre pode entrar com um código por e-mail.", + "confirmLabel": "Remover chave de acesso", + "defaultName": "Chave de acesso em {system}", + "thisDevice": "este dispositivo" + }, + "devices": { + "title": "Launchers", + "description": "Todo PC em que o launcher está conectado à sua conta.", + "detail": "{platform} · Último uso: {used}", + "signOut": "Sair", + "empty": "Nenhum launcher conectado ainda.", + "confirmTitle": "Desconectar este launcher?", + "confirmDescription": "Ele fica offline até alguém entrar de novo nesse PC.", + "confirmLabel": "Desconectar launcher" + }, + "browsers": { + "title": "Navegadores", + "description": "Todo navegador conectado a esta página da conta. Guardamos sobre eles apenas quando foram usados.", + "thisBrowser": "Este navegador", + "here": "Você está aqui", + "other": "Outro navegador", + "detail": "Entrou {created} · Último uso: {used}", + "signOut": "Sair", + "confirmThisTitle": "Sair deste navegador?", + "confirmOtherTitle": "Desconectar aquele navegador?", + "confirmThisDescription": "Você volta ao início e entra de novo na próxima vez.", + "confirmOtherDescription": "Ele precisa entrar de novo para abrir sua conta.", + "confirmLabel": "Sair" + }, + "everywhere": { + "title": "Sair em todos os lugares", + "description": "Todo launcher e todo navegador, este também. Use quando um dispositivo for perdido ou alguém mais puder ter entrado.", + "button": "Sair em todos os lugares", + "confirmTitle": "Sair em todos os lugares?", + "confirmDescription": "Todo launcher e navegador é desconectado de uma vez, este também.", + "confirmLabel": "Sair em todos os lugares" + } + }, + "privacy": { + "lead": "Quem vê seu perfil e sua atividade. Seu nome sempre fica visível, para que seus amigos encontrem você.", + "profile": { + "title": "Seu perfil", + "description": "As mudanças valem na hora, no launcher também." + }, + "parts": { + "trophies": { + "title": "Troféus", + "detail": "Sua contagem de troféus, seus pontos e seu nível" + }, + "playtime": { + "title": "Tempo de jogo", + "detail": "Quanto você jogou, por jogo" + }, + "about": { + "title": "Sobre", + "detail": "A linha do seu perfil" + }, + "favourites": { + "title": "Jogos favoritos", + "detail": "Até três jogos que você fixa" + }, + "avatar": { + "title": "Foto", + "detail": "Sua foto de perfil" + } + }, + "whoSees": "Quem vê: {part}", + "activity": { + "title": "Sua atividade", + "description": "O que você faz no AnyPlay, como seus amigos veem no feed deles.", + "row": "Atividade", + "detail": "Seus amigos veem no feed deles. Desconhecidos não veem feed. {nobody} esconde o que está lá e para novos itens." + }, + "appearOffline": { + "title": "Status online", + "description": "Vale para todo launcher e navegador conectado à sua conta.", + "label": "Aparecer offline", + "detail": "Seus amigos veem você offline. Você ainda pode jogar, conversar e entrar." + }, + "publicProfile": { + "title": "Perfil público", + "description": "Desativado até você ativar. Vale para todo launcher e navegador conectado à sua conta.", + "label": "Mostrar um perfil público", + "detail": "Qualquer pessoa com o link vê seu nível, seus troféus e os jogos que você jogou. Nunca mostra se você está online.", + "page": "Sua página: {url}" + }, + "playedWith": { + "title": "Jogadores recentes", + "description": "Se você desativar, ninguém novo é adicionado. A lista fica até você esvaziá-la.", + "label": "Lembrar com quem eu jogo", + "detail": "Guarda por 30 dias as pessoas com quem você dividiu uma party ou uma sessão de jogo, até 50. Só você vê a lista.", + "clear": "Esvaziar a lista", + "cleared": "A lista está vazia.", + "confirmTitle": "Esvaziar a lista?", + "confirmDescription": "Todos saem da sua lista de pessoas com quem você jogou. A lista de outras pessoas não muda, e você não pode desfazer isso." + } + }, + "connections": { + "lead": "Vincule as contas com que você já joga, para que seus amigos encontrem você e vejam onde mais você está.", + "result": { + "linked": "{name} está vinculado à sua conta.", + "denied": "{name} não foi vinculado: o pedido foi recusado lá.", + "expired": "Vincular {name} demorou demais. Comece de novo.", + "fresh": "Vincular {name} exige uma entrada recente. Comece de novo e confirme que é você.", + "taken": "Essa conta {name} já está vinculada a outra conta do AnyPlay.", + "exists": "Você já tem uma conta {name} vinculada. Desvincule-a primeiro.", + "failed": "Não foi possível vincular {name}. Tente de novo em um minuto." + }, + "whoSees": "Quem vê sua conta {name}", + "linkedAgo": "Vinculada {ago}", + "unlink": "Desvincular", + "link": "Vincular {name}", + "opening": "Abrindo…", + "soon": "Em breve", + "notLinked": "Não vinculada", + "arrivesSoon": "A vinculação chega em breve", + "confirmTitle": "Desvincular {name}?", + "confirmDescription": "Sua conta {name} deixa de aparecer no seu perfil. Você pode vinculá-la de novo quando quiser.", + "confirmLabel": "Desvincular {name}" + }, + "data": { + "lead": "Tudo o que o AnyPlay guarda sobre você é seu: leve uma cópia ou exclua a conta.", + "export": { + "title": "Baixar seus dados", + "description": "Sua conta, seu perfil, amigos, troféus, tempo de jogo e contas vinculadas, em um único arquivo JSON.", + "preparing": "Preparando…", + "button": "Baixar" + }, + "scheduled": { + "title": "Exclusão agendada", + "description": "Sua conta e tudo que há nela serão excluídos em {date}. Até lá, você pode mantê-la.", + "keep": "Manter minha conta" + }, + "delete": { + "title": "Excluir sua conta", + "description": "Sua conta é excluída após 30 dias; até lá, você pode mudar de ideia. Depois disso ela some, junto com seus amigos, troféus e tempo de jogo.", + "button": "Excluir conta" + }, + "confirm": { + "title": "Excluir sua conta?", + "description": "Ela é excluída em 30 dias. Todo launcher e navegador é desconectado agora." + } + }, + "compare": { + "title": "Comparar troféus", + "description": "Seus troféus ao lado dos de {handle}.", + "descriptionShared": "Você e {handle}, em um jogo que vocês dois têm.", + "signInDescription": "Veja como seus troféus se comparam aos de {handle}.", + "signIn": "Entre para comparar troféus", + "hidden": "Os troféus de {handle} não estão disponíveis para comparação.", + "loading": "Carregando", + "loadingComparison": "Carregando a comparação", + "empty": "Você e {handle} ainda não têm nenhum jogo em comum. Jogue um jogo que {handle} já jogou e ele aparece aqui.", + "game": "Jogo", + "gameNamed": "Jogo {title}", + "verdictNone": "Nenhum dos dois conquistou um troféu neste jogo.", + "verdictLevel": "Vocês estão empatados em pontos neste jogo.", + "verdictAhead": "Você está {count, plural, one {# ponto} other {# pontos}} à frente neste jogo.", + "verdictBehind": "{handle} está {count, plural, one {# ponto} other {# pontos}} à frente neste jogo.", + "caption": "Troféus em {titleId}: você ao lado de {handle}", + "stat": "Categoria", + "you": "Você", + "ahead": "(à frente)", + "rowTrophies": "Troféus", + "rowPoints": "Pontos neste jogo", + "rowLevel": "Nível geral", + "grade": { + "platinum": "Platina", + "gold": "Ouro", + "silver": "Prata", + "bronze": "Bronze" + }, + "both": "Conquistados por ambos", + "onlyYou": "Só você", + "onlyThem": "Só {handle}" + }, + "publicProfile": { + "home": "AnyPlay, início", + "yourAccount": "Sua conta", + "signIn": "Entrar", + "loading": "Carregando", + "missingTitle": "Nenhum jogador chamado {handle}", + "missingBody": "Verifique a grafia do nome ou peça ao jogador que o envie de novo.", + "goHome": "Ir para o AnyPlay", + "player": "Jogador", + "trophies": "Troféus", + "trophiesEarned": "{count, number} conquistados.", + "noTrophies": "{handle} ainda não conquistou nenhum troféu.", + "favourites": "Jogos favoritos", + "favouritesNote": "Exibidos pelo ID do título.", + "game": "Jogo", + "connections": "Conexões", + "playtime": "Jogos jogados", + "playtimeNote": "{count, plural, one {# jogo} other {# jogos}}, {total} no total.", + "nothing": "Ainda não há mais nada para mostrar de {handle}.", + "openProfile": "Abrir perfil", + "openProfileOn": "no {brand} (abre em uma nova guia)", + "signInToAdd": "Entre para adicionar {handle}", + "editProfile": "Editar seu perfil", + "blocked": "Bloqueado", + "friends": "Amigos", + "requestSent": "Solicitação enviada", + "accepting": "Aceitando…", + "accept": "Aceitar solicitação", + "sending": "Enviando…", + "addFriend": "Adicionar amigo", + "addInAnyPlay": "Adicionar amigo no AnyPlay", + "minutes": "{n} min", + "hours": "{n} h" + }, + "friends": { + "title": "Amigos", + "lead": "Adicione as pessoas com quem você joga, responda a pedidos e decida quem fica de fora.", + "add": { + "title": "Adicionar um amigo", + "description": "Digite o nome de um jogador para enviar um pedido a ele.", + "handle": "Nome", + "hint": "O nome dele, com ou sem o @.", + "send": "Enviar pedido", + "sending": "Enviando…", + "enterHandle": "Digite o nome do jogador que você quer adicionar.", + "nowFriends": "Você e @{handle} agora são amigos.", + "sent": "Pedido enviado para @{handle}. Vocês serão amigos assim que ele for aceito." + }, + "requests": { + "title": "Pedidos", + "description": "Jogadores que querem ser seus amigos e os pedidos que você enviou.", + "empty": "Nenhum pedido em aberto. Quando alguém adicionar você, o pedido aparece aqui.", + "received": "Recebidos", + "sent": "Enviados", + "receivedAgo": "Recebido {when}", + "sentAgo": "Enviado {when}", + "decline": "Recusar", + "accept": "Aceitar", + "withdraw": "Retirar" + }, + "list": { + "title": "Seus amigos", + "description": "Veja quem está online e o que está jogando.", + "empty": "Você ainda não tem amigos. Adicione alguém pelo nome acima; a pessoa aparece aqui quando aceitar.", + "online": "Online", + "offline": "Offline", + "playing": "Jogando {title}", + "block": "Bloquear", + "remove": "Remover" + }, + "blocked": { + "title": "Bloqueados", + "description": "Jogadores bloqueados são removidos dos seus amigos e pedidos.", + "empty": "Você não bloqueou ninguém. Bloqueie um jogador pela sua lista de amigos quando precisar.", + "unblock": "Desbloquear" + }, + "confirm": { + "blockTitle": "Bloquear @{handle}?", + "removeTitle": "Remover @{handle} dos amigos?", + "blockText": "Vocês deixam de ser amigos e qualquer pedido em aberto é descartado. Você pode desbloquear a qualquer momento.", + "removeText": "Vocês deixam de ver quando o outro está online. Você pode enviar um novo pedido depois.", + "blockLabel": "Bloquear jogador", + "removeLabel": "Remover amigo" + } + }, + "trophies": { + "title": "Troféus", + "lead": "O que você conquistou nos seus jogos. O AnyPlay guarda contagens por jogo e nível, nunca nomes ou imagens de troféus.", + "totals": { + "title": "Totais", + "description": "{total} conquistados em {games, plural, one {# jogo} other {# jogos}}.", + "empty": "Ainda não há troféus. Jogue algo pelo AnyPlay e os que você conquistar serão contados aqui." + }, + "byGame": { + "title": "Por jogo", + "description": "Os jogos são listados por ID do título, os mais recentes primeiro.", + "detail": "{earned, plural, one {# troféu} other {# troféus}} · última conquista: {when}" + }, + "grades": { + "platinum": "Platina", + "gold": "Ouro", + "silver": "Prata", + "bronze": "Bronze" + } + }, + "notifications": { + "title": "Notificações", + "lead": "Receba uma notificação neste dispositivo quando um amigo enviar um pedido, convidar você para uma party ou escrever para você.", + "device": { + "title": "Este dispositivo", + "description": "Cada navegador ou celular é configurado separadamente. Ative onde você quiser ser avisado.", + "notEnabled": "Esta nuvem não envia notificações.", + "checking": "Verificando este dispositivo…", + "unsupported": "Este navegador não pode receber notificações. Use uma versão atual do Chrome, Edge, Firefox ou Safari.", + "needsInstall": "No iPhone e no iPad, as notificações precisam do AnyPlay na sua tela de início. Toque em Compartilhar, escolha Adicionar à Tela de Início e abra o AnyPlay por lá.", + "blocked": "As notificações estão bloqueadas para este site. Permita-as nas configurações do site no seu navegador e volte.", + "switch": "Notificar este dispositivo", + "others": "{count, plural, one {Outro dispositivo também recebe notificações. Desative-as nesse dispositivo.} other {Outros # dispositivos também recebem notificações. Desative-as nesses dispositivos.}}" + }, + "kinds": { + "title": "Sobre o que avisar", + "description": "Apenas para este dispositivo.", + "friend_request": { + "label": "Pedidos de amizade", + "detail": "Alguém quer ser seu amigo." + }, + "party_invite": { + "label": "Convites para party", + "detail": "Um amigo convida você para uma party." + }, + "message": { + "label": "Mensagens", + "detail": "Um amigo escreve para você. A mensagem em si não aparece na notificação." + } + }, + "errors": { + "timeout": "O serviço de notificações do seu navegador não respondeu. Verifique sua conexão e tente de novo.", + "noKey": "Esta nuvem não tem uma chave para enviar notificações.", + "lostSubscription": "Este navegador perdeu a assinatura. Desative as notificações e ative de novo.", + "notAllowed": "Este navegador não permitiu notificações. Elas não funcionam em uma janela privada; caso contrário, verifique as configurações do site." + } + } +} diff --git a/apps/web/messages/pt/auth.json b/apps/web/messages/pt/auth.json index 0967ef4..3d5089f 100644 --- a/apps/web/messages/pt/auth.json +++ b/apps/web/messages/pt/auth.json @@ -1 +1,143 @@ -{} +{ + "meta": { + "signIn": { + "title": "Entrar", + "description": "Entre no AnyPlay com uma chave de acesso ou um código por e-mail." + }, + "create": { + "title": "Crie sua conta", + "description": "Crie sua conta do AnyPlay: um e-mail e um nome." + }, + "link": { + "title": "Abra sua conta", + "description": "Abra sua conta do AnyPlay neste navegador, a partir do launcher." + }, + "join": { + "title": "Convite", + "description": "Um convite para jogar junto no AnyPlay." + } + }, + "shell": { + "home": "AnyPlay, início", + "back": "Voltar ao início", + "promise": "Jogue seus jogos juntos.", + "toastOnline": "mira está online", + "toastParty": "kit entrou na sua party", + "toastTrophy": "Troféu conquistado · Into the Ember", + "help": "Precisa de ajuda?", + "helpLink": "Pergunte no Discord", + "legalNav": "Legal", + "legalNotice": "Aviso legal", + "privacyNotice": "Aviso de privacidade", + "termsOfUse": "Termos de uso" + }, + "step": { + "loading": "Carregando", + "unreachable": "Não foi possível conectar ao AnyPlay", + "tryAgain": "Tentar de novo", + "newTab": "(abre em uma nova aba)", + "backToStart": "Voltar ao início", + "signIn": "Entrar", + "createAccount": "Criar conta", + "getApp": "Baixar o AnyPlay", + "continue": "Continuar" + }, + "fields": { + "code": "Código" + }, + "flow": { + "authorize": "O AnyPlay em {device} está esperando você entrar." + }, + "email": { + "create": { + "title": "Crie sua conta", + "lead": "Comece com seu e-mail. Enviamos um código para confirmá-lo." + }, + "signIn": { + "title": "Entrar", + "lead": "Use sua chave de acesso ou receba um código por e-mail." + }, + "passkey": "Entrar com uma chave de acesso", + "or": "ou", + "label": "E-mail", + "placeholder": "name@example.com", + "invalid": "Digite seu e-mail, por exemplo name@example.com.", + "sending": "Enviando…", + "send": "Enviar código", + "haveAccount": "Já tem uma conta? Entrar", + "newHere": "Novo no AnyPlay? Criar conta" + }, + "code": { + "title": "Confira seu e-mail", + "lead": "Enviamos um código de 6 dígitos para {email}. Ele vale por 10 minutos.", + "incomplete": "Digite os 6 dígitos do e-mail.", + "checking": "Verificando…", + "resent": "Um novo código está a caminho.", + "noMail": "Não recebeu o e-mail?", + "resendIn": "Enviar novo código em {seconds} s", + "resend": "Enviar novo código" + }, + "details": { + "title": "Escolha um nome", + "lead": "Seu endereço {email} foi confirmado. Os outros jogadores veem seu nome.", + "handle": "Nome", + "handleHint": "Use de 3 a 16 letras, dígitos, hífens ou sublinhados. Comece com uma letra.", + "ageHint": "Ou seja, 16 anos ou mais.", + "age": "Tenho idade suficiente para aceitar os termos por conta própria.", + "terms": "Aceito os termos (versão {version}) e li o aviso de privacidade.", + "creating": "Criando…", + "submit": "Criar conta" + }, + "terms": { + "title": "Revise os termos", + "lead": "Os termos mudaram. Leia os termos e aceite a versão {version} para continuar.", + "accept": "Aceito os termos (versão {version}).", + "required": "Aceite os termos para continuar.", + "submit": "Aceitar e continuar" + }, + "banned": { + "title": "Esta conta foi banida", + "leadUntil": "O banimento termina em {date}.", + "leadForever": "O banimento não termina por conta própria.", + "rule": "Regra", + "reason": "Motivo", + "appeal": "Ler a declaração e apelar" + }, + "handoff": { + "title": "Conectado", + "lead": "Estamos levando você de volta ao AnyPlay. Se nada acontecer, use o botão.", + "open": "Abrir o AnyPlay", + "note": "Você pode fechar esta aba quando o AnyPlay mostrar que você está conectado." + }, + "done": { + "title": "Você está conectado", + "inviteLead": "Estamos levando você de volta ao convite.", + "welcome": "Boas-vindas, {handle}. Seus amigos, partys e troféus estão esperando no launcher.", + "account": "Ir para sua conta" + }, + "join": { + "expiredTitle": "Este convite expirou", + "expiredLead": "O link pode ter expirado, ou o jogador parou de jogar. Peça um novo.", + "title": "{handle} convidou você", + "leadSignedIn": "Abra o AnyPlay no seu PC para ver o convite. Lá você decide se quer entrar.", + "leadSignedOut": "Entre ou crie uma conta primeiro. Depois você volta aqui para abrir o convite.", + "party": "Party", + "game": "Jogo", + "partyName": "Uma party do AnyPlay", + "gameFallback": "Um jogo", + "joined": "Entraram", + "players": "{members} de {max, plural, one {# jogador} other {# jogadores}}", + "open": "Abrir no AnyPlay", + "noApp": "Ainda não tem o AnyPlay neste PC? Baixe primeiro e abra esta página de novo." + }, + "link": { + "expiredTitle": "Este link expirou", + "expiredLead": "Abra a página da sua conta pelo AnyPlay de novo.", + "close": "Você pode fechar esta aba.", + "title": "Abra sua conta", + "lead": "O AnyPlay no seu PC pediu para abrir a conta de {handle} neste navegador.", + "open": "Abrir conta", + "opening": "Abrindo…", + "notYou": "Não foi você? Feche a aba." + } +} diff --git a/apps/web/messages/tr/account.json b/apps/web/messages/tr/account.json index 0967ef4..3e04001 100644 --- a/apps/web/messages/tr/account.json +++ b/apps/web/messages/tr/account.json @@ -1 +1,461 @@ -{} +{ + "ui": { + "never": "hiç", + "on": "Açık", + "off": "Kapalı", + "cancel": "Vazgeç", + "working": "Çalışıyor…", + "typeToConfirm": "Onaylamak için {word} yaz", + "audience": { + "everyone": "Herkes", + "friends": "Arkadaşlar", + "nobody": "Sadece ben" + }, + "stepUp": { + "title": "Sen olduğunu doğrula", + "needed": "Bu değişiklik için yakın zamanda giriş yapmış olman gerekir. 15 dakika geçerli kalır.", + "sent": "{hint} adresine 6 haneli bir kod gönderdik.", + "passkey": "Passkey’ini kullan", + "sending": "Gönderiliyor…", + "sendCode": "E-postayla kod gönder" + } + }, + "shell": { + "home": "AnyPlay, ana sayfa", + "signOut": "Çıkış yap", + "nav": "Hesap", + "nav_overview": "Genel bakış", + "nav_profile": "Profil", + "nav_friends": "Arkadaşlar", + "nav_trophies": "Kupalar", + "nav_security": "Güvenlik", + "nav_notifications": "Bildirimler", + "nav_privacy": "Gizlilik", + "nav_connections": "Bağlantılar", + "nav_data": "Verilerin", + "unreachable": "AnyPlay’e ulaşılamıyor. Bağlantını kontrol et ve tekrar dene.", + "loading": "Yükleniyor" + }, + "level": { + "top": "En üst seviyeye ulaşıldı", + "toNext": "{count, plural, one {# puan} other {# puan}} sonra seviye {level}", + "level": "Seviye {level}", + "points": "{count, plural, one {# puan} other {# puan}}", + "progress": "Seviye ilerlemesi", + "progressTo": "Seviye {level} ilerlemesi" + }, + "meta": { + "title": "Hesabın", + "template": "%s · Hesabın · AnyPlay" + }, + "state": { + "loading": "Yükleniyor", + "tryAgain": "Tekrar dene" + }, + "overview": { + "label": "Hesabın", + "memberSince": "Üyelik başlangıcı: {date}", + "appearingOffline": "Çevrimdışı görünüyorsun", + "deletion": "Hesabın {date} tarihinde silinecek. Hesabımı koru", + "passkeys": "{count, plural, one {# geçiş anahtarı} other {# geçiş anahtarı}}", + "noPasskey": "Henüz geçiş anahtarı yok", + "launchersBrowsers": "{launchers, plural, one {# Launcher} other {# Launcher}} · {browsers, plural, one {# tarayıcı} other {# tarayıcı}}", + "trophiesAudience": "Kupalar: {audience}", + "pictureAudience": "Profil resmi: {audience}", + "audience": { + "everyone": "Herkes", + "friends": "Arkadaşlar", + "nobody": "Yalnızca sen" + }, + "audienceInline": { + "everyone": "herkes", + "friends": "arkadaşlar", + "nobody": "yalnızca sen" + }, + "linked": "{count} bağlı", + "nothingLinked": "Hiçbir şey bağlı değil", + "connectionsDetail": "Discord, Steam", + "dataValue": "Dışa aktar ya da sil", + "dataDetail": "Hakkında tuttuğumuz her şey", + "launcher": { + "title": "Launcher’ı indir", + "description": "Bu hesapla Launcher’a giriş yap; arkadaşların, partilerin ve kupaların orada olur." + } + }, + "profile": { + "lead": "Profilini açan arkadaşlarının gördükleri. Her bölümü kimin göreceğini Gizlilik sayfasından sen seçersin.", + "avatar": { + "title": "Resim", + "description": "PNG ya da JPEG, en fazla 1 MB. Yuvarlak görünür; bu yüzden yüzünü ortada tut.", + "uploading": "Yükleniyor…", + "change": "Resmi değiştir", + "upload": "Resim yükle", + "remove": "Kaldır" + }, + "about": { + "title": "Hakkında", + "description": "Kendin hakkında bir iki satır: ne oynadığın, ne zaman çevrimiçi olduğun.", + "placeholder": "Olursa ortak oyun, olmazsa tek başıma.", + "saved": "Kaydedildi", + "saving": "Kaydediliyor…", + "save": "Kaydet" + }, + "favourites": { + "title": "Favori oyunlar", + "description": "En fazla {max}, oyun kimliğiyle. Bu site kimliği gösterir. Launcher her oyunun adını kendi kopyandan gösterir.", + "empty": "Henüz favori oyun yok. Aşağıdan bir oyun kimliği ekle.", + "listLabel": "Favori oyunların", + "remove": "{id} kaldır", + "fieldLabel": "Oyun kimliği", + "full": "{max} oyunun var. Yenisini eklemek için birini kaldır.", + "add": "Oyun ekle", + "saved": "Kaydedildi", + "saving": "Kaydediliyor…", + "save": "Kaydet", + "badId": "Yalnızca harf, rakam, tire ve alt çizgi kullan.", + "duplicate": "{id} zaten listede." + } + }, + "security": { + "lead": "Nasıl giriş yaptığın ve hesabına giriş yapmış her Launcher ile tarayıcı.", + "email": { + "title": "E-posta", + "description": "Giriş kodlarının gittiği adres. Değiştirmek için yeni adrese gönderilen bir kod gerekir.", + "change": "Değiştir", + "invalid": "Yeni adresi gir, örneğin ad@ornek.com.", + "done": "Giriş adresin artık {email}.", + "newAddress": "Yeni adres", + "sending": "Gönderiliyor…", + "send": "Kod gönder", + "cancel": "Vazgeç", + "sentTo": "{email} adresine 6 haneli bir kod gönderdik." + }, + "passkeys": { + "title": "Geçiş anahtarları", + "description": "Kod yerine parmak izin, yüzün ya da cihaz PIN’inle giriş yap. Geçiş anahtarını kimse oltalayamaz.", + "add": "Geçiş anahtarı ekle", + "waiting": "Cihazın bekleniyor…", + "detail": "Eklenme: {added} · Son kullanım: {used}", + "remove": "Kaldır", + "empty": "Henüz geçiş anahtarı yok. Bir tane ekle; e-posta kodu yerine tek dokunuşla giriş yaparsın.", + "full": "Bir hesabın tutabileceği en fazla geçiş anahtarına sahipsin ({max}). Yenisini eklemek için birini kaldır.", + "confirmTitle": "Bu geçiş anahtarı kaldırılsın mı?", + "confirmDescription": "Bu cihaz artık onunla giriş yapamaz. E-postayla gelen kodla her zaman giriş yapabilirsin.", + "confirmLabel": "Geçiş anahtarını kaldır", + "defaultName": "{system} geçiş anahtarı", + "thisDevice": "bu cihaz" + }, + "devices": { + "title": "Launcher’lar", + "description": "Launcher’ın hesabına giriş yaptığı tüm bilgisayarlar.", + "detail": "{platform} · Son kullanım: {used}", + "signOut": "Çıkış yap", + "empty": "Henüz hiçbir Launcher’da giriş yapılmadı.", + "confirmTitle": "Bu Launcher’dan çıkış yapılsın mı?", + "confirmDescription": "O bilgisayarda biri tekrar giriş yapana kadar çevrimdışı olur.", + "confirmLabel": "Launcher’dan çıkış yap" + }, + "browsers": { + "title": "Tarayıcılar", + "description": "Bu hesap sayfasına giriş yapmış her tarayıcı. Onlar hakkında, ne zaman kullanıldıklarından fazlasını tutmuyoruz.", + "thisBrowser": "Bu tarayıcı", + "here": "Şu an buradasın", + "other": "Başka tarayıcı", + "detail": "Giriş: {created} · Son kullanım: {used}", + "signOut": "Çıkış yap", + "confirmThisTitle": "Bu tarayıcıdan çıkış yapılsın mı?", + "confirmOtherTitle": "O tarayıcıdan çıkış yapılsın mı?", + "confirmThisDescription": "Başa dönersin ve bir sonraki sefer yeniden giriş yaparsın.", + "confirmOtherDescription": "Hesabını açmak için yeniden giriş yapması gerekir.", + "confirmLabel": "Çıkış yap" + }, + "everywhere": { + "title": "Her yerden çıkış yap", + "description": "Bu dahil her Launcher ve her tarayıcı. Bir cihaz kaybolduysa ya da başkası giriş yapmış olabilirse kullan.", + "button": "Her yerden çıkış yap", + "confirmTitle": "Her yerden çıkış yapılsın mı?", + "confirmDescription": "Bu dahil her Launcher ve tarayıcıdan aynı anda çıkış yapılır.", + "confirmLabel": "Her yerden çıkış yap" + } + }, + "privacy": { + "lead": "Profilini ve etkinliğini kimin gördüğü. Kullanıcı adın her zaman görünür; böylece arkadaşların seni bulabilir.", + "profile": { + "title": "Profilin", + "description": "Değişiklikler hemen geçerli olur, Launcher’da da." + }, + "parts": { + "trophies": { + "title": "Kupalar", + "detail": "Kupa sayıların, puanların ve seviyen" + }, + "playtime": { + "title": "Oynama süresi", + "detail": "Her oyunu ne kadar oynadığın" + }, + "about": { + "title": "Hakkında", + "detail": "Profilindeki satır" + }, + "favourites": { + "title": "Favori oyunlar", + "detail": "Sabitlediğin en fazla üç oyun" + }, + "avatar": { + "title": "Resim", + "detail": "Profil resmin" + } + }, + "whoSees": "Kim görür: {part}", + "activity": { + "title": "Etkinliğin", + "description": "AnyPlay’de yaptıkların, arkadaşlarının akışında göründüğü gibi.", + "row": "Etkinlik", + "detail": "Arkadaşların akışlarında görür. Yabancılar akış görmez. {nobody} oradakini gizler ve yeni öğeleri durdurur." + }, + "appearOffline": { + "title": "Çevrimiçi durumu", + "description": "Hesabına giriş yapmış her Launcher ve tarayıcı için geçerlidir.", + "label": "Çevrimdışı görün", + "detail": "Arkadaşların seni çevrimdışı görür. Yine de oynayabilir, sohbet edebilir ve katılabilirsin." + }, + "publicProfile": { + "title": "Herkese açık profil", + "description": "Sen açana kadar kapalıdır. Hesabına giriş yapmış her Launcher ve tarayıcı için geçerlidir.", + "label": "Herkese açık profil göster", + "detail": "Bağlantıya sahip herkes seviyeni, kupalarını ve oynadığın oyunları görür. Çevrimiçi olup olmadığını asla göstermez.", + "page": "Sayfan: {url}" + }, + "playedWith": { + "title": "Son oynadıkların", + "description": "Kapatırsan yeni kimse eklenmez. Liste, sen temizleyene kadar kalır.", + "label": "Oynadığım kişileri hatırla", + "detail": "Birlikte parti ya da oyun oturumu paylaştığın kişileri 30 gün boyunca, en fazla 50 kişi tutar. Listeyi yalnızca sen görürsün.", + "clear": "Listeyi temizle", + "cleared": "Liste boş.", + "confirmTitle": "Liste temizlensin mi?", + "confirmDescription": "Birlikte oynadıklarının listesindeki herkes kaldırılır. Başkalarının listesi değişmez ve bunu geri alamazsın." + } + }, + "connections": { + "lead": "Zaten oynadığın hesapları bağla; arkadaşların seni bulsun ve başka nerede olduğunu görsün.", + "result": { + "linked": "{name} hesabına bağlandı.", + "denied": "{name} bağlanmadı: istek orada reddedildi.", + "expired": "{name} bağlama çok uzun sürdü. Baştan başla.", + "fresh": "{name} bağlamak için yakın zamanda giriş yapmış olman gerekir. Baştan başla ve senin olduğunu doğrula.", + "taken": "Bu {name} hesabı başka bir AnyPlay hesabına zaten bağlı.", + "exists": "Zaten bir {name} hesabın bağlı. Önce onun bağlantısını kaldır.", + "failed": "{name} bağlanamadı. Bir dakika sonra tekrar dene." + }, + "whoSees": "{name} hesabını kim görür", + "linkedAgo": "Bağlanma: {ago}", + "unlink": "Bağlantıyı kaldır", + "link": "{name} bağla", + "opening": "Açılıyor…", + "soon": "Yakında", + "notLinked": "Bağlı değil", + "arrivesSoon": "Bağlama yakında geliyor", + "confirmTitle": "{name} bağlantısı kaldırılsın mı?", + "confirmDescription": "{name} hesabın artık profilinde görünmez. İstediğin zaman yeniden bağlayabilirsin.", + "confirmLabel": "{name} bağlantısını kaldır" + }, + "data": { + "lead": "AnyPlay’in hakkında tuttuğu her şey senindir: bir kopyasını al ya da hesabı sil.", + "export": { + "title": "Verilerini indir", + "description": "Hesabın, profilin, arkadaşların, kupaların, oynama sürelerin ve bağlı hesapların tek bir JSON dosyası olarak.", + "preparing": "Hazırlanıyor…", + "button": "İndir" + }, + "scheduled": { + "title": "Silme planlandı", + "description": "Hesabın ve içindeki her şey {date} tarihinde silinecek. O zamana kadar hesabını koruyabilirsin.", + "keep": "Hesabımı koru" + }, + "delete": { + "title": "Hesabını sil", + "description": "Hesabın 30 gün sonra silinir; o zamana kadar fikrini değiştirebilirsin. Sonrasında arkadaşların, kupaların ve oynama sürelerinle birlikte tamamen gider.", + "button": "Hesabı sil" + }, + "confirm": { + "title": "Hesabın silinsin mi?", + "description": "Hesap 30 gün içinde silinir. Her Launcher ve tarayıcıdan şimdi çıkış yapılır." + } + }, + "compare": { + "title": "Kupaları karşılaştır", + "description": "Kupaların, {handle} ile yan yana.", + "descriptionShared": "Sen ve {handle}, ikinizin de sahip olduğu bir oyunda.", + "signInDescription": "Kupalarının {handle} ile nasıl kıyaslandığını gör.", + "signIn": "Kupaları karşılaştırmak için giriş yap", + "hidden": "{handle} kullanıcısının kupaları karşılaştırmaya uygun değil.", + "loading": "Yükleniyor", + "loadingComparison": "Karşılaştırma yükleniyor", + "empty": "Sen ve {handle} için henüz ortak bir oyun yok. {handle} oynadığı bir oyunu oynarsan burada görünür.", + "game": "Oyun", + "gameNamed": "Oyun {title}", + "verdictNone": "İkinizin de bu oyunda kazandığı bir kupa yok.", + "verdictLevel": "Bu oyunda puanlarınız eşit.", + "verdictAhead": "Bu oyunda {count, number} puan öndesin.", + "verdictBehind": "Bu oyunda {handle}, {count, number} puan önde.", + "caption": "{titleId} oyunundaki kupalar: sen ve {handle}", + "stat": "Kategori", + "you": "Sen", + "ahead": "(önde)", + "rowTrophies": "Kupalar", + "rowPoints": "Bu oyunda puan", + "rowLevel": "Genel seviye", + "grade": { + "platinum": "Platin", + "gold": "Altın", + "silver": "Gümüş", + "bronze": "Bronz" + }, + "both": "İkinizin de kazandığı", + "onlyYou": "Sadece sen", + "onlyThem": "Sadece {handle}" + }, + "publicProfile": { + "home": "AnyPlay, ana sayfa", + "yourAccount": "Hesabın", + "signIn": "Giriş yap", + "loading": "Yükleniyor", + "missingTitle": "{handle} adında bir oyuncu yok", + "missingBody": "Kullanıcı adının yazımını kontrol et ya da oyuncudan tekrar göndermesini iste.", + "goHome": "AnyPlay’e git", + "player": "Oyuncu", + "trophies": "Kupalar", + "trophiesEarned": "{count, number} kazanıldı.", + "noTrophies": "{handle} henüz kupa kazanmadı.", + "favourites": "Favori oyunlar", + "favouritesNote": "Başlık kimliğiyle gösterilir.", + "game": "Oyun", + "connections": "Bağlantılar", + "playtime": "Oynanan oyunlar", + "playtimeNote": "{count, number} oyun, toplam {total}.", + "nothing": "{handle} hakkında gösterilecek başka bir şey yok.", + "openProfile": "Profili aç", + "openProfileOn": "{brand} üzerinde (yeni sekmede açılır)", + "signInToAdd": "{handle} kullanıcısını eklemek için giriş yap", + "editProfile": "Profilini düzenle", + "blocked": "Engellendi", + "friends": "Arkadaşlar", + "requestSent": "İstek gönderildi", + "accepting": "Kabul ediliyor…", + "accept": "İsteği kabul et", + "sending": "Gönderiliyor…", + "addFriend": "Arkadaş ekle", + "addInAnyPlay": "AnyPlay’de arkadaş ekle", + "minutes": "{n} dk", + "hours": "{n} sa" + }, + "friends": { + "title": "Arkadaşlar", + "lead": "Birlikte oynadığın kişileri ekle, isteklere yanıt ver ve kimin dışarıda kalacağına karar ver.", + "add": { + "title": "Arkadaş ekle", + "description": "İstek göndermek için bir oyuncunun adını gir.", + "handle": "Ad", + "hint": "Oyuncunun adı, @ ile ya da @ olmadan.", + "send": "İstek gönder", + "sending": "Gönderiliyor…", + "enterHandle": "Eklemek istediğin oyuncunun adını gir.", + "nowFriends": "Sen ve @{handle} artık arkadaşsınız.", + "sent": "İstek @{handle} kişisine gönderildi. Kabul ettiğinde arkadaş olursunuz." + }, + "requests": { + "title": "İstekler", + "description": "Arkadaşın olmak isteyen oyuncular ve gönderdiğin istekler.", + "empty": "Açık istek yok. Biri seni eklediğinde istek burada görünür.", + "received": "Gelen", + "sent": "Giden", + "receivedAgo": "Alındı: {when}", + "sentAgo": "Gönderildi: {when}", + "decline": "Reddet", + "accept": "Kabul et", + "withdraw": "Geri çek" + }, + "list": { + "title": "Arkadaşların", + "description": "Kimlerin çevrimiçi olduğunu ve ne oynadıklarını gör.", + "empty": "Henüz arkadaşın yok. Yukarıdan birini adıyla ekle; kabul ettiğinde burada görünür.", + "online": "Çevrimiçi", + "offline": "Çevrimdışı", + "playing": "{title} oynuyor", + "block": "Engelle", + "remove": "Çıkar" + }, + "blocked": { + "title": "Engellenenler", + "description": "Engellenen oyuncular arkadaşlarından ve isteklerinden çıkarılır.", + "empty": "Kimseyi engellemedin. Gerektiğinde arkadaş listenden bir oyuncuyu engelleyebilirsin.", + "unblock": "Engeli kaldır" + }, + "confirm": { + "blockTitle": "@{handle} engellensin mi?", + "removeTitle": "@{handle} arkadaşlıktan çıkarılsın mı?", + "blockText": "Artık arkadaş olmazsınız ve açık istekler silinir. Engeli istediğin zaman kaldırabilirsin.", + "removeText": "Birbirinizin çevrimiçi olup olmadığını görmezsiniz. Sonra yeni istek gönderebilirsin.", + "blockLabel": "Oyuncuyu engelle", + "removeLabel": "Arkadaşı çıkar" + } + }, + "trophies": { + "title": "Kupalar", + "lead": "Oyunlarında kazandıkların. AnyPlay oyun ve derece başına sayıları tutar, kupa adlarını ya da görsellerini asla tutmaz.", + "totals": { + "title": "Toplam", + "description": "{games} oyunda toplam {total} kupa kazanıldı.", + "empty": "Henüz kupa yok. AnyPlay üzerinden bir oyun oyna, kazandıkların burada sayılır." + }, + "byGame": { + "title": "Oyuna göre", + "description": "Oyunlar başlık kimliğine göre, en yenisi başta listelenir.", + "detail": "{earned} kupa · son kazanılan: {when}" + }, + "grades": { + "platinum": "Platin", + "gold": "Altın", + "silver": "Gümüş", + "bronze": "Bronz" + } + }, + "notifications": { + "title": "Bildirimler", + "lead": "Bir arkadaşın seni arkadaş olarak eklemek istediğinde, seni bir partiye davet ettiğinde ya da sana yazdığında bu cihazda bildirim al.", + "device": { + "title": "Bu cihaz", + "description": "Her tarayıcı ya da telefon ayrı ayarlanır. Bildirim almak istediğin yerde bunu aç.", + "notEnabled": "Bu bulut bildirim göndermiyor.", + "checking": "Bu cihaz kontrol ediliyor…", + "unsupported": "Bu tarayıcı bildirim alamıyor. Güncel bir Chrome, Edge, Firefox ya da Safari kullan.", + "needsInstall": "iPhone ve iPad’de bildirimler için AnyPlay’in Ana Ekran’da olması gerekir. Paylaş’a dokun, Ana Ekrana Ekle’yi seç, sonra AnyPlay’i oradan aç.", + "blocked": "Bu site için bildirimler engellenmiş. Tarayıcının site ayarlarından izin ver, sonra geri dön.", + "switch": "Bu cihaza bildirim gönder", + "others": "{count, plural, one {Başka bir cihaz da bildirim alıyor. Bildirimleri o cihazda kapat.} other {Başka # cihaz da bildirim alıyor. Bildirimleri o cihazlarda kapat.}}" + }, + "kinds": { + "title": "Hangi bildirimler gelsin", + "description": "Yalnızca bu cihaz için.", + "friend_request": { + "label": "Arkadaşlık istekleri", + "detail": "Biri seninle arkadaş olmak istiyor." + }, + "party_invite": { + "label": "Parti davetleri", + "detail": "Bir arkadaşın seni bir partiye davet ediyor." + }, + "message": { + "label": "Mesajlar", + "detail": "Bir arkadaşın sana yazıyor. Mesajın kendisi bildirimde görünmez." + } + }, + "errors": { + "timeout": "Tarayıcının bildirim hizmeti yanıt vermedi. Bağlantını kontrol edip tekrar dene.", + "noKey": "Bu bulutta bildirim göndermek için anahtar yok.", + "lostSubscription": "Bu tarayıcı aboneliğini kaybetti. Bildirimleri kapatıp yeniden aç.", + "notAllowed": "Bu tarayıcı bildirimlere izin vermedi. Gizli pencerede çalışmazlar; değilse site ayarlarına bak." + } + } +} diff --git a/apps/web/messages/tr/auth.json b/apps/web/messages/tr/auth.json index 0967ef4..bdcb551 100644 --- a/apps/web/messages/tr/auth.json +++ b/apps/web/messages/tr/auth.json @@ -1 +1,143 @@ -{} +{ + "meta": { + "signIn": { + "title": "Giriş yap", + "description": "AnyPlay’e geçiş anahtarıyla ya da e-postayla gelen kodla giriş yap." + }, + "create": { + "title": "Hesabını oluştur", + "description": "AnyPlay hesabını oluştur: bir e-posta adresi ve bir ad yeterli." + }, + "link": { + "title": "Hesabını aç", + "description": "AnyPlay hesabını başlatıcıdan bu tarayıcıda aç." + }, + "join": { + "title": "Davet", + "description": "AnyPlay’de birlikte oynamak için bir davet." + } + }, + "shell": { + "home": "AnyPlay, ana sayfa", + "back": "Başa dön", + "promise": "Oyunlarını birlikte oyna.", + "toastOnline": "mira çevrimiçi", + "toastParty": "kit partine katıldı", + "toastTrophy": "Kupa kazanıldı · Into the Ember", + "help": "Yardım mı lazım?", + "helpLink": "Discord’da sor", + "legalNav": "Yasal", + "legalNotice": "Yasal bildirim", + "privacyNotice": "Gizlilik bildirimi", + "termsOfUse": "Kullanım koşulları" + }, + "step": { + "loading": "Yükleniyor", + "unreachable": "AnyPlay’e ulaşılamıyor", + "tryAgain": "Tekrar dene", + "newTab": "(yeni sekmede açılır)", + "backToStart": "Başa dön", + "signIn": "Giriş yap", + "createAccount": "Hesap oluştur", + "getApp": "AnyPlay’i indir", + "continue": "Devam" + }, + "fields": { + "code": "Kod" + }, + "flow": { + "authorize": "{device} cihazındaki AnyPlay bu girişi bekliyor." + }, + "email": { + "create": { + "title": "Hesabını oluştur", + "lead": "E-posta adresinle başla. Onaylamak için bir kod gönderiyoruz." + }, + "signIn": { + "title": "Giriş yap", + "lead": "Geçiş anahtarını kullan ya da e-postayla bir kod al." + }, + "passkey": "Geçiş anahtarıyla giriş yap", + "or": "ya da", + "label": "E-posta adresi", + "placeholder": "name@example.com", + "invalid": "E-posta adresini yaz, örneğin name@example.com.", + "sending": "Gönderiliyor…", + "send": "Kodu gönder", + "haveAccount": "Zaten hesabın var mı? Giriş yap", + "newHere": "AnyPlay’de yeni misin? Hesap oluştur" + }, + "code": { + "title": "E-postana bak", + "lead": "{email} adresine 6 haneli bir kod gönderdik. 10 dakika geçerli.", + "incomplete": "E-postadaki 6 haneyi gir.", + "checking": "Denetleniyor…", + "resent": "Yeni bir kod yolda.", + "noMail": "E-posta gelmedi mi?", + "resendIn": "Yeni kodu {seconds} sn sonra gönder", + "resend": "Yeni kod gönder" + }, + "details": { + "title": "Bir ad seç", + "lead": "{email} adresin onaylandı. Diğer oyuncular adını görür.", + "handle": "Ad", + "handleHint": "3 ile 16 arasında harf, rakam, tire veya alt çizgi kullan. Bir harfle başla.", + "ageHint": "Yani 16 yaşında ya da daha büyüksün.", + "age": "Koşulları tek başıma kabul edecek yaştayım.", + "terms": "Koşulları (sürüm {version}) kabul ediyorum ve gizlilik bildirimini okudum.", + "creating": "Oluşturuluyor…", + "submit": "Hesap oluştur" + }, + "terms": { + "title": "Koşulları gözden geçir", + "lead": "Koşullar değişti. Koşulları oku ve devam etmek için {version} sürümünü kabul et.", + "accept": "Koşulları kabul ediyorum (sürüm {version}).", + "required": "Devam etmek için koşulları kabul et.", + "submit": "Kabul et ve devam et" + }, + "banned": { + "title": "Bu hesap engellendi", + "leadUntil": "Engel {date} tarihinde bitiyor.", + "leadForever": "Engel kendiliğinden bitmez.", + "rule": "Kural", + "reason": "Sebep", + "appeal": "Açıklamayı oku ve itiraz et" + }, + "handoff": { + "title": "Giriş yapıldı", + "lead": "Seni AnyPlay’e geri götürüyoruz. Bir şey olmazsa düğmeyi kullan.", + "open": "AnyPlay’i aç", + "note": "AnyPlay seni giriş yapmış olarak gösterdiğinde bu sekmeyi kapatabilirsin." + }, + "done": { + "title": "Giriş yaptın", + "inviteLead": "Seni davete geri götürüyoruz.", + "welcome": "Hoş geldin, {handle}. Arkadaşların, partilerin ve kupaların başlatıcıda seni bekliyor.", + "account": "Hesabına git" + }, + "join": { + "expiredTitle": "Bu davetin süresi doldu", + "expiredLead": "Bağlantının süresi dolmuş ya da oyuncu oynamayı bırakmış olabilir. Yenisini iste.", + "title": "{handle} seni davet etti", + "leadSignedIn": "Daveti görmek için PC’nde AnyPlay’i aç. Katılıp katılmayacağına orada sen karar verirsin.", + "leadSignedOut": "Önce giriş yap ya da hesap oluştur. Daveti açmak için buraya geri dönersin.", + "party": "Parti", + "game": "Oyun", + "partyName": "Bir AnyPlay partisi", + "gameFallback": "Bir oyun", + "joined": "Katılanlar", + "players": "{members} / {max, plural, other {# oyuncu}}", + "open": "AnyPlay’de aç", + "noApp": "Bu PC’de henüz AnyPlay yok mu? Önce indir, sonra bu sayfayı yeniden aç." + }, + "link": { + "expiredTitle": "Bu bağlantının süresi doldu", + "expiredLead": "Hesap sayfanı AnyPlay’den yeniden aç.", + "close": "Bu sekmeyi kapatabilirsin.", + "title": "Hesabını aç", + "lead": "PC’ndeki AnyPlay, {handle} hesabının bu tarayıcıda açılmasını istedi.", + "open": "Hesabı aç", + "opening": "Açılıyor…", + "notYou": "Bunu sen istemedin mi? Sekmeyi kapat." + } +} diff --git a/apps/web/src/app/(account)/account/connections/page.tsx b/apps/web/src/app/(account)/account/connections/page.tsx index 00f5ab2..95a6417 100644 --- a/apps/web/src/app/(account)/account/connections/page.tsx +++ b/apps/web/src/app/(account)/account/connections/page.tsx @@ -1,9 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { Suspense } from "react"; import { ConnectionsPage } from "@/components/account/pages"; -export const metadata: Metadata = { title: "Connections" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("account.shell"); + return { title: t("nav_connections") }; +} export default function Page() { return ( diff --git a/apps/web/src/app/(account)/account/data/page.tsx b/apps/web/src/app/(account)/account/data/page.tsx index 0cbbc38..20b6347 100644 --- a/apps/web/src/app/(account)/account/data/page.tsx +++ b/apps/web/src/app/(account)/account/data/page.tsx @@ -1,8 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { DataPage } from "@/components/account/pages"; -export const metadata: Metadata = { title: "Your data" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("account.shell"); + return { title: t("nav_data") }; +} export default function Page() { return ; diff --git a/apps/web/src/app/(account)/account/friends/page.tsx b/apps/web/src/app/(account)/account/friends/page.tsx index a9ace41..5138986 100644 --- a/apps/web/src/app/(account)/account/friends/page.tsx +++ b/apps/web/src/app/(account)/account/friends/page.tsx @@ -1,8 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { FriendsPage } from "@/components/account/social"; -export const metadata: Metadata = { title: "Friends" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("account.shell"); + return { title: t("nav_friends") }; +} export default function Page() { return ; diff --git a/apps/web/src/app/(account)/account/layout.tsx b/apps/web/src/app/(account)/account/layout.tsx index c2cb2e3..329a0fb 100644 --- a/apps/web/src/app/(account)/account/layout.tsx +++ b/apps/web/src/app/(account)/account/layout.tsx @@ -1,16 +1,27 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Metadata } from "next"; +import { NextIntlClientProvider } from "next-intl"; +import { getMessages, getTranslations } from "next-intl/server"; import { connection } from "next/server"; import type { ReactNode } from "react"; import { AccountShell } from "@/components/account/shell"; -export const metadata: Metadata = { - title: { default: "Your account", template: "%s · Your account · AnyPlay" }, - robots: { index: false }, -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("account.meta"); + return { + title: { default: t("title"), template: t("template") }, + robots: { index: false }, + }; +} export default async function AccountLayout({ children }: { children: ReactNode }) { // Per request, so Next's inline scripts carry the nonce of the script policy (src/proxy.ts). await connection(); - return {children}; + // The account pages add the auth words too: a sensitive change asks for a code or a passkey. + const { common, account, auth } = (await getMessages()) as Record>; + return ( + + {children} + + ); } diff --git a/apps/web/src/app/(account)/account/notifications/page.tsx b/apps/web/src/app/(account)/account/notifications/page.tsx index f8a9a01..cd1d399 100644 --- a/apps/web/src/app/(account)/account/notifications/page.tsx +++ b/apps/web/src/app/(account)/account/notifications/page.tsx @@ -1,8 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { NotificationsPage } from "@/components/account/notifications"; -export const metadata: Metadata = { title: "Notifications" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("account.shell"); + return { title: t("nav_notifications") }; +} export default function Page() { return ; diff --git a/apps/web/src/app/(account)/account/page.tsx b/apps/web/src/app/(account)/account/page.tsx index 87b23f5..7508016 100644 --- a/apps/web/src/app/(account)/account/page.tsx +++ b/apps/web/src/app/(account)/account/page.tsx @@ -1,8 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { OverviewPage } from "@/components/account/pages"; -export const metadata: Metadata = { title: "Overview" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("account.shell"); + return { title: t("nav_overview") }; +} export default function Page() { return ; diff --git a/apps/web/src/app/(account)/account/privacy/page.tsx b/apps/web/src/app/(account)/account/privacy/page.tsx index dfee064..5b715f5 100644 --- a/apps/web/src/app/(account)/account/privacy/page.tsx +++ b/apps/web/src/app/(account)/account/privacy/page.tsx @@ -1,8 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { PrivacyPage } from "@/components/account/pages"; -export const metadata: Metadata = { title: "Privacy" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("account.shell"); + return { title: t("nav_privacy") }; +} export default function Page() { return ; diff --git a/apps/web/src/app/(account)/account/profile/page.tsx b/apps/web/src/app/(account)/account/profile/page.tsx index 6cbee78..be3c1e2 100644 --- a/apps/web/src/app/(account)/account/profile/page.tsx +++ b/apps/web/src/app/(account)/account/profile/page.tsx @@ -1,8 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { ProfilePage } from "@/components/account/pages"; -export const metadata: Metadata = { title: "Profile" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("account.shell"); + return { title: t("nav_profile") }; +} export default function Page() { return ; diff --git a/apps/web/src/app/(account)/account/security/page.tsx b/apps/web/src/app/(account)/account/security/page.tsx index 775352b..3ac4aa8 100644 --- a/apps/web/src/app/(account)/account/security/page.tsx +++ b/apps/web/src/app/(account)/account/security/page.tsx @@ -1,8 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { SecurityPage } from "@/components/account/pages"; -export const metadata: Metadata = { title: "Security" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("account.shell"); + return { title: t("nav_security") }; +} export default function Page() { return ; diff --git a/apps/web/src/app/(account)/account/trophies/page.tsx b/apps/web/src/app/(account)/account/trophies/page.tsx index 11c5196..df50e6c 100644 --- a/apps/web/src/app/(account)/account/trophies/page.tsx +++ b/apps/web/src/app/(account)/account/trophies/page.tsx @@ -1,8 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { TrophiesPage } from "@/components/account/social"; -export const metadata: Metadata = { title: "Trophies" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("account.shell"); + return { title: t("nav_trophies") }; +} export default function Page() { return ; diff --git a/apps/web/src/app/(auth)/create/page.tsx b/apps/web/src/app/(auth)/create/page.tsx index d60038d..43fb314 100644 --- a/apps/web/src/app/(auth)/create/page.tsx +++ b/apps/web/src/app/(auth)/create/page.tsx @@ -1,15 +1,19 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { connection } from "next/server"; import { AuthFlow } from "@/components/auth/auth-flow"; import { AuthShell } from "@/components/auth/auth-shell"; import { safeNext } from "@/lib/join"; -export const metadata: Metadata = { - title: "Create your account", - description: "Create your AnyPlay account: an e-mail address and a handle.", - robots: { index: false }, -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("auth.meta.create"); + return { + title: t("title"), + description: t("description"), + robots: { index: false }, + }; +} export default async function Page({ searchParams }: { searchParams: Promise<{ next?: string | string[] }> }) { // Per request, so Next's inline scripts carry the nonce of the script policy (src/proxy.ts). diff --git a/apps/web/src/app/(auth)/join/[code]/page.tsx b/apps/web/src/app/(auth)/join/[code]/page.tsx index b0bfd61..698eebd 100644 --- a/apps/web/src/app/(auth)/join/[code]/page.tsx +++ b/apps/web/src/app/(auth)/join/[code]/page.tsx @@ -1,17 +1,21 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { connection } from "next/server"; import { AuthShell } from "@/components/auth/auth-shell"; import { JoinFlow } from "@/components/auth/join-flow"; // The address holds a join code. It stays out of search results, and out of the Referer of // everything this page links to or loads. -export const metadata: Metadata = { - title: "Invitation", - description: "An invitation to play together on AnyPlay.", - robots: { index: false, follow: false }, - referrer: "no-referrer", -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("auth.meta.join"); + return { + title: t("title"), + description: t("description"), + robots: { index: false, follow: false }, + referrer: "no-referrer", + }; +} export default async function Page({ params }: { params: Promise<{ code: string }> }) { // Per request, so Next's inline scripts carry the nonce of the script policy (src/proxy.ts). diff --git a/apps/web/src/app/(auth)/link/page.tsx b/apps/web/src/app/(auth)/link/page.tsx index fb37341..5bd8ada 100644 --- a/apps/web/src/app/(auth)/link/page.tsx +++ b/apps/web/src/app/(auth)/link/page.tsx @@ -1,14 +1,18 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { connection } from "next/server"; import { AuthShell } from "@/components/auth/auth-shell"; import { LinkFlow } from "@/components/auth/link-flow"; -export const metadata: Metadata = { - title: "Open your account", - description: "Open your AnyPlay account in this browser, from the launcher.", - robots: { index: false }, -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("auth.meta.link"); + return { + title: t("title"), + description: t("description"), + robots: { index: false }, + }; +} export default async function Page({ searchParams }: { searchParams: Promise<{ c?: string | string[] }> }) { // Per request, so Next's inline scripts carry the nonce of the script policy (src/proxy.ts). diff --git a/apps/web/src/app/(auth)/sign-in/page.tsx b/apps/web/src/app/(auth)/sign-in/page.tsx index c3ad816..0597832 100644 --- a/apps/web/src/app/(auth)/sign-in/page.tsx +++ b/apps/web/src/app/(auth)/sign-in/page.tsx @@ -1,15 +1,19 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { connection } from "next/server"; import { AuthFlow } from "@/components/auth/auth-flow"; import { AuthShell } from "@/components/auth/auth-shell"; import { safeNext } from "@/lib/join"; -export const metadata: Metadata = { - title: "Sign in", - description: "Sign in to AnyPlay with a passkey or a code by e-mail.", - robots: { index: false }, -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("auth.meta.signIn"); + return { + title: t("title"), + description: t("description"), + robots: { index: false }, + }; +} export default async function Page({ searchParams }: { searchParams: Promise<{ next?: string | string[] }> }) { // Per request, so Next's inline scripts carry the nonce of the script policy (src/proxy.ts). diff --git a/apps/web/src/app/u/layout.tsx b/apps/web/src/app/u/layout.tsx new file mode 100644 index 0000000..82e0e56 --- /dev/null +++ b/apps/web/src/app/u/layout.tsx @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { NextIntlClientProvider } from "next-intl"; +import { getMessages } from "next-intl/server"; +import type { ReactNode } from "react"; + +/** Public profiles: the shared words and the account words, in the visitor's language. */ +export default async function ProfileLayout({ children }: { children: ReactNode }) { + const { common, account } = (await getMessages()) as Record>; + return {children}; +} diff --git a/apps/web/src/components/account/compare.tsx b/apps/web/src/components/account/compare.tsx index 23c8b5d..7e90d62 100644 --- a/apps/web/src/components/account/compare.tsx +++ b/apps/web/src/components/account/compare.tsx @@ -1,12 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-only "use client"; -import { type CompareSide, type PublicUser, type TrophyCompare, describeError, isApiError } from "@anyplay/api-client"; +import { type CompareSide, type PublicUser, type TrophyCompare, isApiError } from "@anyplay/api-client"; +import { useFormatter, useTranslations } from "next-intl"; import Link from "next/link"; import { type ReactNode, useEffect, useId, useState } from "react"; import { Failed } from "./pages"; import { GRADES } from "./social"; import { Card, Empty, Notice, smallButton } from "./ui"; +import { useDescribeError } from "@/i18n/errors"; import { loadApi } from "@/lib/api"; /** @@ -16,8 +18,6 @@ import { loadApi } from "@/lib/api"; * would be a column of digits that says nothing without the names. */ -const num = (n: number) => n.toLocaleString("en"); - type Shared = | { status: "loading" } | { status: "ready"; titles: string[] } @@ -30,10 +30,10 @@ type Result = | { status: "hidden" } | { status: "failed"; message: string }; -const HIDDEN = (handle: string) => `${handle}’s trophies aren’t available to compare.`; - /** The compare card of a profile; the page shows it only when the player's trophies are visible. */ export function CompareCard({ user }: { user: PublicUser }) { + const t = useTranslations("account.compare"); + const describeError = useDescribeError(); const { handle } = user.account; const [shared, setShared] = useState({ status: "loading" }); const [attempt, setAttempt] = useState(0); @@ -61,14 +61,14 @@ export function CompareCard({ user }: { user: PublicUser }) { return () => { alive = false; }; - }, [handle, attempt]); + }, [handle, attempt, describeError]); - const description = shared.status === "ready" && shared.titles.length > 0 ? `You and ${handle}, in a game you both have.` : `Your trophies next to ${handle}’s.`; + const description = shared.status === "ready" && shared.titles.length > 0 ? t("descriptionShared", { handle }) : t("description", { handle }); let body: ReactNode; if (shared.status === "loading") { body = (
- Loading + {t("loading")}
@@ -84,16 +84,16 @@ export function CompareCard({ user }: { user: PublicUser }) { /> ); } else if (shared.status === "hidden") { - body = {HIDDEN(handle)}; + body = {t("hidden", { handle })}; } else if (shared.titles.length === 0 || !title) { - body = You and {handle} have no game in common yet. Play a game {handle} has played and it shows up here.; + body = {t("empty", { handle })}; } else { body = (
{shared.titles.length > 1 ? (