From 2a598d48e18ea594a74c0ab5d3cc1148e4275dc9 Mon Sep 17 00:00:00 2001 From: asdofen Date: Sun, 16 Nov 2025 14:32:25 +0300 Subject: [PATCH 01/31] feat: Add i18n framework support --- .../(site)/components/ServerStatus.tsx | 15 +- app/(website)/(site)/page.tsx | 51 +++--- app/layout.tsx | 13 +- components/Providers.tsx | 9 +- i18n/request.ts | 10 ++ messages/en.json | 65 ++++++++ next.config.mjs | 5 +- package-lock.json | 154 +++++++++++++++++- package.json | 1 + 9 files changed, 282 insertions(+), 41 deletions(-) create mode 100644 i18n/request.ts create mode 100644 messages/en.json diff --git a/app/(website)/(site)/components/ServerStatus.tsx b/app/(website)/(site)/components/ServerStatus.tsx index 2998f10..88be019 100644 --- a/app/(website)/(site)/components/ServerStatus.tsx +++ b/app/(website)/(site)/components/ServerStatus.tsx @@ -1,6 +1,7 @@ import PrettyCounter from "@/components/General/PrettyCounter"; import { Skeleton } from "@/components/ui/skeleton"; import { Activity, Users, AlertTriangle, Trophy, Wifi } from "lucide-react"; +import { useTranslations } from "next-intl"; interface Props { type: @@ -15,23 +16,23 @@ interface Props { const statuses = { total_users: { - name: "Total Users", + nameKey: "total_users", icon: , }, users_online: { - name: "Users Online", + nameKey: "users_online", icon: , }, users_restricted: { - name: "Users Restricted", + nameKey: "users_restricted", icon: , }, total_scores: { - name: "Total Scores", + nameKey: "total_scores", icon: , }, server_status: { - name: "Server Status", + nameKey: "server_status", icon: , }, }; @@ -39,6 +40,8 @@ const statuses = { export default function ServerStatus({ type, data, children }: Props) { const isDataNumber = !isNaN(Number(data)); + const t = useTranslations("statuses"); + return (
- {statuses[type].name} + {t(statuses[type].nameKey)}
diff --git a/app/(website)/(site)/page.tsx b/app/(website)/(site)/page.tsx index cee79fe..9133434 100644 --- a/app/(website)/(site)/page.tsx +++ b/app/(website)/(site)/page.tsx @@ -18,20 +18,20 @@ import { useServerStatus } from "@/lib/hooks/api/useServerStatus"; import { BookOpenCheck, DoorOpen, Download } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; -import { useState } from "react"; +import { useState, useTransition } from "react"; import { twMerge } from "tailwind-merge"; +import { useTranslations, NextIntlClientProvider } from "next-intl"; const cards = [ { - title: "Truly Free Features", - description: - "Enjoy features like osu!direct and username changes without any paywalls — completely free for all players!", + titleKey: "cards.free_features.title", + descriptionKey: "cards.free_features.description", imageUrl: "/images/frontpage/freefeatures.png", }, + { - title: "Custom PP Calculations", - description: - "We use the latest performance point (PP) system for vanilla scores while applying a custom, well-balanced formula for Relax and Autopilot modes.", + titleKey: "cards.pp_system.title", + descriptionKey: "cards.pp_system.description", imageUrl: "/images/frontpage/ppsystem.png", }, // TODO: Soon™... @@ -42,27 +42,23 @@ const cards = [ // imageUrl: "/images/not-found.jpg", // }, { - title: "Earn Custom Medals", - description: - "Earn unique, server-exclusive medals as you accomplish various milestones and achievements.", + titleKey: "cards.medals.title", + descriptionKey: "cards.medals.description", imageUrl: "/images/frontpage/medals.png", }, { - title: "Frequent Updates", - description: - "We’re always improving! Expect regular updates, new features, and ongoing performance optimizations.", + titleKey: "cards.updates.title", + descriptionKey: "cards.updates.description", imageUrl: "/images/frontpage/updates.png", }, { - title: "Built-in PP Calculator", - description: - "Our website offers a built-in PP calculator for quick and easy performance point estimates.", + titleKey: "cards.pp_calc.title", + descriptionKey: "cards.pp_calc.description", imageUrl: "/images/frontpage/ppcalc.png", }, { - title: "Custom-Built Bancho Core", - description: - "Unlike most private osu! servers, we’ve developed our own custom bancho core for better stability and unique feature support.", + titleKey: "cards.sunrise_core.title", + descriptionKey: "cards.sunrise_core.description", imageUrl: "/images/frontpage/sunrisecore.png", }, ]; @@ -73,6 +69,7 @@ export default function Home() { boolean | null >(null); + const t = useTranslations("main_page"); const serverStatusQuery = useServerStatus(); const serverStatus = serverStatusQuery.data; @@ -97,14 +94,10 @@ export default function Home() { rise

- - yet another osu! server + {t("features.motto")}

-

- Features rich osu! server with support for Relax, Autopilot and - ScoreV2 gameplay, with a custom art‑state PP calculation system - tailored for Relax and Autopilot. -

+

{t("features.description")}

-

{card.title}

+

+ {t(card.titleKey)} +

- {card.description} + {t(card.descriptionKey)}

diff --git a/app/layout.tsx b/app/layout.tsx index ff0f804..4d36ea5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import ScrollUpButton from "@/components/ScrollUpButton"; import Providers from "@/components/Providers"; import ScrollUp from "@/components/ScrollUp"; +import { getLocale, getMessages } from 'next-intl/server'; const font = Poppins({ weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], @@ -32,15 +33,23 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ + +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + + // Parallel fetch i18n data and global config to reduce waiting time + const [locale, messages] = await Promise.all([ + getLocale(), + getMessages(), + ]); + return ( - + {children} diff --git a/components/Providers.tsx b/components/Providers.tsx index 3279094..cc362cf 100644 --- a/components/Providers.tsx +++ b/components/Providers.tsx @@ -7,8 +7,9 @@ import { SelfProvider } from "@/lib/providers/SelfProvider"; import fetcher from "@/lib/services/fetcher"; import { ReactNode } from "react"; import { SWRConfig } from "swr"; +import { NextIntlClientProvider } from 'next-intl'; -export default function Providers({ children }: { children: ReactNode }) { +export default function Providers({ children, locale, messages }: { children: ReactNode, locale: string, messages: Record }) { return ( - {children} - + + {children} + + diff --git a/i18n/request.ts b/i18n/request.ts new file mode 100644 index 0000000..3b614cb --- /dev/null +++ b/i18n/request.ts @@ -0,0 +1,10 @@ +import { getRequestConfig } from "next-intl/server"; + +export default getRequestConfig(async({ locale }) => { + const selectedLocale = locale || "en"; + + return { + locale: selectedLocale, + messages: (await import (`../messages/${selectedLocale}.json`)).default + }; +}); \ No newline at end of file diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 0000000..d06c945 --- /dev/null +++ b/messages/en.json @@ -0,0 +1,65 @@ +{ + "main_page": { + "features": { + "motto": "- yet another osu! server", + "description": "Features rich osu! server with support for Relax, Autopilot and ScoreV2 gameplay, with a custom art‑state PP calculation system tailored for Relax and Autopilot.", + "buttons": { + "register": "Join now", + "wiki": "How to connect" + } + }, + "why_us": "Why us?", + "cards": { + "free_features": { + "title": "Truly Free Features", + "description": "Enjoy features like osu!direct and username changes without any paywalls — completely free for all players!" + }, + "pp_system": { + "title": "Custom PP Calculations", + "description": "We use the latest performance point (PP) system for vanilla scores while applying a custom, well-balanced formula for Relax and Autopilot modes." + }, + "medals": { + "title": "Earn Custom Medals", + "description": "Earn unique, server-exclusive medals as you accomplish various milestones and achievements." + }, + "updates": { + "title": "Frequent Updates", + "description": "We’re always improving! Expect regular updates, new features, and ongoing performance optimizations." + }, + "pp_calc": { + "title": "Built-in PP Calculator", + "description": "Our website offers a built-in PP calculator for quick and easy performance point estimates." + }, + "sunrise_core": { + "title": "Custom-Built Bancho Core", + "description": "Unlike most private osu! servers, we’ve developed our own custom bancho core for better stability and unique feature support." + } + }, + "how_to_start": { + "title": "How do I start playing?", + "description": "Just three simple steps and you're ready to go!", + "download_tile": { + "title": "Download osu! clinet", + "description": "If you do not already have an installed client", + "button": "Download" + }, + "registr_tile": { + "title": "Register osu!sunrise account", + "description": "Account will allow you to join the osu!sunrise community", + "button": "Sign up" + }, + "guide_tuile": { + "title": "Follow the connection guide", + "description": "Which helps you set up your osu! client to connect to osu!sunrise", + "button": "Open guide" + } + } + }, + "statuses": { + "total_users": "Total Users", + "users_online": "Users Online", + "users_restricted": "Users Restricted", + "total_scores": "Total Scores", + "server_status": "Server Status" + } +} \ No newline at end of file diff --git a/next.config.mjs b/next.config.mjs index 831064a..c52ec8f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,3 +1,5 @@ +import createNextIntlPlugin from 'next-intl/plugin'; + /** @type {import('next').NextConfig} */ const domain = process.env.NEXT_PUBLIC_SERVER_DOMAIN || "ppy.sh"; @@ -67,4 +69,5 @@ const nextConfig = { reactStrictMode: false, }; -export default nextConfig; +const withNextIntl = createNextIntlPlugin("./i18n/request.ts"); +export default withNextIntl(nextConfig); diff --git a/package-lock.json b/package-lock.json index 621c8bd..c77da45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "ky": "^1.7.5", "lucide-react": "^0.441.0", "next": "15.3.0", + "next-intl": "^4.4.0", "next-themes": "^0.4.6", "react": "19.1.0", "react-dom": "19.1.0", @@ -121,6 +122,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.8.tgz", "integrity": "sha512-Oixnb+DzmRT30qu9d3tJSQkxuygWm32DFykT4bRoORPa9hZ/L4KhVB/XiRm6KG+roIEM7DBQlmg27kw2HZkdZg==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.25.7", @@ -1926,6 +1928,66 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==" }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", + "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/intl-localematcher": "0.6.2", + "decimal.js": "^10.4.3", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", + "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", + "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", + "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/icu-skeleton-parser": "1.8.16", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", + "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz", + "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==", + "license": "MIT", + "dependencies": { + "tslib": "2" + } + }, "node_modules/@hey-api/client-fetch": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.10.0.tgz", @@ -1958,6 +2020,7 @@ "version": "0.66.7", "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.66.7.tgz", "integrity": "sha512-BQMrbiNTWPHwZQ2wMnFdKXVMR47P77hc8fuUseQmmXsXG3Rvt2qstXtlQPmbEds/RO5iTL+JKqdcbhPSWuu/og==", + "peer": true, "dependencies": { "@hey-api/json-schema-ref-parser": "1.0.5", "c12": "2.0.1", @@ -4909,6 +4972,12 @@ "integrity": "sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==", "dev": true }, + "node_modules/@schummar/icu-type-parser": { + "version": "1.21.5", + "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", + "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -5073,6 +5142,7 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -5309,6 +5379,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz", "integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==", "devOptional": true, + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5318,6 +5389,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz", "integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==", "devOptional": true, + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -5356,6 +5428,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.1.tgz", "integrity": "sha512-zczrHVEqEaTwh12gWBIJWj8nx+ayDcCJs06yoNMY0kwjMWDM6+kppljY+BxWI06d2Ja+h4+WdufDcwMnnMEWmg==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.29.1", "@typescript-eslint/types": "8.29.1", @@ -5540,6 +5613,7 @@ "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5948,6 +6022,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001663", "electron-to-chromium": "^1.5.28", @@ -6629,6 +6704,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -6874,7 +6955,8 @@ "node_modules/embla-carousel": { "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", - "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==" + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -7130,6 +7212,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7283,6 +7366,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -8169,6 +8253,18 @@ "node": ">=12" } }, + "node_modules/intl-messageformat": { + "version": "10.7.18", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", + "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/icu-messageformat-parser": "2.11.4", + "tslib": "^2.8.0" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -8979,6 +9075,15 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -9037,6 +9142,33 @@ } } }, + "node_modules/next-intl": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.4.0.tgz", + "integrity": "sha512-QHqnP9V9Pe7Tn0PdVQ7u1Z8k9yCkW5SJKeRy2g5gxzhSt/C01y3B9qNxuj3Fsmup/yreIHe6osxU6sFa+9WIkQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/intl-localematcher": "^0.5.4", + "negotiator": "^1.0.0", + "use-intl": "^4.4.0" + }, + "peerDependencies": { + "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", + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -9889,6 +10021,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.1", @@ -10078,6 +10211,7 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10086,6 +10220,7 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -10097,6 +10232,7 @@ "version": "7.55.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.55.0.tgz", "integrity": "sha512-XRnjsH3GVMQz1moZTW53MxfoWN7aDpUg/GpVNc4A3eXRVNdGXfbzJ4vM4aLQ8g6XCUh1nIbx70aaNCl7kxnjog==", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -11106,6 +11242,7 @@ "version": "3.4.9", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz", "integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -11375,6 +11512,7 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11523,6 +11661,20 @@ } } }, + "node_modules/use-intl": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.4.0.tgz", + "integrity": "sha512-smFekJWtokDRBLC5/ZumlBREzdXOkw06+56Ifj2uRe9266Mk+yWQm2PcJO+EwlOE5sHIXHixOTzN6V8E0RGUbw==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "^2.2.0", + "@schummar/icu-type-parser": "1.21.5", + "intl-messageformat": "^10.5.14" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + } + }, "node_modules/use-sidecar": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", diff --git a/package.json b/package.json index d245961..3d06bd0 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "ky": "^1.7.5", "lucide-react": "^0.441.0", "next": "15.3.0", + "next-intl": "^4.4.0", "next-themes": "^0.4.6", "react": "19.1.0", "react-dom": "19.1.0", From 111be3f502721f3bf3e65499d267f2137a3b4d85 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Tue, 2 Dec 2025 06:23:45 +0200 Subject: [PATCH 02/31] feat: Add cookies and save state to change locale --- components/Header/Header.tsx | 15 ++++- i18n/request.ts | 16 +++-- messages/en.json | 124 +++++++++++++++++------------------ messages/ru.json | 66 +++++++++++++++++++ 4 files changed, 151 insertions(+), 70 deletions(-) create mode 100644 messages/ru.json diff --git a/components/Header/Header.tsx b/components/Header/Header.tsx index 131065d..0b8129e 100644 --- a/components/Header/Header.tsx +++ b/components/Header/Header.tsx @@ -1,5 +1,5 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import HeaderLink from "@/components/Header/HeaderLink"; import { twMerge } from "tailwind-merge"; @@ -7,6 +7,7 @@ import { ThemeModeToggle } from "@/components/Header/ThemeModeToggle"; import HeaderSearchCommand from "@/components/Header/HeaderSearchCommand"; import HeaderMobileDrawer from "@/components/Header/HeaderMobileDrawer"; import HeaderAvatar from "@/components/Header/HeaderAvatar"; +import Cookies from "js-cookie"; import Link from "next/link"; import { DropdownMenu, @@ -16,6 +17,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Brand } from "@/components/Brand"; +import { Button } from "@/components/ui/button"; export default function Header() { const [scrolled, setScrolled] = useState(false); @@ -35,6 +37,11 @@ export default function Header() { scrolled ? `bg-pos-100 bg-size-200` : `hover:bg-pos-100 hover:bg-size-200` }`; + const changeLanguage = useCallback((locale: string) => { + Cookies.set("locale", locale); + window.location.reload(); + }, []); + return (
+
+ {/* TODO: temp position */} + + +
+
diff --git a/i18n/request.ts b/i18n/request.ts index 3b614cb..57320ff 100644 --- a/i18n/request.ts +++ b/i18n/request.ts @@ -1,10 +1,12 @@ import { getRequestConfig } from "next-intl/server"; +import { cookies } from "next/headers"; -export default getRequestConfig(async({ locale }) => { - const selectedLocale = locale || "en"; +export default getRequestConfig(async () => { + const store = await cookies(); + const selectedLocale = store.get("locale")?.value || "en"; - return { - locale: selectedLocale, - messages: (await import (`../messages/${selectedLocale}.json`)).default - }; -}); \ No newline at end of file + return { + locale: selectedLocale, + messages: (await import(`../messages/${selectedLocale}.json`)).default, + }; +}); diff --git a/messages/en.json b/messages/en.json index d06c945..1b5999f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,65 +1,65 @@ { - "main_page": { - "features": { - "motto": "- yet another osu! server", - "description": "Features rich osu! server with support for Relax, Autopilot and ScoreV2 gameplay, with a custom art‑state PP calculation system tailored for Relax and Autopilot.", - "buttons": { - "register": "Join now", - "wiki": "How to connect" - } - }, - "why_us": "Why us?", - "cards": { - "free_features": { - "title": "Truly Free Features", - "description": "Enjoy features like osu!direct and username changes without any paywalls — completely free for all players!" - }, - "pp_system": { - "title": "Custom PP Calculations", - "description": "We use the latest performance point (PP) system for vanilla scores while applying a custom, well-balanced formula for Relax and Autopilot modes." - }, - "medals": { - "title": "Earn Custom Medals", - "description": "Earn unique, server-exclusive medals as you accomplish various milestones and achievements." - }, - "updates": { - "title": "Frequent Updates", - "description": "We’re always improving! Expect regular updates, new features, and ongoing performance optimizations." - }, - "pp_calc": { - "title": "Built-in PP Calculator", - "description": "Our website offers a built-in PP calculator for quick and easy performance point estimates." - }, - "sunrise_core": { - "title": "Custom-Built Bancho Core", - "description": "Unlike most private osu! servers, we’ve developed our own custom bancho core for better stability and unique feature support." - } - }, - "how_to_start": { - "title": "How do I start playing?", - "description": "Just three simple steps and you're ready to go!", - "download_tile": { - "title": "Download osu! clinet", - "description": "If you do not already have an installed client", - "button": "Download" - }, - "registr_tile": { - "title": "Register osu!sunrise account", - "description": "Account will allow you to join the osu!sunrise community", - "button": "Sign up" - }, - "guide_tuile": { - "title": "Follow the connection guide", - "description": "Which helps you set up your osu! client to connect to osu!sunrise", - "button": "Open guide" - } - } + "main_page": { + "features": { + "motto": "- yet another osu! server", + "description": "Features rich osu! server with support for Relax, Autopilot and ScoreV2 gameplay, with a custom art‑state PP calculation system tailored for Relax and Autopilot.", + "buttons": { + "register": "Join now", + "wiki": "How to connect" + } }, - "statuses": { - "total_users": "Total Users", - "users_online": "Users Online", - "users_restricted": "Users Restricted", - "total_scores": "Total Scores", - "server_status": "Server Status" + "why_us": "Why us?", + "cards": { + "free_features": { + "title": "Truly Free Features", + "description": "Enjoy features like osu!direct and username changes without any paywalls — completely free for all players!" + }, + "pp_system": { + "title": "Custom PP Calculations", + "description": "We use the latest performance point (PP) system for vanilla scores while applying a custom, well-balanced formula for Relax and Autopilot modes." + }, + "medals": { + "title": "Earn Custom Medals", + "description": "Earn unique, server-exclusive medals as you accomplish various milestones and achievements." + }, + "updates": { + "title": "Frequent Updates", + "description": "We’re always improving! Expect regular updates, new features, and ongoing performance optimizations." + }, + "pp_calc": { + "title": "Built-in PP Calculator", + "description": "Our website offers a built-in PP calculator for quick and easy performance point estimates." + }, + "sunrise_core": { + "title": "Custom-Built Bancho Core", + "description": "Unlike most private osu! servers, we’ve developed our own custom bancho core for better stability and unique feature support." + } + }, + "how_to_start": { + "title": "How do I start playing?", + "description": "Just three simple steps and you're ready to go!", + "download_tile": { + "title": "Download osu! clinet", + "description": "If you do not already have an installed client", + "button": "Download" + }, + "registr_tile": { + "title": "Register osu!sunrise account", + "description": "Account will allow you to join the osu!sunrise community", + "button": "Sign up" + }, + "guide_tuile": { + "title": "Follow the connection guide", + "description": "Which helps you set up your osu! client to connect to osu!sunrise", + "button": "Open guide" + } } -} \ No newline at end of file + }, + "statuses": { + "total_users": "Total Users", + "users_online": "Users Online", + "users_restricted": "Users Restricted", + "total_scores": "Total Scores", + "server_status": "Server Status" + } +} diff --git a/messages/ru.json b/messages/ru.json new file mode 100644 index 0000000..33b9b2d --- /dev/null +++ b/messages/ru.json @@ -0,0 +1,66 @@ +{ + "main_page": { + "TODO": "UPDATE TRANSLATION, AI FOR NOW", + "features": { + "motto": "- ещё один osu! сервер", + "description": "Функциональный osu! сервер с поддержкой Relax, Autopilot и ScoreV2, а также собственной системой расчёта PP, адаптированной под Relax и Autopilot.", + "buttons": { + "register": "Присоединиться", + "wiki": "Как подключиться" + } + }, + "why_us": "Почему мы?", + "cards": { + "free_features": { + "title": "Настоящие бесплатные функции", + "description": "Пользуйтесь osu!direct, сменой никнейма и другими функциями без каких-либо платных ограничений — абсолютно бесплатно!" + }, + "pp_system": { + "title": "Уникальная система PP", + "description": "Мы используем современную систему performance point (PP) для обычных результатов и собственную сбалансированную формулу для Relax и Autopilot." + }, + "medals": { + "title": "Получайте уникальные медали", + "description": "Зарабатывайте уникальные, доступные только на нашем сервере медали за достижения и выполнение различных задач." + }, + "updates": { + "title": "Частые обновления", + "description": "Мы постоянно улучшаем сервер! Ожидайте регулярные обновления, новые функции и оптимизацию производительности." + }, + "pp_calc": { + "title": "Встроенный калькулятор PP", + "description": "Наш сайт включает встроенный калькулятор PP для быстрых и удобных расчётов." + }, + "sunrise_core": { + "title": "Собственный Bancho-ядро", + "description": "В отличие от большинства приватных серверов osu!, мы разработали собственное ядро bancho для лучшей стабильности и поддержки уникальных функций." + } + }, + "how_to_start": { + "title": "Как начать играть?", + "description": "Всего три простых шага — и вы готовы!", + "download_tile": { + "title": "Скачать osu! клиент", + "description": "Если у вас ещё не установлен клиент", + "button": "Скачать" + }, + "registr_tile": { + "title": "Создать аккаунт osu!sunrise", + "description": "Аккаунт позволит вам присоединиться к сообществу osu!sunrise", + "button": "Регистрация" + }, + "guide_tuile": { + "title": "Следовать инструкции по подключению", + "description": "Поможет настроить ваш osu! клиент для подключения к osu!sunrise", + "button": "Открыть инструкцию" + } + } + }, + "statuses": { + "total_users": "Всего пользователей", + "users_online": "Пользователи онлайн", + "users_restricted": "Ограниченные пользователи", + "total_scores": "Всего результатов", + "server_status": "Статус сервера" + } +} From b3e872b25b67e200c71aa94487f08230ce0bd82f Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Thu, 4 Dec 2025 08:33:36 +0200 Subject: [PATCH 03/31] feat: Use router to refresh page on translation change and fallback to english if no translation --- app/layout.tsx | 10 +---- components/Header/Header.tsx | 5 ++- lib/i18n/messages/en.json | 81 +++++++++++++++++++++++++++++++++++ lib/i18n/messages/ru.json | 68 +++++++++++++++++++++++++++++ {i18n => lib/i18n}/request.ts | 10 ++++- messages/en.json | 65 ---------------------------- messages/ru.json | 66 ---------------------------- next.config.mjs | 4 +- package-lock.json | 31 ++++++-------- package.json | 2 + 10 files changed, 180 insertions(+), 162 deletions(-) create mode 100644 lib/i18n/messages/en.json create mode 100644 lib/i18n/messages/ru.json rename {i18n => lib/i18n}/request.ts (56%) delete mode 100644 messages/en.json delete mode 100644 messages/ru.json diff --git a/app/layout.tsx b/app/layout.tsx index 4d36ea5..7a6efc9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,7 +4,7 @@ import "./globals.css"; import ScrollUpButton from "@/components/ScrollUpButton"; import Providers from "@/components/Providers"; import ScrollUp from "@/components/ScrollUp"; -import { getLocale, getMessages } from 'next-intl/server'; +import { getLocale, getMessages } from "next-intl/server"; const font = Poppins({ weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], @@ -33,18 +33,12 @@ export const metadata: Metadata = { }, }; - export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - - // Parallel fetch i18n data and global config to reduce waiting time - const [locale, messages] = await Promise.all([ - getLocale(), - getMessages(), - ]); + const [locale, messages] = await Promise.all([getLocale(), getMessages()]); return ( diff --git a/components/Header/Header.tsx b/components/Header/Header.tsx index 0b8129e..ad7d01c 100644 --- a/components/Header/Header.tsx +++ b/components/Header/Header.tsx @@ -18,10 +18,13 @@ import { } from "@/components/ui/dropdown-menu"; import { Brand } from "@/components/Brand"; import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; export default function Header() { const [scrolled, setScrolled] = useState(false); + const router = useRouter(); + useEffect(() => { const handleScroll = () => { setScrolled(window.scrollY > 0); @@ -39,7 +42,7 @@ export default function Header() { const changeLanguage = useCallback((locale: string) => { Cookies.set("locale", locale); - window.location.reload(); + router.refresh(); }, []); return ( diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json new file mode 100644 index 0000000..98808d1 --- /dev/null +++ b/lib/i18n/messages/en.json @@ -0,0 +1,81 @@ +{ + "general": { + "server_title": { + "full": "sunrise", + "split": { + "part_1": "sun", + "part_2": "rise" + } + } + }, + "components": { + "server_maintenance_dialog": { + "message": "The server is currently in maintenance mode, so some features of the website may not function correctly." + } + }, + "pages": { + "main_page": { + "features": { + "motto": "- yet another osu! server", + "description": "Features rich osu! server with support for Relax, Autopilot and ScoreV2 gameplay, with a custom art‑state PP calculation system tailored for Relax and Autopilot.", + "buttons": { + "register": "Join now", + "wiki": "How to connect" + } + }, + "why_us": "Why us?", + "cards": { + "free_features": { + "title": "Truly Free Features", + "description": "Enjoy features like osu!direct and username changes without any paywalls — completely free for all players!" + }, + "pp_system": { + "title": "Custom PP Calculations", + "description": "We use the latest performance point (PP) system for vanilla scores while applying a custom, well-balanced formula for Relax and Autopilot modes." + }, + "medals": { + "title": "Earn Custom Medals", + "description": "Earn unique, server-exclusive medals as you accomplish various milestones and achievements." + }, + "updates": { + "title": "Frequent Updates", + "description": "We’re always improving! Expect regular updates, new features, and ongoing performance optimizations." + }, + "pp_calc": { + "title": "Built-in PP Calculator", + "description": "Our website offers a built-in PP calculator for quick and easy performance point estimates." + }, + "sunrise_core": { + "title": "Custom-Built Bancho Core", + "description": "Unlike most private osu! servers, we’ve developed our own custom bancho core for better stability and unique feature support." + } + }, + "how_to_start": { + "title": "How do I start playing?", + "description": "Just three simple steps and you're ready to go!", + "download_tile": { + "title": "Download osu! clinet", + "description": "If you do not already have an installed client", + "button": "Download" + }, + "register_tile": { + "title": "Register osu!sunrise account", + "description": "Account will allow you to join the osu!sunrise community", + "button": "Sign up" + }, + "guide_tile": { + "title": "Follow the connection guide", + "description": "Which helps you set up your osu! client to connect to osu!sunrise", + "button": "Open guide" + } + } + } + }, + "statuses": { + "total_users": "Total Users", + "users_online": "Users Online", + "users_restricted": "Users Restricted", + "total_scores": "Total Scores", + "server_status": "Server Status" + } +} diff --git a/lib/i18n/messages/ru.json b/lib/i18n/messages/ru.json new file mode 100644 index 0000000..1e60aff --- /dev/null +++ b/lib/i18n/messages/ru.json @@ -0,0 +1,68 @@ +{ + "pages": { + "main_page": { + "TODO": "UPDATE TRANSLATION, AI FOR NOW", + "features": { + "motto": "- ещё один osu! сервер", + "description": "Функциональный osu! сервер с поддержкой Relax, Autopilot и ScoreV2, а также собственной системой расчёта PP, адаптированной под Relax и Autopilot.", + "buttons": { + "register": "Присоединиться", + "wiki": "Как подключиться" + } + }, + "why_us": "Почему мы?", + "cards": { + "free_features": { + "title": "Настоящие бесплатные функции", + "description": "Пользуйтесь osu!direct, сменой никнейма и другими функциями без каких-либо платных ограничений — абсолютно бесплатно!" + }, + "pp_system": { + "title": "Уникальная система PP", + "description": "Мы используем современную систему performance point (PP) для обычных результатов и собственную сбалансированную формулу для Relax и Autopilot." + }, + "medals": { + "title": "Получайте уникальные медали", + "description": "Зарабатывайте уникальные, доступные только на нашем сервере медали за достижения и выполнение различных задач." + }, + "updates": { + "title": "Частые обновления", + "description": "Мы постоянно улучшаем сервер! Ожидайте регулярные обновления, новые функции и оптимизацию производительности." + }, + "pp_calc": { + "title": "Встроенный калькулятор PP", + "description": "Наш сайт включает встроенный калькулятор PP для быстрых и удобных расчётов." + }, + "sunrise_core": { + "title": "Собственный Bancho-ядро", + "description": "В отличие от большинства приватных серверов osu!, мы разработали собственное ядро bancho для лучшей стабильности и поддержки уникальных функций." + } + }, + "how_to_start": { + "title": "Как начать играть?", + "description": "Всего три простых шага — и вы готовы!", + "download_tile": { + "title": "Скачать osu! клиент", + "description": "Если у вас ещё не установлен клиент", + "button": "Скачать" + }, + "register_tile": { + "title": "Создать аккаунт osu!sunrise", + "description": "Аккаунт позволит вам присоединиться к сообществу osu!sunrise", + "button": "Регистрация" + }, + "guide_tile": { + "title": "Следовать инструкции по подключению", + "description": "Поможет настроить ваш osu! клиент для подключения к osu!sunrise", + "button": "Открыть инструкцию" + } + } + } + }, + "statuses": { + "total_users": "Всего пользователей", + "users_online": "Пользователи онлайн", + "users_restricted": "Ограниченные пользователи", + "total_scores": "Всего результатов", + "server_status": "Статус сервера" + } +} diff --git a/i18n/request.ts b/lib/i18n/request.ts similarity index 56% rename from i18n/request.ts rename to lib/i18n/request.ts index 57320ff..be90a00 100644 --- a/i18n/request.ts +++ b/lib/i18n/request.ts @@ -1,12 +1,20 @@ import { getRequestConfig } from "next-intl/server"; import { cookies } from "next/headers"; +import defaultMessages from "./messages/en.json"; +import { merge } from "lodash"; export default getRequestConfig(async () => { const store = await cookies(); const selectedLocale = store.get("locale")?.value || "en"; + const messages = merge( + {}, + defaultMessages, + (await import(`./messages/${selectedLocale}.json`)).default + ); + return { locale: selectedLocale, - messages: (await import(`../messages/${selectedLocale}.json`)).default, + messages, }; }); diff --git a/messages/en.json b/messages/en.json deleted file mode 100644 index 1b5999f..0000000 --- a/messages/en.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "main_page": { - "features": { - "motto": "- yet another osu! server", - "description": "Features rich osu! server with support for Relax, Autopilot and ScoreV2 gameplay, with a custom art‑state PP calculation system tailored for Relax and Autopilot.", - "buttons": { - "register": "Join now", - "wiki": "How to connect" - } - }, - "why_us": "Why us?", - "cards": { - "free_features": { - "title": "Truly Free Features", - "description": "Enjoy features like osu!direct and username changes without any paywalls — completely free for all players!" - }, - "pp_system": { - "title": "Custom PP Calculations", - "description": "We use the latest performance point (PP) system for vanilla scores while applying a custom, well-balanced formula for Relax and Autopilot modes." - }, - "medals": { - "title": "Earn Custom Medals", - "description": "Earn unique, server-exclusive medals as you accomplish various milestones and achievements." - }, - "updates": { - "title": "Frequent Updates", - "description": "We’re always improving! Expect regular updates, new features, and ongoing performance optimizations." - }, - "pp_calc": { - "title": "Built-in PP Calculator", - "description": "Our website offers a built-in PP calculator for quick and easy performance point estimates." - }, - "sunrise_core": { - "title": "Custom-Built Bancho Core", - "description": "Unlike most private osu! servers, we’ve developed our own custom bancho core for better stability and unique feature support." - } - }, - "how_to_start": { - "title": "How do I start playing?", - "description": "Just three simple steps and you're ready to go!", - "download_tile": { - "title": "Download osu! clinet", - "description": "If you do not already have an installed client", - "button": "Download" - }, - "registr_tile": { - "title": "Register osu!sunrise account", - "description": "Account will allow you to join the osu!sunrise community", - "button": "Sign up" - }, - "guide_tuile": { - "title": "Follow the connection guide", - "description": "Which helps you set up your osu! client to connect to osu!sunrise", - "button": "Open guide" - } - } - }, - "statuses": { - "total_users": "Total Users", - "users_online": "Users Online", - "users_restricted": "Users Restricted", - "total_scores": "Total Scores", - "server_status": "Server Status" - } -} diff --git a/messages/ru.json b/messages/ru.json deleted file mode 100644 index 33b9b2d..0000000 --- a/messages/ru.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "main_page": { - "TODO": "UPDATE TRANSLATION, AI FOR NOW", - "features": { - "motto": "- ещё один osu! сервер", - "description": "Функциональный osu! сервер с поддержкой Relax, Autopilot и ScoreV2, а также собственной системой расчёта PP, адаптированной под Relax и Autopilot.", - "buttons": { - "register": "Присоединиться", - "wiki": "Как подключиться" - } - }, - "why_us": "Почему мы?", - "cards": { - "free_features": { - "title": "Настоящие бесплатные функции", - "description": "Пользуйтесь osu!direct, сменой никнейма и другими функциями без каких-либо платных ограничений — абсолютно бесплатно!" - }, - "pp_system": { - "title": "Уникальная система PP", - "description": "Мы используем современную систему performance point (PP) для обычных результатов и собственную сбалансированную формулу для Relax и Autopilot." - }, - "medals": { - "title": "Получайте уникальные медали", - "description": "Зарабатывайте уникальные, доступные только на нашем сервере медали за достижения и выполнение различных задач." - }, - "updates": { - "title": "Частые обновления", - "description": "Мы постоянно улучшаем сервер! Ожидайте регулярные обновления, новые функции и оптимизацию производительности." - }, - "pp_calc": { - "title": "Встроенный калькулятор PP", - "description": "Наш сайт включает встроенный калькулятор PP для быстрых и удобных расчётов." - }, - "sunrise_core": { - "title": "Собственный Bancho-ядро", - "description": "В отличие от большинства приватных серверов osu!, мы разработали собственное ядро bancho для лучшей стабильности и поддержки уникальных функций." - } - }, - "how_to_start": { - "title": "Как начать играть?", - "description": "Всего три простых шага — и вы готовы!", - "download_tile": { - "title": "Скачать osu! клиент", - "description": "Если у вас ещё не установлен клиент", - "button": "Скачать" - }, - "registr_tile": { - "title": "Создать аккаунт osu!sunrise", - "description": "Аккаунт позволит вам присоединиться к сообществу osu!sunrise", - "button": "Регистрация" - }, - "guide_tuile": { - "title": "Следовать инструкции по подключению", - "description": "Поможет настроить ваш osu! клиент для подключения к osu!sunrise", - "button": "Открыть инструкцию" - } - } - }, - "statuses": { - "total_users": "Всего пользователей", - "users_online": "Пользователи онлайн", - "users_restricted": "Ограниченные пользователи", - "total_scores": "Всего результатов", - "server_status": "Статус сервера" - } -} diff --git a/next.config.mjs b/next.config.mjs index c52ec8f..19bc2f4 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,4 @@ -import createNextIntlPlugin from 'next-intl/plugin'; +import createNextIntlPlugin from "next-intl/plugin"; /** @type {import('next').NextConfig} */ const domain = process.env.NEXT_PUBLIC_SERVER_DOMAIN || "ppy.sh"; @@ -69,5 +69,5 @@ const nextConfig = { reactStrictMode: false, }; -const withNextIntl = createNextIntlPlugin("./i18n/request.ts"); +const withNextIntl = createNextIntlPlugin("./lib/i18n/request.ts"); export default withNextIntl(nextConfig); diff --git a/package-lock.json b/package-lock.json index c77da45..3d32798 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "embla-carousel-react": "^8.6.0", "js-cookie": "^3.0.5", "ky": "^1.7.5", + "lodash": "^4.17.21", "lucide-react": "^0.441.0", "next": "15.3.0", "next-intl": "^4.4.0", @@ -59,6 +60,7 @@ "@hey-api/openapi-ts": "^0.66.7", "@svgr/webpack": "^8.1.0", "@types/js-cookie": "^3.0.6", + "@types/lodash": "^4.17.21", "@types/node": "^20", "@types/react": "19.1.1", "@types/react-dom": "19.1.2", @@ -122,7 +124,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.8.tgz", "integrity": "sha512-Oixnb+DzmRT30qu9d3tJSQkxuygWm32DFykT4bRoORPa9hZ/L4KhVB/XiRm6KG+roIEM7DBQlmg27kw2HZkdZg==", "dev": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.25.7", @@ -2020,7 +2021,6 @@ "version": "0.66.7", "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.66.7.tgz", "integrity": "sha512-BQMrbiNTWPHwZQ2wMnFdKXVMR47P77hc8fuUseQmmXsXG3Rvt2qstXtlQPmbEds/RO5iTL+JKqdcbhPSWuu/og==", - "peer": true, "dependencies": { "@hey-api/json-schema-ref-parser": "1.0.5", "c12": "2.0.1", @@ -5142,7 +5142,6 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -5365,6 +5364,13 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.14.14", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", @@ -5379,7 +5385,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz", "integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==", "devOptional": true, - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5389,7 +5394,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz", "integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==", "devOptional": true, - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -5428,7 +5432,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.1.tgz", "integrity": "sha512-zczrHVEqEaTwh12gWBIJWj8nx+ayDcCJs06yoNMY0kwjMWDM6+kppljY+BxWI06d2Ja+h4+WdufDcwMnnMEWmg==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.29.1", "@typescript-eslint/types": "8.29.1", @@ -5613,7 +5616,6 @@ "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6022,7 +6024,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001663", "electron-to-chromium": "^1.5.28", @@ -6955,8 +6956,7 @@ "node_modules/embla-carousel": { "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", - "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "peer": true + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -7212,7 +7212,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7366,7 +7365,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -8870,7 +8868,8 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" }, "node_modules/lodash.debounce": { "version": "4.0.8", @@ -10021,7 +10020,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.1", @@ -10211,7 +10209,6 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10220,7 +10217,6 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -10232,7 +10228,6 @@ "version": "7.55.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.55.0.tgz", "integrity": "sha512-XRnjsH3GVMQz1moZTW53MxfoWN7aDpUg/GpVNc4A3eXRVNdGXfbzJ4vM4aLQ8g6XCUh1nIbx70aaNCl7kxnjog==", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -11242,7 +11237,6 @@ "version": "3.4.9", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz", "integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -11512,7 +11506,6 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 3d06bd0..a4237de 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "embla-carousel-react": "^8.6.0", "js-cookie": "^3.0.5", "ky": "^1.7.5", + "lodash": "^4.17.21", "lucide-react": "^0.441.0", "next": "15.3.0", "next-intl": "^4.4.0", @@ -61,6 +62,7 @@ "@hey-api/openapi-ts": "^0.66.7", "@svgr/webpack": "^8.1.0", "@types/js-cookie": "^3.0.6", + "@types/lodash": "^4.17.21", "@types/node": "^20", "@types/react": "19.1.1", "@types/react-dom": "19.1.2", From 326c1db5bb90e76ec4fbc86d1e587cb2fc665969 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Thu, 4 Dec 2025 08:40:59 +0200 Subject: [PATCH 04/31] feat: Localise main page --- .../(site)/components/ServerStatus.tsx | 6 +- app/(website)/(site)/page.tsx | 63 ++++++++++++------- components/ServerMaintenanceDialog.tsx | 7 ++- lib/i18n/messages/en.json | 17 ++--- lib/i18n/translationTags.tsx | 7 +++ 5 files changed, 65 insertions(+), 35 deletions(-) create mode 100644 lib/i18n/translationTags.tsx diff --git a/app/(website)/(site)/components/ServerStatus.tsx b/app/(website)/(site)/components/ServerStatus.tsx index 88be019..785657d 100644 --- a/app/(website)/(site)/components/ServerStatus.tsx +++ b/app/(website)/(site)/components/ServerStatus.tsx @@ -40,7 +40,7 @@ const statuses = { export default function ServerStatus({ type, data, children }: Props) { const isDataNumber = !isNaN(Number(data)); - const t = useTranslations("statuses"); + const t = useTranslations("pages.main_page.statuses"); return (
(null); - const t = useTranslations("main_page"); + const t = useTranslations("pages.main_page"); + const tGeneral = useTranslations("general"); + const serverStatusQuery = useServerStatus(); const serverStatus = serverStatusQuery.data; @@ -90,8 +92,12 @@ export default function Home() {

- sun - rise + + {tGeneral("server_title.split.part_1")} + + + {tGeneral("server_title.split.part_2")} +

{t("features.motto")} @@ -104,10 +110,12 @@ export default function Home() { size="lg" asChild > - Join now + {t("features.buttons.register")}

@@ -141,9 +149,9 @@ export default function Home() { serverStatus ? serverStatus.is_online ? serverStatus.is_on_maintenance - ? "Under Maintenance" - : "Online" - : "Offline" + ? t("statuses.under_maintenance") + : t("statuses.online") + : t("statuses.offline") : undefined } /> @@ -165,7 +173,7 @@ export default function Home() {
-

Why us?

+

{t("why_us")}

@@ -205,11 +213,11 @@ export default function Home() {

- How do I start playing? + {t("how_to_start.title")}

- Just three simple steps and you're ready to go! + {t("how_to_start.description")}

@@ -217,40 +225,49 @@ export default function Home() { } className="rounded-lg">
-

Download osu! client

+

+ {t("how_to_start.download_tile.title")} +

- If you do not already have an installed client + {t("how_to_start.download_tile.description")}

} className="rounded-lg">
-

Register osu!sunrise account

+

+ {t("how_to_start.register_tile.title")} +

- Account will allow you to join the osu!sunrise community + {t("how_to_start.register_tile.description")}

} className="rounded-lg">
-

Follow the connection guide

+

{t("how_to_start.guide_tile.title")}

- Which helps you set up your osu! client to connect to - osu!sunrise + {t("how_to_start.guide_tile.description")}

diff --git a/components/ServerMaintenanceDialog.tsx b/components/ServerMaintenanceDialog.tsx index 288b069..832d783 100644 --- a/components/ServerMaintenanceDialog.tsx +++ b/components/ServerMaintenanceDialog.tsx @@ -8,6 +8,8 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { tTags } from "@/lib/i18n/translationTags"; +import { useTranslations } from "next-intl"; import Image from "next/image"; export default function ServerMaintenanceDialog({ @@ -17,6 +19,8 @@ export default function ServerMaintenanceDialog({ open: boolean; setOpen: (e: boolean) => void; }) { + const t = useTranslations("components.server_maintenance_dialog"); + return ( @@ -25,8 +29,7 @@ export default function ServerMaintenanceDialog({

- The server is currently in maintenance mode, so some - features of the website may not function correctly. + {t.rich("message", tTags)} {process.env.NEXT_PUBLIC_DISCORD_LINK && (
diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json index 98808d1..a183d59 100644 --- a/lib/i18n/messages/en.json +++ b/lib/i18n/messages/en.json @@ -68,14 +68,17 @@ "description": "Which helps you set up your osu! client to connect to osu!sunrise", "button": "Open guide" } + }, + "statuses": { + "total_users": "Total Users", + "users_online": "Users Online", + "users_restricted": "Users Restricted", + "total_scores": "Total Scores", + "server_status": "Server Status", + "online": "Online", + "offline": "Offline", + "under_maintenance": "Under Maintenance" } } - }, - "statuses": { - "total_users": "Total Users", - "users_online": "Users Online", - "users_restricted": "Users Restricted", - "total_scores": "Total Scores", - "server_status": "Server Status" } } diff --git a/lib/i18n/translationTags.tsx b/lib/i18n/translationTags.tsx new file mode 100644 index 0000000..7ae44f1 --- /dev/null +++ b/lib/i18n/translationTags.tsx @@ -0,0 +1,7 @@ +import { ReactNode } from "react"; + +export const tTags = { + p: (chunks: ReactNode) =>

{chunks}

, + b: (chunks: ReactNode) => {chunks}, + i: (chunks: ReactNode) => {chunks}, +}; From 07fb3363b1262e38f82a7d4906a0df0569e1ee01 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sat, 20 Dec 2025 22:53:15 +0200 Subject: [PATCH 05/31] feat: Add context for locale messages --- lib/i18n/messages/en.json | 204 ++++++++++++++++++++++++++++++-------- lib/i18n/request.ts | 22 +++- 2 files changed, 185 insertions(+), 41 deletions(-) diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json index a183d59..2a068db 100644 --- a/lib/i18n/messages/en.json +++ b/lib/i18n/messages/en.json @@ -1,83 +1,207 @@ { "general": { + "app_name": { + "text": "osu!sunrise", + "context": "The name of the application or server" + }, "server_title": { - "full": "sunrise", + "full": { + "text": "sunrise", + "context": "The full name of the server" + }, "split": { - "part_1": "sun", - "part_2": "rise" + "part_1": { + "text": "sun", + "context": "The first part of the split server name" + }, + "part_2": { + "text": "rise", + "context": "The second part of the split server name" + } } } }, "components": { "server_maintenance_dialog": { - "message": "The server is currently in maintenance mode, so some features of the website may not function correctly." + "message": { + "text": "The server is currently in maintenance mode, so some features of the website may not function correctly.", + "context": "This message is shown to users when the server is under maintenance." + } } }, "pages": { "main_page": { "features": { - "motto": "- yet another osu! server", - "description": "Features rich osu! server with support for Relax, Autopilot and ScoreV2 gameplay, with a custom art‑state PP calculation system tailored for Relax and Autopilot.", + "motto": { + "text": "- yet another osu! server", + "context": "The tagline or motto displayed on the main page" + }, + "description": { + "text": "Features rich osu! server with support for Relax, Autopilot and ScoreV2 gameplay, with a custom art‑state PP calculation system tailored for Relax and Autopilot.", + "context": "The main description text explaining the server's features on the homepage" + }, "buttons": { - "register": "Join now", - "wiki": "How to connect" + "register": { + "text": "Join now", + "context": "Button text to register a new account" + }, + "wiki": { + "text": "How to connect", + "context": "Button text linking to the connection guide" + } } }, - "why_us": "Why us?", + "why_us": { + "text": "Why us?", + "context": "Section heading asking why users should choose this server" + }, "cards": { "free_features": { - "title": "Truly Free Features", - "description": "Enjoy features like osu!direct and username changes without any paywalls — completely free for all players!" + "title": { + "text": "Truly Free Features", + "context": "Title of the free features card on the main page" + }, + "description": { + "text": "Enjoy features like osu!direct and username changes without any paywalls — completely free for all players!", + "context": "Description text explaining the free features available on the server" + } }, "pp_system": { - "title": "Custom PP Calculations", - "description": "We use the latest performance point (PP) system for vanilla scores while applying a custom, well-balanced formula for Relax and Autopilot modes." + "title": { + "text": "Custom PP Calculations", + "context": "Title of the PP system card on the main page" + }, + "description": { + "text": "We use the latest performance point (PP) system for vanilla scores while applying a custom, well-balanced formula for Relax and Autopilot modes.", + "context": "Description text explaining the custom PP calculation system" + } }, "medals": { - "title": "Earn Custom Medals", - "description": "Earn unique, server-exclusive medals as you accomplish various milestones and achievements." + "title": { + "text": "Earn Custom Medals", + "context": "Title of the medals card on the main page" + }, + "description": { + "text": "Earn unique, server-exclusive medals as you accomplish various milestones and achievements.", + "context": "Description text explaining the custom medals system" + } }, "updates": { - "title": "Frequent Updates", - "description": "We’re always improving! Expect regular updates, new features, and ongoing performance optimizations." + "title": { + "text": "Frequent Updates", + "context": "Title of the updates card on the main page" + }, + "description": { + "text": "We're always improving! Expect regular updates, new features, and ongoing performance optimizations.", + "context": "Description text explaining the server's update frequency" + } }, "pp_calc": { - "title": "Built-in PP Calculator", - "description": "Our website offers a built-in PP calculator for quick and easy performance point estimates." + "title": { + "text": "Built-in PP Calculator", + "context": "Title of the PP calculator card on the main page" + }, + "description": { + "text": "Our website offers a built-in PP calculator for quick and easy performance point estimates.", + "context": "Description text explaining the built-in PP calculator feature" + } }, "sunrise_core": { - "title": "Custom-Built Bancho Core", - "description": "Unlike most private osu! servers, we’ve developed our own custom bancho core for better stability and unique feature support." + "title": { + "text": "Custom-Built Bancho Core", + "context": "Title of the bancho core card on the main page" + }, + "description": { + "text": "Unlike most private osu! servers, we've developed our own custom bancho core for better stability and unique feature support.", + "context": "Description text explaining the custom bancho core development" + } } }, "how_to_start": { - "title": "How do I start playing?", - "description": "Just three simple steps and you're ready to go!", + "title": { + "text": "How do I start playing?", + "context": "Section heading for the getting started guide" + }, + "description": { + "text": "Just three simple steps and you're ready to go!", + "context": "Description text introducing the getting started steps" + }, "download_tile": { - "title": "Download osu! clinet", - "description": "If you do not already have an installed client", - "button": "Download" + "title": { + "text": "Download osu! clinet", + "context": "Title of the download step tile" + }, + "description": { + "text": "If you do not already have an installed client", + "context": "Description text for the download step" + }, + "button": { + "text": "Download", + "context": "Button text to download the osu! client" + } }, "register_tile": { - "title": "Register osu!sunrise account", - "description": "Account will allow you to join the osu!sunrise community", - "button": "Sign up" + "title": { + "text": "Register osu!sunrise account", + "context": "Title of the registration step tile" + }, + "description": { + "text": "Account will allow you to join the osu!sunrise community", + "context": "Description text for the registration step" + }, + "button": { + "text": "Sign up", + "context": "Button text to register a new account" + } }, "guide_tile": { - "title": "Follow the connection guide", - "description": "Which helps you set up your osu! client to connect to osu!sunrise", - "button": "Open guide" + "title": { + "text": "Follow the connection guide", + "context": "Title of the connection guide step tile" + }, + "description": { + "text": "Which helps you set up your osu! client to connect to osu!sunrise", + "context": "Description text for the connection guide step" + }, + "button": { + "text": "Open guide", + "context": "Button text to open the connection guide" + } } }, "statuses": { - "total_users": "Total Users", - "users_online": "Users Online", - "users_restricted": "Users Restricted", - "total_scores": "Total Scores", - "server_status": "Server Status", - "online": "Online", - "offline": "Offline", - "under_maintenance": "Under Maintenance" + "total_users": { + "text": "Total Users", + "context": "Label for the total number of registered users" + }, + "users_online": { + "text": "Users Online", + "context": "Label for the number of currently online users" + }, + "users_restricted": { + "text": "Users Restricted", + "context": "Label for the number of restricted users" + }, + "total_scores": { + "text": "Total Scores", + "context": "Label for the total number of scores submitted" + }, + "server_status": { + "text": "Server Status", + "context": "Label for the current server status" + }, + "online": { + "text": "Online", + "context": "Status indicator when the server is online" + }, + "offline": { + "text": "Offline", + "context": "Status indicator when the server is offline" + }, + "under_maintenance": { + "text": "Under Maintenance", + "context": "Status indicator when the server is under maintenance" + } } } } diff --git a/lib/i18n/request.ts b/lib/i18n/request.ts index be90a00..e6815e1 100644 --- a/lib/i18n/request.ts +++ b/lib/i18n/request.ts @@ -3,16 +3,36 @@ import { cookies } from "next/headers"; import defaultMessages from "./messages/en.json"; import { merge } from "lodash"; +const hasContext = (value: any): value is { text: string; context: string } => { + return typeof value === "object" && "text" in value && "context" in value; +}; + +const extractTextFromMessages = (messages: any, result: any = {}) => { + Object.entries(messages).forEach(([key, value]) => { + if (typeof value === "string") { + result[key] = value; + } else if (hasContext(value)) { + result[key] = value.text; + } else { + result[key] = extractTextFromMessages(value); + } + }); + + return result; +}; + export default getRequestConfig(async () => { const store = await cookies(); const selectedLocale = store.get("locale")?.value || "en"; - const messages = merge( + const rawMessages = merge( {}, defaultMessages, (await import(`./messages/${selectedLocale}.json`)).default ); + const messages = extractTextFromMessages(rawMessages); + return { locale: selectedLocale, messages, From 8d3d7a133ee8da34eb154230f15025e05b1ee8a2 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sat, 20 Dec 2025 22:53:40 +0200 Subject: [PATCH 06/31] feat: Create useT hook which extends useTranslations with custom default tags --- components/ServerMaintenanceDialog.tsx | 7 +++--- lib/i18n/translationTags.tsx | 7 ------ lib/i18n/utils.tsx | 34 ++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 11 deletions(-) delete mode 100644 lib/i18n/translationTags.tsx create mode 100644 lib/i18n/utils.tsx diff --git a/components/ServerMaintenanceDialog.tsx b/components/ServerMaintenanceDialog.tsx index 832d783..d858b3d 100644 --- a/components/ServerMaintenanceDialog.tsx +++ b/components/ServerMaintenanceDialog.tsx @@ -8,8 +8,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { tTags } from "@/lib/i18n/translationTags"; -import { useTranslations } from "next-intl"; +import { useT } from "@/lib/i18n/utils"; import Image from "next/image"; export default function ServerMaintenanceDialog({ @@ -19,7 +18,7 @@ export default function ServerMaintenanceDialog({ open: boolean; setOpen: (e: boolean) => void; }) { - const t = useTranslations("components.server_maintenance_dialog"); + const t = useT("components.server_maintenance_dialog"); return ( @@ -29,7 +28,7 @@ export default function ServerMaintenanceDialog({

- {t.rich("message", tTags)} + {t.rich("message")} {process.env.NEXT_PUBLIC_DISCORD_LINK && (
diff --git a/lib/i18n/translationTags.tsx b/lib/i18n/translationTags.tsx deleted file mode 100644 index 7ae44f1..0000000 --- a/lib/i18n/translationTags.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { ReactNode } from "react"; - -export const tTags = { - p: (chunks: ReactNode) =>

{chunks}

, - b: (chunks: ReactNode) => {chunks}, - i: (chunks: ReactNode) => {chunks}, -}; diff --git a/lib/i18n/utils.tsx b/lib/i18n/utils.tsx new file mode 100644 index 0000000..ab3431a --- /dev/null +++ b/lib/i18n/utils.tsx @@ -0,0 +1,34 @@ +"use client"; +import { + RichTranslationValues, + TranslationValues, + useTranslations, +} from "next-intl"; +import { ReactNode } from "react"; + +export type TranslationKey = string; + +export const defaultTags = { + p: (chunks: ReactNode) =>

{chunks}

, + b: (chunks: ReactNode) => {chunks}, + i: (chunks: ReactNode) => {chunks}, + code: (chunks: ReactNode) => ( + {chunks} + ), + br: () =>
, +}; + +export function useT(namespace: string) { + const t = useTranslations(namespace); + const appName = useTranslations("general")("app_name"); + + const plainT = (key: TranslationKey, values?: TranslationValues) => + t(key, { appName, ...values }); + + const richT = (key: TranslationKey, values?: RichTranslationValues) => + t.rich(key, { ...defaultTags, appName, ...values }); + + plainT.rich = richT; + + return plainT; +} From 50961fa10cd8a0cd7f4d471f09b3e181341de38a Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sat, 20 Dec 2025 23:00:18 +0200 Subject: [PATCH 07/31] chore: Update naming convention for locales --- .../(site)/components/ServerStatus.tsx | 14 ++--- app/(website)/(site)/page.tsx | 54 +++++++++---------- components/ServerMaintenanceDialog.tsx | 2 +- lib/i18n/messages/en.json | 42 +++++++-------- lib/i18n/messages/ru.json | 34 ++++++------ lib/i18n/utils.tsx | 2 +- 6 files changed, 71 insertions(+), 77 deletions(-) diff --git a/app/(website)/(site)/components/ServerStatus.tsx b/app/(website)/(site)/components/ServerStatus.tsx index 785657d..0e793c3 100644 --- a/app/(website)/(site)/components/ServerStatus.tsx +++ b/app/(website)/(site)/components/ServerStatus.tsx @@ -16,23 +16,23 @@ interface Props { const statuses = { total_users: { - nameKey: "total_users", + nameKey: "totalUsers", icon: , }, users_online: { - nameKey: "users_online", + nameKey: "usersOnline", icon: , }, users_restricted: { - nameKey: "users_restricted", + nameKey: "usersRestricted", icon: , }, total_scores: { - nameKey: "total_scores", + nameKey: "totalScores", icon: , }, server_status: { - nameKey: "server_status", + nameKey: "serverStatus", icon: , }, }; @@ -40,7 +40,7 @@ const statuses = { export default function ServerStatus({ type, data, children }: Props) { const isDataNumber = !isNaN(Number(data)); - const t = useTranslations("pages.main_page.statuses"); + const t = useTranslations("pages.mainPage.statuses"); return (
(null); - const t = useTranslations("pages.main_page"); + const t = useTranslations("pages.mainPage"); const tGeneral = useTranslations("general"); const serverStatusQuery = useServerStatus(); @@ -93,10 +93,10 @@ export default function Home() {

- {tGeneral("server_title.split.part_1")} + {tGeneral("serverTitle.split.part1")} - {tGeneral("server_title.split.part_2")} + {tGeneral("serverTitle.split.part2")}

@@ -149,7 +149,7 @@ export default function Home() { serverStatus ? serverStatus.is_online ? serverStatus.is_on_maintenance - ? t("statuses.under_maintenance") + ? t("statuses.underMaintenance") : t("statuses.online") : t("statuses.offline") : undefined @@ -173,7 +173,7 @@ export default function Home() {

-

{t("why_us")}

+

{t("whyUs")}

@@ -213,28 +213,24 @@ export default function Home() {

- {t("how_to_start.title")} + {t("howToStart.title")}

-

- {t("how_to_start.description")} -

+

{t("howToStart.description")}

} className="rounded-lg">
-

- {t("how_to_start.download_tile.title")} -

+

{t("howToStart.downloadTile.title")}

- {t("how_to_start.download_tile.description")} + {t("howToStart.downloadTile.description")}

@@ -242,16 +238,14 @@ export default function Home() { } className="rounded-lg">
-

- {t("how_to_start.register_tile.title")} -

+

{t("howToStart.registerTile.title")}

- {t("how_to_start.register_tile.description")} + {t("howToStart.registerTile.description")}

@@ -259,14 +253,14 @@ export default function Home() { } className="rounded-lg">
-

{t("how_to_start.guide_tile.title")}

+

{t("howToStart.guideTile.title")}

- {t("how_to_start.guide_tile.description")} + {t("howToStart.guideTile.description")}

diff --git a/components/ServerMaintenanceDialog.tsx b/components/ServerMaintenanceDialog.tsx index d858b3d..6467112 100644 --- a/components/ServerMaintenanceDialog.tsx +++ b/components/ServerMaintenanceDialog.tsx @@ -18,7 +18,7 @@ export default function ServerMaintenanceDialog({ open: boolean; setOpen: (e: boolean) => void; }) { - const t = useT("components.server_maintenance_dialog"); + const t = useT("components.serverMaintenanceDialog"); return ( diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json index 2a068db..656883d 100644 --- a/lib/i18n/messages/en.json +++ b/lib/i18n/messages/en.json @@ -1,20 +1,20 @@ { "general": { - "app_name": { + "appName": { "text": "osu!sunrise", "context": "The name of the application or server" }, - "server_title": { + "serverTitle": { "full": { "text": "sunrise", "context": "The full name of the server" }, "split": { - "part_1": { + "part1": { "text": "sun", "context": "The first part of the split server name" }, - "part_2": { + "part2": { "text": "rise", "context": "The second part of the split server name" } @@ -22,7 +22,7 @@ } }, "components": { - "server_maintenance_dialog": { + "serverMaintenanceDialog": { "message": { "text": "The server is currently in maintenance mode, so some features of the website may not function correctly.", "context": "This message is shown to users when the server is under maintenance." @@ -30,7 +30,7 @@ } }, "pages": { - "main_page": { + "mainPage": { "features": { "motto": { "text": "- yet another osu! server", @@ -51,12 +51,12 @@ } } }, - "why_us": { + "whyUs": { "text": "Why us?", "context": "Section heading asking why users should choose this server" }, "cards": { - "free_features": { + "freeFeatures": { "title": { "text": "Truly Free Features", "context": "Title of the free features card on the main page" @@ -66,7 +66,7 @@ "context": "Description text explaining the free features available on the server" } }, - "pp_system": { + "ppSystem": { "title": { "text": "Custom PP Calculations", "context": "Title of the PP system card on the main page" @@ -96,7 +96,7 @@ "context": "Description text explaining the server's update frequency" } }, - "pp_calc": { + "ppCalc": { "title": { "text": "Built-in PP Calculator", "context": "Title of the PP calculator card on the main page" @@ -106,7 +106,7 @@ "context": "Description text explaining the built-in PP calculator feature" } }, - "sunrise_core": { + "sunriseCore": { "title": { "text": "Custom-Built Bancho Core", "context": "Title of the bancho core card on the main page" @@ -117,7 +117,7 @@ } } }, - "how_to_start": { + "howToStart": { "title": { "text": "How do I start playing?", "context": "Section heading for the getting started guide" @@ -126,7 +126,7 @@ "text": "Just three simple steps and you're ready to go!", "context": "Description text introducing the getting started steps" }, - "download_tile": { + "downloadTile": { "title": { "text": "Download osu! clinet", "context": "Title of the download step tile" @@ -140,7 +140,7 @@ "context": "Button text to download the osu! client" } }, - "register_tile": { + "registerTile": { "title": { "text": "Register osu!sunrise account", "context": "Title of the registration step tile" @@ -154,7 +154,7 @@ "context": "Button text to register a new account" } }, - "guide_tile": { + "guideTile": { "title": { "text": "Follow the connection guide", "context": "Title of the connection guide step tile" @@ -170,23 +170,23 @@ } }, "statuses": { - "total_users": { + "totalUsers": { "text": "Total Users", "context": "Label for the total number of registered users" }, - "users_online": { + "usersOnline": { "text": "Users Online", "context": "Label for the number of currently online users" }, - "users_restricted": { + "usersRestricted": { "text": "Users Restricted", "context": "Label for the number of restricted users" }, - "total_scores": { + "totalScores": { "text": "Total Scores", "context": "Label for the total number of scores submitted" }, - "server_status": { + "serverStatus": { "text": "Server Status", "context": "Label for the current server status" }, @@ -198,7 +198,7 @@ "text": "Offline", "context": "Status indicator when the server is offline" }, - "under_maintenance": { + "underMaintenance": { "text": "Under Maintenance", "context": "Status indicator when the server is under maintenance" } diff --git a/lib/i18n/messages/ru.json b/lib/i18n/messages/ru.json index 1e60aff..3360c75 100644 --- a/lib/i18n/messages/ru.json +++ b/lib/i18n/messages/ru.json @@ -1,6 +1,6 @@ { "pages": { - "main_page": { + "mainPage": { "TODO": "UPDATE TRANSLATION, AI FOR NOW", "features": { "motto": "- ещё один osu! сервер", @@ -10,13 +10,13 @@ "wiki": "Как подключиться" } }, - "why_us": "Почему мы?", + "whyUs": "Почему мы?", "cards": { - "free_features": { + "freeFeatures": { "title": "Настоящие бесплатные функции", "description": "Пользуйтесь osu!direct, сменой никнейма и другими функциями без каких-либо платных ограничений — абсолютно бесплатно!" }, - "pp_system": { + "ppSystem": { "title": "Уникальная система PP", "description": "Мы используем современную систему performance point (PP) для обычных результатов и собственную сбалансированную формулу для Relax и Autopilot." }, @@ -28,41 +28,41 @@ "title": "Частые обновления", "description": "Мы постоянно улучшаем сервер! Ожидайте регулярные обновления, новые функции и оптимизацию производительности." }, - "pp_calc": { + "ppCalc": { "title": "Встроенный калькулятор PP", "description": "Наш сайт включает встроенный калькулятор PP для быстрых и удобных расчётов." }, - "sunrise_core": { + "sunriseCore": { "title": "Собственный Bancho-ядро", "description": "В отличие от большинства приватных серверов osu!, мы разработали собственное ядро bancho для лучшей стабильности и поддержки уникальных функций." } }, - "how_to_start": { + "howToStart": { "title": "Как начать играть?", "description": "Всего три простых шага — и вы готовы!", - "download_tile": { + "downloadTile": { "title": "Скачать osu! клиент", "description": "Если у вас ещё не установлен клиент", "button": "Скачать" }, - "register_tile": { + "registerTile": { "title": "Создать аккаунт osu!sunrise", "description": "Аккаунт позволит вам присоединиться к сообществу osu!sunrise", "button": "Регистрация" }, - "guide_tile": { + "guideTile": { "title": "Следовать инструкции по подключению", "description": "Поможет настроить ваш osu! клиент для подключения к osu!sunrise", "button": "Открыть инструкцию" } + }, + "statuses": { + "totalUsers": "Всего пользователей", + "usersOnline": "Пользователи онлайн", + "usersRestricted": "Ограниченные пользователи", + "totalScores": "Всего результатов", + "serverStatus": "Статус сервера" } } - }, - "statuses": { - "total_users": "Всего пользователей", - "users_online": "Пользователи онлайн", - "users_restricted": "Ограниченные пользователи", - "total_scores": "Всего результатов", - "server_status": "Статус сервера" } } diff --git a/lib/i18n/utils.tsx b/lib/i18n/utils.tsx index ab3431a..82a5304 100644 --- a/lib/i18n/utils.tsx +++ b/lib/i18n/utils.tsx @@ -20,7 +20,7 @@ export const defaultTags = { export function useT(namespace: string) { const t = useTranslations(namespace); - const appName = useTranslations("general")("app_name"); + const appName = useTranslations("general")("appName"); const plainT = (key: TranslationKey, values?: TranslationValues) => t(key, { appName, ...values }); From e353bedef9fd0f62068906b5fd6085fb16e466a9 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sat, 20 Dec 2025 23:05:07 +0200 Subject: [PATCH 08/31] chore: use useT instead of useTranslations --- app/(website)/(site)/components/ServerStatus.tsx | 4 ++-- app/(website)/(site)/page.tsx | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/(website)/(site)/components/ServerStatus.tsx b/app/(website)/(site)/components/ServerStatus.tsx index 0e793c3..010ab53 100644 --- a/app/(website)/(site)/components/ServerStatus.tsx +++ b/app/(website)/(site)/components/ServerStatus.tsx @@ -1,7 +1,7 @@ import PrettyCounter from "@/components/General/PrettyCounter"; import { Skeleton } from "@/components/ui/skeleton"; +import { useT } from "@/lib/i18n/utils"; import { Activity, Users, AlertTriangle, Trophy, Wifi } from "lucide-react"; -import { useTranslations } from "next-intl"; interface Props { type: @@ -40,7 +40,7 @@ const statuses = { export default function ServerStatus({ type, data, children }: Props) { const isDataNumber = !isNaN(Number(data)); - const t = useTranslations("pages.mainPage.statuses"); + const t = useT("pages.mainPage.statuses"); return (
(null); - const t = useTranslations("pages.mainPage"); - const tGeneral = useTranslations("general"); + const t = useT("pages.mainPage"); + const tGeneral = useT("general"); const serverStatusQuery = useServerStatus(); const serverStatus = serverStatusQuery.data; From 323648392659093cb051f0cfbc01223e51453783 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 21 Dec 2025 01:00:55 +0200 Subject: [PATCH 09/31] fix!: Disable prefetching for links to fix missing metadata on titles --- lib/overrides/next/link.tsx | 13 +++++++++++++ next.config.mjs | 13 ++++++++++++- tsconfig.json | 3 ++- 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 lib/overrides/next/link.tsx diff --git a/lib/overrides/next/link.tsx b/lib/overrides/next/link.tsx new file mode 100644 index 0000000..fe055da --- /dev/null +++ b/lib/overrides/next/link.tsx @@ -0,0 +1,13 @@ +import NextLink from "next/dist/client/link"; +import { ComponentPropsWithRef } from "react"; + +/* + * ! Using prefetched pages omits their metadata, which causes pages to miss titles/descriptions. + * ! Therefore, we disable prefetching for all links by default. + */ + +const Link = (props: ComponentPropsWithRef) => ( + +); + +export default Link; diff --git a/next.config.mjs b/next.config.mjs index 19bc2f4..2befc7b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,8 +1,14 @@ import createNextIntlPlugin from "next-intl/plugin"; -/** @type {import('next').NextConfig} */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + const domain = process.env.NEXT_PUBLIC_SERVER_DOMAIN || "ppy.sh"; +/** @type {import('next').NextConfig} */ const nextConfig = { webpack(config) { config.module.rules.push({ @@ -11,6 +17,11 @@ const nextConfig = { use: [{ loader: "@svgr/webpack", options: { icon: true } }], }); + config.resolve.alias["next/link"] = path.resolve( + __dirname, + "lib/overrides/next/link" + ); + return config; }, diff --git a/tsconfig.json b/tsconfig.json index 64c2104..70be686 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,8 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + "next/link": ["./lib/overrides/next/link"] }, "target": "ES2017" }, From 326084a8e966a950d5f6a479acb48b5caac3bec3 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 21 Dec 2025 01:03:25 +0200 Subject: [PATCH 10/31] chore: Update next to latest stable version --- app/(admin)/admin/beatmaps/[id]/page.tsx | 5 +- app/(admin)/admin/beatmapsets/[id]/page.tsx | 6 +- app/(admin)/admin/users/[id]/edit/layout.tsx | 2 +- app/(website)/beatmaps/[id]/page.tsx | 5 +- app/(website)/beatmapsets/[...ids]/page.tsx | 2 +- app/(website)/score/[id]/layout.tsx | 2 +- app/(website)/score/[id]/page.tsx | 18 +- app/(website)/user/[id]/layout.tsx | 3 +- app/(website)/user/[id]/page.tsx | 6 +- lib/i18n/utils.tsx | 20 +- package-lock.json | 422 +++++++++++-------- package.json | 4 +- 12 files changed, 290 insertions(+), 205 deletions(-) diff --git a/app/(admin)/admin/beatmaps/[id]/page.tsx b/app/(admin)/admin/beatmaps/[id]/page.tsx index a2b1628..902a05f 100644 --- a/app/(admin)/admin/beatmaps/[id]/page.tsx +++ b/app/(admin)/admin/beatmaps/[id]/page.tsx @@ -6,14 +6,15 @@ import RoundedContent from "@/components/General/RoundedContent"; import Image from "next/image"; import PrettyHeader from "@/components/General/PrettyHeader"; import { Music2 } from "lucide-react"; +import { tryParseNumber } from "@/lib/utils/type.util"; interface BeatmapsProps { - params: Promise<{ id: number }>; + params: Promise<{ id: string }>; } export default function BeatmapsRedirect(props: BeatmapsProps) { const params = use(props.params); - const beatmapQuery = useBeatmap(params.id); + const beatmapQuery = useBeatmap(tryParseNumber(params.id) ?? 0); const beatmap = beatmapQuery.data; if (beatmap) { diff --git a/app/(admin)/admin/beatmapsets/[id]/page.tsx b/app/(admin)/admin/beatmapsets/[id]/page.tsx index 64a2438..e038673 100644 --- a/app/(admin)/admin/beatmapsets/[id]/page.tsx +++ b/app/(admin)/admin/beatmapsets/[id]/page.tsx @@ -22,16 +22,17 @@ import { BeatmapsStatusTable } from "@/app/(admin)/admin/beatmapsets/components/ import { BeatmapSetEvents } from "@/app/(admin)/admin/beatmapsets/components/BeatmapSetEvents"; import PrettyHeader from "@/components/General/PrettyHeader"; import Link from "next/link"; +import { tryParseNumber } from "@/lib/utils/type.util"; export interface BeatmapsetProps { - params: Promise<{ id: number }>; + params: Promise<{ id: string }>; } export default function AdminBeatmapset(props: BeatmapsetProps) { const params = use(props.params); const router = useRouter(); - const beatmapSetId = params.id; + const beatmapSetId = tryParseNumber(params.id) ?? 0; const beatmapsetQuery = useBeatmapSet(beatmapSetId); const beatmapSet = beatmapsetQuery.data; @@ -120,6 +121,7 @@ export default function AdminBeatmapset(props: BeatmapsetProps) { - By signing up, you agree to the server{" "} - - rules - + {termsText} @@ -258,34 +294,28 @@ export default function Register() { open={isSuccessfulDialogOpen} onOpenChange={setIsSuccessfulDialogOpen} > - + - You’re all set! + {t("success.dialog.title")} - Your account has been successfully created. + {t("success.dialog.description")} -

- You can now connect to the server by following the guide on our{" "} - - Wiki page - {" "} - , or customize your profile by updating your avatar and banner - before you start playing! -

+

{successMessage}

{self && ( )} diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json index e357977..2e0d9a7 100644 --- a/lib/i18n/messages/en.json +++ b/lib/i18n/messages/en.json @@ -471,6 +471,136 @@ } } } + }, + "register": { + "meta": { + "title": { + "text": "Register | {appName}", + "context": "The title for the register page of the osu!sunrise website" + } + }, + "header": { + "text": "Register", + "context": "The main header text for the register page" + }, + "welcome": { + "title": { + "text": "Welcome to the registration page!", + "context": "Welcome title on the registration page" + }, + "description": { + "text": "Hello! Please enter your details to create an account. If you aren't sure how to connect to the server, or if you have any other questions, please visit our Wiki page.", + "context": "Welcome description text with link to wiki page" + } + }, + "form": { + "title": { + "text": "Enter your details", + "context": "Title for the registration form section" + }, + "labels": { + "username": { + "text": "Username", + "context": "Label for the username input field" + }, + "email": { + "text": "Email", + "context": "Label for the email input field" + }, + "password": { + "text": "Password", + "context": "Label for the password input field" + }, + "confirmPassword": { + "text": "Confirm Password", + "context": "Label for the confirm password input field" + } + }, + "placeholders": { + "username": { + "text": "e.g. username", + "context": "Placeholder text for the username input field" + }, + "email": { + "text": "e.g. username@mail.com", + "context": "Placeholder text for the email input field" + }, + "password": { + "text": "************", + "context": "Placeholder text for the password input field" + } + }, + "validation": { + "usernameMin": { + "text": "Username must be at least {min} characters.", + "context": "Validation error message when username is too short, includes minimum length parameter" + }, + "usernameMax": { + "text": "Username must be {max} characters or fewer.", + "context": "Validation error message when username is too long, includes maximum length parameter" + }, + "passwordMin": { + "text": "Password must be at least {min} characters.", + "context": "Validation error message when password is too short, includes minimum length parameter" + }, + "passwordMax": { + "text": "Password must be {max} characters or fewer.", + "context": "Validation error message when password is too long, includes maximum length parameter" + }, + "passwordsDoNotMatch": { + "text": "Passwords do not match", + "context": "Validation error message when password and confirm password do not match" + } + }, + "error": { + "title": { + "text": "Error", + "context": "Title for the error alert" + }, + "unknown": { + "text": "Unknown error.", + "context": "Generic error message when an unknown error occurs" + } + }, + "submit": { + "text": "Register", + "context": "Text for the registration submit button" + }, + "terms": { + "text": "By signing up, you agree to the server rules", + "context": "Terms agreement text with link to rules page" + } + }, + "success": { + "dialog": { + "title": { + "text": "You're all set!", + "context": "Title of the success dialog after registration" + }, + "description": { + "text": "Your account has been successfully created.", + "context": "Description in the success dialog" + }, + "message": { + "text": "You can now connect to the server by following the guide on our Wiki page, or customize your profile by updating your avatar and banner before you start playing!", + "context": "Success message with link to wiki page" + }, + "buttons": { + "viewWiki": { + "text": "View Wiki Guide", + "context": "Button text to view the wiki guide" + }, + "goToProfile": { + "text": "Go to Profile", + "context": "Button text to go to user profile" + } + } + }, + "toast": { + "text": "Account successfully created!", + "context": "Toast notification message when account is successfully created" + } + } } } } diff --git a/lib/i18n/messages/ru.json b/lib/i18n/messages/ru.json index 8e79d9b..852c9d8 100644 --- a/lib/i18n/messages/ru.json +++ b/lib/i18n/messages/ru.json @@ -169,6 +169,55 @@ } } } + }, + "register": { + "meta": { + "title": "Регистрация | {appName}" + }, + "header": "Регистрация", + "welcome": { + "title": "Добро пожаловать на страницу регистрации!", + "description": "Привет! Пожалуйста, введите свои данные для создания аккаунта. Если вы не уверены, как подключиться к серверу, или у вас есть другие вопросы, пожалуйста, посетите нашу страницу Вики." + }, + "form": { + "title": "Введите свои данные", + "labels": { + "username": "Имя пользователя", + "email": "Электронная почта", + "password": "Пароль", + "confirmPassword": "Подтвердите пароль" + }, + "placeholders": { + "username": "например, username", + "email": "например, username@mail.com", + "password": "************" + }, + "validation": { + "usernameMin": "Имя пользователя должно содержать не менее {min} символов.", + "usernameMax": "Имя пользователя должно содержать не более {max} символов.", + "passwordMin": "Пароль должен содержать не менее {min} символов.", + "passwordMax": "Пароль должен содержать не более {max} символов.", + "passwordsDoNotMatch": "Пароли не совпадают" + }, + "error": { + "title": "Ошибка", + "unknown": "Неизвестная ошибка." + }, + "submit": "Зарегистрироваться", + "terms": "Регистрируясь, вы соглашаетесь с правилами сервера" + }, + "success": { + "dialog": { + "title": "Всё готово!", + "description": "Ваш аккаунт был успешно создан.", + "message": "Теперь вы можете подключиться к серверу, следуя инструкции на нашей странице Вики, или настроить свой профиль, обновив аватар и баннер перед началом игры!", + "buttons": { + "viewWiki": "Посмотреть инструкцию Вики", + "goToProfile": "Перейти в профиль" + } + }, + "toast": "Аккаунт успешно создан!" + } } } } From 358da2f6847956eee954f61508acd24b76a87110 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 21 Dec 2025 01:57:20 +0200 Subject: [PATCH 16/31] feat: Localise support page --- app/(website)/support/layout.tsx | 16 +++++---- app/(website)/support/page.tsx | 45 +++++++++-------------- lib/i18n/messages/en.json | 62 ++++++++++++++++++++++++++++++++ lib/i18n/messages/ru.json | 26 ++++++++++++++ 4 files changed, 114 insertions(+), 35 deletions(-) diff --git a/app/(website)/support/layout.tsx b/app/(website)/support/layout.tsx index f337d75..919965d 100644 --- a/app/(website)/support/layout.tsx +++ b/app/(website)/support/layout.tsx @@ -1,11 +1,15 @@ import { Metadata } from "next"; import Page from "./page"; +import { getT } from "@/lib/i18n/utils"; -export const metadata: Metadata = { - title: "Support Us | osu!sunrise", - openGraph: { - title: "Support Us | osu!sunrise", - }, -}; +export async function generateMetadata(): Promise { + const t = await getT("pages.support.meta"); + return { + title: t("title"), + openGraph: { + title: t("title"), + }, + }; +} export default Page; diff --git a/app/(website)/support/page.tsx b/app/(website)/support/page.tsx index 239c659..7a129d3 100644 --- a/app/(website)/support/page.tsx +++ b/app/(website)/support/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import { BookCopy, HeartHandshake, @@ -8,60 +10,52 @@ import RoundedContent from "@/components/General/RoundedContent"; import Image from "next/image"; import { Button } from "@/components/ui/button"; import Link from "next/link"; +import { useT } from "@/lib/i18n/utils"; export default function SupportUs() { + const t = useT("pages.support"); + return (
} roundBottom={true} />
} />
-

- While all osu!sunrise features has always been free, running - and improving the server requires resources, time, and effort, - while being mainly maintained by a single developer. -
-
If you love osu!sunrise and want to see it grow even - further, here are a few ways you can support us: -

+

{t.rich("section.intro")}

    {(process.env.NEXT_PUBLIC_KOFI_LINK || process.env.NEXT_PUBLIC_BOOSTY_LINK) && (
  1. - Donate. + {t.rich("section.donate.title")}

    - Your generous donations help us maintain and enhance - the osu! servers. Every little bit counts! With your - support, we can cover hosting costs, implement new - features, and ensure a smoother experience for - everyone.{" "} + {t("section.donate.description")}

  2. {process.env.NEXT_PUBLIC_KOFI_LINK && ( )} {process.env.NEXT_PUBLIC_BOOSTY_LINK && ( )} @@ -69,22 +63,15 @@ export default function SupportUs() {
    )}
  3. - Spread the Word. + {t.rich("section.spreadTheWord.title")}

    - The more people who know about osu!sunrise, the more - vibrant and exciting our community will be. Tell your - friends, share on social media, and invite new players to - join. + {t("section.spreadTheWord.description")}

  4. - Just Play on the Server. + {t.rich("section.justPlay.title")}

    - One of the easiest ways to support osu!sunrise is simply - by playing on the server! The more players we have, the - better the community and experience become. By joining in, - you’re helping to grow the server and keeping it active - for all players. + {t("section.justPlay.description")}

diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json index 2e0d9a7..9e8829a 100644 --- a/lib/i18n/messages/en.json +++ b/lib/i18n/messages/en.json @@ -601,6 +601,68 @@ "context": "Toast notification message when account is successfully created" } } + }, + "support": { + "meta": { + "title": { + "text": "Support Us | {appName}", + "context": "The title for the support page of the osu!sunrise website" + } + }, + "header": { + "text": "Support Us", + "context": "The main header text for the support page" + }, + "section": { + "title": { + "text": "How You Can Help Us", + "context": "Title of the support section explaining how users can help" + }, + "intro": { + "text": "While all osu!sunrise features has always been free, running and improving the server requires resources, time, and effort, while being mainly maintained by a single developer.



If you love osu!sunrise and want to see it grow even further, here are a few ways you can support us:", + "context": "Introduction text explaining why support is needed, includes bold text for emphasis and line breaks" + }, + "donate": { + "title": { + "text": "Donate.", + "context": "Title of the donation option, displayed in bold" + }, + "description": { + "text": "Your generous donations help us maintain and enhance the osu! servers. Every little bit counts! With your support, we can cover hosting costs, implement new features, and ensure a smoother experience for everyone.", + "context": "Description text explaining how donations help the server" + }, + "buttons": { + "kofi": { + "text": "Ko-fi", + "context": "Button text for Ko-fi donation platform" + }, + "boosty": { + "text": "Boosty", + "context": "Button text for Boosty donation platform" + } + } + }, + "spreadTheWord": { + "title": { + "text": "Spread the Word.", + "context": "Title of the spread the word option, displayed in bold" + }, + "description": { + "text": "The more people who know about osu!sunrise, the more vibrant and exciting our community will be. Tell your friends, share on social media, and invite new players to join.", + "context": "Description text encouraging users to share the server" + } + }, + "justPlay": { + "title": { + "text": "Just Play on the Server.", + "context": "Title of the just play option, displayed in bold" + }, + "description": { + "text": "One of the easiest ways to support osu!sunrise is simply by playing on the server! The more players we have, the better the community and experience become. By joining in, you're helping to grow the server and keeping it active for all players.", + "context": "Description text explaining that playing on the server is a form of support" + } + } + } } } } diff --git a/lib/i18n/messages/ru.json b/lib/i18n/messages/ru.json index 852c9d8..35abc91 100644 --- a/lib/i18n/messages/ru.json +++ b/lib/i18n/messages/ru.json @@ -218,6 +218,32 @@ }, "toast": "Аккаунт успешно создан!" } + }, + "support": { + "meta": { + "title": "Поддержать нас | {appName}" + }, + "header": "Поддержать нас", + "section": { + "title": "Как вы можете нам помочь", + "intro": "Хотя все функции osu!sunrise всегда были бесплатными, запуск и улучшение сервера требуют ресурсов, времени и усилий, при этом сервер в основном поддерживается одним разработчиком.



Если вы любите osu!sunrise и хотите, чтобы он развивался ещё дальше, вот несколько способов, как вы можете нас поддержать:", + "donate": { + "title": "Пожертвовать.", + "description": "Ваши щедрые пожертвования помогают нам поддерживать и улучшать серверы osu!. Каждая мелочь имеет значение! С вашей поддержкой мы можем покрыть расходы на хостинг, внедрить новые функции и обеспечить более плавный опыт для всех.", + "buttons": { + "kofi": "Ko-fi", + "boosty": "Boosty" + } + }, + "spreadTheWord": { + "title": "Распространяйте информацию.", + "description": "Чем больше людей знают об osu!sunrise, тем более ярким и захватывающим станет наше сообщество. Расскажите своим друзьям, поделитесь в социальных сетях и пригласите новых игроков присоединиться." + }, + "justPlay": { + "title": "Просто играйте на сервере.", + "description": "Один из самых простых способов поддержать osu!sunrise — это просто играть на сервере! Чем больше у нас игроков, тем лучше становятся сообщество и опыт. Присоединяясь, вы помогаете развивать сервер и поддерживать его активность для всех игроков." + } + } } } } From d9977cae2cf2909c47632e1cb885ddfcc2b187e4 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 21 Dec 2025 02:08:12 +0200 Subject: [PATCH 17/31] feat: Localise topplays page --- .../topplays/components/UserScoreMinimal.tsx | 10 +++++-- app/(website)/topplays/layout.tsx | 16 +++++++---- app/(website)/topplays/page.tsx | 26 +++++++++-------- lib/i18n/messages/en.json | 28 +++++++++++++++++++ lib/i18n/messages/ru.json | 13 +++++++++ 5 files changed, 72 insertions(+), 21 deletions(-) diff --git a/app/(website)/topplays/components/UserScoreMinimal.tsx b/app/(website)/topplays/components/UserScoreMinimal.tsx index 73f5910..2f3c55a 100644 --- a/app/(website)/topplays/components/UserScoreMinimal.tsx +++ b/app/(website)/topplays/components/UserScoreMinimal.tsx @@ -8,6 +8,7 @@ import UserRankColor from "@/components/UserRankNumber"; import { useBeatmap } from "@/lib/hooks/api/beatmap/useBeatmap"; import { useUserStats } from "@/lib/hooks/api/user/useUser"; import { ScoreResponse } from "@/lib/types/api"; +import { useT } from "@/lib/i18n/utils"; import Image from "next/image"; import Link from "next/link"; @@ -25,6 +26,7 @@ export default function UserScoreMinimal({ showUser = true, className, }: UserScoreMinimalProps) { + const t = useT("pages.topplays.components.userScoreMinimal"); const userStatsQuery = useUserStats(score.user_id, score.game_mode_extended); const beatmapQuery = useBeatmap(score.beatmap_id); @@ -127,10 +129,12 @@ export default function UserScoreMinimal({

{beatmap && beatmap.is_ranked ? score.performance_points.toFixed() - : "- "} - pp + : "- "}{" "} + {t("pp")} +

+

+ {t("accuracy")} {score.accuracy.toFixed(2)}%

-

acc: {score.accuracy.toFixed(2)}%

diff --git a/app/(website)/topplays/layout.tsx b/app/(website)/topplays/layout.tsx index 0a75e20..5e152ba 100644 --- a/app/(website)/topplays/layout.tsx +++ b/app/(website)/topplays/layout.tsx @@ -1,13 +1,17 @@ import { Metadata } from "next"; import TopPlaysPage from "./page"; import { Suspense } from "react"; +import { getT } from "@/lib/i18n/utils"; -export const metadata: Metadata = { - title: "Top Plays | osu!sunrise", - openGraph: { - title: "Top Plays | osu!sunrise", - }, -}; +export async function generateMetadata(): Promise { + const t = await getT("pages.topplays.meta"); + return { + title: t("title"), + openGraph: { + title: t("title"), + }, + }; +} export default function Page() { return ( diff --git a/app/(website)/topplays/page.tsx b/app/(website)/topplays/page.tsx index c31632d..6c22df4 100644 --- a/app/(website)/topplays/page.tsx +++ b/app/(website)/topplays/page.tsx @@ -11,10 +11,12 @@ import { Button } from "@/components/ui/button"; import { usePathname, useSearchParams } from "next/navigation"; import { GameMode } from "@/lib/types/api"; import { isInstance } from "@/lib/utils/type.util"; +import { useT } from "@/lib/i18n/utils"; export default function Topplays() { const pathname = usePathname(); const searchParams = useSearchParams(); + const t = useT("pages.topplays"); const mode = searchParams.get("mode") ?? GameMode.STANDARD; @@ -27,22 +29,14 @@ export default function Topplays() { const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === "undefined"); - const handleShowMore = () => { + const handleShowMore = useCallback(() => { setSize(size + 1); - }; + }, [setSize, size]); const scores = data?.flatMap((item) => item.scores); const totalCountScores = data?.find((item) => item.total_count !== undefined)?.total_count ?? 0; - useEffect(() => { - window.history.replaceState( - null, - "", - pathname + "?" + createQueryString("mode", activeMode.toString()) - ); - }, [activeMode]); - const createQueryString = useCallback( (name: string, value: string) => { const params = new URLSearchParams(searchParams.toString()); @@ -53,10 +47,18 @@ export default function Topplays() { [searchParams] ); + useEffect(() => { + window.history.replaceState( + null, + "", + pathname + "?" + createQueryString("mode", activeMode.toString()) + ); + }, [activeMode, pathname, createQueryString]); + return (
} roundBottom={true} /> @@ -94,7 +96,7 @@ export default function Topplays() { variant="secondary" > - Show more + {t("showMore")}
)} diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json index 9e8829a..06de070 100644 --- a/lib/i18n/messages/en.json +++ b/lib/i18n/messages/en.json @@ -663,6 +663,34 @@ } } } + }, + "topplays": { + "meta": { + "title": { + "text": "Top Plays | {appName}", + "context": "The title for the top plays page of the osu!sunrise website" + } + }, + "header": { + "text": "Top plays", + "context": "The main header text for the top plays page" + }, + "showMore": { + "text": "Show more", + "context": "Button text to load more top plays" + }, + "components": { + "userScoreMinimal": { + "pp": { + "text": "pp", + "context": "Abbreviation for performance points, displayed after the PP value" + }, + "accuracy": { + "text": "acc:", + "context": "Abbreviation for accuracy, displayed before the accuracy percentage" + } + } + } } } } diff --git a/lib/i18n/messages/ru.json b/lib/i18n/messages/ru.json index 35abc91..6b5d5f3 100644 --- a/lib/i18n/messages/ru.json +++ b/lib/i18n/messages/ru.json @@ -244,6 +244,19 @@ "description": "Один из самых простых способов поддержать osu!sunrise — это просто играть на сервере! Чем больше у нас игроков, тем лучше становятся сообщество и опыт. Присоединяясь, вы помогаете развивать сервер и поддерживать его активность для всех игроков." } } + }, + "topplays": { + "meta": { + "title": "Топ игры | {appName}" + }, + "header": "Топ игры", + "showMore": "Показать ещё", + "components": { + "userScoreMinimal": { + "pp": "pp", + "accuracy": "точность:" + } + } } } } From 99ac371251413caff8e9119cde7b463ac00bc354 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 21 Dec 2025 02:35:44 +0200 Subject: [PATCH 18/31] feat: Localise score page --- app/(website)/score/[id]/layout.tsx | 47 +++++++++++------- app/(website)/score/[id]/page.tsx | 34 ++++++------- lib/i18n/messages/en.json | 74 +++++++++++++++++++++++++++++ lib/i18n/messages/ru.json | 29 +++++++++++ 4 files changed, 152 insertions(+), 32 deletions(-) diff --git a/app/(website)/score/[id]/layout.tsx b/app/(website)/score/[id]/layout.tsx index a56746d..5d115d0 100644 --- a/app/(website)/score/[id]/layout.tsx +++ b/app/(website)/score/[id]/layout.tsx @@ -4,6 +4,7 @@ import { notFound } from "next/navigation"; import { getBeatmapStarRating } from "@/lib/utils/getBeatmapStarRating"; import fetcher from "@/lib/services/fetcher"; import { BeatmapResponse, ScoreResponse, UserResponse } from "@/lib/types/api"; +import { getT } from "@/lib/i18n/utils"; export const revalidate = 60; @@ -17,8 +18,6 @@ export async function generateMetadata(props: { return notFound(); } - if (!score) return notFound(); - const user = await fetcher(`user/${score.user_id}`); if (!user) { @@ -31,22 +30,38 @@ export async function generateMetadata(props: { return notFound(); } + const t = await getT("pages.score.meta"); + const starRating = getBeatmapStarRating(beatmap).toFixed(2); + const pp = score.performance_points.toFixed(2); + return { - title: `${user.username} on ${beatmap.title} [${beatmap.version}] | osu!sunrise`, - description: `User ${ - user.username - } has scored ${score.performance_points.toFixed(2)}pp on ${ - beatmap.title - } [${beatmap.version}] in osu!sunrise.`, + title: t("title", { + username: user.username, + beatmapTitle: beatmap.title ?? "Unknown", + beatmapVersion: beatmap.version, + }), + description: t("description", { + username: user.username, + pp, + beatmapTitle: beatmap.title ?? "Unknown", + beatmapVersion: beatmap.version, + }), openGraph: { - title: `${user.username} on ${beatmap.title} - ${beatmap.artist} [${beatmap.version}] | osu!sunrise`, - description: `User ${ - user.username - } has scored ${score.performance_points.toFixed(2)}pp on ${ - beatmap.title - } - ${beatmap.artist} [${beatmap.version}] ★${getBeatmapStarRating( - beatmap - ).toFixed(2)} ${score.mods} in osu!sunrise.`, + title: t("openGraph.title", { + username: user.username, + beatmapTitle: beatmap.title ?? "Unknown", + beatmapArtist: beatmap.artist ?? "Unknown", + beatmapVersion: beatmap.version, + }), + description: t("openGraph.description", { + username: user.username, + pp, + beatmapTitle: beatmap.title ?? "Unknown", + beatmapArtist: beatmap.artist ?? "Unknown", + beatmapVersion: beatmap.version, + starRating, + mods: score.mods ?? "", + }), images: [ `https://assets.ppy.sh/beatmaps/${beatmap.beatmapset_id}/covers/list@2x.jpg`, ], diff --git a/app/(website)/score/[id]/page.tsx b/app/(website)/score/[id]/page.tsx index 88ec9f8..48b4f87 100644 --- a/app/(website)/score/[id]/page.tsx +++ b/app/(website)/score/[id]/page.tsx @@ -34,10 +34,12 @@ import ScoreStats from "@/components/ScoreStats"; import { BeatmapStatusWeb } from "@/lib/types/api"; import { ModIcons } from "@/components/ModIcons"; import { tryParseNumber } from "@/lib/utils/type.util"; +import { useT } from "@/lib/i18n/utils"; export default function Score(props: { params: Promise<{ id: string }> }) { const params = use(props.params); const paramsId = tryParseNumber(params.id) ?? 0; + const t = useT("pages.score"); const { self } = useSelf(); @@ -70,15 +72,11 @@ export default function Score(props: { params: Promise<{ id: string }> }) { scoreQuery.error?.message ?? userQuery?.error?.message ?? beatmapQuery?.error?.message ?? - "Score not found"; + t("error.notFound"); return (
- } - /> + } /> {score && user && beatmap ? ( <> @@ -140,7 +138,8 @@ export default function Score(props: { params: Promise<{ id: string }> }) { [
- {beatmap?.version || "Unknown"} + {beatmap?.version || + t("beatmap.versionUnknown")}
] @@ -150,7 +149,8 @@ export default function Score(props: { params: Promise<{ id: string }> }) {

- mapped by {beatmap?.creator || "Unknown Creator"} + {t("beatmap.mappedBy")}{" "} + {beatmap?.creator || t("beatmap.creatorUnknown")}

@@ -160,7 +160,9 @@ export default function Score(props: { params: Promise<{ id: string }> }) {

-

Submitted on 

+

+ {t("score.submittedOn")}  +

}) {

- Played by {user?.username ?? "Unknown user"} + {t("score.playedBy")}{" "} + {user?.username ?? t("score.userUnknown")}

@@ -181,12 +184,14 @@ export default function Score(props: { params: Promise<{ id: string }> }) { variant="secondary" > - Download Replay + {t("actions.downloadReplay")} @@ -238,10 +243,7 @@ export default function Score(props: { params: Promise<{ id: string }> }) {

{errorMessage}

-

- The score you are looking for does not exist or has been - deleted. -

+

{t("error.description")}

Date: Sun, 21 Dec 2025 02:36:28 +0200 Subject: [PATCH 19/31] chore: Set timezone to UTC --- components/Providers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/Providers.tsx b/components/Providers.tsx index cc362cf..9c82c0d 100644 --- a/components/Providers.tsx +++ b/components/Providers.tsx @@ -27,7 +27,7 @@ export default function Providers({ children, locale, messages }: { children: Re - + {children} From 8f10ae97d54dcf2e665b1a06a3d6b9dabfd62ac7 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 21 Dec 2025 02:46:34 +0200 Subject: [PATCH 20/31] feat: Localise leaderboard page --- .../leaderboard/components/UserColumns.tsx | 405 +++++++++--------- .../leaderboard/components/UserDataTable.tsx | 28 +- app/(website)/leaderboard/layout.tsx | 16 +- app/(website)/leaderboard/page.tsx | 70 +-- lib/i18n/messages/en.json | 82 ++++ lib/i18n/messages/ru.json | 31 ++ 6 files changed, 387 insertions(+), 245 deletions(-) diff --git a/app/(website)/leaderboard/components/UserColumns.tsx b/app/(website)/leaderboard/components/UserColumns.tsx index a253b8b..7a5ff13 100644 --- a/app/(website)/leaderboard/components/UserColumns.tsx +++ b/app/(website)/leaderboard/components/UserColumns.tsx @@ -19,217 +19,228 @@ import Image from "next/image"; import Link from "next/link"; import { Suspense, useContext } from "react"; import { twMerge } from "tailwind-merge"; +import { useT } from "@/lib/i18n/utils"; -export const userColumns: ColumnDef<{ - user: UserResponse; - stats: UserStatsResponse; -}>[] = [ - { - accessorKey: "stats.rank", - sortingFn: (a, b) => { - return a.index - b.index; - }, - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const table = useContext(UserTableContext); - const pageIndex = table.getState().pagination.pageIndex; - const pageSize = table.getState().pagination.pageSize; - const value = row.index + pageIndex * pageSize + 1; +export function useUserColumns() { + const t = useT("pages.leaderboard.table"); + + return [ + { + accessorKey: "stats.rank", + sortingFn: (a, b) => { + return a.index - b.index; + }, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const table = useContext(UserTableContext); + const pageIndex = table.getState().pagination.pageIndex; + const pageSize = table.getState().pagination.pageSize; + const value = row.index + pageIndex * pageSize + 1; - const textSize = - value === 1 - ? "text-2xl" - : value === 2 - ? "text-lg" - : value === 3 - ? "text-base" - : "text-ms"; + const textSize = + value === 1 + ? "text-2xl" + : value === 2 + ? "text-lg" + : value === 3 + ? "text-base" + : "text-ms"; - return ( - - # {value} - - ); + return ( + + # {value} + + ); + }, }, - }, - { - accessorKey: "user.country_code", - header: "", - cell: ({ row }) => { - const countryCode = row.original.user.country_code; - return ( - User Flag - ); + { + accessorKey: "user.country_code", + header: "", + cell: ({ row }) => { + const countryCode = row.original.user.country_code; + return ( + User Flag + ); + }, }, - }, - { - accessorKey: "user.username", - header: "", - cell: ({ row }) => { - const userId = row.original.user.user_id; - const { username, avatar_url } = row.original.user; + { + accessorKey: "user.username", + header: "", + cell: ({ row }) => { + const userId = row.original.user.user_id; + const { username, avatar_url } = row.original.user; - const table = useContext(UserTableContext); - const pageIndex = table.getState().pagination.pageIndex; - const pageSize = table.getState().pagination.pageSize; - const userRank = row.index + pageIndex * pageSize + 1; + const table = useContext(UserTableContext); + const pageIndex = table.getState().pagination.pageIndex; + const pageSize = table.getState().pagination.pageSize; + const userRank = row.index + pageIndex * pageSize + 1; - return ( -
- - UA}> - logo - - + return ( +
+ + UA}> + logo + + - - - - {username} - - - -
- ); - }, - }, - { - id: "pp", - accessorKey: "stats.pp", - header: () => ( -
- Performance -
- ), - cell: ({ row }) => { - const formatted = numberWith(row.original.stats.pp.toFixed(2), ","); - return ( -
{formatted}
- ); + + + + {username} + + + +
+ ); + }, }, - }, - { - id: "ranked_score", - accessorKey: "stats.ranked_score", - header: () => ( -
- Ranked Score -
- ), - cell: ({ row }) => { - const formatted = numberWith(row.original.stats.ranked_score, ","); - return ( -
- {formatted} + { + id: "pp", + accessorKey: "stats.pp", + header: () => ( +
+ {t("columns.performance")}
- ); - }, - }, - { - accessorKey: "stats.accuracy", - header: ({ column }) => { - return ( - - ); + ), + cell: ({ row }) => { + const formatted = numberWith(row.original.stats.pp.toFixed(2), ","); + return ( +
+ {formatted} +
+ ); + }, }, - cell: ({ row }) => { - const formatted = row.original.stats.accuracy.toFixed(2); - return ( -
- {formatted}% + { + id: "ranked_score", + accessorKey: "stats.ranked_score", + header: () => ( +
+ {t("columns.rankedScore")}
- ); + ), + cell: ({ row }) => { + const formatted = numberWith(row.original.stats.ranked_score, ","); + return ( +
+ {formatted} +
+ ); + }, }, - }, - { - accessorKey: "stats.play_count", - header: ({ column }) => { - return ( - - ); + { + accessorKey: "stats.accuracy", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const formatted = row.original.stats.accuracy.toFixed(2); + return ( +
+ {formatted}% +
+ ); + }, }, - cell: ({ row }) => { - const value = row.original.stats.play_count; - return ( -
{value}
- ); + { + accessorKey: "stats.play_count", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const value = row.original.stats.play_count; + return ( +
+ {value} +
+ ); + }, }, - }, - { - id: "actions", - cell: ({ row }) => { - const userId = row.original.user.user_id; + { + id: "actions", + cell: ({ row }) => { + const userId = row.original.user.user_id; - return ( - - - - - - - View user profile - - {/* TODO: Add report option */} - - - ); + return ( + + + + + + + + {t("actions.viewUserProfile")} + + + {/* TODO: Add report option */} + + + ); + }, }, - }, -]; + ] as ColumnDef<{ + user: UserResponse; + stats: UserStatsResponse; + }>[]; +} diff --git a/app/(website)/leaderboard/components/UserDataTable.tsx b/app/(website)/leaderboard/components/UserDataTable.tsx index a7d4132..58bef59 100644 --- a/app/(website)/leaderboard/components/UserDataTable.tsx +++ b/app/(website)/leaderboard/components/UserDataTable.tsx @@ -39,6 +39,7 @@ import { SelectValue, } from "@/components/ui/select"; import { LeaderboardSortType, UserResponse } from "@/lib/types/api"; +import { useT } from "@/lib/i18n/utils"; interface DataTableProps { columns: ColumnDef[]; @@ -62,6 +63,7 @@ export function UserDataTable({ leaderboardType, setPagination, }: DataTableProps) { + const t = useT("pages.leaderboard.table"); const [sorting, setSorting] = useState([]); const [columnVisibility, setColumnVisibility] = @@ -158,7 +160,7 @@ export function UserDataTable({ colSpan={columns.length} className="h-24 text-center" > - No results. + {t("emptyState")} )} @@ -183,22 +185,22 @@ export function UserDataTable({ 50 -

users per page

+

{t("pagination.usersPerPage")}

- Showing{" "} - {Math.min( - pagination.pageIndex * pagination.pageSize + 1, - totalCount - )}{" "} - -{" "} - {Math.min( - (pagination.pageIndex + 1) * pagination.pageSize, - totalCount - )}{" "} - of {totalCount} + {t("pagination.showing", { + start: Math.min( + pagination.pageIndex * pagination.pageSize + 1, + totalCount + ), + end: Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + totalCount + ), + total: totalCount, + })}

diff --git a/app/(website)/leaderboard/layout.tsx b/app/(website)/leaderboard/layout.tsx index 698aa06..6ee37d4 100644 --- a/app/(website)/leaderboard/layout.tsx +++ b/app/(website)/leaderboard/layout.tsx @@ -1,13 +1,17 @@ import { Metadata } from "next"; import PageLeaderboard from "./page"; import { Suspense } from "react"; +import { getT } from "@/lib/i18n/utils"; -export const metadata: Metadata = { - title: "Leaderboard | osu!sunrise", - openGraph: { - title: "Leaderboard | osu!sunrise", - }, -}; +export async function generateMetadata(): Promise { + const t = await getT("pages.leaderboard.meta"); + return { + title: t("title"), + openGraph: { + title: t("title"), + }, + }; +} export default function Page() { return ( diff --git a/app/(website)/leaderboard/page.tsx b/app/(website)/leaderboard/page.tsx index 8874450..04af22d 100644 --- a/app/(website)/leaderboard/page.tsx +++ b/app/(website)/leaderboard/page.tsx @@ -2,21 +2,23 @@ import { ChartColumnIncreasing, Router } from "lucide-react"; import PrettyHeader from "@/components/General/PrettyHeader"; import RoundedContent from "@/components/General/RoundedContent"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState, useMemo } from "react"; import Spinner from "@/components/Spinner"; import GameModeSelector from "@/components/GameModeSelector"; import { useUsersLeaderboard } from "@/lib/hooks/api/user/useUsersLeaderboard"; import { Button } from "@/components/ui/button"; import { UserDataTable } from "@/app/(website)/leaderboard/components/UserDataTable"; -import { userColumns } from "@/app/(website)/leaderboard/components/UserColumns"; +import { useUserColumns } from "@/app/(website)/leaderboard/components/UserColumns"; import { usePathname, useSearchParams } from "next/navigation"; import { isInstance, tryParseNumber } from "@/lib/utils/type.util"; import { Combobox } from "@/components/ComboBox"; import { GameMode, LeaderboardSortType } from "@/lib/types/api"; +import { useT } from "@/lib/i18n/utils"; export default function Leaderboard() { const pathname = usePathname(); const searchParams = useSearchParams(); + const t = useT("pages.leaderboard"); const page = tryParseNumber(searchParams.get("page")) ?? 0; const size = tryParseNumber(searchParams.get("size")) ?? 10; @@ -38,13 +40,23 @@ export default function Leaderboard() { pageSize: size, }); + const createQueryString = useCallback( + (name: string, value: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set(name, value); + + return params.toString(); + }, + [searchParams] + ); + useEffect(() => { window.history.replaceState( null, "", pathname + "?" + createQueryString("type", leaderboardType.toString()) ); - }, [leaderboardType]); + }, [leaderboardType, pathname, createQueryString]); useEffect(() => { window.history.replaceState( @@ -52,7 +64,7 @@ export default function Leaderboard() { "", pathname + "?" + createQueryString("mode", activeMode.toString()) ); - }, [activeMode]); + }, [activeMode, pathname, createQueryString]); useEffect(() => { window.history.replaceState( @@ -60,7 +72,7 @@ export default function Leaderboard() { "", pathname + "?" + createQueryString("size", pagination.pageSize.toString()) ); - }, [pagination.pageSize]); + }, [pagination.pageSize, pathname, createQueryString]); useEffect(() => { window.history.replaceState( @@ -70,16 +82,20 @@ export default function Leaderboard() { "?" + createQueryString("page", pagination.pageIndex.toString()) ); - }, [pagination.pageIndex]); - - const createQueryString = useCallback( - (name: string, value: string) => { - const params = new URLSearchParams(searchParams.toString()); - params.set(name, value); - - return params.toString(); - }, - [searchParams] + }, [pagination.pageIndex, pathname, createQueryString]); + + const comboboxValues = useMemo( + () => [ + { + label: t("sortBy.performancePointsShort"), + value: LeaderboardSortType.PP, + }, + { + label: t("sortBy.scoreShort"), + value: LeaderboardSortType.SCORE, + }, + ], + [t] ); const usersLeaderboardQuery = useUsersLeaderboard( @@ -96,12 +112,15 @@ export default function Leaderboard() { total_count: 0, }; + const userColumns = useUserColumns(); + return (
} roundBottom={true} + className="text-nowrap" >
-

Sort by:

+

+ {t("sortBy.label")} +

{ setLeaderboardType(type); }} - values={[ - { - label: "Perf. points", - value: LeaderboardSortType.PP, - }, - { - label: "Score", - value: LeaderboardSortType.SCORE, - }, - ]} + values={comboboxValues} />
diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json index 4deff95..c1cf819 100644 --- a/lib/i18n/messages/en.json +++ b/lib/i18n/messages/en.json @@ -765,6 +765,88 @@ "context": "Error description explaining that the score doesn't exist or was deleted" } } + }, + "leaderboard": { + "meta": { + "title": { + "text": "Leaderboard | {appName}", + "context": "The title for the leaderboard page of the osu!sunrise website" + } + }, + "header": { + "text": "Leaderboard", + "context": "The main header text for the leaderboard page" + }, + "sortBy": { + "label": { + "text": "Sort by:", + "context": "Label for the sort by selector on mobile view" + }, + "performancePoints": { + "text": "Performance points", + "context": "Button text for sorting by performance points" + }, + "rankedScore": { + "text": "Ranked Score", + "context": "Button text for sorting by ranked score" + }, + "performancePointsShort": { + "text": "Perf. points", + "context": "Short label for performance points in mobile combobox" + }, + "scoreShort": { + "text": "Score", + "context": "Short label for score in mobile combobox" + } + }, + "table": { + "columns": { + "rank": { + "text": "Rank", + "context": "Column header for user rank" + }, + "performance": { + "text": "Performance", + "context": "Column header for performance points" + }, + "rankedScore": { + "text": "Ranked Score", + "context": "Column header for ranked score" + }, + "accuracy": { + "text": "Accuracy", + "context": "Column header for accuracy" + }, + "playCount": { + "text": "Play count", + "context": "Column header for play count" + } + }, + "actions": { + "openMenu": { + "text": "Open menu", + "context": "Screen reader text for the dropdown menu button" + }, + "viewUserProfile": { + "text": "View user profile", + "context": "Dropdown menu item text to view user profile" + } + }, + "emptyState": { + "text": "No results.", + "context": "Message displayed when there are no results in the table" + }, + "pagination": { + "usersPerPage": { + "text": "users per page", + "context": "Label for the users per page selector" + }, + "showing": { + "text": "Showing {start} - {end} of {total}", + "context": "Pagination text showing the range of displayed users, includes start, end, and total as parameters" + } + } + } } } } diff --git a/lib/i18n/messages/ru.json b/lib/i18n/messages/ru.json index 2b83d59..98c56a6 100644 --- a/lib/i18n/messages/ru.json +++ b/lib/i18n/messages/ru.json @@ -286,6 +286,37 @@ "notFound": "Счёт не найден", "description": "Счёт, который вы ищете, не существует или был удалён." } + }, + "leaderboard": { + "meta": { + "title": "Таблица лидеров | {appName}" + }, + "header": "Таблица лидеров", + "sortBy": { + "label": "Сортировать по:", + "performancePoints": "Очки производительности", + "rankedScore": "Ранговый счёт", + "performancePointsShort": "Очки произв.", + "scoreShort": "Счёт" + }, + "table": { + "columns": { + "rank": "Ранг", + "performance": "Производительность", + "rankedScore": "Ранговый счёт", + "accuracy": "Точность", + "playCount": "Количество игр" + }, + "actions": { + "openMenu": "Открыть меню", + "viewUserProfile": "Посмотреть профиль пользователя" + }, + "emptyState": "Нет результатов.", + "pagination": { + "usersPerPage": "пользователей на странице", + "showing": "Показано {start} - {end} из {total}" + } + } } } } From 830b42ac4e48e080740c362981bdf713c2045751 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 21 Dec 2025 02:53:54 +0200 Subject: [PATCH 21/31] feat: Localise friends page --- .../friends/components/UsersList.tsx | 7 ++- .../components/UsersListSortingOptions.tsx | 31 ++++++++----- app/(website)/friends/layout.tsx | 16 ++++--- app/(website)/friends/page.tsx | 42 ++++++++++-------- lib/i18n/messages/en.json | 44 +++++++++++++++++++ lib/i18n/messages/ru.json | 17 +++++++ 6 files changed, 120 insertions(+), 37 deletions(-) diff --git a/app/(website)/friends/components/UsersList.tsx b/app/(website)/friends/components/UsersList.tsx index 29bfefb..eed40b0 100644 --- a/app/(website)/friends/components/UsersList.tsx +++ b/app/(website)/friends/components/UsersList.tsx @@ -1,7 +1,10 @@ +"use client"; + import { UsersListViewModeType } from "@/app/(website)/friends/components/UsersListViewModeOptions"; import UserElement from "@/components/UserElement"; import { UserListItem } from "@/components/UserListElement"; import { UserResponse } from "@/lib/types/api"; +import { useT } from "@/lib/i18n/utils"; interface UsersListProps { users: UserResponse[]; @@ -9,10 +12,12 @@ interface UsersListProps { } export function UsersList({ users, viewMode }: UsersListProps) { + const t = useT("pages.friends"); + if (users.length === 0) { return (
- No users found + {t("emptyState")}
); } diff --git a/app/(website)/friends/components/UsersListSortingOptions.tsx b/app/(website)/friends/components/UsersListSortingOptions.tsx index dc2dfa1..1b536d8 100644 --- a/app/(website)/friends/components/UsersListSortingOptions.tsx +++ b/app/(website)/friends/components/UsersListSortingOptions.tsx @@ -1,6 +1,8 @@ "use client"; import { Combobox } from "@/components/ComboBox"; +import { useT } from "@/lib/i18n/utils"; +import { useMemo } from "react"; export type UsersListSortingType = "username" | "lastActive"; @@ -13,23 +15,30 @@ export function UsersListSortingOptions({ sortBy, onSortChange, }: UsersListSortingOptionsProps) { + const t = useT("pages.friends.sorting"); + + const values = useMemo( + () => [ + { + label: t("username"), + value: "username" as const, + }, + { + label: t("recentlyActive"), + value: "lastActive" as const, + }, + ], + [t] + ); + return ( { onSortChange(v); }} - values={[ - { - label: "Username", - value: "username", - }, - { - label: "Recently active", - value: "lastActive", - }, - ]} + values={values} /> ); } diff --git a/app/(website)/friends/layout.tsx b/app/(website)/friends/layout.tsx index 61dbb6c..2e258b6 100644 --- a/app/(website)/friends/layout.tsx +++ b/app/(website)/friends/layout.tsx @@ -1,11 +1,15 @@ import { Metadata } from "next"; import Page from "./page"; +import { getT } from "@/lib/i18n/utils"; -export const metadata: Metadata = { - title: "Your Friends | osu!sunrise", - openGraph: { - title: "Your Friends | osu!sunrise", - }, -}; +export async function generateMetadata(): Promise { + const t = await getT("pages.friends.meta"); + return { + title: t("title"), + openGraph: { + title: t("title"), + }, + }; +} export default Page; diff --git a/app/(website)/friends/page.tsx b/app/(website)/friends/page.tsx index 715e9fa..d103096 100644 --- a/app/(website)/friends/page.tsx +++ b/app/(website)/friends/page.tsx @@ -7,7 +7,7 @@ import RoundedContent from "@/components/General/RoundedContent"; import { useFriends } from "@/lib/hooks/api/user/useFriends"; import { Button } from "@/components/ui/button"; import { UsersList } from "@/app/(website)/friends/components/UsersList"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback, useMemo } from "react"; import { UsersListSortingOptions, UsersListSortingType, @@ -22,10 +22,12 @@ import { FriendsResponse, UserResponse, } from "@/lib/types/api"; +import { useT } from "@/lib/i18n/utils"; type UsersType = "friends" | "followers"; export default function Friends() { + const t = useT("pages.friends"); const [sortBy, setSortBy] = useState("lastActive"); const [viewMode, setViewMode] = useState("grid"); @@ -48,9 +50,9 @@ export default function Friends() { const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === "undefined"); - const handleShowMore = () => { + const handleShowMore = useCallback(() => { setSize(size + 1); - }; + }, [setSize, size]); const users = data?.flatMap((item) => @@ -63,26 +65,28 @@ export default function Friends() { (item) => item.total_count !== undefined )?.total_count; - const sortedUsers = [...users].sort((a, b) => { - if (sortBy === "username") { - return a.username.localeCompare(b.username); - } + const sortedUsers = useMemo(() => { + return [...users].sort((a, b) => { + if (sortBy === "username") { + return a.username.localeCompare(b.username); + } - const getDateSortValue = (user: UserResponse) => { - const time = new Date(user.last_online_time).getTime(); - const isOffline = user.user_status === "Offline"; + const getDateSortValue = (user: UserResponse) => { + const time = new Date(user.last_online_time).getTime(); + const isOffline = user.user_status === "Offline"; - // Offline users get a penalty in priority - return isOffline ? time - 1e12 : time; - }; + // Offline users get a penalty in priority + return isOffline ? time - 1e12 : time; + }; - return getDateSortValue(b) - getDateSortValue(a); - }); + return getDateSortValue(b) - getDateSortValue(a); + }); + }, [users, sortBy]); return (
} roundBottom={true} > @@ -96,7 +100,7 @@ export default function Friends() { }} variant={usersType == "friends" ? "default" : "secondary"} > - Friends + {t("tabs.friends")}
@@ -139,7 +143,7 @@ export default function Friends() { isLoading={isLoadingMore} > - Show more + {t("showMore")}
)} diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json index c1cf819..7e31df4 100644 --- a/lib/i18n/messages/en.json +++ b/lib/i18n/messages/en.json @@ -847,6 +847,50 @@ } } } + }, + "friends": { + "meta": { + "title": { + "text": "Your Friends | {appName}", + "context": "The title for the friends page of the osu!sunrise website" + } + }, + "header": { + "text": "Your Connections", + "context": "The main header text for the friends page" + }, + "tabs": { + "friends": { + "text": "Friends", + "context": "Button text to show friends list" + }, + "followers": { + "text": "Followers", + "context": "Button text to show followers list" + } + }, + "sorting": { + "label": { + "text": "Sort by:", + "context": "Label for the sort by selector" + }, + "username": { + "text": "Username", + "context": "Sorting option to sort by username" + }, + "recentlyActive": { + "text": "Recently active", + "context": "Sorting option to sort by recently active users" + } + }, + "showMore": { + "text": "Show more", + "context": "Button text to load more users" + }, + "emptyState": { + "text": "No users found", + "context": "Message displayed when there are no users in the list" + } } } } diff --git a/lib/i18n/messages/ru.json b/lib/i18n/messages/ru.json index 98c56a6..0a2a387 100644 --- a/lib/i18n/messages/ru.json +++ b/lib/i18n/messages/ru.json @@ -317,6 +317,23 @@ "showing": "Показано {start} - {end} из {total}" } } + }, + "friends": { + "meta": { + "title": "Ваши друзья | {appName}" + }, + "header": "Ваши связи", + "tabs": { + "friends": "Друзья", + "followers": "Подписчики" + }, + "sorting": { + "label": "Сортировать по:", + "username": "Имя пользователя", + "recentlyActive": "Недавно активные" + }, + "showMore": "Показать ещё", + "emptyState": "Пользователи не найдены" } } } From 76a4447e2ce11edf43f764fa32eeadf275d1cca1 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 21 Dec 2025 03:04:49 +0200 Subject: [PATCH 22/31] feat: Localise beatmaps page --- app/(website)/beatmaps/[id]/layout.tsx | 16 +++ app/(website)/beatmaps/[id]/page.tsx | 11 +- app/(website)/beatmaps/search/layout.tsx | 16 +++ app/(website)/beatmaps/search/page.tsx | 6 +- components/Beatmaps/Search/BeatmapsSearch.tsx | 47 ++++---- .../Beatmaps/Search/BeatmapsSearchFilters.tsx | 34 +++--- lib/i18n/messages/en.json | 106 ++++++++++++++++++ lib/i18n/messages/ru.json | 46 ++++++++ 8 files changed, 241 insertions(+), 41 deletions(-) create mode 100644 app/(website)/beatmaps/[id]/layout.tsx create mode 100644 app/(website)/beatmaps/search/layout.tsx diff --git a/app/(website)/beatmaps/[id]/layout.tsx b/app/(website)/beatmaps/[id]/layout.tsx new file mode 100644 index 0000000..03be5a7 --- /dev/null +++ b/app/(website)/beatmaps/[id]/layout.tsx @@ -0,0 +1,16 @@ +import { Metadata } from "next"; +import Page from "./page"; +import { getT } from "@/lib/i18n/utils"; + +export async function generateMetadata(): Promise { + const t = await getT("pages.beatmaps.detail.meta"); + return { + title: t("title"), + openGraph: { + title: t("title"), + }, + }; +} + +export default Page; + diff --git a/app/(website)/beatmaps/[id]/page.tsx b/app/(website)/beatmaps/[id]/page.tsx index b05a450..823ab2f 100644 --- a/app/(website)/beatmaps/[id]/page.tsx +++ b/app/(website)/beatmaps/[id]/page.tsx @@ -8,12 +8,14 @@ import RoundedContent from "@/components/General/RoundedContent"; import Image from "next/image"; import { Music2 } from "lucide-react"; import { tryParseNumber } from "@/lib/utils/type.util"; +import { useT } from "@/lib/i18n/utils"; interface BeatmapsProps { params: Promise<{ id: string }>; } export default function BeatmapsRedirect(props: BeatmapsProps) { + const t = useT("pages.beatmaps.detail"); const params = use(props.params); const beatmapQuery = useBeatmap(tryParseNumber(params.id) ?? 0); const beatmap = beatmapQuery.data; @@ -27,17 +29,14 @@ export default function BeatmapsRedirect(props: BeatmapsProps) {
} - text="Beatmap info" + text={t("header")} className="bg-terracotta-700 mb-4" roundBottom={true} />
-

Beatmapset not found

-

- The beatmapset you are looking for does not exist or has been - deleted. -

+

{t("notFound.title")}

+

{t("notFound.description")}

{ + const t = await getT("pages.beatmaps.search.meta"); + return { + title: t("title"), + openGraph: { + title: t("title"), + }, + }; +} + +export default Page; + diff --git a/app/(website)/beatmaps/search/page.tsx b/app/(website)/beatmaps/search/page.tsx index 9806138..b1193ed 100644 --- a/app/(website)/beatmaps/search/page.tsx +++ b/app/(website)/beatmaps/search/page.tsx @@ -1,11 +1,15 @@ +"use client"; + import BeatmapsSearch from "@/components/Beatmaps/Search/BeatmapsSearch"; import PrettyHeader from "@/components/General/PrettyHeader"; import { Search } from "lucide-react"; +import { useT } from "@/lib/i18n/utils"; export default function Page() { + const t = useT("pages.beatmaps.search"); return (
- } /> + } />
); diff --git a/components/Beatmaps/Search/BeatmapsSearch.tsx b/components/Beatmaps/Search/BeatmapsSearch.tsx index 4949c7a..d8dfb9c 100644 --- a/components/Beatmaps/Search/BeatmapsSearch.tsx +++ b/components/Beatmaps/Search/BeatmapsSearch.tsx @@ -2,7 +2,7 @@ import type React from "react"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Search, Filter, ChevronDown } from "lucide-react"; @@ -16,12 +16,14 @@ import { twMerge } from "tailwind-merge"; import BeatmapSetOverview from "@/app/(website)/user/[id]/components/BeatmapSetOverview"; import useDebounce from "@/lib/hooks/useDebounce"; import { Card, CardContent } from "@/components/ui/card"; +import { useT } from "@/lib/i18n/utils"; export default function BeatmapsSearch({ forceThreeGridCols = false, }: { forceThreeGridCols?: boolean; }) { + const t = useT("pages.beatmaps.components.search"); const [modeFilter, setModeFilter] = useState(null); const [statusFilter, setStatusFilter] = useState([ BeatmapStatusWeb.RANKED, @@ -54,21 +56,24 @@ export default function BeatmapsSearch({ const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === "undefined"); - const handleShowMore = () => { + const handleShowMore = useCallback(() => { setSize(size + 1); - }; - - const applyFilters = (filters: { - mode: GameMode | null; - status: BeatmapStatusWeb[] | null; - searchByCustomStatus: boolean; - }) => { - let { mode, status, searchByCustomStatus } = filters; - - setModeFilter(mode); - setStatusFilter(status ?? null); - setSearchByCustomStatus(searchByCustomStatus); - }; + }, [setSize, size]); + + const applyFilters = useCallback( + (filters: { + mode: GameMode | null; + status: BeatmapStatusWeb[] | null; + searchByCustomStatus: boolean; + }) => { + let { mode, status, searchByCustomStatus } = filters; + + setModeFilter(mode); + setStatusFilter(status ?? null); + setSearchByCustomStatus(searchByCustomStatus); + }, + [] + ); return (
@@ -79,7 +84,7 @@ export default function BeatmapsSearch({ setSearchQuery(e.target.value)} @@ -92,7 +97,7 @@ export default function BeatmapsSearch({ onClick={() => setShowFilters(!showFilters)} > - Filters + {t("filters")} {(statusFilter != null || modeFilter != null) && (
{[statusFilter, modeFilter].filter((v) => v != null).length} @@ -108,9 +113,9 @@ export default function BeatmapsSearch({ onValueChange={setViewMode} className="h-9 " > - - Grid - List + + {t("viewMode.grid")} + {t("viewMode.list")}
@@ -171,7 +176,7 @@ export default function BeatmapsSearch({ variant="secondary" > - Show more + {t("showMore")}
)} diff --git a/components/Beatmaps/Search/BeatmapsSearchFilters.tsx b/components/Beatmaps/Search/BeatmapsSearchFilters.tsx index 1f2ec9a..d55e355 100644 --- a/components/Beatmaps/Search/BeatmapsSearchFilters.tsx +++ b/components/Beatmaps/Search/BeatmapsSearchFilters.tsx @@ -12,7 +12,8 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { BeatmapStatusWeb, GameMode } from "@/lib/types/api"; -import { useState } from "react"; +import { useState, useCallback, useMemo } from "react"; +import { useT } from "@/lib/i18n/utils"; const beatmapSearchStatusList = Object.values(BeatmapStatusWeb) .filter((v) => v != BeatmapStatusWeb.UNKNOWN) @@ -41,45 +42,50 @@ export function BeatmapsSearchFilters({ defaultMode, defaultStatus, }: BeatmapFiltersProps) { + const t = useT("pages.beatmaps.components.filters"); const [mode, setMode] = useState(defaultMode); const [status, setStatus] = useState( defaultStatus ); const [searchByCustomStatus, setSearchByCustomStatus] = useState(false); - const handleApplyFilters = () => { + const handleApplyFilters = useCallback(() => { onApplyFilters({ mode, status: (status?.length ?? 0) > 0 ? status : null, searchByCustomStatus, }); - }; + }, [onApplyFilters, mode, status, searchByCustomStatus]); return (
- +
- +
- + @@ -105,7 +113,7 @@ export function BeatmapsSearchFilters({ className="flex-1" isLoading={isLoading} > - Apply Filters + {t("applyFilters")}
diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json index 7e31df4..f876e5e 100644 --- a/lib/i18n/messages/en.json +++ b/lib/i18n/messages/en.json @@ -891,6 +891,112 @@ "text": "No users found", "context": "Message displayed when there are no users in the list" } + }, + "beatmaps": { + "search": { + "meta": { + "title": { + "text": "Beatmaps search | {appName}", + "context": "The title for the beatmaps search page of the osu!sunrise website" + } + }, + "header": { + "text": "Beatmaps search", + "context": "The main header text for the beatmaps search page" + } + }, + "detail": { + "meta": { + "title": { + "text": "Beatmap info | {appName}", + "context": "The title for the beatmap detail page of the osu!sunrise website" + } + }, + "header": { + "text": "Beatmap info", + "context": "The header text for the beatmap detail page" + }, + "notFound": { + "title": { + "text": "Beatmapset not found", + "context": "Title displayed when a beatmapset is not found" + }, + "description": { + "text": "The beatmapset you are looking for does not exist or has been deleted.", + "context": "Description message when a beatmapset is not found or has been deleted" + } + } + }, + "components": { + "search": { + "searchPlaceholder": { + "text": "Search beatmaps...", + "context": "Placeholder text for the beatmaps search input field" + }, + "filters": { + "text": "Filters", + "context": "Button text to toggle filters panel" + }, + "viewMode": { + "grid": { + "text": "Grid", + "context": "Button text for grid view mode" + }, + "list": { + "text": "List", + "context": "Button text for list view mode" + } + }, + "showMore": { + "text": "Show more", + "context": "Button text to load more beatmapsets" + } + }, + "filters": { + "mode": { + "label": { + "text": "Mode", + "context": "Label for the game mode filter selector" + }, + "any": { + "text": "Any", + "context": "Option to select any game mode (no filter)" + }, + "standard": { + "text": "osu!", + "context": "Game mode option for osu!standard" + }, + "taiko": { + "text": "osu!taiko", + "context": "Game mode option for osu!taiko" + }, + "catch": { + "text": "osu!catch", + "context": "Game mode option for osu!catch" + }, + "mania": { + "text": "osu!mania", + "context": "Game mode option for osu!mania" + } + }, + "status": { + "label": { + "text": "Status", + "context": "Label for the beatmap status filter selector" + } + }, + "searchByCustomStatus": { + "label": { + "text": "Search by Custom Status", + "context": "Label for the search by custom status toggle switch" + } + }, + "applyFilters": { + "text": "Apply Filters", + "context": "Button text to apply the selected filters" + } + } + } } } } diff --git a/lib/i18n/messages/ru.json b/lib/i18n/messages/ru.json index 0a2a387..1be7f96 100644 --- a/lib/i18n/messages/ru.json +++ b/lib/i18n/messages/ru.json @@ -334,6 +334,52 @@ }, "showMore": "Показать ещё", "emptyState": "Пользователи не найдены" + }, + "beatmaps": { + "search": { + "meta": { + "title": "Поиск карт | {appName}" + }, + "header": "Поиск карт" + }, + "detail": { + "meta": { + "title": "Информация о карте | {appName}" + }, + "header": "Информация о карте", + "notFound": { + "title": "Набор карт не найден", + "description": "Набор карт, который вы ищете, не существует или был удалён." + } + }, + "components": { + "search": { + "searchPlaceholder": "Поиск карт...", + "filters": "Фильтры", + "viewMode": { + "grid": "Сетка", + "list": "Список" + }, + "showMore": "Показать ещё" + }, + "filters": { + "mode": { + "label": "Режим", + "any": "Любой", + "standard": "osu!", + "taiko": "osu!taiko", + "catch": "osu!catch", + "mania": "osu!mania" + }, + "status": { + "label": "Статус" + }, + "searchByCustomStatus": { + "label": "Поиск по пользовательскому статусу" + }, + "applyFilters": "Применить фильтры" + } + } } } } From 640cbf33dae2d2fb1bbb95621ca935d145fe9f30 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 21 Dec 2025 04:25:47 +0200 Subject: [PATCH 23/31] feat: Localise beatmapsets page --- app/(website)/beatmapsets/[...ids]/layout.tsx | 34 +- app/(website)/beatmapsets/[...ids]/page.tsx | 23 +- .../components/BeatmapDropdown.tsx | 10 +- .../components/BeatmapInfoAccordion.tsx | 17 +- .../components/BeatmapLeaderboard.tsx | 3 +- .../components/BeatmapNomination.tsx | 30 +- .../components/DifficultyInformation.tsx | 30 +- .../components/DownloadButtons.tsx | 14 +- .../components/PPCalculatorDialog.tsx | 74 ++- .../components/ScoreLeaderboardData.tsx | 10 +- .../components/leaderboard/ScoreColumns.tsx | 534 +++++++++--------- .../components/leaderboard/ScoreDataTable.tsx | 28 +- lib/i18n/messages/en.json | 358 ++++++++++++ lib/i18n/messages/ru.json | 136 +++++ lib/utils/timeSince.ts | 15 +- lib/utils/toPrettyDate.ts | 5 +- 16 files changed, 956 insertions(+), 365 deletions(-) diff --git a/app/(website)/beatmapsets/[...ids]/layout.tsx b/app/(website)/beatmapsets/[...ids]/layout.tsx index b90dd0d..04a7c7a 100644 --- a/app/(website)/beatmapsets/[...ids]/layout.tsx +++ b/app/(website)/beatmapsets/[...ids]/layout.tsx @@ -4,6 +4,7 @@ import { notFound } from "next/navigation"; import { getBeatmapStarRating } from "@/lib/utils/getBeatmapStarRating"; import fetcher from "@/lib/services/fetcher"; import { BeatmapSetResponse } from "@/lib/types/api"; +import { getT } from "@/lib/i18n/utils"; export async function generateMetadata( props: BeatmapsetProps @@ -27,19 +28,32 @@ export async function generateMetadata( ) : null; + const t = await getT("pages.beatmapsets.meta"); + + const difficultyInfo = beatmap + ? `[${beatmap.version}] ★${getBeatmapStarRating(beatmap).toFixed(2)}` + : ""; + return { - title: `${beatmapSet.artist} - ${beatmapSet.title} | osu!sunrise`, - description: `Beatmapset info for ${beatmapSet.title} by ${beatmapSet.artist}`, + title: t("title", { + artist: beatmapSet.artist, + title: beatmapSet.title, + }), + description: t("description", { + title: beatmapSet.title, + artist: beatmapSet.artist, + }), openGraph: { siteName: "osu!sunrise", - title: `${beatmapSet.artist} - ${beatmapSet.title} | osu!sunrise`, - description: `Beatmapset info for ${beatmapSet.title} by ${ - beatmapSet.artist - } ${ - beatmap - ? `[${beatmap.version}] ★${getBeatmapStarRating(beatmap).toFixed(2)}` - : "" - }`, + title: t("openGraph.title", { + artist: beatmapSet.artist, + title: beatmapSet.title, + }), + description: t("openGraph.description", { + title: beatmapSet.title, + artist: beatmapSet.artist, + difficultyInfo: difficultyInfo, + }), images: [ `https://assets.ppy.sh/beatmaps/${beatmapSetId}/covers/list@2x.jpg`, ], diff --git a/app/(website)/beatmapsets/[...ids]/page.tsx b/app/(website)/beatmapsets/[...ids]/page.tsx index 40e2e36..e7a9c84 100644 --- a/app/(website)/beatmapsets/[...ids]/page.tsx +++ b/app/(website)/beatmapsets/[...ids]/page.tsx @@ -24,12 +24,14 @@ import { BeatmapResponse, BeatmapStatusWeb, GameMode } from "@/lib/types/api"; import { BBCodeReactParser } from "@/components/BBCode/BBCodeReactParser"; import { BeatmapInfoAccordion } from "@/app/(website)/beatmapsets/components/BeatmapInfoAccordion"; import { BeatmapNominatorUser } from "@/app/(website)/beatmapsets/components/BeatmapNominatorUser"; +import { useT } from "@/lib/i18n/utils"; export interface BeatmapsetProps { params: Promise<{ ids: (string | undefined)[] }>; } export default function Beatmapset(props: BeatmapsetProps) { + const t = useT("pages.beatmapsets"); const params = use(props.params); const pathname = usePathname(); @@ -125,11 +127,11 @@ export default function Beatmapset(props: BeatmapsetProps) { ); const errorMessage = - beatmapsetQuery?.error?.message ?? "Beatmapset not found"; + beatmapsetQuery?.error?.message ?? t("error.notFound.title"); return (
- } text="Beatmap info" roundBottom={true}> + } text={t("header")} roundBottom={true}>
- submitted by  + {t("submission.submittedBy")} 

{beatmapSet.creator || "Unknown"}

- submitted on  + {t("submission.submittedOn")}  - ranked on  + {t("submission.rankedOn")} 
- {activeBeatmap.status} by{" "} + {t("submission.statusBy", { + status: activeBeatmap.status, + })}{" "}
{beatmapSet.video && (
- +
@@ -275,7 +279,7 @@ export default function Beatmapset(props: BeatmapsetProps) {
} - text="Description" + text={t("description.header")} className="font-normal py-2 px-4" /> @@ -316,8 +320,7 @@ export default function Beatmapset(props: BeatmapsetProps) {

{errorMessage}

- The beatmapset you are looking for does not exist or has been - deleted. + {t("error.notFound.description")}

{ @@ -34,27 +36,27 @@ export function BeatmapDropdown({ e.preventDefault()}> - PP Calculator + {t("ppCalculator")} {includeOpenBanchoButton == "true" && ( - Open on Bancho + {t("openOnBancho")} )} {self && isUserHasBATPrivilege(self) && ( - Open with Admin Panel + {t("openWithAdminPanel")} )} diff --git a/app/(website)/beatmapsets/components/BeatmapInfoAccordion.tsx b/app/(website)/beatmapsets/components/BeatmapInfoAccordion.tsx index b78e352..d6bb394 100644 --- a/app/(website)/beatmapsets/components/BeatmapInfoAccordion.tsx +++ b/app/(website)/beatmapsets/components/BeatmapInfoAccordion.tsx @@ -10,6 +10,7 @@ import { import { BeatmapResponse, BeatmapSetResponse } from "@/lib/types/api"; import { Info, Rocket } from "lucide-react"; import { useEffect, useState } from "react"; +import { useT } from "@/lib/i18n/utils"; export function BeatmapInfoAccordion({ beatmapSet, @@ -18,6 +19,7 @@ export function BeatmapInfoAccordion({ beatmapSet: BeatmapSetResponse; beatmap: BeatmapResponse; }) { + const t = useT("pages.beatmapsets.components.infoAccordion"); const [isScreenSmall, setAccordionType] = useState(false); useEffect(() => { @@ -42,7 +44,7 @@ export function BeatmapInfoAccordion({
-

Community Hype

+

{t("communityHype")}

@@ -54,7 +56,7 @@ export function BeatmapInfoAccordion({
-

Information

+

{t("information")}

@@ -69,7 +71,7 @@ export function BeatmapInfoAccordion({
-

Community Hype

+

{t("communityHype")}

@@ -83,7 +85,7 @@ export function BeatmapInfoAccordion({
-

Information

+

{t("information")}

@@ -97,20 +99,21 @@ export function BeatmapInfoAccordion({ } function BeatmapMetadata({ beatmapSet }: { beatmapSet: BeatmapSetResponse }) { + const t = useT("pages.beatmapsets.components.infoAccordion.metadata"); return ( <>
-

Genre

+

{t("genre")}

{beatmapSet.genre}

-

Language

+

{t("language")}

{beatmapSet.language}

-

Tags

+

{t("tags")}

{beatmapSet.tags.map((tag) => `${tag}`).join(", ")}

diff --git a/app/(website)/beatmapsets/components/BeatmapLeaderboard.tsx b/app/(website)/beatmapsets/components/BeatmapLeaderboard.tsx index 4f82bde..4e90022 100644 --- a/app/(website)/beatmapsets/components/BeatmapLeaderboard.tsx +++ b/app/(website)/beatmapsets/components/BeatmapLeaderboard.tsx @@ -3,7 +3,7 @@ import { useBeatmapLeaderboard } from "@/lib/hooks/api/beatmap/useBeatmapLeaderb import { ScoreDataTable } from "@/app/(website)/beatmapsets/components/leaderboard/ScoreDataTable"; import { useState } from "react"; import { tryParseNumber } from "@/lib/utils/type.util"; -import { scoreColumns } from "@/app/(website)/beatmapsets/components/leaderboard/ScoreColumns"; +import { useScoreColumns } from "@/app/(website)/beatmapsets/components/leaderboard/ScoreColumns"; import ScoreLeaderboardData from "@/app/(website)/beatmapsets/components/ScoreLeaderboardData"; import useSelf from "@/lib/hooks/useSelf"; import { ModsSelector } from "@/app/(website)/beatmapsets/components/leaderboard/ModsSelector"; @@ -19,6 +19,7 @@ export default function BeatmapLeaderboard({ mode, }: BeatmapLeaderboardProps) { const { self } = useSelf(); + const scoreColumns = useScoreColumns(); const [preferedNumberOfScoresPerLeaderboard] = useState(() => { return localStorage.getItem("preferedNumberOfScoresPerLeaderboard"); diff --git a/app/(website)/beatmapsets/components/BeatmapNomination.tsx b/app/(website)/beatmapsets/components/BeatmapNomination.tsx index a4765fe..dd4661e 100644 --- a/app/(website)/beatmapsets/components/BeatmapNomination.tsx +++ b/app/(website)/beatmapsets/components/BeatmapNomination.tsx @@ -9,8 +9,11 @@ import { import useSelf from "@/lib/hooks/useSelf"; import { BeatmapResponse } from "@/lib/types/api"; import { Megaphone } from "lucide-react"; +import { useT } from "@/lib/i18n/utils"; +import { ReactNode } from "react"; export function BeatmapNomination({ beatmap }: { beatmap: BeatmapResponse }) { + const t = useT("pages.beatmapsets.components.nomination"); const { self } = useSelf(); const { trigger } = useBeatmapSetAddHype(beatmap.beatmapset_id); @@ -27,14 +30,14 @@ export function BeatmapNomination({ beatmap }: { beatmap: BeatmapResponse }) { trigger(null, { onSuccess: () => { toast({ - title: "Beatmap hyped successfully!", + title: t("toast.success"), variant: "success", }); }, onError: (err) => { toast({ - title: "Error occured while hyping beatmapset!", - description: err.message ?? "Unknown error.", + title: t("toast.error"), + description: err.message ?? t("toast.error"), variant: "destructive", }); }, @@ -53,15 +56,12 @@ export function BeatmapNomination({ beatmap }: { beatmap: BeatmapResponse }) { return (
-

- Hype this map if you enjoyed playing it to help it progress to{" "} - Ranked status. -

+

{t.rich("description")}

- Hype progress + {t("hypeProgress")} {current_hypes}/{required_hypes} @@ -92,18 +92,18 @@ export function BeatmapNomination({ beatmap }: { beatmap: BeatmapResponse }) { variant="secondary" > - Hype beatmap! + {t("hypeBeatmap")} {self && (

- You have - - {" "} - {userHypesLeft} hypes{" "} - - remaining for this week + {t.rich("hypesRemaining", { + b: (chunks: ReactNode) => ( + {chunks} + ), + count: userHypesLeft, + })}

)} diff --git a/app/(website)/beatmapsets/components/DifficultyInformation.tsx b/app/(website)/beatmapsets/components/DifficultyInformation.tsx index 156b430..2e308eb 100644 --- a/app/(website)/beatmapsets/components/DifficultyInformation.tsx +++ b/app/(website)/beatmapsets/components/DifficultyInformation.tsx @@ -10,6 +10,7 @@ import { twMerge } from "tailwind-merge"; import { Button } from "@/components/ui/button"; import { gameModeToVanilla } from "@/lib/utils/gameMode.util"; import { BeatmapResponse, GameMode } from "@/lib/types/api"; +import { useT } from "@/lib/i18n/utils"; interface DifficultyInformationProps { beatmap: BeatmapResponse; @@ -20,6 +21,7 @@ export default function DifficultyInformation({ beatmap, activeMode, }: DifficultyInformationProps) { + const t = useT("pages.beatmapsets.components.difficultyInformation"); const { player, currentTimestamp, isPlaying, isPlayingThis, pause, play } = useAudioPlayer(); @@ -51,7 +53,7 @@ export default function DifficultyInformation({ }} size="sm" variant="accent" - className=" relative text-xs min-h-8 bg-opacity-80 px-6 py-1 min-w-full rounded-lg overflow-hidden max-w-64" + className="relative text-xs min-h-8 bg-opacity-80 px-6 py-1 min-w-64 rounded-lg overflow-hidden w-full" > {isPlayingCurrent ? ( @@ -73,19 +75,19 @@ export default function DifficultyInformation({ - +

{SecondsToString(beatmap.total_length)}

- +

{beatmap.bpm}

- +

{" "} {getBeatmapStarRating(beatmap, activeMode).toFixed(2)} @@ -94,24 +96,30 @@ export default function DifficultyInformation({ -

+
{possibleKeysValue && isCurrentGamemode(GameMode.MANIA) && ( )} {isCurrentGamemode([GameMode.STANDARD, GameMode.CATCH_THE_BEAT]) && ( - + )} - + {isCurrentGamemode([GameMode.STANDARD, GameMode.CATCH_THE_BEAT]) && ( )} @@ -130,7 +138,7 @@ function ValueWithProgressBar({ }) { return (
-

{title}

+

{title}

{value.toFixed(1)}

diff --git a/app/(website)/beatmapsets/components/DownloadButtons.tsx b/app/(website)/beatmapsets/components/DownloadButtons.tsx index a8e5952..1d73154 100644 --- a/app/(website)/beatmapsets/components/DownloadButtons.tsx +++ b/app/(website)/beatmapsets/components/DownloadButtons.tsx @@ -3,6 +3,8 @@ import { BeatmapSetResponse } from "@/lib/types/api"; import { Download } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { useT } from "@/lib/i18n/utils"; +import useSelf from "@/lib/hooks/useSelf"; interface DownloadButtonsProps { beatmapSet: BeatmapSetResponse; @@ -22,6 +24,8 @@ export default function DownloadButtons({ vairant = "secondary", size = "xl", }: DownloadButtonsProps) { + const t = useT("pages.beatmapsets.components.downloadButtons"); + const { self } = useSelf(); const router = useRouter(); return ( @@ -32,9 +36,9 @@ export default function DownloadButtons({ >
- Download + {t("download")} {beatmapSet.video ? ( -

with Video

+

{t("withVideo")}

) : undefined}
@@ -47,8 +51,8 @@ export default function DownloadButtons({ >
- Download -

without Video

+ {t("download")} +

{t("withoutVideo")}

@@ -57,7 +61,7 @@ export default function DownloadButtons({ diff --git a/app/(website)/beatmapsets/components/PPCalculatorDialog.tsx b/app/(website)/beatmapsets/components/PPCalculatorDialog.tsx index d218466..36f3a05 100644 --- a/app/(website)/beatmapsets/components/PPCalculatorDialog.tsx +++ b/app/(website)/beatmapsets/components/PPCalculatorDialog.tsx @@ -32,26 +32,34 @@ import numberWith from "@/lib/utils/numberWith"; import { SecondsToString } from "@/lib/utils/secondsTo"; import { zodResolver } from "@hookform/resolvers/zod"; import { Clock9, Star } from "lucide-react"; -import { useState } from "react"; +import { useState, useMemo, ReactNode } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; - -const formSchema = z.object({ - accuracy: z.coerce - .number() - .min(0, { - message: "Accuracy can't be negative", - }) - .max(100, { - message: "Accuracy can't be greater that 100", - }), - combo: z.coerce.number().int().min(0, { - message: "Combo can't be negative", - }), - misses: z.coerce.number().int().min(0, { - message: "Misses can't be negative", - }), -}); +import { useT } from "@/lib/i18n/utils"; + +const createFormSchema = (t: ReturnType) => + z.object({ + accuracy: z.coerce + .number() + .min(0, { + message: t("form.accuracy.validation.negative"), + }) + .max(100, { + message: t("form.accuracy.validation.tooHigh"), + }), + combo: z.coerce + .number() + .int() + .min(0, { + message: t("form.combo.validation.negative"), + }), + misses: z.coerce + .number() + .int() + .min(0, { + message: t("form.misses.validation.negative"), + }), + }); export function PPCalculatorDialog({ beatmap, @@ -62,6 +70,7 @@ export function PPCalculatorDialog({ mode: GameMode; children: React.ReactNode; }) { + const t = useT("pages.beatmapsets.components.ppCalculator"); const [isOpen, setIsOpen] = useState(false); const defaultValues = { @@ -70,6 +79,8 @@ export function PPCalculatorDialog({ misses: 0, }; + const formSchema = useMemo(() => createFormSchema(t), [t]); + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues, @@ -120,7 +131,7 @@ export function PPCalculatorDialog({ {children} - PP Calculator + {t("title")} @@ -128,13 +139,18 @@ export function PPCalculatorDialog({ {performanceResult ? ( <>

- PP:{" "} - - ~{numberWith(performanceResult?.pp.toFixed(2), ",")} - + {t.rich("pp", { + b: (chunks: ReactNode) => ( + {chunks} + ), + value: `~${numberWith( + performanceResult?.pp.toFixed(2), + "," + )}`, + })}

- +

@@ -163,7 +179,7 @@ export function PPCalculatorDialog({ name="accuracy" render={({ field }) => ( - Accuracy + {t("form.accuracy.label")} @@ -176,7 +192,7 @@ export function PPCalculatorDialog({ name="combo" render={({ field }) => ( - Combo + {t("form.combo.label")} @@ -189,7 +205,7 @@ export function PPCalculatorDialog({ name="misses" render={({ field }) => ( - Misses + {t("form.misses.label")} @@ -215,12 +231,12 @@ export function PPCalculatorDialog({ {error && (

- {error?.message ?? "Unknown error"} + {error?.message ?? t("form.unknownError")}

)} diff --git a/app/(website)/beatmapsets/components/ScoreLeaderboardData.tsx b/app/(website)/beatmapsets/components/ScoreLeaderboardData.tsx index 5c7349f..1cddde0 100644 --- a/app/(website)/beatmapsets/components/ScoreLeaderboardData.tsx +++ b/app/(website)/beatmapsets/components/ScoreLeaderboardData.tsx @@ -20,6 +20,7 @@ import useSelf from "@/lib/hooks/useSelf"; import { useDownloadReplay } from "@/lib/hooks/api/score/useDownloadReplay"; import UserHoverCard from "@/components/UserHoverCard"; import { BeatmapResponse, ScoreResponse } from "@/lib/types/api"; +import { useT } from "@/lib/i18n/utils"; export default function ScoreLeaderboardData({ score, @@ -30,7 +31,7 @@ export default function ScoreLeaderboardData({ }) { return ( -
+

# {score.leaderboard_rank}

@@ -91,6 +92,7 @@ export default function ScoreLeaderboardData({ } function ScoreDropdownInfo({ score }: { score: ScoreResponse }) { + const t = useT("pages.beatmapsets.components.leaderboard.actions"); const { self } = useSelf(); const { downloadReplay } = useDownloadReplay(score.id); @@ -98,19 +100,19 @@ function ScoreDropdownInfo({ score }: { score: ScoreResponse }) { - View Details + {t("viewDetails")} - Download Replay + {t("downloadReplay")} diff --git a/app/(website)/beatmapsets/components/leaderboard/ScoreColumns.tsx b/app/(website)/beatmapsets/components/leaderboard/ScoreColumns.tsx index 193f73d..fe7d56d 100644 --- a/app/(website)/beatmapsets/components/leaderboard/ScoreColumns.tsx +++ b/app/(website)/beatmapsets/components/leaderboard/ScoreColumns.tsx @@ -23,274 +23,304 @@ import { Column, ColumnDef, HeaderContext } from "@tanstack/react-table"; import { MoreHorizontal, SortAsc, SortDesc } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; -import { Suspense, useContext } from "react"; +import { Suspense, useContext, useMemo } from "react"; +import { useT } from "@/lib/i18n/utils"; -export const scoreColumns: ColumnDef[] = [ - { - accessorKey: "rank", - sortingFn: (a, b) => { - return a.index - b.index; - }, - header: ({ column }) => sortableHeader({ column, title: "Rank" }), - cell: ({ row }) => { - const value = row.index + 1; +export const useScoreColumns = (): ColumnDef[] => { + const t = useT("pages.beatmapsets.components.leaderboard"); - return
# {value}
; - }, - }, - { - accessorKey: "grade", - header: "", - cell: ({ row }) => { - const { grade } = row.original; - return ( -

- {grade} -

- ); - }, - }, - { - accessorKey: "total_score", - header: ({ column }) => sortableHeader({ column, title: "Score" }), - cell: ({ row }) => { - const formatted = numberWith(row.original.total_score, ","); - return

{formatted}

; - }, - }, - { - accessorKey: "accuracy", - header: ({ column }) => sortableHeader({ column, title: "Accuracy" }), - cell: ({ row }) => { - const { accuracy } = row.original; - const formatted = accuracy.toFixed(2); + return useMemo( + () => [ + { + accessorKey: "rank", + sortingFn: (a, b) => { + return a.index - b.index; + }, + header: ({ column }) => + sortableHeader({ column, title: t("columns.rank") }), + cell: ({ row }) => { + const value = row.index + 1; - return ( -
- {formatted}% -
- ); - }, - }, - { - accessorKey: "flag", - header: "", - cell: ({ row }) => { - const countryCode = row.original.user.country_code; - return ( - User Flag - ); - }, - }, - { - accessorKey: "user.username", - header: ({ column }) => sortableHeader({ column, title: "Player" }), - cell: ({ row }) => { - const userId = row.original.user.user_id; - const username = row.original.user.username; + return
# {value}
; + }, + }, + { + accessorKey: "grade", + header: "", + cell: ({ row }) => { + const { grade } = row.original; + return ( +

+ {grade} +

+ ); + }, + }, + { + accessorKey: "total_score", + header: ({ column }) => + sortableHeader({ column, title: t("columns.score") }), + cell: ({ row }) => { + const formatted = numberWith(row.original.total_score, ","); + return ( +

{formatted}

+ ); + }, + }, + { + accessorKey: "accuracy", + header: ({ column }) => + sortableHeader({ column, title: t("columns.accuracy") }), + cell: ({ row }) => { + const { accuracy } = row.original; + const formatted = accuracy.toFixed(2); - return ( -
- - UA}> - logo - - + return ( +
+ {formatted}% +
+ ); + }, + }, + { + accessorKey: "flag", + header: "", + cell: ({ row }) => { + const countryCode = row.original.user.country_code; + return ( + User Flag + ); + }, + }, + { + accessorKey: "user.username", + header: ({ column }) => + sortableHeader({ column, title: t("columns.player") }), + cell: ({ row }) => { + const userId = row.original.user.user_id; + const username = row.original.user.username; - - - {username} - - -
- ); - }, - }, - { - accessorKey: "max_combo", - header: ({ column }) => sortableHeader({ column, title: "Max Combo" }), - cell: ({ row }) => { - const maxPossibleCombo = useContext(ScoreTableContext)?.beatmap.max_combo; - const { max_combo } = row.original; + return ( +
+ + UA}> + logo + + - return ( -
- {max_combo}x -
- ); - }, - }, - { - accessorKey: "count_geki", - header: ({ column }) => sortableHeader({ column, title: "Perfect" }), - cell: ({ row }) => { - const { count_geki } = row.original; + + + {username} + + +
+ ); + }, + }, + { + accessorKey: "max_combo", + header: ({ column }) => + sortableHeader({ column, title: t("columns.maxCombo") }), + cell: ({ row }) => { + const maxPossibleCombo = + useContext(ScoreTableContext)?.beatmap.max_combo; + const { max_combo } = row.original; - return ( -
- {count_geki} -
- ); - }, - }, - { - accessorKey: "count_300", - header: ({ column }) => sortableHeader({ column, title: "Great" }), - cell: ({ row }) => { - const { count_300 } = row.original; - - return ( -
- {count_300} -
- ); - }, - }, - { - accessorKey: "count_katu", - header: ({ column }) => sortableHeader({ column, title: "Good" }), - cell: ({ row }) => { - const { count_katu } = row.original; + return ( +
+ {max_combo}x +
+ ); + }, + }, + { + accessorKey: "count_geki", + header: ({ column }) => + sortableHeader({ column, title: t("columns.perfect") }), + cell: ({ row }) => { + const { count_geki } = row.original; - return ( -
- {count_katu} -
- ); - }, - }, - { - accessorKey: "count_100", - header: ({ column }) => - sortableHeader({ - column, - title: - gameModeToVanilla( - useContext(ScoreTableContext)?.gamemode ?? GameMode.STANDARD - ) === GameMode.CATCH_THE_BEAT - ? "L DRP" - : "Ok", - }), - cell: ({ row }) => { - const { count_100 } = row.original; + return ( +
+ {count_geki} +
+ ); + }, + }, + { + accessorKey: "count_300", + header: ({ column }) => + sortableHeader({ column, title: t("columns.great") }), + cell: ({ row }) => { + const { count_300 } = row.original; - return ( -
- {count_100} -
- ); - }, - }, - { - accessorKey: "count_50", - header: ({ column }) => - sortableHeader({ - column, - title: - gameModeToVanilla( - useContext(ScoreTableContext)?.gamemode ?? GameMode.STANDARD - ) === GameMode.CATCH_THE_BEAT - ? "S DRP" - : "Meh", - }), - cell: ({ row }) => { - const { count_50 } = row.original; + return ( +
+ {count_300} +
+ ); + }, + }, + { + accessorKey: "count_katu", + header: ({ column }) => + sortableHeader({ column, title: t("columns.good") }), + cell: ({ row }) => { + const { count_katu } = row.original; - return ( -
- {count_50} -
- ); - }, - }, - { - accessorKey: "count_miss", - header: ({ column }) => sortableHeader({ column, title: "Miss" }), - cell: ({ row }) => { - const { count_miss } = row.original; + return ( +
+ {count_katu} +
+ ); + }, + }, + { + accessorKey: "count_100", + header: ({ column }) => + sortableHeader({ + column, + title: + gameModeToVanilla( + useContext(ScoreTableContext)?.gamemode ?? GameMode.STANDARD + ) === GameMode.CATCH_THE_BEAT + ? t("columns.lDrp") + : t("columns.ok"), + }), + cell: ({ row }) => { + const { count_100 } = row.original; - return ( -
- {count_miss} -
- ); - }, - }, - { - accessorKey: "performance_points", - header: ({ column }) => sortableHeader({ column, title: "PP" }), - cell: ({ row }) => { - const { performance_points } = row.original; - const formatted = numberWith(performance_points.toFixed(2), ","); + return ( +
+ {count_100} +
+ ); + }, + }, + { + accessorKey: "count_50", + header: ({ column }) => + sortableHeader({ + column, + title: + gameModeToVanilla( + useContext(ScoreTableContext)?.gamemode ?? GameMode.STANDARD + ) === GameMode.CATCH_THE_BEAT + ? t("columns.sDrp") + : t("columns.meh"), + }), + cell: ({ row }) => { + const { count_50 } = row.original; - return formatted; - }, - }, - { - accessorKey: "when_played", - header: ({ column }) => sortableHeader({ column, title: "Time" }), - cell: ({ row }) => { - const { when_played } = row.original; + return ( +
+ {count_50} +
+ ); + }, + }, + { + accessorKey: "count_miss", + header: ({ column }) => + sortableHeader({ column, title: t("columns.miss") }), + cell: ({ row }) => { + const { count_miss } = row.original; - return ( - - {timeSince(when_played, undefined, true)} - - ); - }, - }, - { - accessorKey: "mods_int", - header: ({ column }) => sortableHeader({ column, title: "Mods" }), - cell: ({ row }) => { - const { mods } = row.original; - return mods; - }, - }, - { - id: "actions", - cell: ({ row }) => { - const score = row.original; + return ( +
+ {count_miss} +
+ ); + }, + }, + { + accessorKey: "performance_points", + header: ({ column }) => + sortableHeader({ column, title: t("columns.pp") }), + cell: ({ row }) => { + const { performance_points } = row.original; + const formatted = numberWith(performance_points.toFixed(2), ","); - const { self } = useSelf(); - const { downloadReplay } = useDownloadReplay(score.id); + return formatted; + }, + }, + { + accessorKey: "when_played", + header: ({ column }) => + sortableHeader({ column, title: t("columns.time") }), + cell: ({ row }) => { + const { when_played } = row.original; - return ( - - - - - - - View Details - - - Download Replay - - {/* TODO: Add report score option */} - - - ); - }, - }, -]; + {timeSince(when_played, undefined, true)} + + ); + }, + }, + { + accessorKey: "mods_int", + header: ({ column }) => + sortableHeader({ column, title: t("columns.mods") }), + cell: ({ row }) => { + const { mods } = row.original; + return mods; + }, + }, + { + id: "actions", + cell: ({ row }) => { + const score = row.original; + + const { self } = useSelf(); + const { downloadReplay } = useDownloadReplay(score.id); + + return ( + + + + + + + + {t("actions.viewDetails")} + + + + {t("actions.downloadReplay")} + + {/* TODO: Add report score option */} + + + ); + }, + }, + ], + [t] + ); +}; const sortableHeader = ({ column, diff --git a/app/(website)/beatmapsets/components/leaderboard/ScoreDataTable.tsx b/app/(website)/beatmapsets/components/leaderboard/ScoreDataTable.tsx index 33f7fcc..7bd8e7b 100644 --- a/app/(website)/beatmapsets/components/leaderboard/ScoreDataTable.tsx +++ b/app/(website)/beatmapsets/components/leaderboard/ScoreDataTable.tsx @@ -34,6 +34,7 @@ import { } from "@/components/ui/select"; import { gameModeToVanilla } from "@/lib/utils/gameMode.util"; import { BeatmapResponse, GameMode } from "@/lib/types/api"; +import { useT } from "@/lib/i18n/utils"; interface DataTableProps { columns: ColumnDef[]; @@ -62,6 +63,7 @@ export function ScoreDataTable({ pagination, setPagination, }: DataTableProps) { + const t = useT("pages.beatmapsets.components.leaderboard.table"); const [sorting, setSorting] = useState([]); const [columnVisibility, setColumnVisibility] = @@ -168,7 +170,7 @@ export function ScoreDataTable({ colSpan={columns.length} className="h-24 text-center" > - No scores found. Be the first to submit one! + {t("emptyState")} )} @@ -194,22 +196,22 @@ export function ScoreDataTable({ 100 -

scores per page

+

{t("pagination.scoresPerPage")}

- Showing{" "} - {Math.min( - pagination.pageIndex * pagination.pageSize + 1, - totalCount - )}{" "} - -{" "} - {Math.min( - (pagination.pageIndex + 1) * pagination.pageSize, - totalCount - )}{" "} - of {totalCount} + {t("pagination.showing", { + start: Math.min( + pagination.pageIndex * pagination.pageSize + 1, + totalCount + ), + end: Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + totalCount + ), + total: totalCount, + })}

diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json index f876e5e..095dcc6 100644 --- a/lib/i18n/messages/en.json +++ b/lib/i18n/messages/en.json @@ -997,6 +997,364 @@ } } } + }, + "beatmapsets": { + "meta": { + "title": { + "text": "{artist} - {title} | {appName}", + "context": "The title for the beatmapset detail page, includes artist and title as parameters" + }, + "description": { + "text": "Beatmapset info for {title} by {artist}", + "context": "The meta description for the beatmapset detail page, includes title and artist as parameters" + }, + "openGraph": { + "title": { + "text": "{artist} - {title} | {appName}", + "context": "The OpenGraph title for the beatmapset detail page, includes artist and title as parameters" + }, + "description": { + "text": "Beatmapset info for {title} by {artist} {difficultyInfo}", + "context": "The OpenGraph description for the beatmapset detail page, includes title, artist, and optional difficulty info as parameters" + } + } + }, + "header": { + "text": "Beatmap info", + "context": "The main header text for the beatmapset detail page" + }, + "error": { + "notFound": { + "title": { + "text": "Beatmapset not found", + "context": "Title displayed when a beatmapset is not found" + }, + "description": { + "text": "The beatmapset you are looking for does not exist or has been deleted.", + "context": "Description message when a beatmapset is not found or has been deleted" + } + } + }, + "submission": { + "submittedBy": { + "text": "submitted by", + "context": "Text label indicating who submitted the beatmapset" + }, + "submittedOn": { + "text": "submitted on", + "context": "Text label indicating when the beatmapset was submitted" + }, + "rankedOn": { + "text": "ranked on", + "context": "Text label indicating when the beatmapset was ranked" + }, + "statusBy": { + "text": "{status} by", + "context": "Text indicating the beatmap status and who set it, includes status as parameter" + } + }, + "video": { + "tooltip": { + "text": "This beatmap contains video", + "context": "Tooltip text for the video icon indicating the beatmap has a video" + } + }, + "description": { + "header": { + "text": "Description", + "context": "Header text for the beatmapset description section" + } + }, + "components": { + "dropdown": { + "openMenu": { + "text": "Open menu", + "context": "Screen reader text for the dropdown menu button" + }, + "ppCalculator": { + "text": "PP Calculator", + "context": "Dropdown menu item text to open PP calculator" + }, + "openOnBancho": { + "text": "Open on Bancho", + "context": "Dropdown menu item text to open beatmap on official osu! website" + }, + "openWithAdminPanel": { + "text": "Open with Admin Panel", + "context": "Dropdown menu item text to open beatmap in admin panel (for BAT users)" + } + }, + "infoAccordion": { + "communityHype": { + "text": "Community Hype", + "context": "Header text for the community hype section" + }, + "information": { + "text": "Information", + "context": "Header text for the beatmap information section" + }, + "metadata": { + "genre": { + "text": "Genre", + "context": "Label for the beatmap genre" + }, + "language": { + "text": "Language", + "context": "Label for the beatmap language" + }, + "tags": { + "text": "Tags", + "context": "Label for the beatmap tags" + } + } + }, + "downloadButtons": { + "download": { + "text": "Download", + "context": "Button text to download the beatmapset" + }, + "withVideo": { + "text": "with Video", + "context": "Text indicating the download includes video" + }, + "withoutVideo": { + "text": "without Video", + "context": "Text indicating the download excludes video" + }, + "osuDirect": { + "text": "osu!direct", + "context": "Button text for osu!direct download" + } + }, + "difficultyInformation": { + "tooltips": { + "totalLength": { + "text": "Total Length", + "context": "Tooltip text for the total length of the beatmap" + }, + "bpm": { + "text": "BPM", + "context": "Tooltip text for beats per minute" + }, + "starRating": { + "text": "Star Rating", + "context": "Tooltip text for the star rating" + } + }, + "labels": { + "keyCount": { + "text": "Key Count:", + "context": "Label for the key count (mania mode)" + }, + "circleSize": { + "text": "Circle Size:", + "context": "Label for circle size (standard/catch mode)" + }, + "hpDrain": { + "text": "HP Drain:", + "context": "Label for HP drain" + }, + "accuracy": { + "text": "Accuracy:", + "context": "Label for accuracy difficulty" + }, + "approachRate": { + "text": "Approach Rate:", + "context": "Label for approach rate (standard/catch mode)" + } + } + }, + "nomination": { + "description": { + "text": "Hype this map if you enjoyed playing it to help it progress to Ranked status.", + "context": "Description text explaining the hype feature, includes bold tag for Ranked" + }, + "hypeProgress": { + "text": "Hype progress", + "context": "Label for the hype progress indicator" + }, + "hypeBeatmap": { + "text": "Hype beatmap!", + "context": "Button text to hype a beatmap" + }, + "hypesRemaining": { + "text": "You have {count} hypes remaining for this week", + "context": "Text showing remaining hypes for the week, includes count as parameter and bold tag" + }, + "toast": { + "success": { + "text": "Beatmap hyped successfully!", + "context": "Success toast message when a beatmap is hyped" + }, + "error": { + "text": "Error occured while hyping beatmapset!", + "context": "Error toast message when hyping fails" + } + } + }, + "ppCalculator": { + "title": { + "text": "PP Calculator", + "context": "Title of the PP calculator dialog" + }, + "pp": { + "text": "PP: {value}", + "context": "Label for performance points" + }, + "totalLength": { + "text": "Total Length", + "context": "Tooltip text for total length in PP calculator" + }, + "form": { + "accuracy": { + "label": { + "text": "Accuracy", + "context": "Form label for accuracy input" + }, + "validation": { + "negative": { + "text": "Accuracy can't be negative", + "context": "Validation error message for negative accuracy" + }, + "tooHigh": { + "text": "Accuracy can't be greater that 100", + "context": "Validation error message for accuracy over 100" + } + } + }, + "combo": { + "label": { + "text": "Combo", + "context": "Form label for combo input" + }, + "validation": { + "negative": { + "text": "Combo can't be negative", + "context": "Validation error message for negative combo" + } + } + }, + "misses": { + "label": { + "text": "Misses", + "context": "Form label for misses input" + }, + "validation": { + "negative": { + "text": "Misses can't be negative", + "context": "Validation error message for negative misses" + } + } + }, + "calculate": { + "text": "Calculate", + "context": "Button text to calculate PP" + }, + "unknownError": { + "text": "Unknown error", + "context": "Generic error message for unknown errors" + } + } + }, + "leaderboard": { + "columns": { + "rank": { + "text": "Rank", + "context": "Column header for rank" + }, + "score": { + "text": "Score", + "context": "Column header for score" + }, + "accuracy": { + "text": "Accuracy", + "context": "Column header for accuracy" + }, + "player": { + "text": "Player", + "context": "Column header for player" + }, + "maxCombo": { + "text": "Max Combo", + "context": "Column header for max combo" + }, + "perfect": { + "text": "Perfect", + "context": "Column header for perfect hits (geki)" + }, + "great": { + "text": "Great", + "context": "Column header for great hits (300)" + }, + "good": { + "text": "Good", + "context": "Column header for good hits (katu)" + }, + "ok": { + "text": "Ok", + "context": "Column header for ok hits (100)" + }, + "lDrp": { + "text": "L DRP", + "context": "Column header for large droplets (catch mode, 100)" + }, + "meh": { + "text": "Meh", + "context": "Column header for meh hits (50)" + }, + "sDrp": { + "text": "S DRP", + "context": "Column header for small droplets (catch mode, 50)" + }, + "miss": { + "text": "Miss", + "context": "Column header for misses" + }, + "pp": { + "text": "PP", + "context": "Column header for performance points" + }, + "time": { + "text": "Time", + "context": "Column header for time played" + }, + "mods": { + "text": "Mods", + "context": "Column header for mods" + } + }, + "actions": { + "openMenu": { + "text": "Open menu", + "context": "Screen reader text for the dropdown menu button" + }, + "viewDetails": { + "text": "View Details", + "context": "Dropdown menu item text to view score details" + }, + "downloadReplay": { + "text": "Download Replay", + "context": "Dropdown menu item text to download replay" + } + }, + "table": { + "emptyState": { + "text": "No scores found. Be the first to submit one!", + "context": "Message displayed when there are no scores in the leaderboard" + }, + "pagination": { + "scoresPerPage": { + "text": "scores per page", + "context": "Label for the scores per page selector" + }, + "showing": { + "text": "Showing {start} - {end} of {total}", + "context": "Pagination text showing the range of displayed scores, includes start, end, and total as parameters" + } + } + } + } + } } } } diff --git a/lib/i18n/messages/ru.json b/lib/i18n/messages/ru.json index 1be7f96..0901cc5 100644 --- a/lib/i18n/messages/ru.json +++ b/lib/i18n/messages/ru.json @@ -380,6 +380,142 @@ "applyFilters": "Применить фильтры" } } + }, + "beatmapsets": { + "meta": { + "title": "{artist} - {title} | {appName}", + "description": "Информация о наборе карт {title} от {artist}", + "openGraph": { + "title": "{artist} - {title} | {appName}", + "description": "Информация о наборе карт {title} от {artist} {difficultyInfo}" + } + }, + "header": "Информация о карте", + "error": { + "notFound": { + "title": "Набор карт не найден", + "description": "Набор карт, который вы ищете, не существует или был удалён." + } + }, + "submission": { + "submittedBy": "автор", + "submittedOn": "опубликована", + "rankedOn": "стала рейтинговой", + "statusBy": "{status} от" + }, + "video": { + "tooltip": "Эта карта содержит видео" + }, + "description": { + "header": "Описание" + }, + "components": { + "dropdown": { + "openMenu": "Открыть меню", + "ppCalculator": "Калькулятор PP", + "openOnBancho": "Открыть на Bancho", + "openWithAdminPanel": "Открыть в панели администратора" + }, + "infoAccordion": { + "communityHype": "Хайп сообщества", + "information": "Информация", + "metadata": { + "genre": "Жанр", + "language": "Язык", + "tags": "Теги" + } + }, + "downloadButtons": { + "download": "Скачать", + "withVideo": "с видео", + "withoutVideo": "без видео", + "osuDirect": "osu!direct" + }, + "difficultyInformation": { + "tooltips": { + "totalLength": "Общая длина", + "bpm": "BPM", + "starRating": "Звёздный рейтинг" + }, + "labels": { + "keyCount": "Количество клавиш:", + "circleSize": "Размер нот:", + "hpDrain": "Скорость потери HP:", + "accuracy": "Точность:", + "approachRate": "Скорость появления нот:" + } + }, + "nomination": { + "description": "Хайпните эту карту, если вам понравилось играть на ней, чтобы помочь ей перейти в статус Рейтинговая.", + "hypeProgress": "Прогресс хайпа", + "hypeBeatmap": "Хайпнуть карту!", + "hypesRemaining": "У вас осталось {count} хайпов на эту неделю", + "toast": { + "success": "Карта успешно хайпнута!", + "error": "Произошла ошибка при хайпе набора карт!" + } + }, + "ppCalculator": { + "title": "Калькулятор PP", + "pp": "PP: {value}", + "totalLength": "Общая длина", + "form": { + "accuracy": { + "label": "Точность", + "validation": { + "negative": "Точность не может быть отрицательной", + "tooHigh": "Точность не может быть больше 100" + } + }, + "combo": { + "label": "Комбо", + "validation": { + "negative": "Комбо не может быть отрицательным" + } + }, + "misses": { + "label": "Промахи", + "validation": { + "negative": "Промахи не могут быть отрицательными" + } + }, + "calculate": "Рассчитать", + "unknownError": "Неизвестная ошибка" + } + }, + "leaderboard": { + "columns": { + "rank": "Ранг", + "score": "Счёт", + "accuracy": "Точность", + "player": "Игрок", + "maxCombo": "Макс. комбо", + "perfect": "Идеально", + "great": "Отлично", + "good": "Хорошо", + "ok": "Ок", + "lDrp": "Б капля", + "meh": "Плохо", + "sDrp": "М капля", + "miss": "Промах", + "pp": "PP", + "time": "Время", + "mods": "Моды" + }, + "actions": { + "openMenu": "Открыть меню", + "viewDetails": "Посмотреть детали", + "downloadReplay": "Скачать реплей" + }, + "table": { + "emptyState": "Результаты не найдены. Станьте первым, кто отправит результат!", + "pagination": { + "scoresPerPage": "результатов на странице", + "showing": "Показано {start} - {end} из {total}" + } + } + } + } } } } diff --git a/lib/utils/timeSince.ts b/lib/utils/timeSince.ts index 5f05682..8b85b62 100644 --- a/lib/utils/timeSince.ts +++ b/lib/utils/timeSince.ts @@ -1,4 +1,7 @@ +"use client"; + import { toLocalTime } from "@/lib/utils/toLocalTime"; +import Cookies from "js-cookie"; export function timeSince( input: string | Date, @@ -7,7 +10,13 @@ export function timeSince( ) { const date = toLocalTime(input); - const formatter = new Intl.RelativeTimeFormat("en"); + const locale = Cookies.get("locale") || "en"; + + const formatter = new Intl.RelativeTimeFormat(locale); + const formatterDays = new Intl.RelativeTimeFormat(locale, { + numeric: "auto", + }); + const ranges: { [key: string]: number } = { years: 3600 * 24 * 365, months: 3600 * 24 * 30, @@ -22,7 +31,7 @@ export function timeSince( if (forceDays) { const delta = Math.round(secondsElapsed / ranges["days"]); - return delta === 0 ? "Today" : formatter.format(delta, "days"); + return formatterDays.format(delta, "day"); } for (let key in ranges) { if (ranges[key] <= Math.abs(secondsElapsed)) { @@ -44,5 +53,5 @@ export function timeSince( } } - return "Now"; + return formatterDays.format(0, "seconds"); } diff --git a/lib/utils/toPrettyDate.ts b/lib/utils/toPrettyDate.ts index 39237c3..78dc8fb 100644 --- a/lib/utils/toPrettyDate.ts +++ b/lib/utils/toPrettyDate.ts @@ -1,4 +1,5 @@ import { toLocalTime } from "@/lib/utils/toLocalTime"; +import Cookies from "js-cookie"; export default function toPrettyDate(input: string | Date, withTime?: boolean) { const options: Intl.DateTimeFormatOptions = { @@ -13,5 +14,7 @@ export default function toPrettyDate(input: string | Date, withTime?: boolean) { options.hour = "numeric"; } - return toLocalTime(input).toLocaleDateString("en-US", options); + const locale = Cookies.get("locale") || "en"; + + return toLocalTime(input).toLocaleDateString(locale, options); } From 5cd13e4afadd3031fb57234b51daecae232633bd Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 21 Dec 2025 05:00:31 +0200 Subject: [PATCH 24/31] feat: Localise settings page --- .../components/ChangeCountryInput.tsx | 22 +- .../components/ChangeDescriptionInput.tsx | 7 +- .../components/ChangePasswordInput.tsx | 102 +++--- .../components/ChangePlaystyleForm.tsx | 13 +- .../settings/components/ChangeSocialsForm.tsx | 19 +- .../components/ChangeUsernameInput.tsx | 47 +-- .../settings/components/SiteLocalOptions.tsx | 6 +- .../settings/components/UploadImageForm.tsx | 12 +- app/(website)/settings/layout.tsx | 16 +- app/(website)/settings/page.tsx | 258 +++++++-------- lib/i18n/messages/en.json | 300 ++++++++++++++++++ lib/i18n/messages/ru.json | 117 +++++++ 12 files changed, 693 insertions(+), 226 deletions(-) diff --git a/app/(website)/settings/components/ChangeCountryInput.tsx b/app/(website)/settings/components/ChangeCountryInput.tsx index 37768e5..2cc46bc 100644 --- a/app/(website)/settings/components/ChangeCountryInput.tsx +++ b/app/(website)/settings/components/ChangeCountryInput.tsx @@ -35,6 +35,8 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { twMerge } from "tailwind-merge"; import { z } from "zod"; +import { useT } from "@/lib/i18n/utils"; +import Cookies from "js-cookie"; const formSchema = zCountryChangeRequest; @@ -45,6 +47,8 @@ export default function ChangeCountryInput({ user: UserResponse; className?: string; }) { + const t = useT("pages.settings.components.country"); + const tCommon = useT("pages.settings.common"); const [error, setError] = useState(null); const self = useSelf(); @@ -87,15 +91,15 @@ export default function ChangeCountryInput({ form.reset(); toast({ - title: "Country flag changed successfully!", + title: t("toast.success"), variant: "success", }); }, onError: (err: any) => { - showError(err.message ?? "Unknown error."); + showError(err.message ?? tCommon("unknownError")); toast({ - title: "Error occured while changing country flag!", - description: err.message ?? "Unknown error.", + title: t("toast.error"), + description: err.message ?? tCommon("unknownError"), variant: "destructive", }); }, @@ -108,7 +112,9 @@ export default function ChangeCountryInput({ } } - const regionNames = new Intl.DisplayNames(["en"], { type: "region" }); + const regionNames = new Intl.DisplayNames([Cookies.get("locale") || "en"], { + type: "region", + }); return (
@@ -119,7 +125,7 @@ export default function ChangeCountryInput({ name="new_country" render={({ field }) => ( - New Country Flag + {t("label")} @@ -129,11 +141,11 @@ export default function ChangePasswordInput() { name="password" render={({ field }) => ( - New Password + {t("labels.new")} @@ -146,11 +158,11 @@ export default function ChangePasswordInput() { name="confirmPassword" render={({ field }) => ( - Confirm Password + {t("labels.confirm")} @@ -160,7 +172,7 @@ export default function ChangePasswordInput() { /> {error &&

{error}

} - + diff --git a/app/(website)/settings/components/ChangePlaystyleForm.tsx b/app/(website)/settings/components/ChangePlaystyleForm.tsx index 49a2437..bfad0c8 100644 --- a/app/(website)/settings/components/ChangePlaystyleForm.tsx +++ b/app/(website)/settings/components/ChangePlaystyleForm.tsx @@ -29,6 +29,7 @@ import { Separator } from "@radix-ui/react-dropdown-menu"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useT } from "@/lib/i18n/utils"; const formSchema = zEditUserMetadataRequest; @@ -41,6 +42,8 @@ export default function ChangePlaystyleForm({ metadata: UserMetadataResponse; className?: string; }) { + const t = useT("pages.settings.components.playstyle"); + const tCommon = useT("pages.settings.common"); const [error, setError] = useState(null); const [playstyle, setPlaystyle] = useState( @@ -84,15 +87,15 @@ export default function ChangePlaystyleForm({ { onSuccess: () => { toast({ - title: "Playstyle updated successfully!", + title: t("toast.success"), variant: "success", }); }, onError: (err) => { - showError(err.message ?? "Unknown error."); + showError(err.message ?? tCommon("unknownError")); toast({ - title: "Error occured while updating playstyle!", - description: err.message ?? "Unknown error.", + title: t("toast.error"), + description: err.message ?? tCommon("unknownError"), variant: "destructive", }); }, @@ -133,7 +136,7 @@ export default function ChangePlaystyleForm({ htmlFor={field.name} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" > - {value} + {t(`options.${value}`)}
diff --git a/app/(website)/settings/components/ChangeSocialsForm.tsx b/app/(website)/settings/components/ChangeSocialsForm.tsx index 0f07ccc..9b2056e 100644 --- a/app/(website)/settings/components/ChangeSocialsForm.tsx +++ b/app/(website)/settings/components/ChangeSocialsForm.tsx @@ -25,6 +25,7 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { twMerge } from "tailwind-merge"; import { z } from "zod"; +import { useT } from "@/lib/i18n/utils"; const formSchema = zEditUserMetadataRequest; @@ -37,6 +38,8 @@ export default function ChangeSocialsForm({ metadata: UserMetadataResponse; className?: string; }) { + const t = useT("pages.settings.components.socials"); + const tCommon = useT("pages.settings.common"); const [error, setError] = useState(null); const self = useSelf(); @@ -71,15 +74,15 @@ export default function ChangeSocialsForm({ { onSuccess: () => { toast({ - title: "Socials updated successfully!", + title: t("toast.success"), variant: "success", }); }, onError: (err) => { - showError(err.message ?? "Unknown error."); + showError(err.message ?? tCommon("unknownError")); toast({ - title: "Error occured while updating socials!", - description: err.message ?? "Unknown error.", + title: t("toast.error"), + description: err.message ?? tCommon("unknownError"), variant: "destructive", }); }, @@ -91,7 +94,7 @@ export default function ChangeSocialsForm({
-

General

+

{t("headings.general")}

{Object.keys(formSchema.shape) .filter((v) => ["location", "interest", "occupation"].includes(v)) @@ -105,7 +108,7 @@ export default function ChangeSocialsForm({ {socialIcons[v as keyof UserMetadataResponse]} -

{v}

+

{t(`fields.${v}`)}

@@ -121,7 +124,7 @@ export default function ChangeSocialsForm({
-

Socials

+

{t("headings.socials")}

{Object.keys(formSchema.shape) .filter( @@ -152,7 +155,7 @@ export default function ChangeSocialsForm({ {error &&

{error}

} - + diff --git a/app/(website)/settings/components/ChangeUsernameInput.tsx b/app/(website)/settings/components/ChangeUsernameInput.tsx index 917b548..452202b 100644 --- a/app/(website)/settings/components/ChangeUsernameInput.tsx +++ b/app/(website)/settings/components/ChangeUsernameInput.tsx @@ -14,28 +14,34 @@ import { Input } from "@/components/ui/input"; import { useToast } from "@/hooks/use-toast"; import { useUsernameChange } from "@/lib/hooks/api/user/useUsernameChange"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useT } from "@/lib/i18n/utils"; -const formSchema = z.object({ - newUsername: z - .string() - .min(2, { - message: "Username must be at least 2 characters.", - }) - .max(32, { - message: "Username must be 32 characters or fewer.", - }), -}); +const createFormSchema = (t: ReturnType) => + z.object({ + newUsername: z + .string() + .min(2, { + message: t("validation.minLength", { min: 2 }), + }) + .max(32, { + message: t("validation.maxLength", { max: 32 }), + }), + }); export default function ChangeUsernameInput() { + const t = useT("pages.settings.components.username"); + const tCommon = useT("pages.settings.common"); const [error, setError] = useState(null); const { toast } = useToast(); const { trigger } = useUsernameChange(); + const formSchema = useMemo(() => createFormSchema(t), [t]); + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -61,15 +67,15 @@ export default function ChangeUsernameInput() { form.reset(); toast({ - title: "Username changed successfully!", + title: t("toast.success"), variant: "success", }); }, onError: (err) => { - showError(err.message ?? "Unknown error."); + showError(err.message ?? tCommon("unknownError")); toast({ - title: "Error occured while changing username!", - description: err.message ?? "Unknown error.", + title: t("toast.error"), + description: err.message ?? tCommon("unknownError"), variant: "destructive", }); }, @@ -86,9 +92,9 @@ export default function ChangeUsernameInput() { name="newUsername" render={({ field }) => ( - New Username + {t("label")} - + @@ -96,14 +102,11 @@ export default function ChangeUsernameInput() { /> {error &&

{error}

} - + - +
); } diff --git a/app/(website)/settings/components/SiteLocalOptions.tsx b/app/(website)/settings/components/SiteLocalOptions.tsx index f6bee65..2e2da37 100644 --- a/app/(website)/settings/components/SiteLocalOptions.tsx +++ b/app/(website)/settings/components/SiteLocalOptions.tsx @@ -3,8 +3,10 @@ import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { useEffect, useState } from "react"; +import { useT } from "@/lib/i18n/utils"; export default function SiteLocalOptions() { + const t = useT("pages.settings.components.siteOptions"); const [includeOpenBanchoButton, setIncludeOpenBanchoButton] = useState(() => { return localStorage.getItem("includeOpenBanchoButton") || "false"; }); @@ -32,7 +34,7 @@ export default function SiteLocalOptions() { } />
@@ -44,7 +46,7 @@ export default function SiteLocalOptions() { } />
diff --git a/app/(website)/settings/components/UploadImageForm.tsx b/app/(website)/settings/components/UploadImageForm.tsx index 5f4eff7..14e0261 100644 --- a/app/(website)/settings/components/UploadImageForm.tsx +++ b/app/(website)/settings/components/UploadImageForm.tsx @@ -8,12 +8,14 @@ import { useUserUpload } from "@/lib/hooks/api/user/useUserUpload"; import useSelf from "@/lib/hooks/useSelf"; import { CloudUpload } from "lucide-react"; import { useEffect, useState } from "react"; +import { useT } from "@/lib/i18n/utils"; type UploadImageFormProps = { type: UserFileUpload; }; export default function UploadImageForm({ type }: UploadImageFormProps) { + const t = useT("pages.settings.components.uploadImage"); const [file, setFile] = useState(null); const [isFileUploading, setIsFileUploading] = useState(false); @@ -21,6 +23,8 @@ export default function UploadImageForm({ type }: UploadImageFormProps) { const { trigger: triggerUserUpload } = useUserUpload(); + const localizedType = t(`types.${type}`); + useEffect(() => { if (self === undefined || file != null) return; @@ -47,7 +51,7 @@ export default function UploadImageForm({ type }: UploadImageFormProps) { { onSuccess(data, key, config) { toast({ - title: `${type} updated successfully!`, + title: t("toast.success", { type: localizedType }), variant: "success", className: "capitalize", }); @@ -55,7 +59,7 @@ export default function UploadImageForm({ type }: UploadImageFormProps) { }, onError(err, key, config) { toast({ - title: err?.message ?? "An unknown error occurred", + title: err?.message ?? t("toast.error"), variant: "destructive", }); setIsFileUploading(false); @@ -79,10 +83,10 @@ export default function UploadImageForm({ type }: UploadImageFormProps) { variant="secondary" > - Upload {type} + {t("button", { type: localizedType })} ); diff --git a/app/(website)/settings/layout.tsx b/app/(website)/settings/layout.tsx index 53382f7..ce434f7 100644 --- a/app/(website)/settings/layout.tsx +++ b/app/(website)/settings/layout.tsx @@ -1,11 +1,15 @@ import { Metadata } from "next"; import Page from "./page"; +import { getT } from "@/lib/i18n/utils"; -export const metadata: Metadata = { - title: "Settings | osu!sunrise", - openGraph: { - title: "Settings | osu!sunrise", - }, -}; +export async function generateMetadata(): Promise { + const t = await getT("pages.settings.meta"); + return { + title: t("title"), + openGraph: { + title: t("title"), + }, + }; +} export default Page; diff --git a/app/(website)/settings/page.tsx b/app/(website)/settings/page.tsx index defbfbd..7d0de65 100644 --- a/app/(website)/settings/page.tsx +++ b/app/(website)/settings/page.tsx @@ -12,7 +12,7 @@ import { NotebookPenIcon, User2Icon, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useMemo } from "react"; import PrettyHeader from "@/components/General/PrettyHeader"; import BBCodeInput from "../../../components/BBCode/BBCodeInput"; import ChangePasswordInput from "@/app/(website)/settings/components/ChangePasswordInput"; @@ -35,134 +35,146 @@ import { import UploadImageForm from "@/app/(website)/settings/components/UploadImageForm"; import ChangeCountryInput from "@/app/(website)/settings/components/ChangeCountryInput"; import ChangeDescriptionInput from "@/app/(website)/settings/components/ChangeDescriptionInput"; +import { useT } from "@/lib/i18n/utils"; export default function Settings() { + const t = useT("pages.settings"); const { self, isLoading } = useSelf(); const { data: userMetadata } = useUserMetadata(self?.user_id ?? null); - const settingsContent = [ - { - openByDefault: true, - icon: , - title: "Change avatar", - content: ( - -
- -
-
- ), - }, - { - openByDefault: true, - icon: , - title: "Change banner", - content: ( - -
- -
-
- ), - }, - { - icon: , - title: "Change description", - content: ( - -
- {self ? ( - <> - {" "} - - - ) : ( - - )} -
-
- ), - }, - { - icon: , - title: "Socials", - content: ( - -
- {userMetadata && self ? ( - - ) : ( - - )} -
-
- ), - }, - { - icon: , - title: "Playstyle", - content: ( - -
-
+ const settingsContent = useMemo( + () => [ + { + openByDefault: true, + icon: , + title: t("sections.changeAvatar"), + content: ( + +
+ +
+
+ ), + }, + { + openByDefault: true, + icon: , + title: t("sections.changeBanner"), + content: ( + +
+ +
+
+ ), + }, + { + icon: , + title: t("sections.changeDescription"), + content: ( + +
+ {self ? ( + <> + {" "} + + + ) : ( + + )} +
+
+ ), + }, + { + icon: , + title: t("sections.socials"), + content: ( + +
{userMetadata && self ? ( - + ) : ( )}
-
- - ), - }, - { - icon: , - title: "Options", - content: ( - -
- -
-
- ), - }, - { - icon: , - title: "Change password", - content: ( - -
- -
-
- ), - }, - { - icon: , - title: "Change username", - content: ( - -
- -
-
- ), - }, - { - icon: , - title: "Change country flag", - content: ( - -
- {self ? : ""} -
-
- ), - }, - ]; + + ), + }, + { + icon: , + title: t("sections.playstyle"), + content: ( + +
+
+ {userMetadata && self ? ( + + ) : ( + + )} +
+
+
+ ), + }, + { + icon: , + title: t("sections.options"), + content: ( + +
+ +
+
+ ), + }, + { + icon: , + title: t("sections.changePassword"), + content: ( + +
+ +
+
+ ), + }, + { + icon: , + title: t("sections.changeUsername"), + content: ( + +
+ +
+
+ ), + }, + { + icon: , + title: t("sections.changeCountryFlag"), + content: ( + +
+ {self ? : ""} +
+
+ ), + }, + ], + [t, self, userMetadata] + ); + + const defaultOpenValues = useMemo( + () => + settingsContent + .filter((v) => v.openByDefault) + .map((_, i) => i.toString()), + [settingsContent] + ); if (isLoading) return ( @@ -174,7 +186,7 @@ export default function Settings() { return (
} roundBottom /> @@ -184,9 +196,7 @@ export default function Settings() { v.openByDefault) - .map((_, i) => i.toString())} + defaultValue={defaultOpenValues} > {settingsContent.map(({ icon, title, content }, index) => ( ) : ( - You must be logged in to view this page. + {t("notLoggedIn")} )}
diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json index 095dcc6..e6ef125 100644 --- a/lib/i18n/messages/en.json +++ b/lib/i18n/messages/en.json @@ -1355,6 +1355,306 @@ } } } + }, + "settings": { + "meta": { + "title": { + "text": "Settings | {appName}", + "context": "The title for the settings page of the osu!sunrise website" + } + }, + "header": { + "text": "Settings", + "context": "The main header text for the settings page" + }, + "notLoggedIn": { + "text": "You must be logged in to view this page.", + "context": "Message displayed when user is not logged in" + }, + "sections": { + "changeAvatar": { + "text": "Change avatar", + "context": "Title for the change avatar section" + }, + "changeBanner": { + "text": "Change banner", + "context": "Title for the change banner section" + }, + "changeDescription": { + "text": "Change description", + "context": "Title for the change description section" + }, + "socials": { + "text": "Socials", + "context": "Title for the socials section" + }, + "playstyle": { + "text": "Playstyle", + "context": "Title for the playstyle section" + }, + "options": { + "text": "Options", + "context": "Title for the options section" + }, + "changePassword": { + "text": "Change password", + "context": "Title for the change password section" + }, + "changeUsername": { + "text": "Change username", + "context": "Title for the change username section" + }, + "changeCountryFlag": { + "text": "Change country flag", + "context": "Title for the change country flag section" + } + }, + "description": { + "reminder": { + "text": "* Reminder: Do not post any inappropriate content. Try to keep it family friendly :)", + "context": "Reminder message for description field about keeping content appropriate" + } + }, + "components": { + "username": { + "label": { + "text": "New Username", + "context": "Form label for new username input" + }, + "placeholder": { + "text": "e.g. username", + "context": "Placeholder text for username input" + }, + "button": { + "text": "Change username", + "context": "Button text to change username" + }, + "validation": { + "minLength": { + "text": "Username must be at least {min} characters.", + "context": "Validation error message for username minimum length, includes min as parameter" + }, + "maxLength": { + "text": "Username must be {max} characters or fewer.", + "context": "Validation error message for username maximum length, includes max as parameter" + } + }, + "toast": { + "success": { + "text": "Username changed successfully!", + "context": "Success toast message when username is changed" + }, + "error": { + "text": "Error occured while changing username!", + "context": "Error toast message when username change fails" + } + }, + "reminder": { + "text": "* Reminder: Please keep your username family friendly, or it will be changed for you. Abusing this feature will result in a ban.", + "context": "Reminder message about keeping username appropriate" + } + }, + "password": { + "labels": { + "current": { + "text": "Current Password", + "context": "Form label for current password input" + }, + "new": { + "text": "New Password", + "context": "Form label for new password input" + }, + "confirm": { + "text": "Confirm Password", + "context": "Form label for confirm password input" + } + }, + "placeholder": { + "text": "************", + "context": "Placeholder text for password inputs" + }, + "button": { + "text": "Change password", + "context": "Button text to change password" + }, + "validation": { + "minLength": { + "text": "Password must be at least {min} characters.", + "context": "Validation error message for password minimum length, includes min as parameter" + }, + "maxLength": { + "text": "Password must be {max} characters or fewer.", + "context": "Validation error message for password maximum length, includes max as parameter" + }, + "mismatch": { + "text": "Passwords do not match", + "context": "Validation error message when passwords don't match" + } + }, + "toast": { + "success": { + "text": "Password changed successfully!", + "context": "Success toast message when password is changed" + }, + "error": { + "text": "Error occured while changing password!", + "context": "Error toast message when password change fails" + } + } + }, + "description": { + "toast": { + "success": { + "text": "Description updated successfully!", + "context": "Success toast message when description is updated" + }, + "error": { + "text": "An unknown error occurred", + "context": "Generic error message for description update" + } + } + }, + "country": { + "label": { + "text": "New Country Flag", + "context": "Form label for country flag selector" + }, + "placeholder": { + "text": "Select new country flag", + "context": "Placeholder text for country flag selector" + }, + "button": { + "text": "Change country flag", + "context": "Button text to change country flag" + }, + "toast": { + "success": { + "text": "Country flag changed successfully!", + "context": "Success toast message when country flag is changed" + }, + "error": { + "text": "Error occured while changing country flag!", + "context": "Error toast message when country flag change fails" + } + } + }, + "socials": { + "headings": { + "general": { + "text": "General", + "context": "Heading for general information section in socials form" + }, + "socials": { + "text": "Socials", + "context": "Heading for socials section in socials form" + } + }, + "fields": { + "location": { + "text": "location", + "context": "Field label for location" + }, + "interest": { + "text": "interest", + "context": "Field label for interest" + }, + "occupation": { + "text": "occupation", + "context": "Field label for occupation" + } + }, + "button": { + "text": "Update socials", + "context": "Button text to update socials" + }, + "toast": { + "success": { + "text": "Socials updated successfully!", + "context": "Success toast message when socials are updated" + }, + "error": { + "text": "Error occured while updating socials!", + "context": "Error toast message when socials update fails" + } + } + }, + "playstyle": { + "options": { + "Mouse": { + "text": "Mouse", + "context": "Playstyle option for mouse input" + }, + "Keyboard": { + "text": "Keyboard", + "context": "Playstyle option for keyboard input" + }, + "Tablet": { + "text": "Tablet", + "context": "Playstyle option for tablet input" + }, + "TouchScreen": { + "text": "Touch Screen", + "context": "Playstyle option for touch screen input" + } + }, + "toast": { + "success": { + "text": "Playstyle updated successfully!", + "context": "Success toast message when playstyle is updated" + }, + "error": { + "text": "Error occured while updating playstyle!", + "context": "Error toast message when playstyle update fails" + } + } + }, + "uploadImage": { + "types": { + "avatar": { + "text": "avatar", + "context": "The word 'avatar' for image upload type" + }, + "banner": { + "text": "banner", + "context": "The word 'banner' for image upload type" + } + }, + "button": { + "text": "Upload {type}", + "context": "Button text to upload image, includes type (avatar/banner) as parameter" + }, + "toast": { + "success": { + "text": "{type} updated successfully!", + "context": "Success toast message when image is uploaded, includes type (avatar/banner) as parameter" + }, + "error": { + "text": "An unknown error occurred", + "context": "Generic error message for image upload" + } + }, + "note": { + "text": "* Note: {type}s are limited to 5MB in size", + "context": "Note about file size limit, includes type (avatar/banner) as parameter" + } + }, + "siteOptions": { + "includeBanchoButton": { + "text": "Include \"Open on Bancho\" button in beatmap page", + "context": "Label for toggle to include Open on Bancho button" + }, + "useSpaciousUI": { + "text": "Use spacious UI (Increase spacing between elements)", + "context": "Label for toggle to use spacious UI" + } + } + }, + "common": { + "unknownError": { + "text": "Unknown error.", + "context": "Generic error message for unknown errors" + } + } } } } diff --git a/lib/i18n/messages/ru.json b/lib/i18n/messages/ru.json index 0901cc5..295cef3 100644 --- a/lib/i18n/messages/ru.json +++ b/lib/i18n/messages/ru.json @@ -516,6 +516,123 @@ } } } + }, + "settings": { + "meta": { + "title": "Настройки | {appName}" + }, + "header": "Настройки", + "notLoggedIn": "Вы должны быть авторизованы для просмотра этой страницы.", + "sections": { + "changeAvatar": "Изменить аватар", + "changeBanner": "Изменить баннер", + "changeDescription": "Изменить описание", + "socials": "Социальные сети", + "playstyle": "Стиль игры", + "options": "Параметры", + "changePassword": "Изменить пароль", + "changeUsername": "Изменить имя пользователя", + "changeCountryFlag": "Изменить флаг страны" + }, + "description": { + "reminder": "* Напоминание: Не публикуйте неприемлемый контент. Постарайтесь сделать его подходящим для всей семьи :)" + }, + "components": { + "username": { + "label": "Новое имя пользователя", + "placeholder": "например, username", + "button": "Изменить имя пользователя", + "validation": { + "minLength": "Имя пользователя должно содержать не менее {min} символов.", + "maxLength": "Имя пользователя должно содержать не более {max} символов." + }, + "toast": { + "success": "Имя пользователя успешно изменено!", + "error": "Произошла ошибка при изменении имени пользователя!" + }, + "reminder": "* Напоминание: Пожалуйста, сохраняйте ваше имя пользователя подходящим для всех возрастов (т.е. не должен содержать наготы, ненормативной лексики или вызывающего контента), иначе оно будет изменено. Злоупотребление этой функцией приведёт к бану." + }, + "password": { + "labels": { + "current": "Текущий пароль", + "new": "Новый пароль", + "confirm": "Подтвердите пароль" + }, + "placeholder": "************", + "button": "Изменить пароль", + "validation": { + "minLength": "Пароль должен содержать не менее {min} символов.", + "maxLength": "Пароль должен содержать не более {max} символов.", + "mismatch": "Пароли не совпадают" + }, + "toast": { + "success": "Пароль успешно изменён!", + "error": "Произошла ошибка при изменении пароля!" + } + }, + "description": { + "toast": { + "success": "Описание успешно обновлено!", + "error": "Произошла неизвестная ошибка" + } + }, + "country": { + "label": "Новый флаг страны", + "placeholder": "Выберите новый флаг страны", + "button": "Изменить флаг страны", + "toast": { + "success": "Флаг страны успешно изменён!", + "error": "Произошла ошибка при изменении флага страны!" + } + }, + "socials": { + "headings": { + "general": "Общее", + "socials": "Социальные сети" + }, + "fields": { + "location": "местоположение", + "interest": "интерес", + "occupation": "род занятий" + }, + "button": "Обновить социальные сети", + "toast": { + "success": "Социальные сети успешно обновлены!", + "error": "Произошла ошибка при обновлении социальных сетей!" + } + }, + "playstyle": { + "options": { + "Mouse": "Мышь", + "Keyboard": "Клавиатура", + "Tablet": "Планшет", + "TouchScreen": "Сенсорный экран" + }, + "toast": { + "success": "Стиль игры успешно обновлён!", + "error": "Произошла ошибка при обновлении стиля игры!" + } + }, + "uploadImage": { + "types": { + "avatar": "аватар", + "banner": "баннер" + }, + "button": "Загрузить {type}", + "toast": { + "success": "{type} успешно обновлён!", + "error": "Произошла неизвестная ошибка" + }, + "note": "* Примечание: {type} ограничен размером 5 МБ" + }, + "siteOptions": { + "includeBanchoButton": "Включить кнопку \"Открыть на Bancho\" на странице карты", + "useSpaciousUI": "Использовать просторный интерфейс (Увеличить расстояние между элементами)" + } + }, + "common": { + "unknownError": "Неизвестная ошибка." + } } } } From 3fc8d646bbca5ee23cda776507d5415f1038bb3d Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 21 Dec 2025 05:55:35 +0200 Subject: [PATCH 25/31] feat: Localise user page --- .../[id]/components/BeatmapSetOverview.tsx | 6 +- .../components/SetDefaultGamemodeButton.tsx | 12 +- .../[id]/components/Tabs/UserTabBeatmaps.tsx | 15 +- .../[id]/components/Tabs/UserTabGeneral.tsx | 28 +- .../[id]/components/Tabs/UserTabMedals.tsx | 38 ++- .../[id]/components/Tabs/UserTabScores.tsx | 25 +- .../components/UserGeneralInformation.tsx | 72 +++-- .../UserPreviousUsernamesTooltip.tsx | 4 +- .../[id]/components/UserPrivilegeBadges.tsx | 20 +- .../user/[id]/components/UserRanks.tsx | 24 +- .../[id]/components/UserScoreOverview.tsx | 16 +- .../user/[id]/components/UserStatsChart.tsx | 9 +- .../user/[id]/components/UserStatusText.tsx | 15 +- app/(website)/user/[id]/layout.tsx | 11 +- app/(website)/user/[id]/page.tsx | 176 ++++++----- components/BBCode/BBCodeReactParser.tsx | 24 +- components/General/PrettyDate.tsx | 10 +- lib/i18n/messages/en.json | 294 ++++++++++++++++++ lib/i18n/messages/ru.json | 105 +++++++ lib/utils/playtimeToString.ts | 48 ++- 20 files changed, 750 insertions(+), 202 deletions(-) diff --git a/app/(website)/user/[id]/components/BeatmapSetOverview.tsx b/app/(website)/user/[id]/components/BeatmapSetOverview.tsx index 0d52032..8f5f4d1 100644 --- a/app/(website)/user/[id]/components/BeatmapSetOverview.tsx +++ b/app/(website)/user/[id]/components/BeatmapSetOverview.tsx @@ -16,6 +16,7 @@ import { BeatmapSetResponse } from "@/lib/types/api"; import { CircularProgress } from "@/components/ui/circular-progress"; import { CollapsibleBadgeList } from "@/components/CollapsibleBadgeList"; import BeatmapDifficultyBadge from "@/components/BeatmapDifficultyBadge"; +import { useT } from "@/lib/i18n/utils"; interface BeatmapSetOverviewProps { beatmapSet: BeatmapSetResponse; } @@ -23,6 +24,7 @@ interface BeatmapSetOverviewProps { export default function BeatmapSetOverview({ beatmapSet, }: BeatmapSetOverviewProps) { + const t = useT("pages.user.components.beatmapSetOverview"); const [isHovered, setIsHovered] = useState(false); const { player, isPlaying, currentTimestamp } = useAudioPlayer(); @@ -111,10 +113,10 @@ export default function BeatmapSetOverview({

- by {beatmapSet.artist} + {t("by", { artist: beatmapSet.artist })}

- mapped by {beatmapSet.creator} + {t("mappedBy", { creator: beatmapSet.creator })}

diff --git a/app/(website)/user/[id]/components/SetDefaultGamemodeButton.tsx b/app/(website)/user/[id]/components/SetDefaultGamemodeButton.tsx index 1f2b64b..80a2d94 100644 --- a/app/(website)/user/[id]/components/SetDefaultGamemodeButton.tsx +++ b/app/(website)/user/[id]/components/SetDefaultGamemodeButton.tsx @@ -8,6 +8,7 @@ import { import { useUpdateUserDefaultGamemode } from "@/lib/hooks/api/user/useUserDefaultGamemode"; import useSelf from "@/lib/hooks/useSelf"; import { GameMode, UserResponse } from "@/lib/types/api"; +import { useT } from "@/lib/i18n/utils"; export function SetDefaultGamemodeButton({ user, @@ -16,6 +17,7 @@ export function SetDefaultGamemodeButton({ user: UserResponse; gamemode: GameMode; }) { + const t = useT("pages.user"); const { self } = useSelf(); const { trigger } = useUpdateUserDefaultGamemode(); @@ -42,11 +44,11 @@ export function SetDefaultGamemodeButton({ variant={"secondary"} > - Set - - {` ${GameModeToGameRuleMap[gamemode]} ${GameModeToFlagMap[gamemode]} `} - - as profile default game mode + {t.rich("buttons.setDefaultGamemode", { + gamemode: GameModeToGameRuleMap[gamemode] || "Unknown", + flag: GameModeToFlagMap[gamemode] || "Unknown", + b: (chunks) => {chunks}, + })} ); diff --git a/app/(website)/user/[id]/components/Tabs/UserTabBeatmaps.tsx b/app/(website)/user/[id]/components/Tabs/UserTabBeatmaps.tsx index 9766cd2..5cf811d 100644 --- a/app/(website)/user/[id]/components/Tabs/UserTabBeatmaps.tsx +++ b/app/(website)/user/[id]/components/Tabs/UserTabBeatmaps.tsx @@ -16,6 +16,7 @@ import { useUserMostPlayed } from "@/lib/hooks/api/user/useUserMostPlayed"; import { useUserFavourites } from "@/lib/hooks/api/user/useUserFavourites"; import { Button } from "@/components/ui/button"; import { GameMode } from "@/lib/types/api"; +import { useT } from "@/lib/i18n/utils"; interface UserTabBeatmapsProps { userId: number; @@ -73,10 +74,12 @@ export default function UserTabBeatmaps({ setFavouritesSize(favouritesSize + 1); }; + const t = useT("pages.user.components.beatmapsTab"); + return (
} counter={ totalCountMostPlayed ?? 0 > 0 ? totalCountMostPlayed : undefined @@ -90,7 +93,7 @@ export default function UserTabBeatmaps({ )} {totalCountMostPlayed === 0 && ( - + )} {totalCountMostPlayed != undefined && mostPlayed != undefined && ( @@ -111,7 +114,7 @@ export default function UserTabBeatmaps({ variant="secondary" isLoading={isLoadingMoreMostPlayed} > - Show more + {t("showMore")}
)} @@ -120,7 +123,7 @@ export default function UserTabBeatmaps({
} counter={ totalCountFavourites && totalCountFavourites > 0 @@ -136,7 +139,7 @@ export default function UserTabBeatmaps({ )} {totalCountFavourites === 0 && ( - + )} {totalCountFavourites != undefined && favourites != undefined && ( @@ -160,7 +163,7 @@ export default function UserTabBeatmaps({ variant="secondary" > - Show more + {t("showMore")}
)} diff --git a/app/(website)/user/[id]/components/Tabs/UserTabGeneral.tsx b/app/(website)/user/[id]/components/Tabs/UserTabGeneral.tsx index e689bd1..8e51df8 100644 --- a/app/(website)/user/[id]/components/Tabs/UserTabGeneral.tsx +++ b/app/(website)/user/[id]/components/Tabs/UserTabGeneral.tsx @@ -15,6 +15,7 @@ import { GameMode, UserResponse, UserStatsResponse } from "@/lib/types/api"; import BBCodeTextField from "@/components/BBCode/BBCodeTextField"; import { UserLevelProgress } from "@/app/(website)/user/[id]/components/UserLevelProgress"; +import { useT } from "@/lib/i18n/utils"; interface UserTabGeneralProps { user: UserResponse; @@ -27,6 +28,7 @@ export default function UserTabGeneral({ stats, gameMode, }: UserTabGeneralProps) { + const t = useT("pages.user.components.generalTab"); const [chartValue, setChartValue] = useState<"pp" | "rank">("pp"); const userGradesQuery = useUserGrades(user.user_id, gameMode); @@ -40,7 +42,7 @@ export default function UserTabGeneral({
} /> @@ -54,7 +56,7 @@ export default function UserTabGeneral({
-

Ranked Score

+

{t("rankedScore")}

{stats ? ( NumberWith(stats.ranked_score ?? 0, ",") @@ -65,7 +67,7 @@ export default function UserTabGeneral({
-

Hit Accuracy

+

{t("hitAccuracy")}

{stats ? ( `${stats?.accuracy.toFixed(2)} %` @@ -76,7 +78,7 @@ export default function UserTabGeneral({
-

Playcount

+

{t("playcount")}

{stats ? ( NumberWith(stats?.play_count ?? 0, ",") @@ -87,7 +89,7 @@ export default function UserTabGeneral({
-

Total Score

+

{t("totalScore")}

{stats ? ( NumberWith(stats?.total_score ?? 0, ",") @@ -98,7 +100,7 @@ export default function UserTabGeneral({
-

Maximum Combo

+

{t("maximumCombo")}

{stats ? ( NumberWith(stats?.max_combo ?? 0, ",") @@ -109,7 +111,7 @@ export default function UserTabGeneral({
-

Playtime

+

{t("playtime")}

{stats ? ( playtimeToString(stats?.play_time ?? 0) @@ -133,12 +135,12 @@ export default function UserTabGeneral({
} - className="px-4 py-1" + className="px-4 py-1 flex-wrap" >
-

Performance

+

{t("performance")}

{NumberWith(Math.round(stats?.pp ?? 0) ?? 0, ",")}

@@ -153,13 +155,13 @@ export default function UserTabGeneral({ onClick={() => setChartValue("rank")} variant={chartValue == "rank" ? "default" : "secondary"} > - Show by rank + {t("showByRank")}
@@ -169,7 +171,7 @@ export default function UserTabGeneral({ {user.description && user.description.length > 0 && (
- } /> + } />
diff --git a/app/(website)/user/[id]/components/Tabs/UserTabMedals.tsx b/app/(website)/user/[id]/components/Tabs/UserTabMedals.tsx index fa84446..3f82c3e 100644 --- a/app/(website)/user/[id]/components/Tabs/UserTabMedals.tsx +++ b/app/(website)/user/[id]/components/Tabs/UserTabMedals.tsx @@ -13,23 +13,29 @@ import { UserMedalResponse, UserResponse, } from "@/lib/types/api"; +import { useT } from "@/lib/i18n/utils"; +import { useMemo } from "react"; interface UserTabMedalsProps { user: UserResponse; gameMode: GameMode; } -const MEDALS_NAMES: Record = { - hush_hush: "Hush hush", - beatmap_hunt: "Beatmap hunt", - mod_introduction: "Mod introduction", - skill: "Skill", -}; - export default function UserTabMedals({ user, gameMode }: UserTabMedalsProps) { + const t = useT("pages.user.components.medalsTab"); const userMedalsQuery = useUserMedals(user.user_id, gameMode); const userMedals = userMedalsQuery.data; + const medalsNames = useMemo( + () => ({ + hush_hush: t("categories.hushHush"), + beatmap_hunt: t("categories.beatmapHunt"), + mod_introduction: t("categories.modIntroduction"), + skill: t("categories.skill"), + }), + [t] + ); + const latestMedals = userMedals ? Object.values(userMedals) .flatMap((group) => group.medals as UserMedalResponse[]) @@ -43,33 +49,33 @@ export default function UserTabMedals({ user, gameMode }: UserTabMedalsProps) { return (
- } /> + } /> {latestMedals.length > 0 && (
-

Latest

+

{t("latest")}

{latestMedals.map((medal) => (
- {MedalElement(medal)} + {MedalElement(medal, t)}
))}
)} - {Object.keys(MEDALS_NAMES).map((category) => ( + {Object.keys(medalsNames).map((category) => (

- {MEDALS_NAMES[category]} + {medalsNames[category as keyof typeof medalsNames]}

@@ -78,7 +84,7 @@ export default function UserTabMedals({ user, gameMode }: UserTabMedalsProps) { {userMedals ? ( userMedals[ category as keyof GetUserByIdMedalsResponse - ].medals.map((medal) => MedalElement(medal)) + ].medals.map((medal) => MedalElement(medal, t)) ) : (
@@ -92,7 +98,7 @@ export default function UserTabMedals({ user, gameMode }: UserTabMedalsProps) { ); } -function MedalElement(medal: UserMedalResponse) { +function MedalElement(medal: UserMedalResponse, t: ReturnType) { const isAchieved = medal.unlocked_at !== null; return ( @@ -120,11 +126,11 @@ function MedalElement(medal: UserMedalResponse) { > {isAchieved ? (
- achieved on  + {t("achievedOn")} 
) : ( - `Not achieved` + t("notAchieved") )}
diff --git a/app/(website)/user/[id]/components/Tabs/UserTabScores.tsx b/app/(website)/user/[id]/components/Tabs/UserTabScores.tsx index 5d922ae..ce0e9be 100644 --- a/app/(website)/user/[id]/components/Tabs/UserTabScores.tsx +++ b/app/(website)/user/[id]/components/Tabs/UserTabScores.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { useUserScores } from "@/lib/hooks/api/user/useUserScores"; import { GameMode, ScoreTableType } from "@/lib/types/api"; import { ChartColumnIncreasing, ChevronDown } from "lucide-react"; +import { useT } from "@/lib/i18n/utils"; interface UserTabScoresProps { userId: number; @@ -20,6 +21,7 @@ export default function UserTabScores({ gameMode, type, }: UserTabScoresProps) { + const t = useT("pages.user.components.scoresTab"); const { data, setSize, size, isLoading } = useUserScores( userId, gameMode, @@ -43,10 +45,25 @@ export default function UserTabScores({ total_count = Math.min(100, total_count); } + const getHeaderText = () => { + if (type === ScoreTableType.BEST) return t("bestScores"); + if (type === ScoreTableType.RECENT) return t("recentScores"); + if (type === ScoreTableType.TOP) return t("firstPlaces"); + return `${type} scores`; + }; + + const getNoScoresText = () => { + if (type === ScoreTableType.BEST) return t("noScores", { type: "best" }); + if (type === ScoreTableType.RECENT) + return t("noScores", { type: "recent" }); + if (type === ScoreTableType.TOP) + return t("noScores", { type: "first places" }); + }; + return (
} counter={total_count && total_count > 0 ? total_count : undefined} /> @@ -59,9 +76,7 @@ export default function UserTabScores({ {data && scores && total_count != undefined && (
- {scores.length <= 0 && ( - - )} + {scores.length <= 0 && } {scores.map((score) => (
@@ -76,7 +91,7 @@ export default function UserTabScores({ variant="secondary" > - Show more + {t("showMore")}
)} diff --git a/app/(website)/user/[id]/components/UserGeneralInformation.tsx b/app/(website)/user/[id]/components/UserGeneralInformation.tsx index a065b84..e8f7697 100644 --- a/app/(website)/user/[id]/components/UserGeneralInformation.tsx +++ b/app/(website)/user/[id]/components/UserGeneralInformation.tsx @@ -25,6 +25,7 @@ import { } from "@/lib/types/api"; import { timeSince } from "@/lib/utils/timeSince"; import { useUserFriendsCount } from "@/lib/hooks/api/user/useUserFriends"; +import { useT } from "@/lib/i18n/utils"; interface UserGeneralInformationProps { user: UserResponse; @@ -35,54 +36,73 @@ export default function UserGeneralInformation({ user, metadata, }: UserGeneralInformationProps) { + const t = useT("pages.user.components.generalInformation"); + const tPlaystyle = useT("pages.settings.components.playstyle"); const userPlaystyle = metadata ? metadata.playstyle.join(", ") : null; const friendsQuery = useUserFriendsCount(user.user_id); const friendsData = friendsQuery.data; + const localizedPlaystyle = metadata + ? metadata.playstyle.map((p) => tPlaystyle(`options.${p}`)).join(", ") + : null; + return (
- Joined{" "} - - -

- {timeSince(user.register_date)} -

-
-
+ + {t.rich("joined", { + b: (chunks) => ( + + {chunks} + + ), + time: timeSince(user.register_date), + })} +
- - {friendsData?.followers ?? 0} - {" "} - Followers + {t.rich("followers", { + b: (chunks) => ( + {chunks} + ), + count: friendsData?.followers ?? 0, + })}
- - {friendsData?.following ?? 0} - {" "} - Following + + {t.rich("following", { + b: (chunks) => ( + {chunks} + ), + count: friendsData?.following ?? 0, + })}
- {userPlaystyle && userPlaystyle != UserPlaystyle.NONE && ( -
- - - Plays with{" "} - - {userPlaystyle} + {userPlaystyle && + userPlaystyle != UserPlaystyle.NONE && + localizedPlaystyle && ( +
+ + + {t.rich("playsWith", { + playstyle: localizedPlaystyle, + b: (chunks) => ( + + {chunks} + + ), + })} - -
- )} +
+ )}
); } diff --git a/app/(website)/user/[id]/components/UserPreviousUsernamesTooltip.tsx b/app/(website)/user/[id]/components/UserPreviousUsernamesTooltip.tsx index a33e996..038759e 100644 --- a/app/(website)/user/[id]/components/UserPreviousUsernamesTooltip.tsx +++ b/app/(website)/user/[id]/components/UserPreviousUsernamesTooltip.tsx @@ -5,6 +5,7 @@ import { twMerge } from "tailwind-merge"; import React from "react"; import { UserResponse } from "@/lib/types/api"; import { useUserPreviousUsernames } from "@/lib/hooks/api/user/useUserPreviousUsernames"; +import { useT } from "@/lib/i18n/utils"; interface UserPreviousUsernamesTooltipProps { user: UserResponse; @@ -15,6 +16,7 @@ export default function UserPreviousUsernamesTooltip({ user, className, }: UserPreviousUsernamesTooltipProps) { + const t = useT("pages.user.components.previousUsernames"); const userPreviousUsernamesResult = useUserPreviousUsernames(user.user_id); if ( @@ -29,7 +31,7 @@ export default function UserPreviousUsernamesTooltip({ -

This user was previously known as:

+

{t("previouslyKnownAs")}

{
    {userPreviousUsernamesResult.data.usernames.map( diff --git a/app/(website)/user/[id]/components/UserPrivilegeBadges.tsx b/app/(website)/user/[id]/components/UserPrivilegeBadges.tsx index d3c58aa..9b155c2 100644 --- a/app/(website)/user/[id]/components/UserPrivilegeBadges.tsx +++ b/app/(website)/user/[id]/components/UserPrivilegeBadges.tsx @@ -11,8 +11,9 @@ import { import { Badge } from "@/components/ui/badge"; import { twMerge } from "tailwind-merge"; -import React from "react"; +import React, { useMemo } from "react"; import { UserBadge } from "@/lib/types/api"; +import { useT } from "@/lib/i18n/utils"; interface UserPrivilegeBadgesProps { badges: UserBadge[]; @@ -53,6 +54,19 @@ export default function UserPrivilegeBadges({ className, withToolTip = true, }: UserPrivilegeBadgesProps) { + const t = useT("pages.user.components.privilegeBadges"); + + const badgeNames = useMemo( + () => ({ + [UserBadge.DEVELOPER]: t("badges.Developer"), + [UserBadge.ADMIN]: t("badges.Admin"), + [UserBadge.BAT]: t("badges.Bat"), + [UserBadge.BOT]: t("badges.Bot"), + [UserBadge.SUPPORTER]: t("badges.Supporter"), + }), + [t] + ); + return (
    {badges.map((badge, index) => { @@ -68,9 +82,11 @@ export default function UserPrivilegeBadges({ className: "w-4 h-4", }); + const badgeName = badgeNames[badge] || badge; + return ( {badge}

    } + content={

    {badgeName}

    } key={`user-badge-${index}`} disabled={!withToolTip} > diff --git a/app/(website)/user/[id]/components/UserRanks.tsx b/app/(website)/user/[id]/components/UserRanks.tsx index 9bae8f9..4076965 100644 --- a/app/(website)/user/[id]/components/UserRanks.tsx +++ b/app/(website)/user/[id]/components/UserRanks.tsx @@ -6,6 +6,7 @@ import { UserResponse, UserStatsResponse } from "@/lib/types/api"; import toPrettyDate from "@/lib/utils/toPrettyDate"; import { Globe } from "lucide-react"; import { JSX } from "react"; +import { useT } from "@/lib/i18n/utils"; interface Props extends React.HTMLAttributes { user: UserResponse; @@ -53,21 +54,26 @@ function UserRank({ variant: "primary" | "secondary"; Icon: React.ReactNode; }) { + const t = useT("pages.user.components.ranks"); return ( - Highest rank{" "} - - #{bestRank} - {" "} - on {toPrettyDate(bestRankDate)} + {t.rich("highestRank", { + rank: bestRank ?? 0, + date: toPrettyDate(bestRankDate), + rankValue: (chunks) => ( + + #{chunks} + + ), + })}
    ) : ( "" diff --git a/app/(website)/user/[id]/components/UserScoreOverview.tsx b/app/(website)/user/[id]/components/UserScoreOverview.tsx index cdc2521..e349a89 100644 --- a/app/(website)/user/[id]/components/UserScoreOverview.tsx +++ b/app/(website)/user/[id]/components/UserScoreOverview.tsx @@ -10,6 +10,7 @@ import { getGradeColor } from "@/lib/utils/getGradeColor"; import { timeSince } from "@/lib/utils/timeSince"; import Link from "next/link"; import { twMerge } from "tailwind-merge"; +import { useT } from "@/lib/i18n/utils"; interface UserScoreOverviewProps { score: ScoreResponse; @@ -20,6 +21,7 @@ export default function UserScoreOverview({ score, className, }: UserScoreOverviewProps) { + const t = useT("pages.user.components.scoreOverview"); const beatmapQuery = useBeatmap(score.beatmap_id); const beatmap = beatmapQuery.data; @@ -70,10 +72,12 @@ export default function UserScoreOverview({

    {beatmap && beatmap.is_ranked ? score.performance_points.toFixed() - : "- "} - pp + : "- "}{" "} + {t("pp")} +

    +

    + {t("accuracy", { accuracy: score.accuracy.toFixed(2) })}

    -

    acc: {score.accuracy.toFixed(2)}%

{beatmap && beatmap.is_ranked ? score.performance_points.toFixed() - : "- "} - pp + : "- "}{" "} + {t("pp")}

- acc: {score.accuracy.toFixed(2)}% + {t("accuracy", { accuracy: score.accuracy.toFixed(2) })}

diff --git a/app/(website)/user/[id]/components/UserStatsChart.tsx b/app/(website)/user/[id]/components/UserStatsChart.tsx index 2087965..a72cf6d 100644 --- a/app/(website)/user/[id]/components/UserStatsChart.tsx +++ b/app/(website)/user/[id]/components/UserStatsChart.tsx @@ -10,6 +10,7 @@ import { ResponsiveContainer, Legend, } from "recharts"; +import { useT } from "@/lib/i18n/utils"; interface Props { data: StatsSnapshotsResponse; @@ -17,6 +18,7 @@ interface Props { } export default function UserStatsChart({ data, value: chartValue }: Props) { + const t = useT("pages.user.components.statsChart"); if (data.snapshots.length === 0) return null; data.snapshots = data.snapshots.filter( @@ -112,7 +114,7 @@ export default function UserStatsChart({ data, value: chartValue }: Props) { [ - `${Math.round(value as number)} ${chartValue}`, + t("tooltip", { + value: Math.round(value as number), + type: t(`types.${chartValue}`), + }), ]} contentStyle={{ color: "#333" }} /> diff --git a/app/(website)/user/[id]/components/UserStatusText.tsx b/app/(website)/user/[id]/components/UserStatusText.tsx index 67bcfa0..d296b9e 100644 --- a/app/(website)/user/[id]/components/UserStatusText.tsx +++ b/app/(website)/user/[id]/components/UserStatusText.tsx @@ -2,6 +2,7 @@ import { dateToPrettyString } from "@/components/General/PrettyDate"; import { Tooltip } from "@/components/Tooltip"; import { UserResponse } from "@/lib/types/api"; import { twMerge } from "tailwind-merge"; +import { useT } from "@/lib/i18n/utils"; export const statusColor = (status: string) => status.trim() === "Offline" @@ -22,14 +23,16 @@ export default function UserStatusText({ disabled, ...props }: Props) { + const t = useT("pages.user.components.statusText"); const userStatus = (isTooltip: boolean) => (

- {user.user_status} - {user.user_status === "Offline" && ( - - , last seen on  - {dateToPrettyString(user.last_online_time)} - + {user.user_status === "Offline" ? ( + <> + {user.user_status} + {t("lastSeenOn", { date: dateToPrettyString(user.last_online_time) })} + + ) : ( + user.user_status )}

); diff --git a/app/(website)/user/[id]/layout.tsx b/app/(website)/user/[id]/layout.tsx index 8c5672b..53af1d5 100644 --- a/app/(website)/user/[id]/layout.tsx +++ b/app/(website)/user/[id]/layout.tsx @@ -3,6 +3,7 @@ import Page from "./page"; import { notFound } from "next/navigation"; import fetcher from "@/lib/services/fetcher"; import { UserResponse } from "@/lib/types/api"; +import { getT } from "@/lib/i18n/utils"; export const revalidate = 60; @@ -16,13 +17,15 @@ export async function generateMetadata(props: { return notFound(); } + const t = await getT("pages.user.meta"); + return { - title: `${user.username} · User Profile | osu!sunrise`, - description: `We don't know much about them, but we're sure ${user.username} is great.`, + title: t("title", { username: user.username }), + description: t("description", { username: user.username }), openGraph: { siteName: "osu!sunrise", - title: `${user.username} · User Profile | osu!sunrise`, - description: `We don't know much about them, but we're sure ${user.username} is great.`, + title: t("title", { username: user.username }), + description: t("description", { username: user.username }), images: [ `https://a.${process.env.NEXT_PUBLIC_SERVER_DOMAIN}/avatar/${user.user_id}`, ], diff --git a/app/(website)/user/[id]/page.tsx b/app/(website)/user/[id]/page.tsx index 3cf6bc4..7b49019 100644 --- a/app/(website)/user/[id]/page.tsx +++ b/app/(website)/user/[id]/page.tsx @@ -1,7 +1,7 @@ "use client"; import Spinner from "@/components/Spinner"; -import { useState, use, useCallback, useEffect } from "react"; +import { useState, use, useCallback, useEffect, useMemo } from "react"; import Image from "next/image"; import { Edit3Icon, LucideSettings, User as UserIcon } from "lucide-react"; import UserPrivilegeBadges from "@/app/(website)/user/[id]/components/UserPrivilegeBadges"; @@ -42,81 +42,12 @@ import { useUserMetadata } from "@/lib/hooks/api/user/useUserMetadata"; import UserSocials from "@/app/(website)/user/[id]/components/UserSocials"; import UserPreviousUsernamesTooltip from "@/app/(website)/user/[id]/components/UserPreviousUsernamesTooltip"; import { isUserHasAdminPrivilege } from "@/lib/utils/userPrivileges.util"; - -const contentTabs = [ - "General", - "Best scores", - "Recent scores", - "First places", - "Beatmaps", - "Medals", -]; - -const renderTabContent = ( - userStats: UserStatsResponse | undefined, - activeTab: string, - activeMode: GameMode, - user: UserResponse -) => { - switch (activeTab) { - case "General": - return ( - - ); - case "Best scores": - return ( - - ); - case "Recent scores": - return ( - - ); - case "First places": - return ( - - ); - case "Beatmaps": - return ( - - ); - case "Medals": - return ( - - ); - } -}; +import { useT } from "@/lib/i18n/utils"; export default function UserPage(props: { params: Promise<{ id: string }> }) { const params = use(props.params); const userId = tryParseNumber(params.id) ?? 0; + const t = useT("pages.user"); const router = useRouter(); const pathname = usePathname(); @@ -124,7 +55,16 @@ export default function UserPage(props: { params: Promise<{ id: string }> }) { const mode = searchParams.get("mode") ?? ""; - const [activeTab, setActiveTab] = useState("General"); + const contentTabs = [ + "tabs.general", + "tabs.bestScores", + "tabs.recentScores", + "tabs.firstPlaces", + "tabs.beatmaps", + "tabs.medals", + ]; + + const [activeTab, setActiveTab] = useState("tabs.general"); const [activeMode, setActiveMode] = useState( isInstance(mode, GameMode) ? (mode as GameMode) : null ); @@ -135,6 +75,76 @@ export default function UserPage(props: { params: Promise<{ id: string }> }) { const userStatsQuery = useUserStats(userId, activeMode); const userMetadataQuery = useUserMetadata(userId); + const renderTabContent = useCallback( + ( + userStats: UserStatsResponse | undefined, + activeTab: string, + activeMode: GameMode, + user: UserResponse + ) => { + if (activeTab === "tabs.general") { + return ( + + ); + } + if (activeTab === "tabs.bestScores") { + return ( + + ); + } + if (activeTab === "tabs.recentScores") { + return ( + + ); + } + if (activeTab === "tabs.firstPlaces") { + return ( + + ); + } + if (activeTab === "tabs.beatmaps") { + return ( + + ); + } + if (activeTab === "tabs.medals") { + return ( + + ); + } + return null; + }, + [t] + ); + useEffect(() => { if (!activeMode) return; @@ -149,7 +159,7 @@ export default function UserPage(props: { params: Promise<{ id: string }> }) { if (activeMode || !userQuery.data) return; setActiveMode(userQuery.data.default_gamemode); - }, [userQuery]); + }, [userQuery, activeMode]); const createQueryString = useCallback( (name: string, value: string) => { @@ -169,8 +179,7 @@ export default function UserPage(props: { params: Promise<{ id: string }> }) { ); } - const errorMessage = - userQuery.error?.message ?? "User not found or an error occurred."; + const errorMessage = userQuery.error?.message ?? t("errors.userNotFound"); const user = userQuery.data; const userStats = userStatsQuery.data?.stats; @@ -178,7 +187,7 @@ export default function UserPage(props: { params: Promise<{ id: string }> }) { return (
- } text="Player info" roundBottom={true}> + } text={t("header")} roundBottom={true}> {user && activeMode && ( )} @@ -277,7 +286,9 @@ export default function UserPage(props: { params: Promise<{ id: string }> }) { className="w-9 md:w-auto" > - Edit profile + + {t("buttons.editProfile")} + ) : ( <> @@ -314,7 +325,7 @@ export default function UserPage(props: { params: Promise<{ id: string }> }) { }`} onClick={() => setActiveTab(tab)} > - {tab} + {t(tab)} ))}
@@ -328,12 +339,9 @@ export default function UserPage(props: { params: Promise<{ id: string }> }) {

{errorMessage}

{errorMessage.includes("restrict") ? ( -

- This means that the user violated the server rules and has - been restricted. -

+

{t("errors.restricted")}

) : ( -

The user may have been deleted or does not exist.

+

{t("errors.userDeleted")}

)}
{ const containerRef = useRef(null); + const locale = useLocale(); + const messages = useMessages(); useLayoutEffect(() => { const container = containerRef.current; @@ -39,7 +43,7 @@ export const BBCodeReactParser = React.memo( parseWell(container); parseBreaks(container); parseImageMapLink(container); - parseProfileLink(container); + parseProfileLink(container, locale, messages); }; return ( @@ -212,7 +216,11 @@ function parseImageMapLink(container: HTMLDivElement) { }); } -function parseProfileLink(container: HTMLDivElement) { +function parseProfileLink( + container: HTMLDivElement, + locale: string, + messages: Record +) { const profileLinks = container.querySelectorAll(".js-usercard"); profileLinks.forEach((link) => { @@ -252,11 +260,13 @@ function parseProfileLink(container: HTMLDivElement) { if (!user) return null; return ( - - - {link.innerHTML} - - + + + + {link.innerHTML} + + + ); } diff --git a/components/General/PrettyDate.tsx b/components/General/PrettyDate.tsx index b307e0b..ec5ef85 100644 --- a/components/General/PrettyDate.tsx +++ b/components/General/PrettyDate.tsx @@ -1,4 +1,6 @@ +"use client"; import { Tooltip } from "@/components/Tooltip"; +import Cookies from "js-cookie"; interface PrettyDateProps { time: string | Date; @@ -19,19 +21,21 @@ export default function PrettyDate({ }: PrettyDateProps) { const date = time instanceof Date ? time : new Date(time); + const locale = Cookies.get("locale") || "en"; + return withTime ? (
- {date.toLocaleDateString("en-US", options)} + {date.toLocaleDateString(locale, options)}
) : ( -
{date.toLocaleDateString("en-US", options)}
+
{date.toLocaleDateString(locale, options)}
); } export function dateToPrettyString(time: string | Date) { const date = time instanceof Date ? time : new Date(time); - return date.toLocaleDateString("en-US", options); + return date.toLocaleDateString(Cookies.get("locale") || "en", options); } diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json index e6ef125..ffdc535 100644 --- a/lib/i18n/messages/en.json +++ b/lib/i18n/messages/en.json @@ -1655,6 +1655,300 @@ "context": "Generic error message for unknown errors" } } + }, + "user": { + "meta": { + "title": { + "text": "{username} · User Profile | {appName}", + "context": "Page title for user profile page, includes username and app name as parameters" + }, + "description": { + "text": "We don't know much about them, but we're sure {username} is great.", + "context": "Meta description for user profile page, includes username as parameter" + } + }, + "header": { + "text": "Player info", + "context": "Header text for the user profile page" + }, + "tabs": { + "general": { + "text": "General", + "context": "Tab name for general information" + }, + "bestScores": { + "text": "Best scores", + "context": "Tab name for best scores" + }, + "recentScores": { + "text": "Recent scores", + "context": "Tab name for recent scores" + }, + "firstPlaces": { + "text": "First places", + "context": "Tab name for first place scores" + }, + "beatmaps": { + "text": "Beatmaps", + "context": "Tab name for beatmaps" + }, + "medals": { + "text": "Medals", + "context": "Tab name for medals" + } + }, + "buttons": { + "editProfile": { + "text": "Edit profile", + "context": "Button text to edit user profile" + }, + "setDefaultGamemode": { + "text": "Set {gamemode} {flag} as profile default game mode", + "context": "Button text to set default gamemode, includes gamemode name and flag emoji as parameters" + } + }, + "errors": { + "userNotFound": { + "text": "User not found or an error occurred.", + "context": "Error message when user is not found or an error occurs" + }, + "restricted": { + "text": "This means that the user violated the server rules and has been restricted.", + "context": "Explanation message when user has been restricted" + }, + "userDeleted": { + "text": "The user may have been deleted or does not exist.", + "context": "Error message when user may have been deleted or doesn't exist" + } + }, + "components": { + "generalTab": { + "info": { + "text": "Info", + "context": "Section header for user information" + }, + "rankedScore": { + "text": "Ranked Score", + "context": "Label for ranked score statistic" + }, + "hitAccuracy": { + "text": "Hit Accuracy", + "context": "Label for hit accuracy statistic" + }, + "playcount": { + "text": "Playcount", + "context": "Label for playcount statistic" + }, + "totalScore": { + "text": "Total Score", + "context": "Label for total score statistic" + }, + "maximumCombo": { + "text": "Maximum Combo", + "context": "Label for maximum combo statistic" + }, + "playtime": { + "text": "Playtime", + "context": "Label for playtime statistic" + }, + "performance": { + "text": "Performance", + "context": "Section header for performance information" + }, + "showByRank": { + "text": "Show by rank", + "context": "Button text to show chart by rank" + }, + "showByPp": { + "text": "Show by pp", + "context": "Button text to show chart by performance points" + }, + "aboutMe": { + "text": "About me", + "context": "Section header for user description/about section" + } + }, + "scoresTab": { + "bestScores": { + "text": "Best scores", + "context": "Header for best scores section" + }, + "recentScores": { + "text": "Recent scores", + "context": "Header for recent scores section" + }, + "firstPlaces": { + "text": "First places", + "context": "Header for first places section" + }, + "noScores": { + "text": "User has no {type} scores", + "context": "Message when user has no scores of the specified type, includes type as parameter" + }, + "showMore": { + "text": "Show more", + "context": "Button text to load more scores" + } + }, + "beatmapsTab": { + "mostPlayed": { + "text": "Most played", + "context": "Header for most played beatmaps section" + }, + "noMostPlayed": { + "text": "User has no most played beatmaps", + "context": "Message when user has no most played beatmaps" + }, + "favouriteBeatmaps": { + "text": "Favourite Beatmaps", + "context": "Header for favourite beatmaps section" + }, + "noFavourites": { + "text": "User has no favourite beatmaps", + "context": "Message when user has no favourite beatmaps" + }, + "showMore": { + "text": "Show more", + "context": "Button text to load more beatmaps" + } + }, + "medalsTab": { + "medals": { + "text": "Medals", + "context": "Header for medals section" + }, + "latest": { + "text": "Latest", + "context": "Header for latest medals section" + }, + "categories": { + "hushHush": { + "text": "Hush hush", + "context": "Medal category name" + }, + "beatmapHunt": { + "text": "Beatmap hunt", + "context": "Medal category name" + }, + "modIntroduction": { + "text": "Mod introduction", + "context": "Medal category name" + }, + "skill": { + "text": "Skill", + "context": "Medal category name" + } + }, + "achievedOn": { + "text": "achieved on", + "context": "Text shown before the date when a medal was achieved" + }, + "notAchieved": { + "text": "Not achieved", + "context": "Text shown when a medal has not been achieved" + } + }, + "generalInformation": { + "joined": { + "text": "Joined {time}", + "context": "Text showing when user joined, includes time as parameter" + }, + "followers": { + "text": "{count} Followers", + "context": "Text showing followers count, includes count as parameter" + }, + "following": { + "text": "{count} Following", + "context": "Text showing following count, includes count as parameter" + }, + "playsWith": { + "text": "Plays with {playstyle}", + "context": "Text showing playstyle, includes playstyle as parameter with formatting" + } + }, + "statusText": { + "lastSeenOn": { + "text": ", last seen on {date}", + "context": "Text shown before the last seen date when user is offline, includes date as parameter" + } + }, + "ranks": { + "highestRank": { + "text": "Highest rank {rank} on {date}", + "context": "Tooltip text for highest rank achieved, includes rank number and date as parameters, rank is formatted" + } + }, + "previousUsernames": { + "previouslyKnownAs": { + "text": "This user was previously known as:", + "context": "Tooltip text explaining that the user had previous usernames" + } + }, + "beatmapSetOverview": { + "by": { + "text": "by {artist}", + "context": "Text showing beatmap artist, includes artist name as parameter" + }, + "mappedBy": { + "text": "mapped by {creator}", + "context": "Text showing beatmap creator/mapper, includes creator name as parameter" + } + }, + "privilegeBadges": { + "badges": { + "Developer": { + "text": "Developer", + "context": "Name of the Developer badge" + }, + "Admin": { + "text": "Admin", + "context": "Name of the Admin badge" + }, + "Bat": { + "text": "BAT", + "context": "Name of the BAT (Beatmap Appreciation Team) badge" + }, + "Bot": { + "text": "Bot", + "context": "Name of the Bot badge" + }, + "Supporter": { + "text": "Supporter", + "context": "Name of the Supporter badge" + } + } + }, + "scoreOverview": { + "pp": { + "text": "pp", + "context": "Abbreviation for performance points" + }, + "accuracy": { + "text": "acc: {accuracy}%", + "context": "Text showing score accuracy, includes accuracy percentage as parameter" + } + }, + "statsChart": { + "date": { + "text": "Date", + "context": "Label for the date axis on the stats chart" + }, + "types": { + "pp": { + "text": "pp", + "context": "Abbreviation for performance points in chart tooltip" + }, + "rank": { + "text": "rank", + "context": "Word for rank in chart tooltip" + } + }, + "tooltip": { + "text": "{value} {type}", + "context": "Tooltip text showing chart value and type (pp/rank), includes value and type as parameters" + } + } + } } } } diff --git a/lib/i18n/messages/ru.json b/lib/i18n/messages/ru.json index 295cef3..bfd0e13 100644 --- a/lib/i18n/messages/ru.json +++ b/lib/i18n/messages/ru.json @@ -633,6 +633,111 @@ "common": { "unknownError": "Неизвестная ошибка." } + }, + "user": { + "meta": { + "title": "{username} · Профиль пользователя | {appName}", + "description": "Мы мало о них знаем, но уверены, что {username} замечательный." + }, + "header": "Информация об игроке", + "tabs": { + "general": "Общее", + "bestScores": "Лучшие результаты", + "recentScores": "Недавние результаты", + "firstPlaces": "Первые места", + "beatmaps": "Карты", + "medals": "Медали" + }, + "buttons": { + "editProfile": "Редактировать профиль", + "setDefaultGamemode": "Установить {gamemode} {flag} режимом профиля по умолчанию" + }, + "errors": { + "userNotFound": "Пользователь не найден или произошла ошибка.", + "restricted": "Это означает, что пользователь нарушил правила сервера и был ограничен.", + "userDeleted": "Пользователь мог быть удалён или не существует." + }, + "components": { + "generalTab": { + "info": "Информация", + "rankedScore": "Ранговый счёт", + "hitAccuracy": "Точность попаданий", + "playcount": "Количество игр", + "totalScore": "Общий счёт", + "maximumCombo": "Максимальное комбо", + "playtime": "Время игры", + "performance": "Производительность", + "showByRank": "Показать по рангу", + "showByPp": "Показать по пп", + "aboutMe": "Обо мне" + }, + "scoresTab": { + "bestScores": "Лучшие результаты", + "recentScores": "Недавние результаты", + "firstPlaces": "Первые места", + "noScores": "У пользователя нет {type} результатов", + "showMore": "Показать ещё" + }, + "beatmapsTab": { + "mostPlayed": "Наиболее играемые", + "noMostPlayed": "У пользователя нет наиболее играемых карт", + "favouriteBeatmaps": "Любимые карты", + "noFavourites": "У пользователя нет любимых карт", + "showMore": "Показать ещё" + }, + "medalsTab": { + "medals": "Медали", + "latest": "Последние", + "categories": { + "hushHush": "Тихий-тихий", + "beatmapHunt": "Охота за картами", + "modIntroduction": "Знакомство с модами", + "skill": "Навык" + }, + "achievedOn": "получена", + "notAchieved": "Не получена" + }, + "generalInformation": { + "joined": "Присоединился {time}", + "followers": "{count} Подписчиков", + "following": "{count} Подписок", + "playsWith": "Играет с {playstyle}" + }, + "statusText": { + "lastSeenOn": ", последний раз в сети {date}" + }, + "ranks": { + "highestRank": "Высший ранг {rank} {date}" + }, + "previousUsernames": { + "previouslyKnownAs": "Этот пользователь ранее был известен как:" + }, + "beatmapSetOverview": { + "by": "от {artist}", + "mappedBy": "создано {creator}" + }, + "privilegeBadges": { + "badges": { + "Developer": "Разработчик", + "Admin": "Администратор", + "Bat": "BAT", + "Bot": "Бот", + "Supporter": "Поддерживающий" + } + }, + "scoreOverview": { + "pp": "пп", + "accuracy": "точность: {accuracy}%" + }, + "statsChart": { + "date": "Дата", + "types": { + "pp": "пп", + "rank": "ранг" + }, + "tooltip": "{value} {type}" + } + } } } } diff --git a/lib/utils/playtimeToString.ts b/lib/utils/playtimeToString.ts index f38f79c..a6a9c3b 100644 --- a/lib/utils/playtimeToString.ts +++ b/lib/utils/playtimeToString.ts @@ -1,7 +1,45 @@ +import Cookies from "js-cookie"; + export const playtimeToString = (playtime: number) => { - const hours = Math.floor(playtime / 1000 / 3600); - const minutes = Math.floor(((playtime / 1000) % 3600) / 60); - const seconds = Math.floor((playtime / 1000) % 60); + const locale = Cookies.get("locale") || "en"; + + const hours = Math.floor(playtime / 1000 / 3600); + const minutes = Math.floor(((playtime / 1000) % 3600) / 60); + const seconds = Math.floor((playtime / 1000) % 60); + + const parts: string[] = []; + + if (hours > 0) { + const hourFormatter = new Intl.NumberFormat(locale, { + style: "unit", + unit: "hour", + unitDisplay: "short", + }); + parts.push(hourFormatter.format(hours)); + } + + if (minutes > 0) { + const minuteFormatter = new Intl.NumberFormat(locale, { + style: "unit", + unit: "minute", + unitDisplay: "short", + }); + parts.push(minuteFormatter.format(minutes)); + } + + if (seconds > 0 || parts.length === 0) { + const secondFormatter = new Intl.NumberFormat(locale, { + style: "unit", + unit: "second", + unitDisplay: "short", + }); + parts.push(secondFormatter.format(seconds)); + } + + const listFormatter = new Intl.ListFormat(locale, { + style: "long", + type: "conjunction", + }); - return `${hours} H, ${minutes} M, ${seconds} S`; - }; \ No newline at end of file + return listFormatter.format(parts); +}; From d223c082075abf68d588defd44f2fa780c9a2138 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:27:47 +0200 Subject: [PATCH 26/31] feat: Add localisation for components --- app/layout.tsx | 45 +-- app/not-found.tsx | 28 +- components/Beatmaps/BeatmapSetCard.tsx | 8 +- components/BeatmapsetRowElement.tsx | 4 +- components/Brand.tsx | 9 +- components/ComboBox.tsx | 8 +- components/ContentNotExist.tsx | 6 +- components/Footer.tsx | 15 +- components/FriendshipButton.tsx | 8 +- components/GameModeSelector.tsx | 4 +- components/General/ImageSelect.tsx | 4 +- components/Header/Header.tsx | 20 +- components/Header/HeaderLoginDialog.tsx | 66 ++-- components/Header/HeaderLogoutAlert.tsx | 14 +- components/Header/HeaderMobileDrawer.tsx | 125 ++++--- components/Header/HeaderSearchCommand.tsx | 146 ++++---- components/Header/HeaderUserDropdown.tsx | 14 +- components/Header/ThemeModeToggle.tsx | 10 +- components/ServerMaintenanceDialog.tsx | 6 +- components/WorkInProgress.tsx | 6 +- lib/i18n/messages/en.json | 430 ++++++++++++++++++++++ lib/i18n/messages/ru.json | 156 ++++++++ 22 files changed, 885 insertions(+), 247 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 7a6efc9..28d5a7b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,33 +5,36 @@ import ScrollUpButton from "@/components/ScrollUpButton"; import Providers from "@/components/Providers"; import ScrollUp from "@/components/ScrollUp"; import { getLocale, getMessages } from "next-intl/server"; +import { getT } from "@/lib/i18n/utils"; const font = Poppins({ weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], subsets: ["latin"], }); -export const metadata: Metadata = { - title: "osu!sunrise", - twitter: { - card: "summary", - }, - description: "osu!sunrise is a private server for osu!, a rhythm game.", - openGraph: { - siteName: "osu!sunrise", - title: "osu!sunrise", - description: "osu!sunrise is a private server for osu!, a rhythm game.", - - images: [ - { - url: `https://${process.env.NEXT_PUBLIC_SERVER_DOMAIN}/images/metadata.png`, - width: 800, - height: 800, - alt: "osu!sunrise Logo", - }, - ], - }, -}; +export async function generateMetadata(): Promise { + const t = await getT("components.rootLayout.meta"); + return { + title: t("title"), + twitter: { + card: "summary", + }, + description: t("description"), + openGraph: { + siteName: t("title"), + title: t("title"), + description: t("description"), + images: [ + { + url: `https://${process.env.NEXT_PUBLIC_SERVER_DOMAIN}/images/metadata.png`, + width: 800, + height: 800, + alt: "osu!sunrise Logo", + }, + ], + }, + }; +} export default async function RootLayout({ children, diff --git a/app/not-found.tsx b/app/not-found.tsx index 47a5b00..b67e3bb 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,23 +1,27 @@ import WebsiteLayout from "@/app/(website)/layout"; import { Metadata } from "next"; import Image from "next/image"; +import { getT } from "@/lib/i18n/utils"; +import { getLocale } from "next-intl/server"; -export const metadata: Metadata = { - title: "Not Found | osu!sunrise", - openGraph: { - title: "Not Found | osu!sunrise", - description: "The page you're looking for isn't here. Sorry.", - }, -}; +export async function generateMetadata(): Promise { + const t = await getT("components.notFound.meta"); + return { + title: t("title"), + openGraph: { + title: t("title"), + description: t("description"), + }, + }; +} -export default function NotFound() { +export default async function NotFound() { + const t = await getT("components.notFound"); return (
-

Not Found | 404

-

- What you're looking for isn't here. Sorry. -

+

{t("title")}

+

{t("description")}

404
diff --git a/components/Beatmaps/BeatmapSetCard.tsx b/components/Beatmaps/BeatmapSetCard.tsx index b77648e..c5825e2 100644 --- a/components/Beatmaps/BeatmapSetCard.tsx +++ b/components/Beatmaps/BeatmapSetCard.tsx @@ -16,12 +16,14 @@ import { BanchoSmallUserElement } from "@/components/SmallUserElement"; import BeatmapStatusIcon from "@/components/BeatmapStatus"; import PrettyDate from "@/components/General/PrettyDate"; import { usePathname } from "next/navigation"; +import { useT } from "@/lib/i18n/utils"; interface BeatmapSetCardProps { beatmapSet: BeatmapSetResponse; } export function BeatmapSetCard({ beatmapSet }: BeatmapSetCardProps) { + const t = useT("components.beatmapSetCard"); const pathname = usePathname(); const { player, isPlaying, isPlayingThis, currentTimestamp } = @@ -99,8 +101,8 @@ export function BeatmapSetCard({ beatmapSet }: BeatmapSetCardProps) {
-

submitted by

-

submitted on

+

{t("submittedBy")}

+

{t("submittedOn")}

@@ -122,7 +124,7 @@ export function BeatmapSetCard({ beatmapSet }: BeatmapSetCardProps) { } > - View + {t("view")} diff --git a/components/BeatmapsetRowElement.tsx b/components/BeatmapsetRowElement.tsx index 0c63a39..3c36581 100644 --- a/components/BeatmapsetRowElement.tsx +++ b/components/BeatmapsetRowElement.tsx @@ -8,6 +8,7 @@ import DifficultyIcon from "@/components/DifficultyIcon"; import { getBeatmapStarRating } from "@/lib/utils/getBeatmapStarRating"; import BeatmapStatusIcon from "@/components/BeatmapStatus"; import { BeatmapSetResponse } from "@/lib/types/api"; +import { useT } from "@/lib/i18n/utils"; interface UserProfileBannerProps { beatmapSet: BeatmapSetResponse; @@ -18,6 +19,7 @@ export default function BeatmapsetRowElement({ beatmapSet, className, }: UserProfileBannerProps) { + const t = useT("components.beatmapsetRowElement"); return (

- mapped by {beatmapSet.creator} + {t("mappedBy", { creator: beatmapSet.creator })}

diff --git a/components/Brand.tsx b/components/Brand.tsx index cca2195..bfcebd6 100644 --- a/components/Brand.tsx +++ b/components/Brand.tsx @@ -1,8 +1,13 @@ +"use client"; + +import { useT } from "@/lib/i18n/utils"; + export function Brand() { + const t = useT("general.serverTitle.split"); return (

- sun - rise + {t("part1")} + {t("part2")}

); } diff --git a/components/ComboBox.tsx b/components/ComboBox.tsx index eb86b00..bd1412f 100644 --- a/components/ComboBox.tsx +++ b/components/ComboBox.tsx @@ -16,6 +16,7 @@ import { cn } from "@/lib/utils"; import { ChevronsUpDown, Check } from "lucide-react"; import { useState } from "react"; +import { useT } from "@/lib/i18n/utils"; interface Props { activeValue: string; @@ -32,6 +33,7 @@ export function Combobox({ includeInput, buttonPreLabel, }: Props) { + const t = useT("components.comboBox"); const [open, setOpen] = useState(false); return ( @@ -46,15 +48,15 @@ export function Combobox({ {activeValue ? (buttonPreLabel ? buttonPreLabel : "") + values.find((data) => data.value === activeValue)?.label - : "Select value..."} + : t("selectValue")} - {includeInput && } + {includeInput && } - No values found. + {t("noValuesFound")} {values.map((data, index) => (
-

- {text ?? "Content not found"} -

+

{text ?? t("defaultText")}

); diff --git a/components/Footer.tsx b/components/Footer.tsx index 53018dd..0bb903f 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -5,8 +5,10 @@ import { UsersRoundIcon, VoteIcon, } from "lucide-react"; +import { getT } from "@/lib/i18n/utils"; export default async function Footer() { + const t = await getT("components.footer"); return (
{process.env.NEXT_PUBLIC_OSU_SERVER_LIST_LINK && ( @@ -16,20 +18,20 @@ export default async function Footer() { >

- Please vote for us on osu-server-list! + {t("voteMessage")}

)}
-

© 2024-2025 Sunrise Community

+

{t("copyright")}

- Source Code + {t("sourceCode")}

- Server Status + {t("serverStatus")}
-

- We are not affiliated with "ppy" and "osu!" in any way. All rights - reserved to their respective owners. -

+

{t("disclaimer")}

); } diff --git a/components/FriendshipButton.tsx b/components/FriendshipButton.tsx index 2326aa0..304cac1 100644 --- a/components/FriendshipButton.tsx +++ b/components/FriendshipButton.tsx @@ -10,6 +10,7 @@ import useSelf from "@/lib/hooks/useSelf"; import { UpdateFriendshipStatusAction } from "@/lib/types/api"; import { UserMinus, UserPlus } from "lucide-react"; import { twMerge } from "tailwind-merge"; +import { useT } from "@/lib/i18n/utils"; export function FriendshipButton({ userId, @@ -20,6 +21,7 @@ export function FriendshipButton({ includeText?: boolean; className?: string; }) { + const t = useT("components.friendshipButton"); const { self } = useSelf(); const { trigger } = useUpdateUserFriendshipStatus(userId); @@ -75,7 +77,11 @@ export function FriendshipButton({ {is_followed_by_you ? : } {includeText && ( - {isMutual ? "Unfriend" : is_followed_by_you ? "Unfollow" : "Follow"} + {isMutual + ? t("unfriend") + : is_followed_by_you + ? t("unfollow") + : t("follow")} )} diff --git a/components/GameModeSelector.tsx b/components/GameModeSelector.tsx index 66e24ce..0c0e13f 100644 --- a/components/GameModeSelector.tsx +++ b/components/GameModeSelector.tsx @@ -13,6 +13,7 @@ import { } from "@/lib/utils/gameMode.util"; import { Star } from "lucide-react"; import { twMerge } from "tailwind-merge"; +import { useT } from "@/lib/i18n/utils"; const GameModesIcons = { 0: GameMode.STANDARD, @@ -76,6 +77,7 @@ export default function GameModeSelector({ userDefaultGameMode, ...props }: GameModeSelectorProps) { + const t = useT("components.gameModeSelector"); if (enabledModes) enrichEnabledModesWithGameModes(enabledModes); var defaultGameModeVanilla = @@ -204,7 +206,7 @@ export default function GameModeSelector({ {mobileVariant === "combobox" && (

- Selected mode: + {t("selectedMode")}

>; @@ -16,6 +17,7 @@ export default function ImageSelect({ isWide, maxFileSizeBytes, }: Props) { + const t = useT("components.imageSelect"); const uniqueId = Math.random().toString(36).substring(7); const { toast } = useToast(); @@ -41,7 +43,7 @@ export default function ImageSelect({ if (!file) return; if (maxFileSizeBytes && file.size > maxFileSizeBytes) { toast({ - title: "Selected image is too big!", + title: t("imageTooBig"), variant: "destructive", }); return; diff --git a/components/Header/Header.tsx b/components/Header/Header.tsx index ad7d01c..5de4ea2 100644 --- a/components/Header/Header.tsx +++ b/components/Header/Header.tsx @@ -19,8 +19,10 @@ import { import { Brand } from "@/components/Brand"; import { Button } from "@/components/ui/button"; import { useRouter } from "next/navigation"; +import { useT } from "@/lib/i18n/utils"; export default function Header() { + const t = useT("components.header"); const [scrolled, setScrolled] = useState(false); const router = useRouter(); @@ -59,36 +61,36 @@ export default function Header() {
- - - + + + - + - wiki + {t("links.wiki")} - rules + {t("links.rules")} - api docs + {t("links.apiDocs")} {process.env.NEXT_PUBLIC_DISCORD_LINK && ( - discord server + {t("links.discordServer")} )} @@ -96,7 +98,7 @@ export default function Header() { {(process.env.NEXT_PUBLIC_KOFI_LINK || process.env.NEXT_PUBLIC_BOOSTY_LINK) && ( - support us + {t("links.supportUs")} )} diff --git a/components/Header/HeaderLoginDialog.tsx b/components/Header/HeaderLoginDialog.tsx index d23041b..f2f16f9 100644 --- a/components/Header/HeaderLoginDialog.tsx +++ b/components/Header/HeaderLoginDialog.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useContext, useState } from "react"; +import React, { useContext, useState, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { @@ -34,27 +34,10 @@ import { useToast } from "@/hooks/use-toast"; import { Separator } from "@/components/ui/separator"; import { useRouter } from "next/navigation"; import { MobileDrawerContext } from "@/components/Header/HeaderMobileDrawer"; - -const formSchema = z.object({ - username: z - .string() - .min(2, { - message: "Username must be at least 2 characters.", - }) - .max(32, { - message: "Username must be 32 characters or fewer.", - }), - password: z - .string() - .min(8, { - message: "Password must be at least 8 characters.", - }) - .max(32, { - message: "Password must be 32 characters or fewer.", - }), -}); +import { useT } from "@/lib/i18n/utils"; export default function HeaderLoginDialog() { + const t = useT("components.headerLoginDialog"); const [error, setError] = useState(""); const router = useRouter(); @@ -67,6 +50,29 @@ export default function HeaderLoginDialog() { const setMobileDrawerOpen = useContext(MobileDrawerContext); + const formSchema = useMemo( + () => + z.object({ + username: z + .string() + .min(2, { + message: t("validation.usernameMinLength"), + }) + .max(32, { + message: t("validation.usernameMaxLength"), + }), + password: z + .string() + .min(8, { + message: t("validation.passwordMinLength"), + }) + .max(32, { + message: t("validation.passwordMaxLength"), + }), + }), + [t] + ); + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -96,7 +102,7 @@ export default function HeaderLoginDialog() { revalidate(); toast({ - title: "You succesfully logged in!", + title: t("toast.success"), variant: "success", }); }, @@ -117,12 +123,12 @@ export default function HeaderLoginDialog() { return ( - + - Sign In To Proceed - Welcome back. + {t("title")} + {t("description")}
@@ -132,9 +138,9 @@ export default function HeaderLoginDialog() { name="username" render={({ field }) => ( - Username + {t("username.label")} - + @@ -145,11 +151,11 @@ export default function HeaderLoginDialog() { name="password" render={({ field }) => ( - Password + {t("password.label")} @@ -167,7 +173,7 @@ export default function HeaderLoginDialog() { }} type="submit" > - Login + {t("login")} @@ -185,7 +191,7 @@ export default function HeaderLoginDialog() { }} className="w-full" > - Don't have an account? Sign up + {t("signUp")}
diff --git a/components/Header/HeaderLogoutAlert.tsx b/components/Header/HeaderLogoutAlert.tsx index b013574..8156e89 100644 --- a/components/Header/HeaderLogoutAlert.tsx +++ b/components/Header/HeaderLogoutAlert.tsx @@ -12,12 +12,14 @@ import { import { useToast } from "@/hooks/use-toast"; import { useUserSelf } from "@/lib/hooks/api/user/useUser"; import { clearAuthCookies } from "@/lib/utils/clearAuthCookies"; +import { useT } from "@/lib/i18n/utils"; interface Props extends React.HTMLAttributes { children: React.ReactNode; } export function HeaderLogoutAlert({ children, ...props }: Props) { + const t = useT("components.headerLogoutAlert"); const { mutate } = useUserSelf(); const { toast } = useToast(); @@ -26,24 +28,22 @@ export function HeaderLogoutAlert({ children, ...props }: Props) { {children} - Are you sure? - - You will need to log in again to access your account. - + {t("title")} + {t("description")} - Cancel + {t("cancel")} { clearAuthCookies(); mutate(undefined); toast({ - title: "You have been successfully logged out.", + title: t("toast.success"), variant: "success", }); }} > - Continue + {t("continue")} diff --git a/components/Header/HeaderMobileDrawer.tsx b/components/Header/HeaderMobileDrawer.tsx index cf3b784..4ba80d3 100644 --- a/components/Header/HeaderMobileDrawer.tsx +++ b/components/Header/HeaderMobileDrawer.tsx @@ -35,75 +35,82 @@ import { SetStateAction, Suspense, useState, + useMemo, } from "react"; import Image from "next/image"; import { HeaderLogoutAlert } from "@/components/Header/HeaderLogoutAlert"; import HeaderLoginDialog from "@/components/Header/HeaderLoginDialog"; import { isUserCanUseAdminPanel } from "@/lib/utils/userPrivileges.util"; - -const navigationList = [ - { - icon: , - title: "Home", - url: "/", - }, - { - icon: , - title: "Leaderboard", - url: "/leaderboard", - }, - { - icon: , - title: "Top plays", - url: "/topplays", - }, - { - icon: , - title: "Beatmaps search", - url: "/beatmaps/search", - }, - { - icon: , - title: "Wiki", - url: "/wiki", - }, - { - icon: , - title: "Rules", - url: "/rules", - }, - { - icon: , - title: "API Docs", - url: `https://api.${process.env.NEXT_PUBLIC_SERVER_DOMAIN}/docs`, - }, -].filter(Boolean); - -if (process.env.NEXT_PUBLIC_DISCORD_LINK) { - navigationList.push({ - icon: , - title: "Discord Server", - url: process.env.NEXT_PUBLIC_DISCORD_LINK, - }); -} - -if (process.env.NEXT_PUBLIC_KOFI_LINK || process.env.NEXT_PUBLIC_BOOSTY_LINK) { - navigationList.push({ - icon: , - title: "Support Us", - url: "/support", - }); -} +import { useT } from "@/lib/i18n/utils"; export const MobileDrawerContext = createContext > | null>(null); export default function HeaderMobileDrawer() { + const t = useT("components.headerMobileDrawer"); const [open, setOpen] = useState(false); const { self } = useSelf(); + const navigationList = useMemo(() => { + const list = [ + { + icon: , + title: t("navigation.home"), + url: "/", + }, + { + icon: , + title: t("navigation.leaderboard"), + url: "/leaderboard", + }, + { + icon: , + title: t("navigation.topPlays"), + url: "/topplays", + }, + { + icon: , + title: t("navigation.beatmapsSearch"), + url: "/beatmaps/search", + }, + { + icon: , + title: t("navigation.wiki"), + url: "/wiki", + }, + { + icon: , + title: t("navigation.rules"), + url: "/rules", + }, + { + icon: , + title: t("navigation.apiDocs"), + url: `https://api.${process.env.NEXT_PUBLIC_SERVER_DOMAIN}/docs`, + }, + ]; + + if (process.env.NEXT_PUBLIC_DISCORD_LINK) { + list.push({ + icon: , + title: t("navigation.discordServer"), + url: process.env.NEXT_PUBLIC_DISCORD_LINK, + }); + } + + if (process.env.NEXT_PUBLIC_KOFI_LINK || process.env.NEXT_PUBLIC_BOOSTY_LINK) { + list.push({ + icon: , + title: t("navigation.supportUs"), + url: "/support", + }); + } + + return list; + }, [t]); + return ( @@ -156,21 +163,21 @@ export default function HeaderMobileDrawer() { className="flex space-x-2" > -

Your profile

+

{t("yourProfile")}

-

Friends

+

{t("friends")}

-

Settings

+

{t("settings")}

{isUserCanUseAdminPanel(self) && ( @@ -180,7 +187,7 @@ export default function HeaderMobileDrawer() { -

Admin panel

+

{t("adminPanel")}

@@ -189,7 +196,7 @@ export default function HeaderMobileDrawer() {
-

Log out

+

{t("logOut")}

diff --git a/components/Header/HeaderSearchCommand.tsx b/components/Header/HeaderSearchCommand.tsx index 7892bc8..29b3cb2 100644 --- a/components/Header/HeaderSearchCommand.tsx +++ b/components/Header/HeaderSearchCommand.tsx @@ -9,7 +9,7 @@ import { UserIcon, Users2, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useMemo } from "react"; import UserRowElement from "../UserRowElement"; import useDebounce from "@/lib/hooks/useDebounce"; import { useUserSearch } from "@/lib/hooks/api/user/useUserSearch"; @@ -29,8 +29,10 @@ import { Button } from "@/components/ui/button"; import { useBeatmapsetSearch } from "@/lib/hooks/api/beatmap/useBeatmapsetSearch"; import BeatmapsetRowElement from "@/components/BeatmapsetRowElement"; import { BeatmapStatusWeb } from "@/lib/types/api"; +import { useT } from "@/lib/i18n/utils"; export default function HeaderSearchCommand() { + const t = useT("components.headerSearchCommand"); const router = useRouter(); const { self } = useSelf(); @@ -38,6 +40,63 @@ export default function HeaderSearchCommand() { const [searchQuery, setSearchQuery] = useState(""); const searchValue = useDebounce(searchQuery, 450); + const pagesList = useMemo( + () => [ + { + icon: , + title: t("pages.leaderboard"), + url: "/leaderboard", + filter: "Leaderboard", + }, + { + icon: , + title: t("pages.topPlays"), + url: "/topplays", + filter: "Top plays", + }, + { + icon: , + title: t("pages.beatmapsSearch"), + url: "/beatmaps/search", + filter: "Beatmaps search", + }, + { + icon: , + title: t("pages.wiki"), + url: "/wiki", + filter: "Wiki", + }, + { + icon: , + title: t("pages.rules"), + url: "/rules", + filter: "Rules", + }, + { + icon: , + title: t("pages.yourProfile"), + url: self != undefined ? `/user/${self.user_id}` : "", + filter: "Your profile", + disabled: !self, + }, + { + icon: , + title: t("pages.friends"), + url: "/friends", + filter: "Friends", + disabled: !self, + }, + { + icon: , + title: t("pages.settings"), + url: "/settings", + filter: "Settings", + disabled: !self, + }, + ], + [t, self] + ); + const userSearchQuery = useUserSearch(searchValue, 1, 5, { keepPreviousData: true, }); @@ -99,11 +158,11 @@ export default function HeaderSearchCommand() { - + {userSearchQuery.isLoading && !userSearch ? (
@@ -121,7 +180,7 @@ export default function HeaderSearchCommand() { )} - + {beatmapsetSearchQuery.isLoading && !beatmapsetSearch ? (
@@ -140,73 +199,18 @@ export default function HeaderSearchCommand() { )} - - openPage("/leaderboard")} - className={filterElement("Leaderboard") ? "hidden" : ""} - > - - Leaderboard - - - openPage("/topplays")} - className={filterElement("Top plays") ? "hidden" : ""} - > - - Top plays - - - openPage("/beatmaps/search")} - className={filterElement("Beatmaps search") ? "hidden" : ""} - > - - Beatmaps search - - - openPage("/wiki")} - className={filterElement("Wiki") ? "hidden" : ""} - > - - Wiki - - - openPage("/rules")} - className={filterElement("Rules") ? "hidden" : ""} - > - - Rules - - - - openPage(self != undefined ? `/user/${self.user_id}` : "") - } - disabled={!self} - className={filterElement("Your profile") ? "hidden" : ""} - > - - Your profile - - openPage("/friends")} - disabled={!self} - className={filterElement("Friends") ? "hidden" : ""} - > - - Friends - - openPage("/settings")} - disabled={!self} - className={filterElement("Settings") ? "hidden" : ""} - > - - Settings - + + {pagesList.map((page) => ( + openPage(page.url)} + disabled={page.disabled} + className={filterElement(page.filter) ? "hidden" : ""} + > + {page.icon} + {page.title} + + ))} diff --git a/components/Header/HeaderUserDropdown.tsx b/components/Header/HeaderUserDropdown.tsx index e2b7b7b..742ba8b 100644 --- a/components/Header/HeaderUserDropdown.tsx +++ b/components/Header/HeaderUserDropdown.tsx @@ -28,6 +28,7 @@ import { import UserPrivilegeBadges from "@/app/(website)/user/[id]/components/UserPrivilegeBadges"; import { usePathname, useRouter } from "next/navigation"; import { isUserCanUseAdminPanel } from "@/lib/utils/userPrivileges.util"; +import { useT } from "@/lib/i18n/utils"; interface Props { self: UserResponse | null; @@ -44,6 +45,7 @@ export default function HeaderUserDropdown({ sideOffset, align, }: Props) { + const t = useT("components.headerUserDropdown"); const pathname = usePathname(); return ( @@ -98,19 +100,19 @@ export default function HeaderUserDropdown({ - My Profile + {t("myProfile")} - Friends + {t("friends")} - Settings + {t("settings")} @@ -122,12 +124,12 @@ export default function HeaderUserDropdown({ {pathname.includes("/admin") ? ( - Return to main site + {t("returnToMainSite")} ) : ( - Admin panel + {t("adminPanel")} )} @@ -142,7 +144,7 @@ export default function HeaderUserDropdown({ > - Log out + {t("logOut")} diff --git a/components/Header/ThemeModeToggle.tsx b/components/Header/ThemeModeToggle.tsx index f4a1835..d32032b 100644 --- a/components/Header/ThemeModeToggle.tsx +++ b/components/Header/ThemeModeToggle.tsx @@ -11,8 +11,10 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useT } from "@/lib/i18n/utils"; export function ThemeModeToggle({ children }: { children?: React.ReactNode }) { + const t = useT("components.themeModeToggle"); const { setTheme } = useTheme(); return ( @@ -28,19 +30,19 @@ export function ThemeModeToggle({ children }: { children?: React.ReactNode }) { > - Toggle theme + {t("toggleTheme")} )} setTheme("light")}> - Light + {t("light")} setTheme("dark")}> - Dark + {t("dark")} setTheme("system")}> - System + {t("system")} diff --git a/components/ServerMaintenanceDialog.tsx b/components/ServerMaintenanceDialog.tsx index 6467112..ddeade5 100644 --- a/components/ServerMaintenanceDialog.tsx +++ b/components/ServerMaintenanceDialog.tsx @@ -24,7 +24,7 @@ export default function ServerMaintenanceDialog({ - Hey! Stop right there! + {t("title")}

@@ -33,7 +33,7 @@ export default function ServerMaintenanceDialog({

- For more information view our Discord server. + {t("discordMessage")}
)}

@@ -53,7 +53,7 @@ export default function ServerMaintenanceDialog({ variant="destructive" onClick={() => setOpen(false)} > - Okay, I understand + {t("button")} diff --git a/components/WorkInProgress.tsx b/components/WorkInProgress.tsx index 672f61d..a787e8e 100644 --- a/components/WorkInProgress.tsx +++ b/components/WorkInProgress.tsx @@ -1,6 +1,8 @@ import Image from "next/image"; +import { useT } from "@/lib/i18n/utils"; export function WorkInProgress() { + const t = useT("components.workInProgress"); return (
-

Work in progress

-

This content is still being worked on. Please check back later.

+

{t("title")}

+

{t("description")}

); diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json index ffdc535..ca61029 100644 --- a/lib/i18n/messages/en.json +++ b/lib/i18n/messages/en.json @@ -23,10 +23,440 @@ }, "components": { "serverMaintenanceDialog": { + "title": { + "text": "Hey! Stop right there!", + "context": "Title of the server maintenance dialog" + }, + "discordMessage": { + "text": "For more information view our Discord server.", + "context": "Message directing users to Discord for more information about server maintenance" + }, + "button": { + "text": "Okay, I understand", + "context": "Button text to acknowledge the maintenance message" + }, "message": { "text": "The server is currently in maintenance mode, so some features of the website may not function correctly.", "context": "This message is shown to users when the server is under maintenance." } + }, + "contentNotExist": { + "defaultText": { + "text": "Content not found", + "context": "Default message when content does not exist" + } + }, + "workInProgress": { + "title": { + "text": "Work in progress", + "context": "Title for work in progress message" + }, + "description": { + "text": "This content is still being worked on. Please check back later.", + "context": "Description message for work in progress content" + } + }, + "beatmapSetCard": { + "submittedBy": { + "text": "submitted by", + "context": "Label showing who submitted the beatmap" + }, + "submittedOn": { + "text": "submitted on", + "context": "Label showing when the beatmap was submitted" + }, + "view": { + "text": "View", + "context": "Button text to view the beatmap" + } + }, + "friendshipButton": { + "unfriend": { + "text": "Unfriend", + "context": "Button text to unfriend a user (mutual friendship)" + }, + "unfollow": { + "text": "Unfollow", + "context": "Button text to unfollow a user" + }, + "follow": { + "text": "Follow", + "context": "Button text to follow a user" + } + }, + "gameModeSelector": { + "selectedMode": { + "text": "Selected mode:", + "context": "Label for selected game mode in mobile combobox view" + } + }, + "header": { + "links": { + "leaderboard": { + "text": "leaderboard", + "context": "Header navigation link text for leaderboard" + }, + "topPlays": { + "text": "top plays", + "context": "Header navigation link text for top plays" + }, + "beatmaps": { + "text": "beatmaps", + "context": "Header navigation link text for beatmaps" + }, + "help": { + "text": "help", + "context": "Header navigation link text for help dropdown" + }, + "wiki": { + "text": "wiki", + "context": "Help dropdown menu item for wiki" + }, + "rules": { + "text": "rules", + "context": "Help dropdown menu item for rules" + }, + "apiDocs": { + "text": "api docs", + "context": "Help dropdown menu item for API documentation" + }, + "discordServer": { + "text": "discord server", + "context": "Help dropdown menu item for Discord server" + }, + "supportUs": { + "text": "support us", + "context": "Help dropdown menu item for support page" + } + } + }, + "headerLoginDialog": { + "signIn": { + "text": "sign in", + "context": "Button text to open sign in dialog" + }, + "title": { + "text": "Sign In To Proceed", + "context": "Title of the login dialog" + }, + "description": { + "text": "Welcome back.", + "context": "Description text in the login dialog" + }, + "username": { + "label": { + "text": "Username", + "context": "Form label for username input" + }, + "placeholder": { + "text": "e.g. username", + "context": "Placeholder text for username input" + } + }, + "password": { + "label": { + "text": "Password", + "context": "Form label for password input" + }, + "placeholder": { + "text": "************", + "context": "Placeholder text for password input (masked)" + } + }, + "login": { + "text": "Login", + "context": "Button text to submit login form" + }, + "signUp": { + "text": "Don't have an account? Sign up", + "context": "Link text to navigate to registration page" + }, + "toast": { + "success": { + "text": "You succesfully logged in!", + "context": "Success toast message after successful login" + } + }, + "validation": { + "usernameMinLength": { + "text": "Username must be at least 2 characters.", + "context": "Validation error message for username minimum length" + }, + "usernameMaxLength": { + "text": "Username must be 32 characters or fewer.", + "context": "Validation error message for username maximum length" + }, + "passwordMinLength": { + "text": "Password must be at least 8 characters.", + "context": "Validation error message for password minimum length" + }, + "passwordMaxLength": { + "text": "Password must be 32 characters or fewer.", + "context": "Validation error message for password maximum length" + } + } + }, + "headerLogoutAlert": { + "title": { + "text": "Are you sure?", + "context": "Title of the logout confirmation dialog" + }, + "description": { + "text": "You will need to log in again to access your account.", + "context": "Description text explaining the consequences of logging out" + }, + "cancel": { + "text": "Cancel", + "context": "Button text to cancel logout" + }, + "continue": { + "text": "Continue", + "context": "Button text to confirm logout" + }, + "toast": { + "success": { + "text": "You have been successfully logged out.", + "context": "Success toast message after successful logout" + } + } + }, + "headerSearchCommand": { + "placeholder": { + "text": "Type to search...", + "context": "Placeholder text for the search command input" + }, + "headings": { + "users": { + "text": "Users", + "context": "Heading for users section in search results" + }, + "beatmapsets": { + "text": "Beatmapsets", + "context": "Heading for beatmapsets section in search results" + }, + "pages": { + "text": "Pages", + "context": "Heading for pages section in search results" + } + }, + "pages": { + "leaderboard": { + "text": "Leaderboard", + "context": "Search result item for leaderboard page" + }, + "topPlays": { + "text": "Top plays", + "context": "Search result item for top plays page" + }, + "beatmapsSearch": { + "text": "Beatmaps search", + "context": "Search result item for beatmaps search page" + }, + "wiki": { + "text": "Wiki", + "context": "Search result item for wiki page" + }, + "rules": { + "text": "Rules", + "context": "Search result item for rules page" + }, + "yourProfile": { + "text": "Your profile", + "context": "Search result item for user's own profile" + }, + "friends": { + "text": "Friends", + "context": "Search result item for friends page" + }, + "settings": { + "text": "Settings", + "context": "Search result item for settings page" + } + } + }, + "headerUserDropdown": { + "myProfile": { + "text": "My Profile", + "context": "Dropdown menu item for user's profile" + }, + "friends": { + "text": "Friends", + "context": "Dropdown menu item for friends page" + }, + "settings": { + "text": "Settings", + "context": "Dropdown menu item for settings page" + }, + "returnToMainSite": { + "text": "Return to main site", + "context": "Dropdown menu item to return from admin panel to main site" + }, + "adminPanel": { + "text": "Admin panel", + "context": "Dropdown menu item to access admin panel" + }, + "logOut": { + "text": "Log out", + "context": "Dropdown menu item to log out" + } + }, + "headerMobileDrawer": { + "navigation": { + "home": { + "text": "Home", + "context": "Mobile drawer navigation item for home page" + }, + "leaderboard": { + "text": "Leaderboard", + "context": "Mobile drawer navigation item for leaderboard" + }, + "topPlays": { + "text": "Top plays", + "context": "Mobile drawer navigation item for top plays" + }, + "beatmapsSearch": { + "text": "Beatmaps search", + "context": "Mobile drawer navigation item for beatmaps search" + }, + "wiki": { + "text": "Wiki", + "context": "Mobile drawer navigation item for wiki" + }, + "rules": { + "text": "Rules", + "context": "Mobile drawer navigation item for rules" + }, + "apiDocs": { + "text": "API Docs", + "context": "Mobile drawer navigation item for API documentation" + }, + "discordServer": { + "text": "Discord Server", + "context": "Mobile drawer navigation item for Discord server" + }, + "supportUs": { + "text": "Support Us", + "context": "Mobile drawer navigation item for support page" + } + }, + "yourProfile": { + "text": "Your profile", + "context": "Mobile drawer menu item for user's profile" + }, + "friends": { + "text": "Friends", + "context": "Mobile drawer menu item for friends" + }, + "settings": { + "text": "Settings", + "context": "Mobile drawer menu item for settings" + }, + "adminPanel": { + "text": "Admin panel", + "context": "Mobile drawer menu item for admin panel" + }, + "logOut": { + "text": "Log out", + "context": "Mobile drawer menu item to log out" + } + }, + "footer": { + "voteMessage": { + "text": "Please vote for us on osu-server-list!", + "context": "Message encouraging users to vote on osu-server-list" + }, + "copyright": { + "text": "© 2024-2025 Sunrise Community", + "context": "Copyright text in footer" + }, + "sourceCode": { + "text": "Source Code", + "context": "Link text for source code repository" + }, + "serverStatus": { + "text": "Server Status", + "context": "Link text for server status page" + }, + "disclaimer": { + "text": "We are not affiliated with \"ppy\" and \"osu!\" in any way. All rights reserved to their respective owners.", + "context": "Disclaimer text about not being affiliated with ppy/osu!" + } + }, + "comboBox": { + "selectValue": { + "text": "Select value...", + "context": "Placeholder text when no value is selected in combobox" + }, + "searchValue": { + "text": "Search value...", + "context": "Placeholder text for search input in combobox" + }, + "noValuesFound": { + "text": "No values found.", + "context": "Message shown when no search results are found in combobox" + } + }, + "beatmapsetRowElement": { + "mappedBy": { + "text": "mapped by {creator}", + "context": "Text showing who mapped the beatmap, includes creator name as parameter" + } + }, + "themeModeToggle": { + "toggleTheme": { + "text": "Toggle theme", + "context": "Screen reader text for theme toggle button" + }, + "light": { + "text": "Light", + "context": "Theme option for light mode" + }, + "dark": { + "text": "Dark", + "context": "Theme option for dark mode" + }, + "system": { + "text": "System", + "context": "Theme option for system default mode" + } + }, + "imageSelect": { + "imageTooBig": { + "text": "Selected image is too big!", + "context": "Error message when selected image exceeds maximum file size" + } + }, + "notFound": { + "meta": { + "title": { + "text": "Not Found | {appName}", + "context": "Page title for 404 not found page" + }, + "description": { + "text": "The page you're looking for isn't here. Sorry.", + "context": "Meta description for 404 not found page" + } + }, + "title": { + "text": "Not Found | 404", + "context": "Main heading for 404 not found page" + }, + "description": { + "text": "What you're looking for isn't here. Sorry.", + "context": "Description text explaining the page was not found" + } + }, + "rootLayout": { + "meta": { + "title": { + "text": "{appName}", + "context": "Root layout page title" + }, + "description": { + "text": "{appName} is a private server for osu!, a rhythm game.", + "context": "Root layout meta description" + } + } } }, "pages": { diff --git a/lib/i18n/messages/ru.json b/lib/i18n/messages/ru.json index bfd0e13..e22629d 100644 --- a/lib/i18n/messages/ru.json +++ b/lib/i18n/messages/ru.json @@ -1,4 +1,160 @@ { + "components": { + "serverMaintenanceDialog": { + "title": "Эй! Стоп!", + "discordMessage": "Для получения дополнительной информации посетите наш Discord сервер.", + "button": "Хорошо, понял", + "message": "Сервер находится в режиме технического обслуживания, поэтому некоторые функции сайта могут работать некорректно." + }, + "contentNotExist": { + "defaultText": "Контент не найден" + }, + "workInProgress": { + "title": "В разработке", + "description": "Этот контент всё ещё разрабатывается. Пожалуйста, зайдите позже." + }, + "beatmapSetCard": { + "submittedBy": "отправлено", + "submittedOn": "отправлено", + "view": "Просмотр" + }, + "friendshipButton": { + "unfriend": "Удалить из друзей", + "unfollow": "Отписаться", + "follow": "Подписаться" + }, + "gameModeSelector": { + "selectedMode": "Выбранный режим:" + }, + "header": { + "links": { + "leaderboard": "таблица лидеров", + "topPlays": "топ игры", + "beatmaps": "карты", + "help": "помощь", + "wiki": "вики", + "rules": "правила", + "apiDocs": "документация API", + "discordServer": "Discord сервер", + "supportUs": "поддержать нас" + } + }, + "headerLoginDialog": { + "signIn": "войти", + "title": "Войдите, чтобы продолжить", + "description": "С возвращением.", + "username": { + "label": "Имя пользователя", + "placeholder": "например, username" + }, + "password": { + "label": "Пароль", + "placeholder": "************" + }, + "login": "Войти", + "signUp": "Нет аккаунта? Зарегистрироваться", + "toast": { + "success": "Вы успешно вошли!" + }, + "validation": { + "usernameMinLength": "Имя пользователя должно содержать не менее 2 символов.", + "usernameMaxLength": "Имя пользователя должно содержать не более 32 символов.", + "passwordMinLength": "Пароль должен содержать не менее 8 символов.", + "passwordMaxLength": "Пароль должен содержать не более 32 символов." + } + }, + "headerLogoutAlert": { + "title": "Вы уверены?", + "description": "Вам нужно будет войти снова, чтобы получить доступ к аккаунту.", + "cancel": "Отмена", + "continue": "Продолжить", + "toast": { + "success": "Вы успешно вышли из системы." + } + }, + "headerSearchCommand": { + "placeholder": "Введите для поиска...", + "headings": { + "users": "Пользователи", + "beatmapsets": "Наборы карт", + "pages": "Страницы" + }, + "pages": { + "leaderboard": "Таблица лидеров", + "topPlays": "Топ игры", + "beatmapsSearch": "Поиск карт", + "wiki": "Вики", + "rules": "Правила", + "yourProfile": "Ваш профиль", + "friends": "Друзья", + "settings": "Настройки" + } + }, + "headerUserDropdown": { + "myProfile": "Мой профиль", + "friends": "Друзья", + "settings": "Настройки", + "returnToMainSite": "Вернуться на главный сайт", + "adminPanel": "Панель администратора", + "logOut": "Выйти" + }, + "headerMobileDrawer": { + "navigation": { + "home": "Главная", + "leaderboard": "Таблица лидеров", + "topPlays": "Топ игры", + "beatmapsSearch": "Поиск карт", + "wiki": "Вики", + "rules": "Правила", + "apiDocs": "Документация API", + "discordServer": "Discord сервер", + "supportUs": "Поддержать нас" + }, + "yourProfile": "Ваш профиль", + "friends": "Друзья", + "settings": "Настройки", + "adminPanel": "Панель администратора", + "logOut": "Выйти" + }, + "footer": { + "voteMessage": "Пожалуйста, проголосуйте за нас на osu-server-list!", + "copyright": "© 2024-2025 Sunrise Community", + "sourceCode": "Исходный код", + "serverStatus": "Статус сервера", + "disclaimer": "Мы не связаны с \"ppy\" и \"osu!\" каким-либо образом. Все права принадлежат их владельцам." + }, + "comboBox": { + "selectValue": "Выберите значение...", + "searchValue": "Поиск значения...", + "noValuesFound": "Значения не найдены." + }, + "beatmapsetRowElement": { + "mappedBy": "создана {creator}" + }, + "themeModeToggle": { + "toggleTheme": "Переключить тему", + "light": "Светлая", + "dark": "Тёмная", + "system": "Системная" + }, + "imageSelect": { + "imageTooBig": "Выбранное изображение слишком большое!" + }, + "notFound": { + "meta": { + "title": "Не найдено | {appName}", + "description": "Страница, которую вы ищете, здесь нет. Извините." + }, + "title": "Не найдено | 404", + "description": "То, что вы ищете, здесь нет. Извините." + }, + "rootLayout": { + "meta": { + "title": "{appName}", + "description": "{appName} — это приватный сервер для osu!, ритм-игры." + } + } + }, "pages": { "mainPage": { "TODO": "UPDATE TRANSLATION, AI FOR NOW", From 6253832b4b36753856244f1d6c743ad00ede4185 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 21 Dec 2025 06:53:12 +0200 Subject: [PATCH 27/31] feat: Add LanguageSelector and deprecate temporary one --- components/Header/Header.tsx | 20 +---- components/Header/HeaderMobileDrawer.tsx | 7 +- components/Header/LanguageSelector.tsx | 104 +++++++++++++++++++++++ lib/i18n/messages/index.ts | 6 ++ lib/i18n/utils.tsx | 9 ++ 5 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 components/Header/LanguageSelector.tsx create mode 100644 lib/i18n/messages/index.ts diff --git a/components/Header/Header.tsx b/components/Header/Header.tsx index 5de4ea2..2a1e656 100644 --- a/components/Header/Header.tsx +++ b/components/Header/Header.tsx @@ -1,5 +1,5 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import HeaderLink from "@/components/Header/HeaderLink"; import { twMerge } from "tailwind-merge"; @@ -7,7 +7,6 @@ import { ThemeModeToggle } from "@/components/Header/ThemeModeToggle"; import HeaderSearchCommand from "@/components/Header/HeaderSearchCommand"; import HeaderMobileDrawer from "@/components/Header/HeaderMobileDrawer"; import HeaderAvatar from "@/components/Header/HeaderAvatar"; -import Cookies from "js-cookie"; import Link from "next/link"; import { DropdownMenu, @@ -17,16 +16,13 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Brand } from "@/components/Brand"; -import { Button } from "@/components/ui/button"; -import { useRouter } from "next/navigation"; import { useT } from "@/lib/i18n/utils"; +import { LanguageSelector } from "@/components/Header/LanguageSelector"; export default function Header() { const t = useT("components.header"); const [scrolled, setScrolled] = useState(false); - const router = useRouter(); - useEffect(() => { const handleScroll = () => { setScrolled(window.scrollY > 0); @@ -42,11 +38,6 @@ export default function Header() { scrolled ? `bg-pos-100 bg-size-200` : `hover:bg-pos-100 hover:bg-size-200` }`; - const changeLanguage = useCallback((locale: string) => { - Cookies.set("locale", locale); - router.refresh(); - }, []); - return (
-
- {/* TODO: temp position */} - - -
-
+
diff --git a/components/Header/HeaderMobileDrawer.tsx b/components/Header/HeaderMobileDrawer.tsx index 4ba80d3..4debb69 100644 --- a/components/Header/HeaderMobileDrawer.tsx +++ b/components/Header/HeaderMobileDrawer.tsx @@ -42,6 +42,7 @@ import { HeaderLogoutAlert } from "@/components/Header/HeaderLogoutAlert"; import HeaderLoginDialog from "@/components/Header/HeaderLoginDialog"; import { isUserCanUseAdminPanel } from "@/lib/utils/userPrivileges.util"; import { useT } from "@/lib/i18n/utils"; +import { LanguageSelector } from "@/components/Header/LanguageSelector"; export const MobileDrawerContext = createContext @@ -100,7 +101,10 @@ export default function HeaderMobileDrawer() { }); } - if (process.env.NEXT_PUBLIC_KOFI_LINK || process.env.NEXT_PUBLIC_BOOSTY_LINK) { + if ( + process.env.NEXT_PUBLIC_KOFI_LINK || + process.env.NEXT_PUBLIC_BOOSTY_LINK + ) { list.push({ icon: , title: t("navigation.supportUs"), @@ -148,6 +152,7 @@ export default function HeaderMobileDrawer() {
+
diff --git a/components/Header/LanguageSelector.tsx b/components/Header/LanguageSelector.tsx new file mode 100644 index 0000000..1a8794e --- /dev/null +++ b/components/Header/LanguageSelector.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import Cookies from "js-cookie"; +import { useLocale } from "next-intl"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { Languages, Check } from "lucide-react"; +import { cn } from "@/lib/utils"; +import Image from "next/image"; +import { AVAILABLE_LOCALES } from "@/lib/i18n/messages"; +import { getCountryCodeForLocale } from "@/lib/i18n/utils"; + +export function LanguageSelector() { + const router = useRouter(); + const currentLocale = useLocale(); + + const changeLanguage = useCallback( + (locale: string) => { + Cookies.set("locale", locale); + router.refresh(); + }, + [router] + ); + + const getLanguageName = useCallback( + (locale: string, displayLocale: string = currentLocale) => { + try { + const displayNames = new Intl.DisplayNames([displayLocale], { + type: "language", + }); + + const name = displayNames.of(locale) || locale.toUpperCase(); + return name; + } catch { + return locale.toUpperCase(); + } + }, + [currentLocale] + ); + + const languages = useMemo(() => { + return AVAILABLE_LOCALES.map((localeCode) => ({ + code: localeCode, + countryCode: getCountryCodeForLocale(localeCode), + nativeName: getLanguageName(localeCode, localeCode), + })); + }, [getLanguageName]); + + return ( + + + + + + {languages.map((locale) => { + const isActive = locale.code === currentLocale; + + return ( + changeLanguage(locale.code)} + className={cn( + "flex items-center gap-3 cursor-pointer py-2.5 px-3", + isActive && "bg-accent" + )} + > + {`${locale.nativeName} + + {locale.nativeName} + + {isActive && ( + + )} + + ); + })} + + + ); +} diff --git a/lib/i18n/messages/index.ts b/lib/i18n/messages/index.ts new file mode 100644 index 0000000..4580dac --- /dev/null +++ b/lib/i18n/messages/index.ts @@ -0,0 +1,6 @@ +export const AVAILABLE_LOCALES = ["en", "ru"] as const; + +export const LOCALE_TO_COUNTRY: Record = { + en: "GB", + ru: "RU", +}; diff --git a/lib/i18n/utils.tsx b/lib/i18n/utils.tsx index bee1f21..ec071c6 100644 --- a/lib/i18n/utils.tsx +++ b/lib/i18n/utils.tsx @@ -5,6 +5,7 @@ import { } from "next-intl"; import { getTranslations } from "next-intl/server"; import { ReactNode } from "react"; +import { LOCALE_TO_COUNTRY } from "./messages"; export type TranslationKey = string; @@ -46,3 +47,11 @@ export async function getT(namespace?: string) { return plainT; } + +export const getCountryCodeForLocale = (locale: string) => { + return LOCALE_TO_COUNTRY[locale] || locale.toUpperCase(); +}; + +export const getLanguageName = (locale: string) => { + return locale.toUpperCase(); +}; From 6e8ccc038a3a503b5c42e3e9e12268f05f74747a Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 21 Dec 2025 07:39:21 +0200 Subject: [PATCH 28/31] feat: Add custom language --- .../[id]/components/Tabs/UserTabGeneral.tsx | 2 +- components/Header/LanguageSelector.tsx | 65 +- lib/i18n/messages/en-GB.json | 2360 +++++++++++++++++ lib/i18n/messages/index.ts | 7 +- public/images/flags/OWO.png | Bin 0 -> 25402 bytes 5 files changed, 2402 insertions(+), 32 deletions(-) create mode 100644 lib/i18n/messages/en-GB.json create mode 100644 public/images/flags/OWO.png diff --git a/app/(website)/user/[id]/components/Tabs/UserTabGeneral.tsx b/app/(website)/user/[id]/components/Tabs/UserTabGeneral.tsx index 8e51df8..621951e 100644 --- a/app/(website)/user/[id]/components/Tabs/UserTabGeneral.tsx +++ b/app/(website)/user/[id]/components/Tabs/UserTabGeneral.tsx @@ -112,7 +112,7 @@ export default function UserTabGeneral({

{t("playtime")}

-
+
{stats ? ( playtimeToString(stats?.play_time ?? 0) ) : ( diff --git a/components/Header/LanguageSelector.tsx b/components/Header/LanguageSelector.tsx index 1a8794e..4c1babf 100644 --- a/components/Header/LanguageSelector.tsx +++ b/components/Header/LanguageSelector.tsx @@ -14,7 +14,7 @@ import { Button } from "@/components/ui/button"; import { Languages, Check } from "lucide-react"; import { cn } from "@/lib/utils"; import Image from "next/image"; -import { AVAILABLE_LOCALES } from "@/lib/i18n/messages"; +import { AVAILABLE_LOCALES, DISPLAY_NAMES_LOCALES } from "@/lib/i18n/messages"; import { getCountryCodeForLocale } from "@/lib/i18n/utils"; export function LanguageSelector() { @@ -36,10 +36,13 @@ export function LanguageSelector() { type: "language", }); - const name = displayNames.of(locale) || locale.toUpperCase(); + const name = + DISPLAY_NAMES_LOCALES[locale] || + displayNames.of(locale) || + locale.toUpperCase(); return name; } catch { - return locale.toUpperCase(); + return DISPLAY_NAMES_LOCALES[locale] || locale.toUpperCase(); } }, [currentLocale] @@ -70,34 +73,36 @@ export function LanguageSelector() { className="min-w-[200px] w-[calc(100vw-2rem)] max-w-[280px] md:min-w-[200px] md:w-auto" sideOffset={8} > - {languages.map((locale) => { - const isActive = locale.code === currentLocale; + {languages + .sort((a, b) => a.nativeName.localeCompare(b.nativeName)) + .map((locale) => { + const isActive = locale.code === currentLocale; - return ( - changeLanguage(locale.code)} - className={cn( - "flex items-center gap-3 cursor-pointer py-2.5 px-3", - isActive && "bg-accent" - )} - > - {`${locale.nativeName} - - {locale.nativeName} - - {isActive && ( - - )} - - ); - })} + return ( + changeLanguage(locale.code)} + className={cn( + "flex items-center gap-3 cursor-pointer py-2.5 px-3", + isActive && "bg-accent" + )} + > + {`${locale.nativeName} + + {locale.nativeName} + + {isActive && ( + + )} + + ); + })} ); diff --git a/lib/i18n/messages/en-GB.json b/lib/i18n/messages/en-GB.json new file mode 100644 index 0000000..b8c2ea8 --- /dev/null +++ b/lib/i18n/messages/en-GB.json @@ -0,0 +1,2360 @@ +{ + "components": { + "serverMaintenanceDialog": { + "title": { + "text": "hey! OwO stop wight thewe!", + "context": "Title of the server maintenance dialog" + }, + "discordMessage": { + "text": "fow mowe infowmation view ouww discowd sewvew.", + "context": "Message directing users to Discord for more information about server maintenance" + }, + "button": { + "text": "Okay, I understand", + "context": "Button text to acknowledge the maintenance message" + }, + "message": { + "text": "the sewvew is cuwwwentwy in maintenance mode, so some featuwwes of the website may not fuwnction cowwectwy.", + "context": "This message is shown to users when the server is under maintenance." + } + }, + "contentNotExist": { + "defaultText": { + "text": "Content not found", + "context": "Default message when content does not exist" + } + }, + "workInProgress": { + "title": { + "text": "wowk in pwogwess", + "context": "Title for work in progress message" + }, + "description": { + "text": "this content is stiww being wowked on. Pwease check back watew.", + "context": "Description message for work in progress content" + } + }, + "beatmapSetCard": { + "submittedBy": { + "text": "suwbmitted by", + "context": "Labew showing who suwbmitted the beatmap" + }, + "submittedOn": { + "text": "suwbmitted on", + "context": "Labew showing when the beatmap was suwbmitted" + }, + "view": { + "text": "view", + "context": "Button text to view the beatmap" + } + }, + "friendshipButton": { + "unfriend": { + "text": "Unfwiend", + "context": "Button text to unfriend a user (mutual friendship)" + }, + "unfollow": { + "text": "Unfowwow", + "context": "Button text to unfollow a user" + }, + "follow": { + "text": "Fowwow", + "context": "Button text to follow a user" + } + }, + "gameModeSelector": { + "selectedMode": { + "text": "sewected mode:", + "context": "Label for selected game mode in mobile combobox view" + } + }, + "header": { + "links": { + "leaderboard": { + "text": "weadewboawd", + "context": "Header navigation link text for leaderboard" + }, + "topPlays": { + "text": "top pways", + "context": "Header navigation link text for top plays" + }, + "beatmaps": { + "text": "beatmaps", + "context": "Header navigation link text for beatmaps" + }, + "help": { + "text": "hewp", + "context": "Header navigation link text for help dropdown" + }, + "wiki": { + "text": "wiki", + "context": "Help dropdown menu item for wiki" + }, + "rules": { + "text": "rules", + "context": "Help dropdown menu item for rules" + }, + "apiDocs": { + "text": "api docs", + "context": "Help dropdown menu item for API documentation" + }, + "discordServer": { + "text": "discowd sewvew", + "context": "Help dropdown menu item for Discord server" + }, + "supportUs": { + "text": "support us", + "context": "Help dropdown menu item for support page" + } + } + }, + "headerLoginDialog": { + "signIn": { + "text": "sign in", + "context": "Button text to open sign in dialog" + }, + "title": { + "text": "sign in to pwoceed", + "context": "Title of the login dialog" + }, + "description": { + "text": "wewcome back.", + "context": "Description text in the login dialog" + }, + "username": { + "label": { + "text": "Usewname", + "context": "Form label for username input" + }, + "placeholder": { + "text": "e.g. uwsewname", + "context": "Placeholder text for username input" + } + }, + "password": { + "label": { + "text": "passwowd", + "context": "Form label for password input" + }, + "placeholder": { + "text": "************", + "context": "Placeholder text for password input (masked)" + } + }, + "login": { + "text": "login", + "context": "Button text to submit login form" + }, + "signUp": { + "text": "don't have an accouwnt? UwU sign uwp", + "context": "Link text to navigate to registration page" + }, + "toast": { + "success": { + "text": "Youw suwccesfuwwwy wogged in!", + "context": "Success toast message after successful login" + } + }, + "validation": { + "usernameMinLength": { + "text": "Username must be at least 2 characters.", + "context": "Validation error message for username minimum length" + }, + "usernameMaxLength": { + "text": "Username must be 32 characters or fewer.", + "context": "Validation error message for username maximum length" + }, + "passwordMinLength": { + "text": "passwowd muwst be at weast 8 chawactews.", + "context": "Validation error message for password minimum length" + }, + "passwordMaxLength": { + "text": "passwowd muwst be 32 chawactews ow fewew.", + "context": "Validation error message for password maximum length" + } + } + }, + "headerLogoutAlert": { + "title": { + "text": "Are you sure?", + "context": "Title of the logout confirmation dialog" + }, + "description": { + "text": "You will need to log in again to access your account.", + "context": "Description text explaining the consequences of logging out" + }, + "cancel": { + "text": "Cancel", + "context": "Button text to cancel logout" + }, + "continue": { + "text": "Continue", + "context": "Button text to confirm logout" + }, + "toast": { + "success": { + "text": "You have been successfully logged out.", + "context": "Success toast message after successful logout" + } + } + }, + "headerSearchCommand": { + "placeholder": { + "text": "type to seawch...", + "context": "Placeholder text for the search command input" + }, + "headings": { + "users": { + "text": "Users", + "context": "Heading for users section in search results" + }, + "beatmapsets": { + "text": "beatmapsets", + "context": "Heading for beatmapsets section in search results" + }, + "pages": { + "text": "pages", + "context": "Heading for pages section in search results" + } + }, + "pages": { + "leaderboard": { + "text": "leadewboawd", + "context": "Search result item for leaderboard page" + }, + "topPlays": { + "text": "top pways", + "context": "Search result item for top plays page" + }, + "beatmapsSearch": { + "text": "beatmaps seawch", + "context": "Search result item for beatmaps search page" + }, + "wiki": { + "text": "wiki", + "context": "Search result item for wiki page" + }, + "rules": { + "text": "Rules", + "context": "Search result item for rules page" + }, + "yourProfile": { + "text": "Your profile", + "context": "Search result item for user's own profile" + }, + "friends": { + "text": "fwiends", + "context": "Search result item for friends page" + }, + "settings": { + "text": "settings", + "context": "Search result item for settings page" + } + } + }, + "headerUserDropdown": { + "myProfile": { + "text": "my pwofiwe", + "context": "Dropdown menu item for user's profile" + }, + "friends": { + "text": "fwiends", + "context": "Dropdown menu item for friends page" + }, + "settings": { + "text": "settings", + "context": "Dropdown menu item for settings page" + }, + "returnToMainSite": { + "text": "Return to main site", + "context": "Dropdown menu item to return from admin panel to main site" + }, + "adminPanel": { + "text": "admin panew", + "context": "Dropdown menu item to access admin panel" + }, + "logOut": { + "text": "Log out", + "context": "Dropdown menu item to log out" + } + }, + "headerMobileDrawer": { + "navigation": { + "home": { + "text": "home", + "context": "Mobile drawer navigation item for home page" + }, + "leaderboard": { + "text": "leadewboawd", + "context": "Mobile drawer navigation item for leaderboard" + }, + "topPlays": { + "text": "top pways", + "context": "Mobile drawer navigation item for top plays" + }, + "beatmapsSearch": { + "text": "beatmaps seawch", + "context": "Mobile drawer navigation item for beatmaps search" + }, + "wiki": { + "text": "wiki", + "context": "Mobile drawer navigation item for wiki" + }, + "rules": { + "text": "Rules", + "context": "Mobile drawer navigation item for rules" + }, + "apiDocs": { + "text": "api docs", + "context": "Mobile drawer navigation item for API documentation" + }, + "discordServer": { + "text": "discowd sewvew", + "context": "Mobile drawer navigation item for Discord server" + }, + "supportUs": { + "text": "Support Us", + "context": "Mobile drawer navigation item for support page" + } + }, + "yourProfile": { + "text": "Youww pwofiwe", + "context": "Mobile drawer menu item for user's profile" + }, + "friends": { + "text": "fwiends", + "context": "Mobile drawer menu item for friends" + }, + "settings": { + "text": "settings", + "context": "Mobiwe dwawer menu item fow settings" + }, + "adminPanel": { + "text": "admin panew", + "context": "Mobile drawer menu item for admin panel" + }, + "logOut": { + "text": "Log ouwt", + "context": "Mobile drawer menu item to log out" + } + }, + "footer": { + "voteMessage": { + "text": "pwease vote fow uws on osuw-sewvew-wist!", + "context": "Message encouraging users to vote on osu-server-list" + }, + "copyright": { + "text": "© 2024-2025 suwnwise commuwnity", + "context": "Copyright text in footer" + }, + "sourceCode": { + "text": "Souwwce code", + "context": "Link text for source code repository" + }, + "serverStatus": { + "text": "Sewvew Statuws", + "context": "Link text for server status page" + }, + "disclaimer": { + "text": "we awe not affiwiated with \"ppy\" and \"osuw!\" in any way. Aww wights wesewved to theiw wespective ownews.", + "context": "Disclaimer text about not being affiliated with ppy/osu!" + } + }, + "comboBox": { + "selectValue": { + "text": "Sewwect vawue...", + "context": "Placeholder text when no value is selected in combobox" + }, + "searchValue": { + "text": "Seawch vawue...", + "context": "Placeholder text for search input in combobox" + }, + "noValuesFound": { + "text": "No vawues found.", + "context": "Message shown when no search results are found in combobox" + } + }, + "beatmapsetRowElement": { + "mappedBy": { + "text": "mapped by {cweatow}", + "context": "Text showing who mapped the beatmap, includes creator name as parameter" + } + }, + "themeModeToggle": { + "toggleTheme": { + "text": "toggwe theme", + "context": "Screen reader text for theme toggle button" + }, + "light": { + "text": "light", + "context": "Theme option for light mode" + }, + "dark": { + "text": "dawk", + "context": "Theme option for dark mode" + }, + "system": { + "text": "system", + "context": "Theme option for system default mode" + } + }, + "imageSelect": { + "imageTooBig": { + "text": "sewected image is too big!", + "context": "Error message when selected image exceeds maximum file size" + } + }, + "notFound": { + "meta": { + "title": { + "text": "not fouwnd | {appName}", + "context": "Page title for 404 not found page" + }, + "description": { + "text": "The page u'we wooking fow isn't hewe. Sowwy.", + "context": "Meta description for 404 not found page" + } + }, + "title": { + "text": "not fouwnd | 404", + "context": "Main heading for 404 not found page" + }, + "description": { + "text": "What you're looking for isn't here. Sorry.", + "context": "Description text explaining the page was not found" + } + }, + "rootLayout": { + "meta": { + "title": { + "text": "{appName}", + "context": "Root layout page title" + }, + "description": { + "text": "{appName} is a private server for osu!, a rhythm game.", + "context": "Root layout meta description" + } + } + } + }, + "pages": { + "mainPage": { + "meta": { + "title": { + "text": "wewcome | {appName}", + "context": "The title for the main page of the osu!sunrise website" + }, + "description": { + "text": "join osuw!suwnwise, a featuwwe-wich pwivate osuw! OwO sewvew with rewax, auwtopiwot, scowev2 suwppowt, and a cuwstom pp system taiwowed fow rewax and auwtopiwot gamepway.", + "context": "The meta description for the main page of the osu!sunrise website" + } + }, + "features": { + "motto": { + "text": "- yet anothew osuw! OwO sewvew", + "context": "The tagline or motto displayed on the main page" + }, + "description": { + "text": "featuwwes wich osuw! OwO sewvew with suwppowt fow rewax, auwtopiwot and scowev2 gamepway, with a cuwstom awt‑state pp cawcuwwation system taiwowed fow rewax and auwtopiwot.", + "context": "The main description text explaining the server's features on the homepage" + }, + "buttons": { + "register": { + "text": "Join now", + "context": "Button text to register a new account" + }, + "wiki": { + "text": "How to connect", + "context": "Button text linking to the connection guide" + } + } + }, + "whyUs": { + "text": "why uws?", + "context": "Section heading asking why users should choose this server" + }, + "cards": { + "freeFeatures": { + "title": { + "text": "Twuwwy fwee featuwwes", + "context": "Title of the free features card on the main page" + }, + "description": { + "text": "enjoy featuwwes wike osuw!diwect and uwsewname changes withouwt any paywawws — compwetewy fwee fow aww pwayews!", + "context": "Description text explaining the free features available on the server" + } + }, + "ppSystem": { + "title": { + "text": "cuwstom pp cawcuwwations", + "context": "Title of the PP system card on the main page" + }, + "description": { + "text": "we uwse the watest pewfowmance point (pp) system fow vaniwwa scowes whiwe appwying a cuwstom, weww-bawanced fowmuwwa fow rewax and auwtopiwot modes.", + "context": "Description text explaining the custom PP calculation system" + } + }, + "medals": { + "title": { + "text": "eawn cuwstom medaws", + "context": "Title of the medals card on the main page" + }, + "description": { + "text": "eawn uwniquwe, sewvew-excwuwsive medaws as u accompwish vawiouws miwestones and achievements.", + "context": "Description text explaining the custom medals system" + } + }, + "updates": { + "title": { + "text": "fwequwent updates", + "context": "Title of the updates card on the main page" + }, + "description": { + "text": "we'we awways impwoving! OwO expect weguwwaw uwpdates, new featuwwes, and ongoing pewfowmance optimizations.", + "context": "Description text explaining the server's update frequency" + } + }, + "ppCalc": { + "title": { + "text": "buwiwt-in pp cawcuwwatow", + "context": "Title of the PP calculator card on the main page" + }, + "description": { + "text": "ouww website offews a buwiwt-in pp cawcuwwatow fow quwick and easy pewfowmance point estimates.", + "context": "Description text explaining the built-in PP calculator feature" + } + }, + "sunriseCore": { + "title": { + "text": "cuwstom-buwiwt bancho cowe", + "context": "Title of the bancho core card on the main page" + }, + "description": { + "text": "unwike most pwivate osuw! OwO sewvews, we've devewoped ouww own cuwstom bancho cowe fow bettew stabiwity and uwniquwe featuwwe suwppowt.", + "context": "Description text explaining the custom bancho core development" + } + } + }, + "howToStart": { + "title": { + "text": "how do i stawt pwaying?", + "context": "Section heading for the getting started guide" + }, + "description": { + "text": "juwst thwee simpwe steps and u'we weady to go!", + "context": "Description text introducing the getting started steps" + }, + "downloadTile": { + "title": { + "text": "downwoad osuw! OwO cwinet", + "context": "Title of the download step tile" + }, + "description": { + "text": "if u do not awweady have an instawwed cwient", + "context": "Description text for the download step" + }, + "button": { + "text": "downwoad", + "context": "Button text to download the osu! client" + } + }, + "registerTile": { + "title": { + "text": "registew osuw!suwnwise accouwnt", + "context": "Title of the registration step tile" + }, + "description": { + "text": "accouwnt wiww awwow u to join the osuw!suwnwise commuwnity", + "context": "Description text for the registration step" + }, + "button": { + "text": "Sign up", + "context": "Button text to register a new account" + } + }, + "guideTile": { + "title": { + "text": "Follow the connection guide", + "context": "Title of the connection guide step tile" + }, + "description": { + "text": "Which helps you set up your osu! client to connect to osu!sunrise", + "context": "Description text for the connection guide step" + }, + "button": { + "text": "Open guide", + "context": "Button text to open the connection guide" + } + } + }, + "statuses": { + "totalUsers": { + "text": "Totaw Usews", + "context": "Label for the total number of registered users" + }, + "usersOnline": { + "text": "Usews Onwine", + "context": "Label for the number of currently online users" + }, + "usersRestricted": { + "text": "Usews Westwicted", + "context": "Label for the number of restricted users" + }, + "totalScores": { + "text": "Totaw Scowes", + "context": "Label for the total number of scores submitted" + }, + "serverStatus": { + "text": "Sewvew Status", + "context": "Label for the current server status" + }, + "online": { + "text": "Onwine", + "context": "Status indicator when the server is online" + }, + "offline": { + "text": "Offwine", + "context": "Status indicator when the server is offline" + }, + "underMaintenance": { + "text": "Undew Maintenance", + "context": "Status indicator when the server is under maintenance" + } + } + }, + "wiki": { + "meta": { + "title": { + "text": "wiki | {appName}", + "context": "The title for the wiki page of the osu!sunrise website" + } + }, + "header": { + "text": "wiki", + "context": "The main header text for the wiki page" + }, + "articles": { + "howToConnect": { + "title": { + "text": "how to connect", + "context": "Title of the wiki article explaining how to connect to the server" + }, + "intro": { + "text": "to connect to the sewvew, u need to have a copy of the game instawwed on uw compuwtew. Youw can downwoad the game fwom the officiaw osuw! OwO website.", + "context": "Introduction text explaining the prerequisites for connecting to the server" + }, + "step1": { + "text": "locate the osu!.exe fiwe in the game diwectowy.", + "context": "First step instruction for connecting to the server" + }, + "step2": { + "text": "cweate a showtcuwt of the fiwe.", + "context": "Second step instruction for connecting to the server" + }, + "step3": { + "text": "right cwick on the showtcuwt and sewect pwopewties.", + "context": "Third step instruction for connecting to the server" + }, + "step4": { + "text": "in the tawget fiewd, add -devserver {serverDomain} at the end of the path.", + "context": "Fourth step instruction for connecting to the server, includes server domain parameter" + }, + "step5": { + "text": "cwick appwy and then ok.", + "context": "Fifth step instruction for connecting to the server" + }, + "step6": { + "text": "douwbwe cwick on the showtcuwt to stawt the game.", + "context": "Sixth step instruction for connecting to the server" + }, + "imageAlt": { + "text": "osuw connect image", + "context": "Alt text for the connection guide image" + } + }, + "multipleAccounts": { + "title": { + "text": "can i have muwwtipwe accouwnts?", + "context": "Title of the wiki article about multiple accounts policy" + }, + "answer": { + "text": "no. Youw awe onwy awwowed to have one accouwnt pew pewson.", + "context": "Direct answer to the multiple accounts question" + }, + "consequence": { + "text": "if u awe cauwght with muwwtipwe accouwnts, u wiww be banned fwom the sewvew. >:3", + "context": "Explanation of the consequence for having multiple accounts" + } + }, + "cheatsHacks": { + "title": { + "text": "can i uwse cheats ow hacks?", + "context": "Title of the wiki article about cheating policy" + }, + "answer": { + "text": "no. Youw wiww be banned if u awe cauwght.", + "context": "Direct answer to the cheating question" + }, + "policy": { + "text": "we awe vewy stwict on cheating and do not towewate it at aww.

if u suwspect someone of cheating, pwease wepowt them to the staff.", + "context": "Explanation of the cheating policy and how to report cheaters" + } + }, + "appealRestriction": { + "title": { + "text": "i think i was westwicted uwnfaiwwy. How can i appeaw?", + "context": "Title of the wiki article about appealing restrictions" + }, + "instructions": { + "text": "if u bewieve u wewe westwicted uwnfaiwwy, u can appeaw uw westwiction by contacting the staff with uw case.", + "context": "Instructions on how to appeal a restriction" + }, + "contactStaff": { + "text": "youw can contact the staff hewe.", + "context": "Information about where to contact staff for appeals, includes link placeholder" + } + }, + "contributeSuggest": { + "title": { + "text": "can i contwibuwte/suwggest changes to the sewvew?", + "context": "Title of the wiki article about contributing to the server" + }, + "answer": { + "text": "yes! OwO we awe awways open to suwggestions.", + "context": "Positive answer about contributing to the server" + }, + "instructions": { + "text": "if u have any suwggestions, pwease suwbmit them at ouww githuwb page.

longtewm contwibuwtows can awso have chance to get pewmanent suwppowtew tag.", + "context": "Instructions on how to contribute, includes GitHub link and information about supporter tags" + } + }, + "multiplayerDownload": { + "title": { + "text": "i can't downwoad maps when i'm in muwwtipwayew, buwt i can downwoad them fwom the main menuw", + "context": "Title of the wiki article about downloading maps in multiplayer" + }, + "solution": { + "text": "disabwe Automatically start osu!direct downloads fwom the options and twy again.", + "context": "Solution to the multiplayer download issue" + } + } + } + }, + "rules": { + "meta": { + "title": { + "text": "Ruwwes | {appName}", + "context": "The title for the rules page of the osu!sunrise website" + } + }, + "header": { + "text": "Ruwwes", + "context": "The main header text for the rules page" + }, + "sections": { + "generalRules": { + "title": { + "text": "Genewaw wuwwes", + "context": "Title of the general rules section" + }, + "noCheating": { + "title": { + "text": "no cheating ow hacking.", + "context": "Title of the no cheating rule, displayed in bold" + }, + "description": { + "text": "any fowm of cheating, incwuwding aimbots, wewax hacks, macwos, ow modified cwients that give uwnfaiw advantage is stwictwy pwohibited. Pway faiw, impwove faiw.", + "context": "Description of the no cheating rule" + }, + "warning": { + "text": "as u can see, i wwote this in a biggew font fow aww u \"wannabe\" cheatews who think u can migwate fwom anothew pwivate sewvew to hewe aftew being banned. Youw wiww be fouwnd and execuwted (in minecwaft) if u cheat. So pwease, don't.", + "context": "Warning message for potential cheaters" + } + }, + "noMultiAccount": { + "title": { + "text": "no muwwti-accouwnting ow accouwnt shawing.", + "context": "Title of the no multi-accounting rule, displayed in bold" + }, + "description": { + "text": "onwy one accouwnt pew pwayew is awwowed. If uw pwimawy accouwnt was westwicted withouwt an expwanation, pwease contact suwppowt.", + "context": "Description of the no multi-accounting rule" + } + }, + "noImpersonation": { + "title": { + "text": "no impewsonating popuwwaw pwayews ow staff", + "context": "Title of the no impersonation rule, displayed in bold" + }, + "description": { + "text": "do not pwetend to be a staff membew ow any weww-known pwayew. Misweading othews can wesuwwt in a uwsewname change ow pewmanent ban.", + "context": "Description of the no impersonation rule" + } + } + }, + "chatCommunityRules": { + "title": { + "text": "chat & commuwnity ruwwes", + "context": "Title of the chat and community rules section" + }, + "beRespectful": { + "title": { + "text": "be respectfuww.", + "context": "Title of the be respectful rule, displayed in bold" + }, + "description": { + "text": "tweat othews with kindness. Hawassment, hate speech, discwimination, ow toxic behaviouww won't be towewated.", + "context": "Description of the be respectful rule" + } + }, + "noNSFW": { + "title": { + "text": "no nsfw ow inappwopwiate content", + "context": "Title of the no NSFW content rule, displayed in bold" + }, + "description": { + "text": "keep the sewvew appwopwiate fow aww auwdiences — this appwies to aww uwsew content, incwuwding buwt not wimited to uwsewnames, bannews, avataws and pwofiwe descwiptions.", + "context": "Description of the no NSFW content rule" + } + }, + "noAdvertising": { + "title": { + "text": "advewtising is fowbidden.", + "context": "Title of the no advertising rule, displayed in bold" + }, + "description": { + "text": "don't pwomote othew sewvews, websites, ow pwoduwcts withouwt admin appwovaw.", + "context": "Description of the no advertising rule" + } + } + }, + "disclaimer": { + "title": { + "text": "impowtant discwaimew", + "context": "Title of the important disclaimer section" + }, + "intro": { + "text": "by cweating and/ow maintaining an accouwnt on ouww pwatfowm, u acknowwedge and agwee to the fowwowing tewms:", + "context": "Introduction text for the disclaimer section" + }, + "noLiability": { + "title": { + "text": "No Liability.", + "context": "Title of the no liability disclaimer, displayed in bold" + }, + "description": { + "text": "You accept full responsibility for your participation in any services provided by Sunrise and acknowledge that you cannot hold the organization accountable for any consequences that may arise from your usage.", + "context": "Description of the no liability disclaimer" + } + }, + "accountRestrictions": { + "title": { + "text": "Account Restrictions.", + "context": "Title of the account restrictions disclaimer, displayed in bold" + }, + "description": { + "text": "The administration reserves the right to restrict or suspend any account, with or without prior notice, for violations of server rules or at their discretion.", + "context": "Description of the account restrictions disclaimer" + } + }, + "ruleChanges": { + "title": { + "text": "Rule Changes.", + "context": "Title of the rule changes disclaimer, displayed in bold" + }, + "description": { + "text": "The server rules are subject to change at any time. The administration may update, modify, or remove any rule with or without prior notification, and all players are expected to stay informed of any changes.", + "context": "Description of the rule changes disclaimer" + } + }, + "agreementByParticipation": { + "title": { + "text": "Agreement by Participation.", + "context": "Title of the agreement by participation disclaimer, displayed in bold" + }, + "description": { + "text": "By creating and/or maintaining an account on the server, you automatically agree to these terms and commit to adhering to the rules and guidelines in effect at the time.", + "context": "Description of the agreement by participation disclaimer" + } + } + } + } + }, + "register": { + "meta": { + "title": { + "text": "registew | {appName}", + "context": "The title for the register page of the osu!sunrise website" + } + }, + "header": { + "text": "registew", + "context": "The main header text for the register page" + }, + "welcome": { + "title": { + "text": "wewcome to the wegistwation page!", + "context": "Welcome title on the registration page" + }, + "description": { + "text": "hewwo! OwO pwease entew uw detaiws to cweate an accouwnt. If u awen't suwwe how to connect to the sewvew, ow if u have any othew quwestions, pwease visit ouww wiki page.", + "context": "Welcome description text with link to wiki page" + } + }, + "form": { + "title": { + "text": "entew uw detaiws", + "context": "Title for the registration form section" + }, + "labels": { + "username": { + "text": "Username", + "context": "Label for the username input field" + }, + "email": { + "text": "emaiw", + "context": "Label for the email input field" + }, + "password": { + "text": "passwowd", + "context": "Label for the password input field" + }, + "confirmPassword": { + "text": "confiwm passwowd", + "context": "Label for the confirm password input field" + } + }, + "placeholders": { + "username": { + "text": "e.g. username", + "context": "Placeholder text for the username input field" + }, + "email": { + "text": "e.g. uwsewname@maiw.com", + "context": "Placeholder text for the email input field" + }, + "password": { + "text": "************", + "context": "Placeholder text for the password input field" + } + }, + "validation": { + "usernameMin": { + "text": "Username must be at least {min} characters.", + "context": "Validation error message when username is too short, includes minimum length parameter" + }, + "usernameMax": { + "text": "Username must be {max} characters or fewer.", + "context": "Validation error message when username is too long, includes maximum length parameter" + }, + "passwordMin": { + "text": "passwowd muwst be at weast {min} chawactews.", + "context": "Validation error message when password is too short, includes minimum length parameter" + }, + "passwordMax": { + "text": "passwowd muwst be {max} chawactews ow fewew.", + "context": "Validation error message when password is too long, includes maximum length parameter" + }, + "passwordsDoNotMatch": { + "text": "passwowds do not match", + "context": "Validation error message when password and confirm password do not match" + } + }, + "error": { + "title": { + "text": "ewwow", + "context": "Title for the error alert" + }, + "unknown": { + "text": "Unknown error.", + "context": "Generic error message when an unknown error occurs" + } + }, + "submit": { + "text": "Register", + "context": "Text for the registration submit button" + }, + "terms": { + "text": "By signing up, you agree to the server rules", + "context": "Terms agreement text with link to rules page" + } + }, + "success": { + "dialog": { + "title": { + "text": "youw'we aww set!", + "context": "Title of the success dialog after registration" + }, + "description": { + "text": "youww accouwnt has been suwccessfuwwwy cweated.", + "context": "Description in the success dialog" + }, + "message": { + "text": "youw can now connect to the sewvew by fowwowing the guwide on ouww wiki page, ow cuwstomize uw pwofiwe by uwpdating uw avataw and bannew befowe u stawt pwaying!", + "context": "Success message with link to wiki page" + }, + "buttons": { + "viewWiki": { + "text": "view wiki guwide", + "context": "Button text to view the wiki guide" + }, + "goToProfile": { + "text": "go to pwofiwe", + "context": "Button text to go to user profile" + } + } + }, + "toast": { + "text": "accouwnt suwccessfuwwwy cweated!", + "context": "Toast notification message when account is successfully created" + } + } + }, + "support": { + "meta": { + "title": { + "text": "Support Us | {appName}", + "context": "The title for the support page of the osu!sunrise website" + } + }, + "header": { + "text": "Support Us", + "context": "The main header text for the support page" + }, + "section": { + "title": { + "text": "how youw can hewp us", + "context": "Title of the support section explaining how users can help" + }, + "intro": { + "text": "whiwe aww osuw!suwnwise featuwwes has awways been fwee, wuwnning and impwoving the sewvew wequwiwes wesouwwces, time, and effowt, whiwe being mainwy maintained by a singwe devewopew.



if u wove osuw!suwnwise and want to see it gwow even fuwwthew, hewe awe a few ways u can suwppowt uws:", + "context": "Introduction text explaining why support is needed, includes bold text for emphasis and line breaks" + }, + "donate": { + "title": { + "text": "Donate.", + "context": "Title of the donation option, displayed in bold" + }, + "description": { + "text": "youww genewouws donations hewp uws maintain and enhance the osuw! OwO sewvews. Evewy wittwe bit couwnts! OwO with uw suwppowt, we can covew hosting costs, impwement new featuwwes, and ensuwwe a smoothew expewience fow evewyone.", + "context": "Description text explaining how donations help the server" + }, + "buttons": { + "kofi": { + "text": "Ko-fi", + "context": "Button text for Ko-fi donation platform" + }, + "boosty": { + "text": "Boosty", + "context": "Button text for Boosty donation platform" + } + } + }, + "spreadTheWord": { + "title": { + "text": "Spread the Word.", + "context": "Title of the spread the word option, displayed in bold" + }, + "description": { + "text": "the mowe peopwe who know abouwt osuw!suwnwise, the mowe vibwant and exciting ouww commuwnity wiww be. Teww uw fwiends, shawe on sociaw media, and invite new pwayews to join." + } + }, + "justPlay": { + "title": { + "text": "Just Play on the Server.", + "context": "Title of the just play option, displayed in bold" + }, + "description": { + "text": "One of the easiest ways to support osu!sunrise is simply by playing on the server! The more players we have, the better the community and experience become. By joining in, you're helping to grow the server and keeping it active for all players.", + "context": "Description text explaining that playing on the server is a form of support" + } + } + } + }, + "topplays": { + "meta": { + "title": { + "text": "top pways | {appName}", + "context": "The title for the top plays page of the osu!sunrise website" + } + }, + "header": { + "text": "top pways", + "context": "The main header text for the top plays page" + }, + "showMore": { + "text": "show mowe", + "context": "Button text to load more top plays" + }, + "components": { + "userScoreMinimal": { + "pp": { + "text": "pp", + "context": "Abbreviation for performance points, displayed after the PP value" + }, + "accuracy": { + "text": "acc:", + "context": "Abbreviation for accuracy, displayed before the accuracy percentage" + } + } + } + }, + "score": { + "meta": { + "title": { + "text": "{uwsewname} on {beatmaptitwe} [{beatmapvewsion}] | {appName}", + "context": "The title for the score page, includes username, beatmap title, version, and app name as parameters" + }, + "description": { + "text": "usew {uwsewname} has scowed {pp}pp on {beatmaptitwe} [{beatmapvewsion}] in {appName}.", + "context": "The meta description for the score page, includes username, PP value, beatmap title, version, and app name as parameters" + }, + "openGraph": { + "title": { + "text": "{uwsewname} on {beatmaptitwe} - {beatmapawtist} [{beatmapvewsion}] | {appName}", + "context": "The OpenGraph title for the score page, includes username, beatmap title, artist, version, and app name as parameters" + }, + "description": { + "text": "usew {uwsewname} has scowed {pp}pp on {beatmaptitwe} - {beatmapawtist} [{beatmapvewsion}] ★{stawrating} {mods} in {appName}.", + "context": "The OpenGraph description for the score page, includes username, PP value, beatmap title, artist, version, star rating, mods, and app name as parameters" + } + } + }, + "header": { + "text": "scowe pewfowmance", + "context": "The main header text for the score page" + }, + "beatmap": { + "versionUnknown": { + "text": "unknown", + "context": "Fallback text when beatmap version is not available" + }, + "mappedBy": { + "text": "mapped by", + "context": "Text displayed before the beatmap creator name" + }, + "creatorUnknown": { + "text": "unknown cweatow", + "context": "Fallback text when beatmap creator is not available" + } + }, + "score": { + "submittedOn": { + "text": "Submitted on", + "context": "Text displayed before the score submission date" + }, + "playedBy": { + "text": "pwayed by", + "context": "Text displayed before the player username" + }, + "userUnknown": { + "text": "Unknown user", + "context": "Fallback text when user is not available" + } + }, + "actions": { + "downloadReplay": { + "text": "downwoad repway", + "context": "Button text to download the replay file" + }, + "openMenu": { + "text": "Open menu", + "context": "Screen reader text for the dropdown menu button" + } + }, + "error": { + "notFound": { + "text": "Score not found", + "context": "Error message when score cannot be found" + }, + "description": { + "text": "the scowe u awe wooking fow does not exist ow has been deweted.", + "context": "Error description explaining that the score doesn't exist or was deleted" + } + } + }, + "leaderboard": { + "meta": { + "title": { + "text": "leadewboawd | {appName}", + "context": "The title for the leaderboard page of the osu!sunrise website" + } + }, + "header": { + "text": "leadewboawd", + "context": "The main header text for the leaderboard page" + }, + "sortBy": { + "label": { + "text": "sowt by:", + "context": "Label for the sort by selector on mobile view" + }, + "performancePoints": { + "text": "pewfowmance points", + "context": "Button text for sorting by performance points" + }, + "rankedScore": { + "text": "ranked scowe", + "context": "Button text for sorting by ranked score" + }, + "performancePointsShort": { + "text": "pewf. points", + "context": "Short label for performance points in mobile combobox" + }, + "scoreShort": { + "text": "scowe", + "context": "Short label for score in mobile combobox" + } + }, + "table": { + "columns": { + "rank": { + "text": "Rank", + "context": "Column header for user rank" + }, + "performance": { + "text": "Performance", + "context": "Column header for performance points" + }, + "rankedScore": { + "text": "Ranked Score", + "context": "Column header for ranked score" + }, + "accuracy": { + "text": "Accuracy", + "context": "Column header for accuracy" + }, + "playCount": { + "text": "Play count", + "context": "Column header for play count" + } + }, + "actions": { + "openMenu": { + "text": "Open menu", + "context": "Screen reader text for the dropdown menu button" + }, + "viewUserProfile": { + "text": "view uwsew pwofiwe", + "context": "Dropdown menu item text to view user profile" + } + }, + "emptyState": { + "text": "no wesuwwts.", + "context": "Message displayed when there are no results in the table" + }, + "pagination": { + "usersPerPage": { + "text": "users per page", + "context": "Label for the users per page selector" + }, + "showing": { + "text": "showing {stawt} - {end} of {totaw}", + "context": "Pagination text showing the range of displayed users, includes start, end, and total as parameters" + } + } + } + }, + "friends": { + "meta": { + "title": { + "text": "youww fwiends | {appName}", + "context": "The title for the friends page of the osu!sunrise website" + } + }, + "header": { + "text": "youww connections", + "context": "The main header text for the friends page" + }, + "tabs": { + "friends": { + "text": "fwiends", + "context": "Button text to show friends list" + }, + "followers": { + "text": "fowwowews", + "context": "Button text to show followers list" + } + }, + "sorting": { + "label": { + "text": "sowt by:", + "context": "Label for the sort by selector" + }, + "username": { + "text": "Username", + "context": "Sorting option to sort by username" + }, + "recentlyActive": { + "text": "recentwy active", + "context": "Sorting option to sort by recently active users" + } + }, + "showMore": { + "text": "show mowe", + "context": "Button text to load more users" + }, + "emptyState": { + "text": "no uwsews fouwnd", + "context": "Message displayed when there are no users in the list" + } + }, + "beatmaps": { + "search": { + "meta": { + "title": { + "text": "beatmaps seawch | {appName}", + "context": "The title for the beatmaps search page of the osu!sunrise website" + } + }, + "header": { + "text": "beatmaps seawch", + "context": "The main header text for the beatmaps search page" + } + }, + "detail": { + "meta": { + "title": { + "text": "beatmap info | {appName}", + "context": "The title for the beatmap detail page of the osu!sunrise website" + } + }, + "header": { + "text": "beatmap info", + "context": "The header text for the beatmap detail page" + }, + "notFound": { + "title": { + "text": "Beatmapset not found", + "context": "Title displayed when a beatmapset is not found" + }, + "description": { + "text": "The beatmapset you are looking for does not exist or has been deleted.", + "context": "Description message when a beatmapset is not found or has been deleted" + } + } + }, + "components": { + "search": { + "searchPlaceholder": { + "text": "seawch beatmaps...", + "context": "Placeholder text for the beatmaps search input field" + }, + "filters": { + "text": "fiwtews", + "context": "Button text to toggle filters panel" + }, + "viewMode": { + "grid": { + "text": "gwid", + "context": "Button text for grid view mode" + }, + "list": { + "text": "list", + "context": "Button text for list view mode" + } + }, + "showMore": { + "text": "show mowe", + "context": "Button text to load more beatmapsets" + } + }, + "filters": { + "mode": { + "label": { + "text": "mode", + "context": "Label for the game mode filter selector" + }, + "any": { + "text": "any", + "context": "Option to select any game mode (no filter)" + }, + "standard": { + "text": "osuw!", + "context": "Game mode option for osu!standard" + }, + "taiko": { + "text": "osuw!taiko", + "context": "Game mode option for osu!taiko" + }, + "catch": { + "text": "osuw!catch", + "context": "Game mode option for osu!catch" + }, + "mania": { + "text": "osuw!mania", + "context": "Game mode option for osu!mania" + } + }, + "status": { + "label": { + "text": "statuws", + "context": "Label for the beatmap status filter selector" + } + }, + "searchByCustomStatus": { + "label": { + "text": "seawch by cuwstom statuws", + "context": "Label for the search by custom status toggle switch" + } + }, + "applyFilters": { + "text": "appwy fiwtews", + "context": "Button text to apply the selected filters" + } + } + } + }, + "beatmapsets": { + "meta": { + "title": { + "text": "{awtist} - {titwe} | {appName}", + "context": "The title for the beatmapset detail page, includes artist and title as parameters" + }, + "description": { + "text": "beatmapset info fow {titwe} by {awtist}", + "context": "The meta description for the beatmapset detail page, includes title and artist as parameters" + }, + "openGraph": { + "title": { + "text": "{awtist} - {titwe} | {appName}", + "context": "The OpenGraph title for the beatmapset detail page, includes artist and title as parameters" + }, + "description": { + "text": "beatmapset info fow {titwe} by {awtist} {difficuwwtyinfo}", + "context": "The OpenGraph description for the beatmapset detail page, includes title, artist, and optional difficulty info as parameters" + } + } + }, + "header": { + "text": "beatmap info", + "context": "The main header text for the beatmapset detail page" + }, + "error": { + "notFound": { + "title": { + "text": "Beatmapset not found", + "context": "Title displayed when a beatmapset is not found" + }, + "description": { + "text": "The beatmapset you are looking for does not exist or has been deleted.", + "context": "Description message when a beatmapset is not found or has been deleted" + } + } + }, + "submission": { + "submittedBy": { + "text": "submitted by", + "context": "Text label indicating who submitted the beatmapset" + }, + "submittedOn": { + "text": "submitted on", + "context": "Text label indicating when the beatmapset was submitted" + }, + "rankedOn": { + "text": "ranked on", + "context": "Text label indicating when the beatmapset was ranked" + }, + "statusBy": { + "text": "{status} by", + "context": "Text indicating the beatmap status and who set it, includes status as parameter" + } + }, + "video": { + "tooltip": { + "text": "this beatmap contains video", + "context": "Tooltip text for the video icon indicating the beatmap has a video" + } + }, + "description": { + "header": { + "text": "descwiption", + "context": "Header text for the beatmapset description section" + } + }, + "components": { + "dropdown": { + "openMenu": { + "text": "Open menu", + "context": "Screen reader text for the dropdown menu button" + }, + "ppCalculator": { + "text": "pp cawcuwwatow", + "context": "Dropdown menu item text to open PP calculator" + }, + "openOnBancho": { + "text": "open on bancho", + "context": "Dropdown menu item text to open beatmap on official osu! website" + }, + "openWithAdminPanel": { + "text": "open with admin panew", + "context": "Dropdown menu item text to open beatmap in admin panel (for BAT users)" + } + }, + "infoAccordion": { + "communityHype": { + "text": "Community Hype", + "context": "Header text for the community hype section" + }, + "information": { + "text": "infowmation", + "context": "Header text for the beatmap information section" + }, + "metadata": { + "genre": { + "text": "genwe", + "context": "Label for the beatmap genre" + }, + "language": { + "text": "Language", + "context": "Label for the beatmap language" + }, + "tags": { + "text": "tags", + "context": "Label for the beatmap tags" + } + } + }, + "downloadButtons": { + "download": { + "text": "Download", + "context": "Button text to download the beatmapset" + }, + "withVideo": { + "text": "with Video", + "context": "Text indicating the download includes video" + }, + "withoutVideo": { + "text": "without Video", + "context": "Text indicating the download excludes video" + }, + "osuDirect": { + "text": "osu!direct", + "context": "Button text for osu!direct download" + } + }, + "difficultyInformation": { + "tooltips": { + "totalLength": { + "text": "Total Length", + "context": "Tooltip text for the total length of the beatmap" + }, + "bpm": { + "text": "BPM", + "context": "Tooltip text for beats per minute" + }, + "starRating": { + "text": "Star Rating", + "context": "Tooltip text for the star rating" + } + }, + "labels": { + "keyCount": { + "text": "Key Count:", + "context": "Label for the key count (mania mode)" + }, + "circleSize": { + "text": "Ciwcwe Size:", + "context": "Label for circle size (standard/catch mode)" + }, + "hpDrain": { + "text": "HP Dwain:", + "context": "Label for HP drain" + }, + "accuracy": { + "text": "Accuwwacy:", + "context": "Label for accuracy difficulty" + }, + "approachRate": { + "text": "Appwoach Wate:", + "context": "Label for approach rate (standard/catch mode)" + } + } + }, + "nomination": { + "description": { + "text": "hype this map if u enjoyed pwaying it to hewp it pwogwess to ranked statuws.", + "context": "Description text explaining the hype feature, includes bold tag for Ranked" + }, + "hypeProgress": { + "text": "hype pwogwess", + "context": "Label for the hype progress indicator" + }, + "hypeBeatmap": { + "text": "hype beatmap!", + "context": "Button text to hype a beatmap" + }, + "hypesRemaining": { + "text": "youw have {couwnt} hypes wemaining fow this week", + "context": "Text showing remaining hypes for the week, includes count as parameter and bold tag" + }, + "toast": { + "success": { + "text": "Beatmap hyped successfully!", + "context": "Success toast message when a beatmap is hyped" + }, + "error": { + "text": "ewwow occuwwed whiwe hyping beatmapset!", + "context": "Error toast message when hyping fails" + } + } + }, + "ppCalculator": { + "title": { + "text": "pp cawcuwwatow", + "context": "titwe of the pp cawcuwwatow diawog" + }, + "pp": { + "text": "PP: {value}", + "context": "Label for performance points" + }, + "totalLength": { + "text": "Total Length", + "context": "Tooltip text for total length in PP calculator" + }, + "form": { + "accuracy": { + "label": { + "text": "Accuracy", + "context": "Form label for accuracy input" + }, + "validation": { + "negative": { + "text": "Accuracy can't be negative", + "context": "Validation error message for negative accuracy" + }, + "tooHigh": { + "text": "Accuracy can't be greater that 100", + "context": "Validation error message for accuracy over 100" + } + } + }, + "combo": { + "label": { + "text": "Combo", + "context": "Form label for combo input" + }, + "validation": { + "negative": { + "text": "Combo can't be negative", + "context": "Validation error message for negative combo" + } + } + }, + "misses": { + "label": { + "text": "Misses", + "context": "Form label for misses input" + }, + "validation": { + "negative": { + "text": "Misses can't be negative", + "context": "Validation error message for negative misses" + } + } + }, + "calculate": { + "text": "Calculate", + "context": "Button text to calculate PP" + }, + "unknownError": { + "text": "Unknown error", + "context": "Generic error message for unknown errors" + } + } + }, + "leaderboard": { + "columns": { + "rank": { + "text": "Rank", + "context": "Column header for rank" + }, + "score": { + "text": "Scowe", + "context": "Column header for score" + }, + "accuracy": { + "text": "Accuwwacy", + "context": "Column header for accuracy" + }, + "player": { + "text": "Pwayew", + "context": "Column header for player" + }, + "maxCombo": { + "text": "Max Combo", + "context": "Column header for max combo" + }, + "perfect": { + "text": "Pewfect", + "context": "Column header for perfect hits (geki)" + }, + "great": { + "text": "Gweat", + "context": "Column header for great hits (300)" + }, + "good": { + "text": "Good", + "context": "Column header for good hits (katu)" + }, + "ok": { + "text": "Ok", + "context": "Column header for ok hits (100)" + }, + "lDrp": { + "text": "L DRP", + "context": "Column header for large droplets (catch mode, 100)" + }, + "meh": { + "text": "Meh", + "context": "Column header for meh hits (50)" + }, + "sDrp": { + "text": "S DRP", + "context": "Column header for small droplets (catch mode, 50)" + }, + "miss": { + "text": "Miss", + "context": "Column header for misses" + }, + "pp": { + "text": "PP", + "context": "Column header for performance points" + }, + "time": { + "text": "Time", + "context": "Column header for time played" + }, + "mods": { + "text": "Mods", + "context": "Column header for mods" + } + }, + "actions": { + "openMenu": { + "text": "Open menu", + "context": "Screen reader text for the dropdown menu button" + }, + "viewDetails": { + "text": "view detaiws", + "context": "Dropdown menu item text to view score details" + }, + "downloadReplay": { + "text": "downwoad repway", + "context": "Dropdown menu item text to download replay" + } + }, + "table": { + "emptyState": { + "text": "no scowes fouwnd. Be the fiwst to suwbmit one!", + "context": "Message displayed when there are no scores in the leaderboard" + }, + "pagination": { + "scoresPerPage": { + "text": "scowes pew page", + "context": "Label for the scores per page selector" + }, + "showing": { + "text": "showing {stawt} - {end} of {totaw}", + "context": "Pagination text showing the range of displayed scores, includes start, end, and total as parameters" + } + } + } + } + } + }, + "settings": { + "meta": { + "title": { + "text": "settings | {appName}", + "context": "The title for the settings page of the osu!sunrise website" + } + }, + "header": { + "text": "settings", + "context": "The main header text for the settings page" + }, + "notLoggedIn": { + "text": "youw muwst be wogged in to view this page.", + "context": "Message displayed when user is not logged in" + }, + "sections": { + "changeAvatar": { + "text": "change avataw", + "context": "Title for the change avatar section" + }, + "changeBanner": { + "text": "change bannew", + "context": "Title for the change banner section" + }, + "changeDescription": { + "text": "change descwiption", + "context": "Title for the change description section" + }, + "socials": { + "text": "sociaws", + "context": "Title for the socials section" + }, + "playstyle": { + "text": "pwaystywe", + "context": "Title for the playstyle section" + }, + "options": { + "text": "options", + "context": "Title for the options section" + }, + "changePassword": { + "text": "change passwowd", + "context": "Title for the change password section" + }, + "changeUsername": { + "text": "change uwsewname", + "context": "Title for the change username section" + }, + "changeCountryFlag": { + "text": "Change country flag", + "context": "Title for the change country flag section" + } + }, + "description": { + "reminder": { + "text": "* remindew: do not post any inappwopwiate content. Twy to keep it famiwy fwiendwy owo", + "context": "Reminder message for description field about keeping content appropriate" + } + }, + "components": { + "username": { + "label": { + "text": "New Username", + "context": "Form label for new username input" + }, + "placeholder": { + "text": "e.g. username", + "context": "Placeholder text for username input" + }, + "button": { + "text": "Change username", + "context": "Button text to change username" + }, + "validation": { + "minLength": { + "text": "Username must be at least {min} characters.", + "context": "Validation error message for username minimum length, includes min as parameter" + }, + "maxLength": { + "text": "Username must be {max} characters or fewer.", + "context": "Validation error message for username maximum length, includes max as parameter" + } + }, + "toast": { + "success": { + "text": "Username changed successfully!", + "context": "Success toast message when username is changed" + }, + "error": { + "text": "Error occured while changing username!", + "context": "Error toast message when username change fails" + } + }, + "reminder": { + "text": "* remindew: pwease keep uw uwsewname famiwy fwiendwy, ow it wiww be changed fow u. Abuwsing this featuwwe wiww wesuwwt in a ban." + } + }, + "password": { + "labels": { + "current": { + "text": "Current Password", + "context": "Form label for current password input" + }, + "new": { + "text": "new passwowd", + "context": "Form label for new password input" + }, + "confirm": { + "text": "confiwm passwowd", + "context": "Form label for confirm password input" + } + }, + "placeholder": { + "text": "************", + "context": "Placeholder text for password inputs" + }, + "button": { + "text": "Change password", + "context": "Button text to change password" + }, + "validation": { + "minLength": { + "text": "passwowd muwst be at weast {min} chawactews.", + "context": "Validation error message for password minimum length, includes min as parameter" + }, + "maxLength": { + "text": "passwowd muwst be {max} chawactews ow fewew.", + "context": "Validation error message for password maximum length, includes max as parameter" + }, + "mismatch": { + "text": "passwowds do not match", + "context": "Validation error message when passwords don't match" + } + }, + "toast": { + "success": { + "text": "Password changed successfully!", + "context": "Success toast message when password is changed" + }, + "error": { + "text": "ewwow occuwwed whiwe changing passwowd!", + "context": "Error toast message when password change fails" + } + } + }, + "description": { + "toast": { + "success": { + "text": "Description updated successfully!", + "context": "Success toast message when description is updated" + }, + "error": { + "text": "an uwnknown ewwow occuwwwed", + "context": "Generic error message for description update" + } + } + }, + "country": { + "label": { + "text": "new couwntwy fwag", + "context": "Form label for country flag selector" + }, + "placeholder": { + "text": "sewect new couwntwy fwag", + "context": "Placeholder text for country flag selector" + }, + "button": { + "text": "Change country flag", + "context": "Button text to change country flag" + }, + "toast": { + "success": { + "text": "couwntwy fwag changed suwccessfuwwwy!", + "context": "Success toast message when country flag is changed" + }, + "error": { + "text": "ewwow occuwwed whiwe changing couwntwy fwag!", + "context": "Error toast message when country flag change fails" + } + } + }, + "socials": { + "headings": { + "general": { + "text": "genewaw", + "context": "Heading for general information section in socials form" + }, + "socials": { + "text": "sociaws", + "context": "Heading for socials section in socials form" + } + }, + "fields": { + "location": { + "text": "wocation", + "context": "Field label for location" + }, + "interest": { + "text": "intewest", + "context": "Field label for interest" + }, + "occupation": { + "text": "occupation", + "context": "Field label for occupation" + } + }, + "button": { + "text": "Update socials", + "context": "Button text to update socials" + }, + "toast": { + "success": { + "text": "Socials updated successfully!", + "context": "Success toast message when socials are updated" + }, + "error": { + "text": "ewwow occuwwed whiwe uwpdating sociaws!", + "context": "Error toast message when socials update fails" + } + } + }, + "playstyle": { + "options": { + "Mouse": { + "text": "Mouse", + "context": "Playstyle option for mouse input" + }, + "Keyboard": { + "text": "keyboawd", + "context": "Playstyle option for keyboard input" + }, + "Tablet": { + "text": "tabwet", + "context": "Playstyle option for tablet input" + }, + "TouchScreen": { + "text": "Touch Screen", + "context": "Playstyle option for touch screen input" + } + }, + "toast": { + "success": { + "text": "Playstyle updated successfully!", + "context": "Success toast message when playstyle is updated" + }, + "error": { + "text": "ewwow occuwwed whiwe uwpdating pwaystywe!", + "context": "Error toast message when playstyle update fails" + } + } + }, + "uploadImage": { + "types": { + "avatar": { + "text": "avatar", + "context": "The word 'avatar' for image upload type" + }, + "banner": { + "text": "banner", + "context": "The word 'banner' for image upload type" + } + }, + "button": { + "text": "Upload {type}", + "context": "Button text to upload image, includes type (avatar/banner) as parameter" + }, + "toast": { + "success": { + "text": "{type} updated successfully!", + "context": "Success toast message when image is uploaded, includes type (avatar/banner) as parameter" + }, + "error": { + "text": "an uwnknown ewwow occuwwwed", + "context": "Generic error message for image upload" + } + }, + "note": { + "text": "* note: {type}s awe wimited to 5mb in size", + "context": "Note about file size limit, includes type (avatar/banner) as parameter" + } + }, + "siteOptions": { + "includeBanchoButton": { + "text": "incwuwde \"open on bancho\" buwtton in beatmap page", + "context": "Label for toggle to include Open on Bancho button" + }, + "useSpaciousUI": { + "text": "use spaciouws ui (incwease spacing between ewements)", + "context": "Label for toggle to use spacious UI" + } + } + }, + "common": { + "unknownError": { + "text": "Unknown error.", + "context": "Generic error message for unknown errors" + } + } + }, + "user": { + "meta": { + "title": { + "text": "{username} · User Profile | {appName}", + "context": "Page title for user profile page, includes username and app name as parameters" + }, + "description": { + "text": "We don't know much about them, but we're sure {username} is great.", + "context": "Meta description for user profile page, includes username as parameter" + } + }, + "header": { + "text": "Player info", + "context": "Header text for the user profile page" + }, + "tabs": { + "general": { + "text": "genewaw", + "context": "Tab name for general information" + }, + "bestScores": { + "text": "Best scowes", + "context": "Tab name for best scores" + }, + "recentScores": { + "text": "Recent scowes", + "context": "Tab name for recent scores" + }, + "firstPlaces": { + "text": "Fiwst pwaces", + "context": "Tab name for first place scores" + }, + "beatmaps": { + "text": "Beatmaps", + "context": "Tab name for beatmaps" + }, + "medals": { + "text": "Medaws", + "context": "Tab name for medals" + } + }, + "buttons": { + "editProfile": { + "text": "Edit pwofiwe", + "context": "Button text to edit user profile" + }, + "setDefaultGamemode": { + "text": "Set {gamemode} {flag} as pwofiwe defauwt game mode", + "context": "Button text to set default gamemode, includes gamemode name and flag emoji as parameters" + } + }, + "errors": { + "userNotFound": { + "text": "Usew not found ow an ewwow occuwwwed.", + "context": "Error message when user is not found or an error occurs" + }, + "restricted": { + "text": "This means that the usew viowated the sewvew wuwes and has been westwicted. :<", + "context": "Explanation message when user has been restricted" + }, + "userDeleted": { + "text": "The usew may have been deweted ow does not exist.", + "context": "Error message when user may have been deleted or doesn't exist" + } + }, + "components": { + "generalTab": { + "info": { + "text": "Info", + "context": "Section header for user information" + }, + "rankedScore": { + "text": "Ranked Scowe", + "context": "Label for ranked score statistic" + }, + "hitAccuracy": { + "text": "Hit Accuwacy", + "context": "Label for hit accuracy statistic" + }, + "playcount": { + "text": "Pway couwnt", + "context": "Label for playcount statistic" + }, + "totalScore": { + "text": "Total Scowe", + "context": "Label for total score statistic" + }, + "maximumCombo": { + "text": "Maximuwm Combo", + "context": "Label for maximum combo statistic" + }, + "playtime": { + "text": "Pwaytime", + "context": "Label for playtime statistic" + }, + "performance": { + "text": "Pewfowmance", + "context": "Section header for performance information" + }, + "showByRank": { + "text": "Show by rank", + "context": "Button text to show chart by rank" + }, + "showByPp": { + "text": "Show by pp", + "context": "Button text to show chart by performance points" + }, + "aboutMe": { + "text": "Abouwt me", + "context": "Section header for user description/about section" + } + }, + "scoresTab": { + "bestScores": { + "text": "Best scowes", + "context": "Header for best scores section" + }, + "recentScores": { + "text": "Recent scowes", + "context": "Header for recent scores section" + }, + "firstPlaces": { + "text": "Fiwst pwaces", + "context": "Header for first places section" + }, + "noScores": { + "text": "Usew has no {type} scowes", + "context": "Message when user has no scores of the specified type, includes type as parameter" + }, + "showMore": { + "text": "Show mowe", + "context": "Button text to load more scores" + } + }, + "beatmapsTab": { + "mostPlayed": { + "text": "Most pwayed", + "context": "Header for most played beatmaps section" + }, + "noMostPlayed": { + "text": "Usew has no most pwayed beatmaps", + "context": "Message when user has no most played beatmaps" + }, + "favouriteBeatmaps": { + "text": "Favouwwite Beatmaps", + "context": "Headew for favouwwite beatmaps section" + }, + "noFavourites": { + "text": "Usew has no favouwwite beatmaps", + "context": "Message when user has no favouwwite beatmaps" + }, + "showMore": { + "text": "Show more", + "context": "Button text to load more beatmaps" + } + }, + "medalsTab": { + "medals": { + "text": "Medals", + "context": "Header for medals section" + }, + "latest": { + "text": "Latest", + "context": "Header for latest medals section" + }, + "categories": { + "hushHush": { + "text": "Hush hush", + "context": "Medal category name" + }, + "beatmapHunt": { + "text": "Beatmap hunt", + "context": "Medal category name" + }, + "modIntroduction": { + "text": "Mod introduction", + "context": "Medal category name" + }, + "skill": { + "text": "Skill", + "context": "Medal category name" + } + }, + "achievedOn": { + "text": "achieved on", + "context": "Text shown before the date when a medal was achieved" + }, + "notAchieved": { + "text": "Not achieved", + "context": "Text shown when a medal has not been achieved" + } + }, + "generalInformation": { + "joined": { + "text": "Joined {time}", + "context": "Text showing when user joined, includes time as parameter" + }, + "followers": { + "text": "{count} Fowwowews", + "context": "Text showing followers count, includes count as parameter" + }, + "following": { + "text": "{count} Fowwowing", + "context": "Text showing following count, includes count as parameter" + }, + "playsWith": { + "text": "Pways wif {playstyle}", + "context": "Text showing playstyle, includes playstyle as parameter with formatting" + } + }, + "statusText": { + "lastSeenOn": { + "text": ", wast seen on {date}", + "context": "Text shown before the last seen date when user is offline, includes date as parameter" + } + }, + "ranks": { + "highestRank": { + "text": "Highest wank {rank} on {date}", + "context": "Tooltip text for highest rank achieved, includes rank number and date as parameters, rank is formatted" + } + }, + "previousUsernames": { + "previouslyKnownAs": { + "text": "This usew was pweviouswy known as:", + "context": "Tooltip text explaining that the user had previous usernames" + } + }, + "beatmapSetOverview": { + "by": { + "text": "by {artist}", + "context": "Text showing beatmap artist, includes artist name as parameter" + }, + "mappedBy": { + "text": "mapped by {creator}", + "context": "Text showing beatmap creator/mapper, includes creator name as parameter" + } + }, + "privilegeBadges": { + "badges": { + "Developer": { + "text": "Devewoper", + "context": "Name of the Developer badge" + }, + "Admin": { + "text": "Admin", + "context": "Name of the Admin badge" + }, + "Bat": { + "text": "BAT", + "context": "Name of the BAT (Beatmap Appreciation Team) badge" + }, + "Bot": { + "text": "Bot", + "context": "Name of the Bot badge" + }, + "Supporter": { + "text": "Suppowter", + "context": "Name of the Supporter badge" + } + } + }, + "scoreOverview": { + "pp": { + "text": "pp", + "context": "Abbreviation for performance points" + }, + "accuracy": { + "text": "acc: {accuracy}%", + "context": "Text showing score accuracy, includes accuracy percentage as parameter" + } + }, + "statsChart": { + "date": { + "text": "Date", + "context": "Label for the date axis on the stats chart" + }, + "types": { + "pp": { + "text": "pp", + "context": "Abbreviation for performance points in chart tooltip" + }, + "rank": { + "text": "rank", + "context": "Word for rank in chart tooltip" + } + }, + "tooltip": { + "text": "{value} {type}", + "context": "Tooltip text showing chart value and type (pp/rank), includes value and type as parameters" + } + } + } + } + } +} diff --git a/lib/i18n/messages/index.ts b/lib/i18n/messages/index.ts index 4580dac..9b1c12f 100644 --- a/lib/i18n/messages/index.ts +++ b/lib/i18n/messages/index.ts @@ -1,6 +1,11 @@ -export const AVAILABLE_LOCALES = ["en", "ru"] as const; +export const AVAILABLE_LOCALES = ["en", "ru", "en-GB"] as const; export const LOCALE_TO_COUNTRY: Record = { en: "GB", ru: "RU", + "en-GB": "OWO", +}; + +export const DISPLAY_NAMES_LOCALES: Record = { + "en-GB": "Engwish", }; diff --git a/public/images/flags/OWO.png b/public/images/flags/OWO.png new file mode 100644 index 0000000000000000000000000000000000000000..919b032f144a1f7d0f0a49958654401542c7603a GIT binary patch literal 25402 zcmdpd^Lr&tuyAmaJ+Zy9y|Ha=Y-?lN_Qu-S8=u&=?QCppw6X7b@AKS0;`@G>nV#wH zda7zlU0ofe^j!)G9v>b80s=`!T3iJJ0y5#h3kVDT64=8z34TDjs7Q%Iu8lDSfM4Jo zrL|okAP_MAyC5MlvvDCHOtxjjMbtdAFLDrC?bNlymE{!_=w%ow&@P#I65#N|oy;jr~lku%GB{S1Ir#sg}7J{#T{T|GxSw?U_--$aeutkBSp{W0#-HiEW*{6`CR!ED^L+9N7 zo$y+$xUHS6C)yOO974B7=nb?Ru(yAoXqr8DDmpjr0Qtm%eBwY|QY)8!S0f=m*o9k& z^h@JFT(O||SP(NOm_a-AMdt!V=Sg#J%O|{^uA5a?ejS>U(AR-;_w94{86R76K+-A- zLwx-jVtqwI{nv-CJm#>ukjoW4dI_QJP>j@y{!1FPDqH(0Up(mXk2S^n}V`R?kMzuX=?Pfi!f-*V)WElCY{>mMnB$$Nd z-SIiMj_$)DH%ky>@UwN#KDsE-7)p?*{c@pYBVpn!Rscm3EDWb4la4m?TA>inM%#*D;WOUN7IN6VDeTUp z2}SLn=e9TK-@oq8qX56C>?-v@bJFua*n$LF|NHQ;2VHcIorOknT1Eh7PP&T)7LnAW z-!Ygt@31uLFADVh(rfRcGhATo3Ievanq*MUUm95Fp(|cXoWPP5WmGKMKs6s;}p8@l)`qRk6$g&l5Hp zQtzKmP!d8DSpRVax_H8mT@gpcP1nt@%YcKZUjILCRsr?_Bb_jj9~y2FH#})k9nEId zqb6%iX%YYRBId@1jubd_e2>vVmJKAXwiCOTwfeBBO7J>`D|B~};KlX(_ik=_A zGmeUg-z{(RKA-cZN`>A}A3mRm0O6k>zy!NNhK#T08SM+-cdA1zYERbQcuIB)`m83O z{ISp7+1>|MGRzoT-F65XkFu)(Moe{e0hca$HXr9bAC=Fu5rT`I?-vdZAJ2K8ZC(Qe zTeEKiLT_TX@t;reQ?J{f9}k~s0AOUm`$~YF!Rz`8C$ncCLZ*e_ZN^k`?p^UwD8_e7 zqW2RiE-4}}Gnc2`h?bQiqWiKVu7F#vEYMZQSNQ z+ScT~w&mIQA8PuS@O(V&|GDCMIVsBeB6uBi@%es@^67Z%xBcihmySQ1ptgma%f+`@ly@bUS&=VI0sCn2 zp5iWN<)xfBhQ3a>Adjv}EG5IIew_8!19F_1>z>cop6yyDepF^s?mIs+sA4hcpBQBN zR1E>$22E$JJ$#E%EQ}z5y4to4hri2F-G)%&rukDO04W!VbNO4@+b;!9OX`{&HQq~= znL;l`Le@kd*AZMouMI+fHV&CQthk=Dw(fpUA=x{I9KEdc_`7tS>(_KWz5U?o(7x5* zzSp06^qqNZgxaOzYLj_&^F!ILku&_`_qdrwuyq|i6>INWbd=S7nzihIW#4hJ{6Q=V zTM+z>=jfMdX2XnNhu2(;!J>{(Yj-0jDl!lQ%qk`Uwojq|rQ}I)<<_vh7dIVRFp8rd z3kGOe(7&n!|INyNgB9iq!j`u;9?XSO?#I4H6Q>CoZ9Y7~PFRELgqmm+I~HjkhB%!&iJ_ zG+E!8P+*T-PjU@;cCE(4x=qaywf`P<=6)O>sO_XMX8rYjbwuz1Nmj#6KWKR%;Sp^3 zkP%FaYJBpiZu`$UKy&LFm?Y@1`l1?{h1frt+S)N|f8N)1;g$NzJSR*<#M}0R08jN1`zPicEDJUbSKY6i9^JP&Sptt64bO2`PjMQ(*IcMHFYj6m_42ijwX%^} zZ(t4dy1n%1>A(tf9`O3D8B2`pl@|$t$7bwzTd5Nq;Fk?McesrI=+Oe@tbe*Du#`wb z**ApZy90gEPu*HBQN&W#H&^?PTk1=TJC#l59@ufl{ISQpz6u)*ZAWYzXzuvu&sOv1 z>VLx6^Uh_MZr*zoSG)5XWpgb642DML&gVHu5YwUG{mC|M`SBG0+3K}*o4fLMX!v1% z?%(gcL!Ni*k@vcB%sJyZ%%)Qrkw@z8{MfPG^&CX}6r=&X>1f0GNJC)I=70U$p!nLL zY2anv!K8)KUFwPGbn+?=7=>ktlW{6&Fo-sO?-0wuUDz`%L&U{?D;2yicQ4pM_{agP z-%$y(pH&tzEh%}mbI0LKghMe%LUWQmeYDVo$e1$!zJc~OksCHRkmPCe(!FJwRanUg z3-wPgov|r3_eZkObMh29@$;zr_m8!_cWru11F%16&d|ww8l+xd6-)Pts$r>Vi|j~f zlgQ6Ttm*+9+pe767ie%$8u#6dX4 z_>cZa1%L$=1-0W)xd!yXt-m}47Ned0!Jau#?&sSv7mD|6NG9LbP2AMX+vH4R<|f#J z^fYZ7R;N|7V;g(7^Fw5kYk|Ptt5P@i6WOCxta5ChjEo$nqiwPfBGxq!B=AX?*G%TctgK|R@Vg@syyn41aKUSYMfDuyDMKp=4|U6*WZKXrBJL}> zZygZ;d2OB>tXvY~LlY+#fMQZHN!*~*nEkTvckY3gs`oPaRisRlssI=mDKpQ`>o8{9 zk2}TBMxKktg>_%2$!){eCPNzqu$Q|ln5y}FscDgr3V4bLSj`Ujc%GUtc&I=6H^JMcDK6tS4vupdci<3M&EUxY2 zfLf@HF{D%d+p67$)KSo^Vh%H!a@~xt4 zKj+pJ`ELf7-G+R{df&iS#*?0#hC=+wvdil+#B1}_JDmZK(`!f$)r>|1u;FA#Dr}~J zR%Cp|eO6Yq?sNRAue8`D|GDCEw6_B@%8k_5kkd&W3#+az2&yKh@niwQQ&Hpm*f&qt zU$*9vmwdZ_Qp+g&)hj!Z{9OJsT1UM2^O1~#iXS7dDnSfIps~q_sq?zOxl9^A(FWUF z@X|%^&q+7CbY9~|%jJx!>m05VMm`p+`%mc8gjN*C&wGJ2&78C8vhqVg#SNG}LxKv3 zL~0QNiVO|g<|+wurGm0|u=u%V!B2b^)R|gITagdn+?5J-*{O>5>Wi9gUCzk>q&0JJ z&+%u5^WMht8b#wtgnS~b)obM=uXZ+{vU;AgmfcF>-D$Hwm`c9hjmg5J=Artggzsu2 zu*S}rfx2JLGzdhrYBN}FRu3E{3dX*{Ss4aC$EXKM_9DDDJG?e?VxdkzC1-qfqGHDa zy%EquUqq(IJ%ltXNCJiEjmeC`;o!+5Sh3bWcme4dC*v=Uj9dEJ_ig(547Y?QS8YZQ z-ngOT(>Z*9t+IAd++8Ku1JlX{RCB`>GL78I} zoXFB@@Vz3j5LUUM$1DsT zF~tPqM8?32^Z|xa!|)XP&@s|Kcx{scz(K@pw%fAyh4|5Dyuicikui#omagX@Y&1#I z4-vsu28_^!NB^rw&d2tZE**^QogKdI=jn4xc|nZHnd+ZF-rx{!piQzTxoJL&lku5-x4Ck);Dw^M0j4ehz+~S^Xh7Y6 zTUaZ26QyXY8grPIoeR$CpAYGUjC zIq020RYb&jx7ddhwG^xiyO6vcLobPwIrv5Bsc*S`_v#%SbNlH!4u(>-#?|gQn0h7P z^;0AYi!`XzqL20%UremT|YoIKubm^#wMj-4&jvE^j z@t1x>c+n{#yDz*-E4m69r+Feb;BwemZWM;<>U3U?+0OqtJ)kYN;9qCG_$u`%i7E5$ zrnl`1R#5l$Ay}bX)wBXwE<>bD*v1@l66QxFtKro+#0FMIB_j@~Pb3p?^5`V&%|uYi zjg?IVD{7KoEzSq8C3?jrV_wn&p3^x=6z}QqbbwS9YI95nd;OndUA4l}A`}ZTRgBn4 zr8wRMHpJ=XyVyVs?HT5R@FTe+yqU6GVSaiId{JeIFk=_BA-JbVf|PbxJ#NcAAevp~ zzq|pZ4Nav@rz-vE=TX~Yh$0`mP-xn-uYr_` zYVzjLba%)jlxAy-uyI~5M!$b}A)QCsNbg?%dLR>|iXk8i16p2!21NI~L;20j>)JWe z26d4NyKk_#Hg+f??Qe1|?4Do!`KMm(qS0@83rbmVp7D`j(V zZNYjPNk={N+u9H-b=;Tp4TZ8g;akdD8AO>n6g@93N9Rhq&_bxP7`9B!+orbWg9eeFc9*8HYtB~|&TjP}LX@KIMod=Z6Rx6Rq)HB1} ziyW$-TlHly!yi@k@NPA&Bz22k{&0#Ha5OzMKRR_&SR6i8^;7(6|3QV~h(Z#pWa0>$ zvSFQ&^ij?|f8|2A%dO)q!!|dyK$oq&^u`0p)S`LAs=O1mU^W^pVF&f}Z4a1H8i=A2 zolE0Ujh~1j^syhmI%X8OJ#F7OX=5`9>sj79007BZaYY+FnTv^Y;YK0@^W2D}4t|yS zQ_?QMUXys3lZ;8?qu;86?hAmZI7Ay5k8wB<9#}T%F{{OTYLhi1LLWDFsD!&Bu~-`I z)Q+QmhclWeOy^>{9w<>C)g(PvHvA~TdEGkBrdABAEoHqU3e$pnP{j_gw8CeA1!0?h z%j>lS%ULN!g8G6*CybZ@J_oX~v0wdAy%J#*njF?sXPG01GrLS&F!QTg+PL!A*zg3* zk4%-JJ|ijxxloUxx?EdyT_^`kvo7dgOq}Q)nYJ$)^4|ZtXLohIqH@-#OWgu}{ZRP^ zl718wOb7Zax7J&~k9gm@87yuy!c<#N`-(poi9uG+9KKpG8d;&L4s?Z_M_Z6Bz$`Iq2Ak>_b4!ILQqBM_X?s z9b$e<79N-a`Z7$yAL*7)h+&vdrj{u;HDAVT($GJUSEQp*AtjPbW)xq}bfK?-fe?*7 zPXT2VRmQ3*YPOav0i}DyWh#Ay1>#|?Vc&YHmQkemC~8I&qw!p)^L$}4J(ocO+RhtZ z1$@4?tn=CvGl(bnjBx!k2cnvXc~yWuP|j0|G3wE=gpbIbWO#9mlyCTp|0Jgl!OF&< zGS|8dIV?GPq@)C{^nBb`=MQ2Oc(V?}NMO*%nh`wI;-|zztVyf{dTy~nk3j6PQEFx) zQbWx-E04XAPxtabnyDrdN-zSMM-|p}PP?Qb_e90(u&}vw$aO}*nSt%)_oFWhJ19ek z;jofazbcLnWp$52mGf1T)21R@F|Gnr%-fJ|jFbIGKfSs}>6orbRbV1j#Dd!TeEa9A zrlo2#xKq}TSL|zI);-@Iy&L31f9Vy_;0!WE*^K(d{1ac^bNGC4Xu14qLacQPSVkin z^fL1+*N_cxNM%4(79i2POAxCYRF42h4q+@R*34I0pqm$_^r?J+bj~8jzKjk61`2KL zwUT7=cPQ04G@Wl5B9;u};^mCo*wuZD7B_P1{Gzd-re9}IzA}bt8{x;h+P}B~tQ-Tjty>`=SI(Xz3qzN`!}IiK$3|k>pcKce zrX5M-JZ9FY_&;%F#8Gr6h%##;eg3Zb%7Vh6HH}@G3<+Y$ov7aShFGJUuCCA;C+KgO zA3)HoSkrnhVcomFwtS2#MMf*~h!G=S_;&v$$KxHG+*fPY- z)NFH4BL5?VCk!_39J?fn7pOP0u4ltISTQ6-$HdQ1E1VH4+n7ihY!g$X>Z5Tk(p&gF$}j3k98MAwV5=||^v!b%kW@uq3)HyVEFql0BGW0q&e0F>U~^Ndg5;EG$8|J= zs^j#Ra{IGbHKJZjv{ZR4CL&f)Y@{M}$ol;+bk9>v&+2dlZn{J}8FhgsWPRfG^$r0Q za|R_M&8a_pTIF;|jjC9mXyQzI>{`6yMaAifsp;*$o|j+=DQnPmP!espgH~E({lx72 z$#o1|82wKnM}OeyPf3%1b8suV((z1doXHEvRe0(yCHu#?$#) z1#6~V<2h<8YlVPF6^%o03E>?P5+`v%|06Ct*)p1yHRAE!J?%66^2_6H6<02VN08_5 z4$`LML}S?`)N@&@F5f{n#R^UhS)@VctUh1@({2I6wvdG#3B_vd26N=SNE01ZjYo}m zR{BxV1Ihj0Pzg(>D( z=-0jX{AW6C5rj?XTw{0N9BFQZ85%Q#*~B2+ke5z0guY;E&rM3tweI6rwjAJ!j>}?8tPf$C=X$9dzdm>q2Q+VM~gi-;f<07ERgje0^ z4?OBhc6${H6&52x*>G|6MhpN?`unXYx#l_eI| z`Iq@cQ&{*)>a)k?cv>SyQ%n?UQLTL6yNab(8%Fy5!wK|_vd&Sl`1s}2S8GkHdaF9{ z`3lmdCG*YU*%R?XUqMv;$NT&z+Yc=QE!)37yu88o1dBI7!ZaMJ)%&5Oa8oB2=e}C;;FG!cnlPITk7z5l+^UJ|B$LnVk-^mjytgDgQ{9 znF@>YTem80>PPTZ1?7ZB1dmwV`L=JXW05ccoB~iQO}0jtq>rI_CC=YHzry@LBhhCv z`TPp=#RDNjLnT-V-s(q1GlhDf?GT3m!cPR73<_YCOpeJYcVXl1mI>l!=7U)XBct%! zsP@$jMRish?0j<_f9tI8n!MBIw_1Pc35`tK=+AFVmbhvQruFDwlCb*2IED)}i}}1k zU3qK*%#i(8WVIf5b5b>Jn3S{EHlLSQANJ?TFeTpylw9s@By%mEEEzO|+IM9WjZAmB zAvuZm!hmQd7K6(N!)Lq}Kh|-jt4C(F&bAjzzDZ@U$+q`~1nw1iu`=a&(pR5q&$jN2 zAKQ{AS*vl!A|$NiwF^G+RWTm4R3S_>&@STsR7h6q*^d|M=IAEx zE3IRMx3hMxOE8v1u<>bwNFkC6zBzX6gcR6EECntWuM=am* z=x1I7(!%gpJ;xK5E*n8slniFq@Luy0Of^hYYk{|80snfs{cZ+N#bWm4Z^f%c50Vd1 z3_O_DHI^PFMiV+DdIniRunJ0%_JvO`6#SNS(pU8AzvKqW!-8}b0?VGWonM!_a|fZ= zW!vIwC4ptCC5nGX`5vd`0xc)e(C<2uY?S-7kv~Y&x6dUw(T)6y!iN3G9GzyY{HxLR zp~T^V`Uo_&l%Cb%n2j1L+jYqN=ho|wYCbBbo6y8fr)Z_g5DWYg%2nEq29(60Fel@m z8R?!RsNK;)^{{YN$+;IJlCQYddN>kSP=oqf#l-@#%Pc_HS2ea zP1Q8Qok_Y-;Pe%psrPqMv#x}AS3`C_&i;z`8L`0Hv82vGVNGN5fUN0}Rtg$}$|m>^ zPUPB7ttk8w|1JodIs-jnk_>^&cqE5GRG2aRDK3GMt(h4 zbulKpP{rN8dBxprMt%uN-X{eSAgdMIggZZDRq?FyV^WnrxZ;1NN4gT`6d}F+(!L=u@xU zbiL4hOra6n+wFkJE$BXHy{}dD%gOLh(xbgKmrNa2j1cjr<5?sHRlJxl%e1>8xWHSC zmb4>t%{~XfC|CclS<|>ljy)2Z6(HKi%2z?DWNijnk1=mOYh?ztz@N8?vSeqZsSV$E zzS?P2Px6?f<_QrbLy5g;TOI1A;-}mhkrNP=cVQaWWw2=g(U=&L5tut28Dm#wVy+bw zuT16@^t0yrz7TJGH@FJ?X_&^QfH{b>MPnc7i5LnaJp~Xn_kG}X$hzwLH~)4!7)tt^ z%12KbFIG!4TW$2m!0XIR$8gmz0fwKI@+eQzS+8hD7DLy+?l$rj@)$&X+*XhM8ERA^xnfp6`WtYtaHDF zhYID!q87~c*z&pnxL5mi$zGA}T%ynSTb1{j6c0dR69wxmE$5!>T6{yCV$_6Q*bo}Z zVJ9;OU3Ysr@DlZI_Uou7cT>m8G^OE@Z)gq7$|g>pQX z4pl~-_jF!Yro8;=pA)sx6=_ev{pst~X7rQGIDBG<2snED{!Q&77mY-V)G}Qyta2!- zBdM73JAu(adoM?LSO|pjccIT=XNvEXdi3`-NOlb{oAjnbWE88D{o<$#CVFm+)QjwO;g{=i0j{sf4GUG=TiRdld`k5k(!=aGYeEwMR%+vWsAf0dmZ8 zTnkk94_tZ%ZU(+8&s5>Llk;r8WKvd7U9I8+qaj;oaRIMa9m_iEWxNKy_YLt^wL zuA(X1z)Hzj;zi-|w_7u$#0*kRNK@%$1nitI3mx-0_sE>Vc_lIQU7)8;AX1wss@9-{ zC8NF#O$7`uyYW>&1Y}U4cwm`9uqV3g&-t=0tDS^n{reD?8VcB1#tcLvO5y9RQTxS? zf3bc#kYtfHWw&*#B7XeaGyjAf>g!g=3QNxp}-6d&UFe8#I4?vaXt=c$r z!oCB}+u5NII*dAS<$erW<_2^2L7)P7_)`lC`3kHCtQ*%!5DX(}u%yRPhDCE)5H^FU zv>sFd?InVTVVe(FWpjEYvf*Q~rlcVGImrMx5a1HTHekc$Fx|N-p~k4s-aCwyxgCb=5@QbA2wbv z`wCjyIvS$s7IX=@BzlQn6BPu9{}qB3%Ow!&-T&38+6n&^Gkio6>-2Bd;>^WH+byru z2dEJS3RJhW!}Xi~Hxth!FL!P{`xo*qr4OHYzt-zA45vzE>Ocf!eRLArOIrXSFf}nO zo?r!AB4VAy)e=P>WAMO2hJZ@zJ2V#@D%0Br9ob#fpL+%|F&7!pPzCIQn5^%#NdS5) z*s^6FHzJgaHs83>`kn1gA)Vk`+j_)V6PX<9TKxd`q~m`Y*Lqq5t>fHRdJ(ZVEtW0= z&s%hk)iA$}`Bjwe1f8_Wg;8hGNm^?od1y)dw>JxuvC_zd`2!3LrG#g3(ay#6_3uCxS)z4bG1kO@+DMA4lGs&1{rmtJa*)d{X^V0&isWppU zjBch^e?5JA$KQ8Hz>4<4uwLQ>Cl=pS7*nPJ87ZV=9wzHa;Ze#odIYJpVYrfr;Q}3t z^AmO|%T8C$3$BulC{6zr#xPmi$QK(q*0mMz6tR1MWf!@Nbs9q34MadFU`B}Nh=edk z0%Loqx8E`991#YefIsSzoVoM78$rBfEs{BFtJF9yTm;JW&_wm@AQ$N^7GdMrizHv> zbY~Z)!UM}XgdSsBMWFo%ct#3cZfiOYC zr*yo5c9!$wh{4ukjJ+s}95;Jsdd8*d{%#)-v#~y1Fe+{@xykQ2E1vDH@Eh4tv>*Q@JA*-v)~)A`1%b&gY(CQF@u@45HGSW}6d@L!FJHeBqG@AgC*C<9yI z7cK6Q(pd_r;8yag7&x^}_<@$ptpsQMu9PBq(975vv5Pm~q%b$A_~0Kks#!7tn#g!! zMB5m$tuGrR5G#O$)bf@5d2r-@m(od+L72HJO(qEGsMiyBtiBHX9ya|Mzn&h=qKE( z`1r4EF|ST(&1_3K@?Q}(;m>t8O`;MDFq6hKOc|nrg;gM=7Y@ig0P z^aABsK>#VZU{)(i?p3`tl7~%?Z4F0b4iJ<+)Zj6$4!cGcg7I0>w9w~U)4sPrI6FJ! zyty%C*6e?I-DY0B2)V?@V2EAz3GEHp54NA=BaE~Dk>Uv2eKfdgifj>QZwuVv_cwoK zkb&g(C8DC*V>C{!s9nR;#}9>Co~VSFqr2ppK=lcKK}af&CO~nK?0khjxcb18U75XE zo(cO~vK%#b(zzl$Nrw3eiK4V%gkd?VKsEDS#IVPE25WyXf@>3H7%&X&#|D0&7GvBC0hV7zB4i) z&i{)twa)!!kNE>#bN;Oiv_J~KOIpCPM}GB%GV6DWmX|ZNY0$NO7kU@H_760MPIUcC zdMnx|8LNf@jB#9udd{+^C0g^QRYoW?{!3Y7sB+SLc=$OrODr*LtQfuhlQnUISbEy3 z8K14&<$A$I7)+$J4_S7VJI+h7;k>2I%FH5svBNbvRMWN7UWWJ}L_9+wB532=#3R{Z zytJs7N`=?gdo&!{)Hm?L!S4@Ca+vH}xNkGOV{7}iRJIM_Xb)NbTs+_ zdT_mDT4gX$_Of+oyYa`9FmA4gyXB5YDXcjd`BB*;$szlx$S1_YLM`iUQ5)s8B@l|?(etVHvR$UNCn!{O?x2aEdY45(c5dO} zBLo7Hq>!TNHUJ<2xFGev`lKxHb10|zsSy~UOQ;b~PJW4z(XJA%Jn3&eEp;!r^L;3Q zB7~H`>aVCJQpkQEGo++Z71L>YyP#J0kyv8|fa@j%HQqLza7zD#q&O=;0jLRHQM=5@iv{LSp>aQ2ln4=gSUl3B zXa}~M^tsdpoqr>2b=)EEOS#Q?+L1nz$xx8X)#B)!J`^xXZGfrDu(gAdmdmLK}c^4fJ>8B;ODF> ztZ)_GFzTh9$Y)2t+XLEwP9kX1k2^IsW#{^~$bh}2Owpcnq+kPOTj5I2*7QbWS|+`p zXe@+#(z5?{Op1omud;TOrW?wf2nEvh`h1Zt?!iS4!29` zTmg}fmZG{ESnVod`wG9l))bk3QYhdD74TA~aAS(Io*uN_tZo%z<%GeZ?hI#e^DZH& zVkc{?qd*HAW2mJ5O~=2D46hgDAO}JQ$73Re3IFEw(F<>-E$ca@+gq0oR4SjGnK08J z$?=GUsjq-4rq`8VVz?hjniUVMo%8-Zj|vmn!KA0$D+FpV#hefgoDIb|H{9lmg_t`I zOpPYM$4RT=BV|`mf2Drhs;YvR13Wac z{u_kbqvc2v$-j7NJI?KBZhId5S{P#_y?7(3)pMp^W6f4H`_cG3w~I@vkn6dm(c!Py zwW@ZLM;~~33J&MKmhz-bx#F`+`SAk_Z>uPXX|f_?1Wkdc@fe8a{_ff&O!7~ouwtc9 zg#RcILZ5Kg%y$T8C<5e!$UpfbG|*-iuPi_b_Y&T1FGdoy*~oBu8w*;VgI`NlNrWAn zlJ0^aG&z4XU2DvTmB2yMAyN=$PyLLdR7eXO>pekqSjp6IQawY!A<0H9i_KL?I0{53 z`FICs0~fzo{D}B$B{%bDd2M(xrru^js3fIZ(e3nI zLMh^UNrOUnxgCH}vJt-maQa3`T9T8L6F+l-RxTungz|!;#!G?TSO9|pdvv(9ZM-Df ziPNp7I5z^K0FAguaRgZ|aHqd;)KPsqbtJL1tc2%g5MyOnaYi1Wxz8wIy1+J4KdZg| z@4-=VI-XyRTHoYQ4;r18{v_e5rwEJYrO73UdL^8+w+VSOD%v5YMdZ=+I6z4S`brvXh5KK`{c=Hv zD*jNGAbFUzquqXdYT=Zjh6-%ybcC28=?EQdN4;Wtm+wG%E@sD;)7Lh7zZ0WG5j?`Vj@*NxYfWjlwjTw1FdlF-=zoHk>_i?@|f> zS@2r?x9h+edC62oCwx6dCal2{d08H(N5jG%YgUw+ml0Das$xVL*fCtI2zHR~#P>pX z^yFQ-cv`liuNN(be9ViSmgwnH??mRcH)s+G(?hZe%3=~Pkt`9o+d7{tp*IA)vNIM2 z6~L4jPi%cpJY5d~cef1#OawP$FKCIkd{-WcKoSA%d_drAAUA<{vcUx=nK0D=2g(M( zG6;w&6mQ&M)%o`IxKRxMr)T#F)c?N7~wpZ1H6=jJt_{Pzs$VD3{#ReCY+3Ki9 zaM%b)E}_;{EpMFbloQH?LH^Zed1l#b+&q}lELbhVqxA*w?pQF9oPtB}qmsPf%C40? zSM**6HMf1Yl`3qz?Vm1$w+=^u>XWfJvIcpm(8sdDKph0O^1a_ZB{u^@5qY_)C<4<_ z_YE#9+E}?!B|RNEr`7_6DZbi6G}{z>J9Tr}8{n05<^VFE?bJI;(`zrQmL*{%>@a=t zJ3IRs*6OcO-}0b5Qie%&Xib-q%Rqr#GAopwd~{3Qgr(!5iTN-S8b)k>A9 zOG}1ENOYlgumkRL&uw9aSwmHJ{Z+aIg(v#2*Bc6Ok@A-?G(S#phb+*4l4YbZOK^sa zYndHtaD^@){>E4!nfAb9c$O&7q~ANVwq;>0?nK|~5rP^1s3_XgN-=c4`cbNq5c%rg z;4eX~Xb4iV5EC|75vjt$E|nT#qa5^iV8Yrp3wdf1optb0Jtpd6anNWMD0v zG2KMi;Scs799}1#`|5-HZf41IXCbi~>kaT!Hy4!|g&hzP-xSZ=7ltRhl=EK)sRf;; zDpcqmwtTKO|0_z?kh~V~t9OL#hmc{T%2|}Vmfkd%*AS*Mr$Omgyso zfSo%N?o*jG&^cs7ON|k#{vBo1z7veF%zzRaxju(VOd^KBwL(YMBLvDl7JO{8%cRK6 zeeB!YZas@+E~kvJ=($&D5^fa&kUh#`ai4aa79KJ0>mMXPbNZO@ux z$p(~1E$1c`TLL?#8P~qIVrgtD)}Pc~o-P;MPiA)P6JD*h>!?GlQzKxJ;3qgig+l!G z>R3mW3Y-jot$|B(R{JI@2BMPJM;0TC-|!(~Ag5z%h*4|co@oyT% zV4K;uDGzWRef5wSJ+3zYq(qWAck*x;rqBSly(R@sqyHxQfz6fgG2fpeT=ujozAEEs zN0BFbsugOmhk-xX?e9l9hYAfcQm%g%50sC$Ks^=jCy(PUbknuUE(b`)7>BqrO8Nhj zF-oQs%x9ZRl^Br%07VfrN9o8!dRG%E%S?UN?owom<4P_4Y!kX=P=fG36JYwo6`&=y z61`PV-xC>2UV?a0sG7bXy5FQIFZrN-nR{Z%ZImZ9icj6MQ_wj4njxWdZsJN66CEr= zOFb-oRDp@7;t|hx+4|s1?B|fjAQAY@D&ZHvqh#}xo!$3Ai-d!(>?%kbM(@@1=Z%ou zpOl)cW%PeSA>G?HFr1W1{O3$&|PqEFaCl$MO2>=DDJxynfPJJAcJ9o5hkETitoM2hpZOlTKIC| zimjb;ic2AQPXk;E-E!p9I+*_8|TZd8cv`>aSI4ATbKV(8t!g0$n%KgTC z3M*fUn-}s&JJ9p2u|18As*|tE8|v5}o@V)rXqYJsuEmLNkhTssHVbNfNM|Ae3+lX> zc7mW&ME$pjxM8_V_-XiSrcMT<4OYM({tuRFh}~nsw_TJLJB(Gc)ZG$9N=(Sc33c%_ z0ul4r98NvJx0%wKImuu_?c!qZ?Sk$bx6*$77x%eCNXVH?a{n;%rsG_n<}3`BIwk~@ zOqV(RphT>Jo(y`+vEmCG&~>7$_55=b`Lpi^$n#CbRC)t`H!k7P9_TEUOr`K+Q>lIx zTSvMq!c1v&kJL?xs(L!fsL%_)#(zeMRWJPKa3qT)eL<}>0bdqOSKRE&FUKu{^560f zfNe2Yr02J%g*C*K>51QRVq7NLXTXZQa=+hc?}9eH&6Q|mX;?!J&Nct!`H2abb=b6j zSYAwOcMtauQ=ZuISU;$^?N3ik?OY*mIA60V)Jj^_DZa<=sjvU^)bxa29ejVEoaZYS z>>1&=Diq&X2Vout^mgZOo{2isRbwL^@fA_HDh~$LV@p!|E zaup4qmR@nMN*`mNsN*jSiPJgUGY|Tr;gBrBt_78z$5MA)+rCcLGZ{ir4}ZhCuk-5ZLrP@>Ls?!3y{ z3O$BBzm<;4O4R)bNI8e!omr=v(vw&bbGm1K6Q+fQ$4T=>lp#=I_E8a@ljg5I>S3-z zNxrJ61X*+N1s6G^)t{n$w(dpK>%^8|Gb;RHh3ylL6S9X}!W>l(R~J>~YAnr6g}(1% zg5D18v=_67{>bYU(!lB$y`%>)M?q@gGyF*6M`Z$6L~Wz^orwMU6|c*_9y&*ccO~JL z3WAbx6JEDf#wqpsX|oTMJX5n6Pw0XrQNi#(qTHi8&_-x2l-F|co$nMPy=owM!f2nL<$wwWhD&r|vqhk($A-dDcTq(3v@zr%4l zPFkeSKIrLUt;#85GhIa7!*odSx$T@=;eQERvwas%qqAb~13`l8*l_jG28pFb?o(9fy<~EJkaiet{e|1SZU6uGT0P-o^fjh>x@vlL6! zf<(Z0+FG0{2c-KQ(h=B>eTGL7n)`8A%H?y>w33Of00q3^t3utV$=qM6sac%#+RaLE zpjyA94KEfc3@xByzc`ks4@Yk=BL4?vVx`UDgBJ*Qj%%(AX*`Sv6S1F;){9Rq z5HAR-c!jSZt1@b<5)eFYbJBOh{HBqShdsu(FM!Tn3M(hNoNWACyC=q^0BVqn8Jn?B z%E75uc+N6bXh48dhoM(G^;iRLI>e zD<*+a{lcbtzyx3C!`x%p1KyqsrxK?Mry2t-pkM>$C#e&!p001tx~&p*Fjha5<{ku| z1cSGpXy$Qr(24f3e)Xlzc*v>mcQSZ549hxe4syugut@9c}Lof_*BZw25#xaY`MXrWyA&Bdy?H=k}$HxrTTU)^?O!qEDbZKW)G0h*N_Jn2yD8TO*J@^?kb+~Z5wV3Z#k z_j;i;CMl1D=Bw+@l&z(|iJmMRjWslHKrOvoCYJzFi85uTDSmVIv3WYe)N-d{pF&Yo zp`0dY5&QddWfJ*YM7LQZ_UaRLuPFpu;O%~nP!>#SW@22W2@0rnH)wZA{)xCRfcbK1 zv)slG|FiM~8dCZ_SQBU?w!bK^$1<3H5WVYB`$W4#@uQGPk>i7vS#Q7t2I7-jNAR(b zjr?tg^u>7BI{Dj$Guuow{TKTs*}!WqCsij`re#7?kUPs;*0WsrZ$Pb{PV|ht>MFOm zUjBIS;Gh1N;*ZGx>FzAQq6)vhKcquSgA6SV4k#r^4G4mSNHdhuB``D+4oG*mG)T?R zF$0nkBCRljgGiSW(tQv2{d=D0A9!BfZ_e85tgFs-omzYE@8@EQ4ExjoT2<|rdtg*y ziu%XD*W8)=a?Aj(A9%0?sqOeT+^i{#QULB09le|=qZoVg!LX=lzQW|`)Ce5b_n}l= zYs8`<#=WO5bUE7W3NT`bS}(5diB7lFJ$* z_;G$=ta2;;onr|=kz~Dl4(whC92WT4j!{yz< z6rbv6hEdn%2vOgY8F&SzULR8zERqfTuKREYC}k4nq8d1MV1?%=TZI;rQqHtBCD?}R zLfV;jg{wog2&FO*q+5sbLBEVzbL=@E1h`c!m!JK06=HWD$Uox9hDDy?@OyXKaas0pai9s&8;+!gqpw|dvWbUZ=_jAe@ zMAMj~Rv1B2vMLLKZ-MYD$3BO^5e?l@m=S6m>WI9QUm`d5l@SYD$Naee)IENZ@I|j) zh~C|Nw82hp0vYv*H^xfMK=>fYSl_-N$S1%eO!)pARiLykQXQa!nO8xUoNJr=xsMw& z0o~`alfo98Iaz$dg(Ce|VPc7xQ)yv?&5iqd)idMz&e}6+G*aS`74JAf31`LczseF& zg(Tt+XPxL^an78w+D+unmHGDN?2R9 zv-N`I{I74toO6e25V8&v)Zw{Mx=q1njP8f7%=4Gc zCbla6{zhf+u+1N_aO=P_)Y`YU?xTR03y-F&hk;_lBg2<;ed)RPiYw;QdAXU%XC*b2 zya!hA=St7AoGv+=M)@0a%zZ8;p(%Z)<3M{Htl@%e6Z+vYDcjDFDGBTvR40l~j18Wd zH}-hF-&*;3ut1X;DJk@(B`|?^sJvV@z&B{~q`Dz*oaB^?1!gOdZek7J;}+HQnGq{E z8oW1<@a!+x^10#0?5* z&yuAq9!I^XA(7O<$6{j}K61OCu^|KTs^+|%PKvjYNd)wouH>jnDo5*M251x zE3;832@d@kVRI)q0`+&Z(g4oi!c)XkomgA)xa02|H)nGg@M#Kc$bF z=0J165e>K-VrG~h2a9}yEixBs%jbEV?O9!S^8ojT2=oZDp}osJHm648x?F|g%wLU(Ti{u%xfZe(F6g-S0RQ?Yk*=~ykV$YJBW zkZnF=e#CQVi*p~1_P5e@bTii*a}XV4?t>%2HiV7;knNT$tuTLnh<)YkgDI>z+*xe- zQ_^%8S#-UTBx9G}Jyn+kV|>C^X_0U*1CMk6XU5cRt%nIV@s8w&QcPl4c%HNNTk+8M zTEN)(n{sieswmj5B&VuoyY3+P|&dFuv0&U*11Gb9o>^}y9+bm8ZaK$Un&IM zUo9|TT10jOa@R>Oh+cM~3S3iV;$;pEoEuTwv=%dDjhBRg&5T^8=Hh1avf>V8q+i7L zNlAA`&?|MDdGpT|9%)kx4%^#{B@vJ8{$`W2dpIihc5iB#EHFNvRSG!k;G|F-ZwS#~ z5WVx@11>8Hp)o80eFFevZR9+=8j4RXYDlfL^I8Vi{=WMdDdr7wlCdKj$NDctu7D zlsu00AheWBfJK&ed1#$mP={PY7LHoNue3`UrBVL*r_m6TGxMQy;Jl=7ppX4}uSHE~ zUtlei#!{zP{Vl}rxAcVZStDi8jcEnhSN1tRS~8-OJE?;~A;z3G*1r>vNgl1><%O#g zMezzUgTS;Mv{Q*yFIFk1!oB=Ax|O;)_VoHvSLER1118X^*vRityAS^ z?}A0{`L_kT&u<2s552XUdqfmPm>pI-m_TSab2h~Enktgqu77ZELzg${hRZN+=YLtm#V60qZwFLWt?ows@PlSNgM7^(4 zX~!Ze#5yA7;fAcB-;hVhk)fh`H#m#qLHqdzuw8 z`R8a!RbfSJ6p#?bH2>kF(u>+(m>W;G%Q$dYoW0<%5l+`Ndd&UwHiY0KS9F9{%-F8? z;Wj4RM%7`bOZ~Y+B96lGxr{P|jpE5}C2{o%H4LM)jA21GuQ#*PukK(8R)L4*%F^9A zDO^S6B{8PdAC-!x7Img|zss_mU$@fX=~~}=uEPD1P3Wmzr?{YIkioHGR}*i1Q;k7? z?7Siehv6ldoFaB~sEteE2TpiRn(<~SHV$p%1N;Pt1aL2UY1s!ZA@Uv zceyPyhQJGPpi01ipk7TeDV2#6vk=iD4~JJ)QhQZeX?OoFZ)d0mxltBnlt=g|LB}^V z$+`+g?#!;zO0D78>ohI@B@)`H9U~4FA(T)lim8U%%lT_1ucZqYFzZ$HYG>+om46!f z*5RGPG0c*|@mw==^((VtNYNF=f{3pHCg5IGcZIMS{L}{TBt(x>LzNnuY5%XHvD%|| zRkCuH$-}mW3)%mO!N$2QAhMQPh$}Wq5h2NHYhJYJ;ZB8TvcNCPwQ~btBDQ1oTf_C?8=zgLL5z7gm5i$*L6tc$SOn?y}je9f_ z;bN}YI|a?gWm{88(B3~FRp@LI&3=0dpIie1w@M$qNIWR6G3XO3lO4BQ?(Kr9>(o~@ z(&;x1C>8IeG)mn{y_@&b%E}kae_FW^Us+uJtZqDd2lTfx`mRuT&zaW9FvY0G+4?uC zj&w+O$)w&&ZI$2#QEFA{bup_6|I#ERiKSWeI*TJkILY#)itG*+8y_4<)xmK8{`oh7Xu_uQo1l&8<@HGZj`s+W4O z1rRA1SU7C8&!)1q9k#2NYlEQMxgYM<*e0ZgC#K~TT3C8r^eN?>Pl^inVI&GsvZI_! zET|NIPzjFl|5fY$1`}Ix${c%UGXB<3=xLj!TmHvU3&)RhfBa8e(qut+tGBv;xwaUx zcv_+K(aiX?Jbza0l}!8K`o+4*q~6$@JUZjGq$!~z@?6%`s{LU^6%#c(tT8zx!Y%Iy zTQ228vix`sp$uxr>NrNFQ0Z~vVa%)-@KIwWm$<{BnxY9G=lPF|f!6%Jji30N|4uH5 z$1XWF;!Dh_TVg8D0vDZ)Kk#SAQO`zy|M~luZjN|BEU993cNO^4`Hd4Gng-rZUY)7$ zG*tp?w}$Nn^RF3LiuX6#!1x=}PGY_?xRLcfbDnOzcVAJ)-ICjnrSGS59n)iBHU!jv zij?Y~w81Bwf7ta&eaFvAd#@U13AR#IU(G-)&FSG}!^(Ij$qNRCSR&u#+!?Tr+^J-3 zeR5J&h7IENM3=vjF|A9A%jw%%HrZpRJ*T4Yg|Mrftw=Lb)K5~1e68$o4Y*}S&IMxK zHiW-@;(dWd*K&?WtM(`k}8bb=sm(MTPb2bu-)? zr_48zt?z#jK7YVKyc@AIYC~nUuftNs%!1JHo!oN9f`-3d;rzfeZ(`cDg02-^7l29L z8#El9S+Y7K8p>kI(EF3=;k`CJvY#&pFZ)!A#ibnms~tkxP{QmoIkc>gzkv9NxlhQg zyCb3`LN7uoh_<4=cA!%WS($*zT{e3k=Y41EH8#56@;GRe>5V81F#ddU=|qp>Q!|>O z^iKY~c^SC~2~QYZ1_>FX(Op9-Xvx^kv6d0&+HUZ(d4xfu&-Ul>Hxm(+#gF?5_E&}O zYZ+EbQF%fBs*@YMNA6baQdDhQR7osFKe9k2s6gEKnMH?>*0q@C4Jw^{!btAv7<(8$ z^r{)K;yZ@(M7sANqWVJ%RUMW|txML!)S;g_gmui$Slf*2eO5>&Iu66`fRBXdE;rb>64*&WvY1cd*YO5G|kDP4<|(ZdRV?yEwjpP zbWBQcU!uOK_AQLB*cDaqzW{Nsp#?oBZJPWy9CQ~n0|~NydyJc9&}INOf(b58N<|8? zPuYHyl6bbf{`DX3r1H>}+6q$W_6}6mtWRG zh1YY7`6Rqhu1?{d?`pBH=irySxUeW^zbv}KwZ`coDt+i6Wi~Lb1fUMH2f}@dG11gRj zH5U(IN^v&cx);H_6VZF!Z;0YDYeY>%rkRsXZSzkfHo&RqZ2zP76xNBF`n(Bz0|PBi z)?MSl;b9_)G4It-)j8g-_wgZdO}G$ti(D2^Doh+Y{#%g}04t@N=X6=dG`3AJY z?Q$6_cjSD|dLGc=(c$MFbzq^7w*(e`XSrqAeNyO? zo$Ak5Qw$*pd}BwqQDx@s+b=C^LDt>^=z@GL`1-&?Vi`OZuj`Q#e6(8%|A8xE+&QuO z&-;GSrnCi8h5|}VWEp46;9{rj&HQyvvu$kEj8FW@nXy((JF$5x0%;l)6m<|h6|gUF zgsf1j*zlBeSfZ_Yht4M%1&=jbLW%d~5&Amxhw=hs)Abw?;S!=}gGA}GThSuJ)`eY35g6E z)nCgVJ~irBRWbu{;U~qz!y^@#6{KR5Pt+ULte9*p7F)QrXfS|TNjhy^MrBrj$%Cfe z!iYlj_o_m&>Sq}2%v*yTar+NROYycEM^fNd9gK8*w26C4h6<{tGvUH_8Tl=o!=Gel z5tBTyxsx(EC@q`D_y?DEY49iOo%{k_*Q_A8Dt^NN?)B!h0}vS5Ci}x!-?&sUUByZE+Y=RU)|aL*7la0?Y{}F;R$H`D zN)aynrRszn5L-&+(MnqHVZATZH?DFF|C7YK!HxfPxmQdS41Cwnvgdtp?cf-%s|ZMm zjqx@k0D`O(xpnu%TsZQJH-qXKW*hDcM<;`6SJzf@t4&{bh0!ShS%Vff zr~6s@Z53G)bkaqf$!CxYd`(NNfrFGQ^MNcOF>Kwvf_#obdinh7xyg(tdGm`gRh&9K z_Ylp|Y-_LgD#YXAT`yi#L(0n3#4hY)K55|oh&Cnt;K$`dSazYqBf=gO-;{BB5clHXGja9?NS(~yM=tT{{Gzp!ZFw}! zorrF3hA0I+?=x@u&zm>gj}JYk5k^wkMRDp`@koC?g_*AiJfcOD+y)Ov&QQhfx&9Qz zp;t$`LZP-Ax@5hf1(PsfPO2`O^O7=#xdz`(XuQ7%NagOhBN7Lo?=gybyO+GY#z{7~ zjmNDsu`^pSo28EFD;Tp&@aY-QMiAwYz!)T*C4+dWQ=`>CbAPk>)?d1OljU$p?%0~u zcvw~Zo1`&2iz565mVL8I-!R}oWNXz-rT2HAy(=t;`EGezD_<=%!>zNLU(t!Bl5_z2 zQ)fN==1<#A^}Xk>B{p-On8SvS9$}n1rs!!4EO(H{rLpHyvLQX|jvrF%6*JT_w=3-I z9F1yUlqWiBFtcqo*c?%% zE%#;)_Y;rbBrB;=G5XSVy+1Uad$eq?!nEf*M3YjsI9>T1zuQ1XMS9lg)Cks!6K*sH zX)licR1CSEd*|dp>qSFmgH@^}Yj(ynf|S+HS?A^)CvF^ zH%2d$_933`Q%SE9Zb!e=p-3!T{T#3)bxO$vBCW`F+5vjnS zNiVV#m9B>|xXQhjGgknS*{FNjLcATbNK;V6W4OEp1&9c#|Bt=d-@9KEYUx-jD%AFvy7NDY+kI9si61g-WW~}pRp%A;%Qq;;x641OUC&}7Tcd&18ky;H%|*U%zMtQ~Jj=It^)*_)E_pLLVzQr17bgHr zM0Y$kNUfCRfCCbD%7#+@1@zM!H5rnGcs2z}x)Mg4L1m29ATG9$Y0rB$5`Gqba;*?0 zwP*Uz{VFv0L*taB8ss*b-~WvFi=K;46npO+q(1E-E`7BgAr9edmbnZ#+J)hJVpSKd zCDYdL9+WxnY<#+a0VRrx%eP{g*|+ER+INb+(i`s9Z|6rgN#X>h;2M+7w~#h}oG)`L z%*(=x<+Cim8n;F0MKnczpnF*C`Q}jn>L)L!8<5-SV3#*iqQ@gx3Jk6xud?1mqOFaJ zsPD0UmTH&`r)psq5>cePpTyA9RmsZX89O4-fT$QOln~Pz8zSJd&1htba-*6kpVJRj zsi&6z$dxy~xfksm_otTNu54X=G>5Q5UPL&A%;!?djP!(1~TqATpvcWq&P?*I8r%OnFcXx}4f5!q)I%By&&nB14W8q2N0v)Rv2$*NITNs+R)f zEB5Z$l{zoAzVVCydmPUQM*rl=P{Wa*>ud?5E>yh*_IO4YL~tp`8aCdXg!~-6PLQEJ zY4)?cSh$oSQNM~A@P4piuTodzT2;G z!M9l8|5e1~|1T8i#aLic?Qswz4kK-t@PbRR6MJHgqevS|`{3+}|Jc7m0I2f+u&K?W z0jR3~*m;ovRLB3ZLjlr_|FN;`cpeT5zSKAX_3Dp1cpiO|N6nqPw~Xpx#edT~?L8ap z?7RQrYG>~7supzA?6%`pr|wbS2L4K2=PvJU(Yh|Or0qmx?P1WfTY~raGgZuykEg1o zpY?(-J+A4!yn53zbs0C@;>OReg9wMfoc~aryTZ7-ulzkf?)~6V1pm3%!9lahGoID* zDZ2jR&bsA@gHUh1<{fGPvpi41n-^dl!<#%|utrGMha|~VLV8USS08h>vVq==z_-by z@^@VD#gWTji{^fyEE%ZkI78*O|Kp3v{@3mdw) z=)Ay{`2^e1?6uAW%zAnSRqpcP23qGOFN$`3*m&DH{TNds*mr)*G^GG-UNp(+z#&+l$^xzf@FM9fY5;^DQWj Date: Sun, 21 Dec 2025 18:59:15 +0200 Subject: [PATCH 29/31] feat: Add even more supported languages --- lib/i18n/messages/de.json | 911 +++++++++++++ lib/i18n/messages/en-GB.json | 2427 +++++++--------------------------- lib/i18n/messages/es.json | 911 +++++++++++++ lib/i18n/messages/fr.json | 911 +++++++++++++ lib/i18n/messages/index.ts | 19 +- lib/i18n/messages/ja.json | 911 +++++++++++++ lib/i18n/messages/ru.json | 27 +- lib/i18n/messages/uk.json | 911 +++++++++++++ lib/i18n/messages/zh-CN.json | 911 +++++++++++++ 9 files changed, 5983 insertions(+), 1956 deletions(-) create mode 100644 lib/i18n/messages/de.json create mode 100644 lib/i18n/messages/es.json create mode 100644 lib/i18n/messages/fr.json create mode 100644 lib/i18n/messages/ja.json create mode 100644 lib/i18n/messages/uk.json create mode 100644 lib/i18n/messages/zh-CN.json diff --git a/lib/i18n/messages/de.json b/lib/i18n/messages/de.json new file mode 100644 index 0000000..ad44bed --- /dev/null +++ b/lib/i18n/messages/de.json @@ -0,0 +1,911 @@ +{ + "general": { + "appName": "osu!sunrise", + "serverTitle": { + "full": "sunrise", + "split": { + "part1": "sun", + "part2": "rise" + } + } + }, + "components": { + "serverMaintenanceDialog": { + "title": "Hey! Halt, nicht so schnell!", + "discordMessage": "Für weitere Informationen besuche unseren Discord-Server.", + "button": "Okay, verstanden", + "message": "Der Server befindet sich derzeit im Wartungsmodus, daher funktionieren einige Funktionen der Website möglicherweise nicht korrekt." + }, + "contentNotExist": { + "defaultText": "Inhalt nicht gefunden" + }, + "workInProgress": { + "title": "In Arbeit", + "description": "An diesem Inhalt wird noch gearbeitet. Bitte schau später wieder vorbei." + }, + "beatmapSetCard": { + "submittedBy": "eingereicht von", + "submittedOn": "eingereicht am", + "view": "Ansehen" + }, + "friendshipButton": { + "unfriend": "Freund entfernen", + "unfollow": "Nicht mehr folgen", + "follow": "Folgen" + }, + "gameModeSelector": { + "selectedMode": "Ausgewählter Modus:" + }, + "header": { + "links": { + "leaderboard": "ranglisten", + "topPlays": "top-Spiele", + "beatmaps": "beatmaps", + "help": "hilfe", + "wiki": "wiki", + "rules": "regeln", + "apiDocs": "api-doku", + "discordServer": "discord-server", + "supportUs": "unterstütze uns" + } + }, + "headerLoginDialog": { + "signIn": "einloggen", + "title": "Einloggen, um fortzufahren", + "description": "Willkommen zurück.", + "username": { + "label": "Benutzername", + "placeholder": "z.B. benutzername" + }, + "password": { + "label": "Passwort", + "placeholder": "************" + }, + "login": "Einloggen", + "signUp": "Noch keinen Account? Registrieren", + "toast": { + "success": "Du hast dich erfolgreich eingeloggt!" + }, + "validation": { + "usernameMinLength": "Der Benutzername muss mindestens 2 Zeichen lang sein.", + "usernameMaxLength": "Der Benutzername darf höchstens 32 Zeichen lang sein.", + "passwordMinLength": "Das Passwort muss mindestens 8 Zeichen lang sein.", + "passwordMaxLength": "Das Passwort darf höchstens 32 Zeichen lang sein." + } + }, + "headerLogoutAlert": { + "title": "Bist du sicher?", + "description": "Du musst dich erneut anmelden, um auf dein Konto zuzugreifen.", + "cancel": "Abbrechen", + "continue": "Fortfahren", + "toast": { + "success": "Du wurdest erfolgreich ausgeloggt." + } + }, + "headerSearchCommand": { + "placeholder": "Zum Suchen tippen...", + "headings": { + "users": "Benutzer", + "beatmapsets": "Beatmapsets", + "pages": "Seiten" + }, + "pages": { + "leaderboard": "Ranglisten", + "topPlays": "Top-Spiele", + "beatmapsSearch": "Beatmaps-Suche", + "wiki": "Wiki", + "rules": "Regeln", + "yourProfile": "Dein Profil", + "friends": "Freunde", + "settings": "Einstellungen" + } + }, + "headerUserDropdown": { + "myProfile": "Mein Profil", + "friends": "Freunde", + "settings": "Einstellungen", + "returnToMainSite": "Zur Hauptseite zurück", + "adminPanel": "Admin-Panel", + "logOut": "Ausloggen" + }, + "headerMobileDrawer": { + "navigation": { + "home": "Startseite", + "leaderboard": "Ranglisten", + "topPlays": "Top-Spiele", + "beatmapsSearch": "Beatmaps-Suche", + "wiki": "Wiki", + "rules": "Regeln", + "apiDocs": "API-Doku", + "discordServer": "Discord-Server", + "supportUs": "Unterstütze uns" + }, + "yourProfile": "Dein Profil", + "friends": "Freunde", + "settings": "Einstellungen", + "adminPanel": "Admin-Panel", + "logOut": "Ausloggen" + }, + "footer": { + "voteMessage": "Bitte vote für uns auf osu-server-list!", + "copyright": "© 2024-2025 Sunrise Community", + "sourceCode": "Quellcode", + "serverStatus": "Serverstatus", + "disclaimer": "Wir sind in keiner Weise mit „ppy“ und „osu!“ verbunden. Alle Rechte liegen bei den jeweiligen Eigentümern." + }, + "comboBox": { + "selectValue": "Wert auswählen...", + "searchValue": "Wert suchen...", + "noValuesFound": "Keine Werte gefunden." + }, + "beatmapsetRowElement": { + "mappedBy": "gemappt von {creator}" + }, + "themeModeToggle": { + "toggleTheme": "Theme wechseln", + "light": "Hell", + "dark": "Dunkel", + "system": "System" + }, + "imageSelect": { + "imageTooBig": "Das ausgewählte Bild ist zu groß!" + }, + "notFound": { + "meta": { + "title": "Seite fehlt | {appName}", + "description": "Entschuldigung, aber die angeforderte Seite existiert nicht!" + }, + "title": "Seite fehlt | 404", + "description": "Entschuldigung, aber die angeforderte Seite existiert nicht!" + }, + "rootLayout": { + "meta": { + "title": "{appName}", + "description": "{appName} ist ein privater Server für osu!, ein Rhythmusspiel." + } + } + }, + "pages": { + "mainPage": { + "meta": { + "title": "Willkommen | {appName}", + "description": "Tritt osu!sunrise bei — ein feature-reicher privater osu!-Server mit Relax-, Autopilot-, ScoreV2-Unterstützung und einem benutzerdefinierten PP-System, abgestimmt auf Relax- und Autopilot-Gameplay." + }, + "features": { + "motto": "- noch ein osu!-server", + "description": "Feature-reicher osu!-Server mit Unterstützung für Relax-, Autopilot- und ScoreV2-Gameplay sowie einem benutzerdefinierten, „art-state“ PP-Berechnungssystem, abgestimmt auf Relax und Autopilot.", + "buttons": { + "register": "Jetzt beitreten", + "wiki": "So verbindest du dich" + } + }, + "whyUs": "Warum wir?", + "cards": { + "freeFeatures": { + "title": "Wirklich kostenlose Features", + "description": "Genieße Features wie osu!direct und Benutzernamen-Änderungen ohne Paywalls — komplett kostenlos für alle Spieler!" + }, + "ppSystem": { + "title": "Benutzerdefinierte PP-Berechnungen", + "description": "Wir nutzen das aktuellste Performance-Point-(PP)-System für Vanilla-Scores und wenden gleichzeitig eine ausgewogene, benutzerdefinierte Formel für Relax- und Autopilot-Modi an." + }, + "medals": { + "title": "Verdiene benutzerdefinierte Medaillen", + "description": "Verdiene einzigartige, server-exklusive Medaillen, während du verschiedene Meilensteine und Erfolge erreichst." + }, + "updates": { + "title": "Häufige Updates", + "description": "Wir verbessern ständig! Erwarte regelmäßige Updates, neue Features und fortlaufende Performance-Optimierungen." + }, + "ppCalc": { + "title": "Integrierter PP-Rechner", + "description": "Unsere Website bietet einen integrierten PP-Rechner für schnelle und einfache Performance-Point-Schätzungen." + }, + "sunriseCore": { + "title": "Eigener Bancho-Core", + "description": "Im Gegensatz zu den meisten privaten osu!-Servern haben wir unseren eigenen Bancho-Core entwickelt — für mehr Stabilität und einzigartige Feature-Unterstützung." + } + }, + "howToStart": { + "title": "Wie fange ich an zu spielen?", + "description": "Nur drei einfache Schritte und du bist startklar!", + "downloadTile": { + "title": "osu!-Client herunterladen", + "description": "Falls du noch keinen Client installiert hast", + "button": "Herunterladen" + }, + "registerTile": { + "title": "osu!sunrise-Konto registrieren", + "description": "Ein Konto ermöglicht dir, der osu!sunrise-Community beizutreten", + "button": "Registrieren" + }, + "guideTile": { + "title": "Folge der Verbindungsanleitung", + "description": "Sie hilft dir, deinen osu!-Client so einzurichten, dass du dich mit osu!sunrise verbinden kannst", + "button": "Anleitung öffnen" + } + }, + "statuses": { + "totalUsers": "Gesamtbenutzer", + "usersOnline": "Benutzer online", + "usersRestricted": "Benutzer eingeschränkt", + "totalScores": "Gesamtscores", + "serverStatus": "Serverstatus", + "online": "Online", + "offline": "Offline", + "underMaintenance": "In Wartung" + } + }, + "wiki": { + "meta": { + "title": "Wiki | {appName}" + }, + "header": "Wiki", + "articles": { + "howToConnect": { + "title": "So verbindest du dich", + "intro": "Um dich mit dem Server zu verbinden, brauchst du eine installierte Kopie des Spiels auf deinem Computer. Du kannst das Spiel von der offiziellen osu!-Website herunterladen.", + "step1": "Finde die Datei osu!.exe im Spielverzeichnis.", + "step2": "Erstelle eine Verknüpfung der Datei.", + "step3": "Klicke mit der rechten Maustaste auf die Verknüpfung und wähle „Eigenschaften“.", + "step4": "Füge im Feld „Ziel“ am Ende des Pfads -devserver {serverDomain} hinzu.", + "step5": "Klicke auf „Übernehmen“ und dann auf „OK“.", + "step6": "Doppelklicke auf die Verknüpfung, um das Spiel zu starten.", + "imageAlt": "osu verbindungsbild" + }, + "multipleAccounts": { + "title": "Kann ich mehrere Konten haben?", + "answer": "Nein. Pro Person ist nur ein Konto erlaubt.", + "consequence": "Wenn du mit mehreren Konten erwischt wirst, wirst du vom Server gebannt." + }, + "cheatsHacks": { + "title": "Darf ich Cheats oder Hacks benutzen?", + "answer": "Nein. Wenn du erwischt wirst, wirst du gebannt.", + "policy": "Wir sind beim Thema Cheating sehr strikt und tolerieren es überhaupt nicht.

Wenn du jemanden des Cheatings verdächtigst, melde ihn bitte dem Team." + }, + "appealRestriction": { + "title": "Ich glaube, ich wurde zu Unrecht eingeschränkt. Wie kann ich Einspruch einlegen?", + "instructions": "Wenn du glaubst, dass du zu Unrecht eingeschränkt wurdest, kannst du Einspruch einlegen, indem du das Team mit deinem Fall kontaktierst.", + "contactStaff": "Du kannst das Team hier kontaktieren." + }, + "contributeSuggest": { + "title": "Kann ich beitragen/Änderungen am Server vorschlagen?", + "answer": "Ja! Wir sind immer offen für Vorschläge.", + "instructions": "Wenn du Vorschläge hast, sende sie bitte über unsere GitHub-Seite ein.

Langfristige Contributors haben außerdem die Chance, einen permanenten Supporter-Tag zu erhalten." + }, + "multiplayerDownload": { + "title": "Ich kann im Multiplayer keine Maps herunterladen, aber im Hauptmenü klappt es", + "solution": "Deaktiviere in den Optionen Automatically start osu!direct downloads und versuche es erneut." + } + } + }, + "rules": { + "meta": { + "title": "Regeln | {appName}" + }, + "header": "Regeln", + "sections": { + "generalRules": { + "title": "Allgemeine Regeln", + "noCheating": { + "title": "Kein Cheating oder Hacking.", + "description": "Jede Form von Cheating, einschließlich Aimbots, Relax-Hacks, Makros oder modifizierte Clients, die einen unfairen Vorteil verschaffen, ist strengstens verboten. Spiel fair, verbessere dich fair.", + "warning": "Wie du siehst, habe ich das in größerer Schrift für all euch „wannabe“-Cheater geschrieben, die glauben, sie könnten nach einem Bann von einem anderen Private-Server hierher wechseln. Wenn du cheatest, wirst du gefunden und exekutiert (in Minecraft). Also bitte: lass es." + }, + "noMultiAccount": { + "title": "Kein Multi-Accounting oder Account-Sharing.", + "description": "Pro Spieler ist nur ein Konto erlaubt. Wenn dein Hauptkonto ohne Erklärung eingeschränkt wurde, kontaktiere bitte den Support." + }, + "noImpersonation": { + "title": "Kein Vortäuschen bekannter Spieler oder Staff", + "description": "Gib dich nicht als Teammitglied oder als bekannter Spieler aus. Andere in die Irre zu führen kann zu einer Namensänderung oder einem permanenten Bann führen." + } + }, + "chatCommunityRules": { + "title": "Chat- & Community-Regeln", + "beRespectful": { + "title": "Sei respektvoll.", + "description": "Behandle andere freundlich. Belästigung, Hassrede, Diskriminierung oder toxisches Verhalten werden nicht toleriert." + }, + "noNSFW": { + "title": "Kein NSFW oder unangemessener Content", + "description": "Halte den Server für alle Zielgruppen geeignet — das gilt für alle Nutzerinhalte, einschließlich (aber nicht beschränkt auf) Benutzernamen, Banner, Avatare und Profilbeschreibungen." + }, + "noAdvertising": { + "title": "Werbung ist verboten.", + "description": "Bewirb keine anderen Server, Websites oder Produkte ohne Admin-Genehmigung." + } + }, + "disclaimer": { + "title": "Wichtiger Hinweis", + "intro": "Durch das Erstellen und/oder das Unterhalten eines Kontos auf unserer Plattform bestätigst du, die folgenden Bedingungen zur Kenntnis genommen zu haben und ihnen zuzustimmen:", + "noLiability": { + "title": "Keine Haftung.", + "description": "Du übernimmst die volle Verantwortung für deine Teilnahme an allen von Sunrise angebotenen Diensten und erkennst an, dass du die Organisation nicht für Konsequenzen verantwortlich machen kannst, die aus deiner Nutzung entstehen könnten." + }, + "accountRestrictions": { + "title": "Konto-Einschränkungen.", + "description": "Die Administration behält sich das Recht vor, jedes Konto mit oder ohne vorherige Ankündigung einzuschränken oder zu sperren — bei Verstößen gegen die Serverregeln oder nach eigenem Ermessen." + }, + "ruleChanges": { + "title": "Regeländerungen.", + "description": "Die Serverregeln können sich jederzeit ändern. Die Administration kann Regeln mit oder ohne vorherige Ankündigung aktualisieren, anpassen oder entfernen; von allen Spielern wird erwartet, sich über Änderungen zu informieren." + }, + "agreementByParticipation": { + "title": "Zustimmung durch Teilnahme.", + "description": "Durch das Erstellen und/oder das Unterhalten eines Kontos auf dem Server stimmst du diesen Bedingungen automatisch zu und verpflichtest dich, die zum jeweiligen Zeitpunkt gültigen Regeln und Richtlinien einzuhalten." + } + } + } + }, + "register": { + "meta": { + "title": "Registrieren | {appName}" + }, + "header": "Registrieren", + "welcome": { + "title": "Willkommen auf der Registrierungsseite!", + "description": "Hallo! Bitte gib deine Daten ein, um ein Konto zu erstellen. Wenn du dir nicht sicher bist, wie du dich mit dem Server verbindest, oder wenn du weitere Fragen hast, besuche bitte unsere Wiki-Seite." + }, + "form": { + "title": "Gib deine Daten ein", + "labels": { + "username": "Benutzername", + "email": "E-Mail", + "password": "Passwort", + "confirmPassword": "Passwort bestätigen" + }, + "placeholders": { + "username": "z.B. benutzername", + "email": "z.B. benutzername@mail.com", + "password": "************" + }, + "validation": { + "usernameMin": "Der Benutzername muss mindestens {min} Zeichen lang sein.", + "usernameMax": "Der Benutzername darf höchstens {max} Zeichen lang sein.", + "passwordMin": "Das Passwort muss mindestens {min} Zeichen lang sein.", + "passwordMax": "Das Passwort darf höchstens {max} Zeichen lang sein.", + "passwordsDoNotMatch": "Passwörter stimmen nicht überein" + }, + "error": { + "title": "Fehler", + "unknown": "Unbekannter Fehler." + }, + "submit": "Registrieren", + "terms": "Mit der Registrierung stimmst du den Server-regeln zu" + }, + "success": { + "dialog": { + "title": "Alles bereit!", + "description": "Dein Konto wurde erfolgreich erstellt.", + "message": "Du kannst dich jetzt mit dem Server verbinden, indem du der Anleitung auf unserer Wiki-Seite folgst, oder dein Profil anpassen, indem du Avatar und Banner aktualisierst, bevor du loslegst!", + "buttons": { + "viewWiki": "Wiki-Anleitung ansehen", + "goToProfile": "Zum Profil" + } + }, + "toast": "Konto erfolgreich erstellt!" + } + }, + "support": { + "meta": { + "title": "Unterstütze uns | {appName}" + }, + "header": "Unterstütze uns", + "section": { + "title": "Wie du uns helfen kannst", + "intro": "Auch wenn alle osu!sunrise-Features schon immer kostenlos waren, benötigen Betrieb und Weiterentwicklung des Servers Ressourcen, Zeit und Aufwand — und er wird größtenteils von einem einzigen Entwickler betreut.



Wenn du osu!sunrise liebst und sehen möchtest, wie es weiter wächst, hier sind ein paar Möglichkeiten, wie du uns unterstützen kannst:", + "donate": { + "title": "Spenden.", + "description": "Deine großzügigen Spenden helfen uns, die osu!-Server zu betreiben und zu verbessern. Jeder Beitrag zählt! Mit deiner Unterstützung können wir Hostingkosten decken, neue Features umsetzen und ein reibungsloseres Erlebnis für alle schaffen.", + "buttons": { + "kofi": "Ko-fi", + "boosty": "Boosty" + } + }, + "spreadTheWord": { + "title": "Erzähl es weiter.", + "description": "Je mehr Leute von osu!sunrise wissen, desto lebendiger und spannender wird unsere Community. Erzähl es deinen Freunden, teile es in Social Media und lade neue Spieler ein." + }, + "justPlay": { + "title": "Spiel einfach auf dem Server.", + "description": "Eine der einfachsten Möglichkeiten, osu!sunrise zu unterstützen, ist einfach auf dem Server zu spielen! Je mehr Spieler wir haben, desto besser werden Community und Erlebnis. Indem du mitmachst, hilfst du dem Server zu wachsen und aktiv zu bleiben." + } + } + }, + "topplays": { + "meta": { + "title": "Top-Plays | {appName}" + }, + "header": "Top-Plays", + "showMore": "Mehr anzeigen", + "components": { + "userScoreMinimal": { + "pp": "pp", + "accuracy": "acc:" + } + } + }, + "score": { + "meta": { + "title": "{username} auf {beatmapTitle} [{beatmapVersion}] | {appName}", + "description": "Benutzer {username} hat {pp}pp auf {beatmapTitle} [{beatmapVersion}] in {appName} erzielt.", + "openGraph": { + "title": "{username} auf {beatmapTitle} - {beatmapArtist} [{beatmapVersion}] | {appName}", + "description": "Benutzer {username} hat {pp}pp auf {beatmapTitle} - {beatmapArtist} [{beatmapVersion}] ★{starRating} {mods} in {appName} erzielt." + } + }, + "header": "Score-Performance", + "beatmap": { + "versionUnknown": "Unbekannt", + "mappedBy": "gemappt von", + "creatorUnknown": "Unbekannter Ersteller" + }, + "score": { + "submittedOn": "Eingereicht am", + "playedBy": "Gespielt von", + "userUnknown": "Unbekannter Benutzer" + }, + "actions": { + "downloadReplay": "Replay herunterladen", + "openMenu": "Menü öffnen" + }, + "error": { + "notFound": "Score nicht gefunden", + "description": "Der Score, den du suchst, existiert nicht oder wurde gelöscht." + } + }, + "leaderboard": { + "meta": { + "title": "Ranglisten | {appName}" + }, + "header": "Ranglisten", + "sortBy": { + "label": "Sortieren nach:", + "performancePoints": "Performance-Punkte", + "rankedScore": "Ranked Score", + "performancePointsShort": "Perf.-Punkte", + "scoreShort": "Score" + }, + "table": { + "columns": { + "rank": "Rang", + "performance": "Performance", + "rankedScore": "Ranked Score", + "accuracy": "Genauigkeit", + "playCount": "Spielanzahl" + }, + "actions": { + "openMenu": "Menü öffnen", + "viewUserProfile": "Benutzerprofil ansehen" + }, + "emptyState": "Keine Ergebnisse.", + "pagination": { + "usersPerPage": "Benutzer pro Seite", + "showing": "Zeige {start} - {end} von {total}" + } + } + }, + "friends": { + "meta": { + "title": "Deine Freunde | {appName}" + }, + "header": "Deine Verbindungen", + "tabs": { + "friends": "Freunde", + "followers": "Follower" + }, + "sorting": { + "label": "Sortieren nach:", + "username": "Benutzername", + "recentlyActive": "Kürzlich aktiv" + }, + "showMore": "Mehr anzeigen", + "emptyState": "Keine Benutzer gefunden" + }, + "beatmaps": { + "search": { + "meta": { + "title": "Beatmaps-Suche | {appName}" + }, + "header": "Beatmaps-Suche" + }, + "detail": { + "meta": { + "title": "Beatmap-Info | {appName}" + }, + "header": "Beatmap-Info", + "notFound": { + "title": "Beatmapset nicht gefunden", + "description": "Das Beatmapset, das du suchst, existiert nicht oder wurde gelöscht." + } + }, + "components": { + "search": { + "searchPlaceholder": "Beatmaps suchen...", + "filters": "Filter", + "viewMode": { + "grid": "Raster", + "list": "Liste" + }, + "showMore": "Mehr anzeigen" + }, + "filters": { + "mode": { + "label": "Modus", + "any": "Beliebig", + "standard": "osu!", + "taiko": "osu!taiko", + "catch": "osu!catch", + "mania": "osu!mania" + }, + "status": { + "label": "Status" + }, + "searchByCustomStatus": { + "label": "Nach benutzerdefiniertem Status suchen" + }, + "applyFilters": "Filter anwenden" + } + } + }, + "beatmapsets": { + "meta": { + "title": "{artist} - {title} | {appName}", + "description": "Beatmapset-Info für {title} von {artist}", + "openGraph": { + "title": "{artist} - {title} | {appName}", + "description": "Beatmapset-Info für {title} von {artist} {difficultyInfo}" + } + }, + "header": "Beatmap-Info", + "error": { + "notFound": { + "title": "Beatmapset nicht gefunden", + "description": "Das Beatmapset, das du suchst, existiert nicht oder wurde gelöscht." + } + }, + "submission": { + "submittedBy": "eingereicht von", + "submittedOn": "eingereicht am", + "rankedOn": "gerankt am", + "statusBy": "{status} von" + }, + "video": { + "tooltip": "Diese Beatmap enthält ein Video" + }, + "description": { + "header": "Beschreibung" + }, + "components": { + "dropdown": { + "openMenu": "Menü öffnen", + "ppCalculator": "PP-Rechner", + "openOnBancho": "Auf Bancho öffnen", + "openWithAdminPanel": "Mit Admin-Panel öffnen" + }, + "infoAccordion": { + "communityHype": "Community-Hype", + "information": "Informationen", + "metadata": { + "genre": "Genre", + "language": "Sprache", + "tags": "Tags" + } + }, + "downloadButtons": { + "download": "Herunterladen", + "withVideo": "mit Video", + "withoutVideo": "ohne Video", + "osuDirect": "osu!direct" + }, + "difficultyInformation": { + "tooltips": { + "totalLength": "Gesamtlänge", + "bpm": "BPM", + "starRating": "Sternebewertung" + }, + "labels": { + "keyCount": "Tastenanzahl:", + "circleSize": "Kreisgröße:", + "hpDrain": "HP-Abzug:", + "accuracy": "Genauigkeit:", + "approachRate": "Approach Rate:" + } + }, + "nomination": { + "description": "Hype diese Map, wenn du Spaß beim Spielen hattest, um ihr zu helfen, in Richtung Ranked-Status voranzukommen.", + "hypeProgress": "Hype-Fortschritt", + "hypeBeatmap": "Beatmap hypen!", + "hypesRemaining": "Du hast diese Woche noch {count} Hypes übrig", + "toast": { + "success": "Beatmap erfolgreich gehyped!", + "error": "Fehler beim Hypen des Beatmapsets!" + } + }, + "ppCalculator": { + "title": "PP-Rechner", + "pp": "PP: {value}", + "totalLength": "Gesamtlänge", + "form": { + "accuracy": { + "label": "Genauigkeit", + "validation": { + "negative": "Genauigkeit darf nicht negativ sein", + "tooHigh": "Genauigkeit darf nicht größer als 100 sein" + } + }, + "combo": { + "label": "Combo", + "validation": { + "negative": "Combo darf nicht negativ sein" + } + }, + "misses": { + "label": "Fehler", + "validation": { + "negative": "Fehler dürfen nicht negativ sein" + } + }, + "calculate": "Berechnen", + "unknownError": "Unbekannter Fehler" + } + }, + "leaderboard": { + "columns": { + "rank": "Rang", + "score": "Score", + "accuracy": "Genauigkeit", + "player": "Spieler", + "maxCombo": "Max. Combo", + "perfect": "Perfekt", + "great": "Großartig", + "good": "Gut", + "ok": "Ok", + "lDrp": "L DRP", + "meh": "Meh", + "sDrp": "S DRP", + "miss": "Miss", + "pp": "PP", + "time": "Zeit", + "mods": "Mods" + }, + "actions": { + "openMenu": "Menü öffnen", + "viewDetails": "Details anzeigen", + "downloadReplay": "Replay herunterladen" + }, + "table": { + "emptyState": "Keine Scores gefunden. Sei der Erste, der einen einreicht!", + "pagination": { + "scoresPerPage": "Scores pro Seite", + "showing": "Zeige {start} - {end} von {total}" + } + } + } + } + }, + "settings": { + "meta": { + "title": "Einstellungen | {appName}" + }, + "header": "Einstellungen", + "notLoggedIn": "Du musst angemeldet sein, um diese Seite zu sehen.", + "sections": { + "changeAvatar": "Avatar ändern", + "changeBanner": "Banner ändern", + "changeDescription": "Beschreibung ändern", + "socials": "Socials", + "playstyle": "Spielstil", + "options": "Optionen", + "changePassword": "Passwort ändern", + "changeUsername": "Benutzername ändern", + "changeCountryFlag": "Länderflagge ändern" + }, + "description": { + "reminder": "* Hinweis: Poste keine unangemessenen Inhalte. Versuch, es familienfreundlich zu halten :)" + }, + "components": { + "username": { + "label": "Neuer Benutzername", + "placeholder": "z.B. benutzername", + "button": "Benutzername ändern", + "validation": { + "minLength": "Der Benutzername muss mindestens {min} Zeichen lang sein.", + "maxLength": "Der Benutzername darf höchstens {max} Zeichen lang sein." + }, + "toast": { + "success": "Benutzername erfolgreich geändert!", + "error": "Fehler beim Ändern des Benutzernamens!" + }, + "reminder": "* Hinweis: Bitte halte deinen Benutzernamen familienfreundlich, sonst wird er für dich geändert. Missbrauch dieser Funktion führt zu einem Bann." + }, + "password": { + "labels": { + "current": "Aktuelles Passwort", + "new": "Neues Passwort", + "confirm": "Passwort bestätigen" + }, + "placeholder": "************", + "button": "Passwort ändern", + "validation": { + "minLength": "Das Passwort muss mindestens {min} Zeichen lang sein.", + "maxLength": "Das Passwort darf höchstens {max} Zeichen lang sein.", + "mismatch": "Passwörter stimmen nicht überein" + }, + "toast": { + "success": "Passwort erfolgreich geändert!", + "error": "Fehler beim Ändern des Passworts!" + } + }, + "description": { + "toast": { + "success": "Beschreibung erfolgreich aktualisiert!", + "error": "Ein unbekannter Fehler ist aufgetreten" + } + }, + "country": { + "label": "Neue Länderflagge", + "placeholder": "Neue Länderflagge auswählen", + "button": "Länderflagge ändern", + "toast": { + "success": "Länderflagge erfolgreich geändert!", + "error": "Fehler beim Ändern der Länderflagge!" + } + }, + "socials": { + "headings": { + "general": "Allgemein", + "socials": "Socials" + }, + "fields": { + "location": "ort", + "interest": "interessen", + "occupation": "beruf" + }, + "button": "Socials aktualisieren", + "toast": { + "success": "Socials erfolgreich aktualisiert!", + "error": "Fehler beim Aktualisieren der Socials!" + } + }, + "playstyle": { + "options": { + "Mouse": "Maus", + "Keyboard": "Tastatur", + "Tablet": "Tablet", + "TouchScreen": "Touchscreen" + }, + "toast": { + "success": "Spielstil erfolgreich aktualisiert!", + "error": "Fehler beim Aktualisieren des Spielstils!" + } + }, + "uploadImage": { + "types": { + "avatar": "Avatar", + "banner": "Banner" + }, + "button": "{type} hochladen", + "toast": { + "success": "{type} erfolgreich aktualisiert!", + "error": "Ein unbekannter Fehler ist aufgetreten" + }, + "note": "* Hinweis: {type}s sind auf 5MB begrenzt" + }, + "siteOptions": { + "includeBanchoButton": "Schaltfläche „Open on Bancho“ auf der Beatmap-Seite anzeigen", + "useSpaciousUI": "Großzügige UI verwenden (Abstände zwischen Elementen erhöhen)" + } + }, + "common": { + "unknownError": "Unbekannter Fehler." + } + }, + "user": { + "meta": { + "title": "{username} · Benutzerprofil | {appName}", + "description": "Wir wissen nicht viel über sie, aber wir sind sicher, dass {username} großartig ist." + }, + "header": "Spielerinfo", + "tabs": { + "general": "Allgemein", + "bestScores": "Beste Scores", + "recentScores": "Letzte Scores", + "firstPlaces": "Erste Plätze", + "beatmaps": "Beatmaps", + "medals": "Medaillen" + }, + "buttons": { + "editProfile": "Profil bearbeiten", + "setDefaultGamemode": "Setze {gamemode} {flag} als Standard-Spielmodus im Profil" + }, + "errors": { + "userNotFound": "Benutzer nicht gefunden oder ein Fehler ist aufgetreten.", + "restricted": "Das bedeutet, dass der Benutzer gegen die Serverregeln verstoßen hat und eingeschränkt wurde.", + "userDeleted": "Der Benutzer wurde möglicherweise gelöscht oder existiert nicht." + }, + "components": { + "generalTab": { + "info": "Info", + "rankedScore": "Ranked Score", + "hitAccuracy": "Treffgenauigkeit", + "playcount": "Spielanzahl", + "totalScore": "Gesamtscore", + "maximumCombo": "Maximale Combo", + "playtime": "Spielzeit", + "performance": "Performance", + "showByRank": "Nach Rang anzeigen", + "showByPp": "Nach PP anzeigen", + "aboutMe": "Über mich" + }, + "scoresTab": { + "bestScores": "Beste Scores", + "recentScores": "Letzte Scores", + "firstPlaces": "Erste Plätze", + "noScores": "Der Benutzer hat keine {type}-Scores", + "showMore": "Mehr anzeigen" + }, + "beatmapsTab": { + "mostPlayed": "Meistgespielt", + "noMostPlayed": "Der Benutzer hat keine meistgespielten Beatmaps", + "favouriteBeatmaps": "Lieblings-Beatmaps", + "noFavourites": "Der Benutzer hat keine Lieblings-Beatmaps", + "showMore": "Mehr anzeigen" + }, + "medalsTab": { + "medals": "Medaillen", + "latest": "Neueste", + "categories": { + "hushHush": "Geheim", + "beatmapHunt": "Beatmap-Jagd", + "modIntroduction": "Mod-Einführung", + "skill": "Fähigkeit" + }, + "achievedOn": "erhalten am", + "notAchieved": "Nicht erhalten" + }, + "generalInformation": { + "joined": "Beigetreten {time}", + "followers": "{count} Follower", + "following": "{count} Folgt", + "playsWith": "Spielt mit {playstyle}" + }, + "statusText": { + "lastSeenOn": ", zuletzt gesehen am {date}" + }, + "ranks": { + "highestRank": "Höchster Rang {rank} am {date}" + }, + "previousUsernames": { + "previouslyKnownAs": "Dieser Benutzer war zuvor bekannt als:" + }, + "beatmapSetOverview": { + "by": "von {artist}", + "mappedBy": "gemappt von {creator}" + }, + "privilegeBadges": { + "badges": { + "Developer": "Entwickler", + "Admin": "Admin", + "Bat": "BAT", + "Bot": "Bot", + "Supporter": "Supporter" + } + }, + "scoreOverview": { + "pp": "pp", + "accuracy": "acc: {accuracy}%" + }, + "statsChart": { + "date": "Datum", + "types": { + "pp": "pp", + "rank": "rang" + }, + "tooltip": "{value} {type}" + } + } + } + } +} diff --git a/lib/i18n/messages/en-GB.json b/lib/i18n/messages/en-GB.json index b8c2ea8..8c07666 100644 --- a/lib/i18n/messages/en-GB.json +++ b/lib/i18n/messages/en-GB.json @@ -1,1763 +1,677 @@ { "components": { "serverMaintenanceDialog": { - "title": { - "text": "hey! OwO stop wight thewe!", - "context": "Title of the server maintenance dialog" - }, - "discordMessage": { - "text": "fow mowe infowmation view ouww discowd sewvew.", - "context": "Message directing users to Discord for more information about server maintenance" - }, - "button": { - "text": "Okay, I understand", - "context": "Button text to acknowledge the maintenance message" - }, - "message": { - "text": "the sewvew is cuwwwentwy in maintenance mode, so some featuwwes of the website may not fuwnction cowwectwy.", - "context": "This message is shown to users when the server is under maintenance." - } + "title": "hey! OwO stop wight thewe!", + "discordMessage": "fow mowe infowmation view ouww discowd sewvew.", + "button": "Okay, I understand", + "message": "the sewvew is cuwwwentwy in maintenance mode, so some featuwwes of the website may not fuwnction cowwectwy." }, "contentNotExist": { - "defaultText": { - "text": "Content not found", - "context": "Default message when content does not exist" - } + "defaultText": "Content not found" }, "workInProgress": { - "title": { - "text": "wowk in pwogwess", - "context": "Title for work in progress message" - }, - "description": { - "text": "this content is stiww being wowked on. Pwease check back watew.", - "context": "Description message for work in progress content" - } + "title": "wowk in pwogwess", + "description": "this content is stiww being wowked on. Pwease check back watew." }, "beatmapSetCard": { - "submittedBy": { - "text": "suwbmitted by", - "context": "Labew showing who suwbmitted the beatmap" - }, - "submittedOn": { - "text": "suwbmitted on", - "context": "Labew showing when the beatmap was suwbmitted" - }, - "view": { - "text": "view", - "context": "Button text to view the beatmap" - } + "submittedBy": "suwbmitted by", + "submittedOn": "suwbmitted on", + "view": "view" }, "friendshipButton": { - "unfriend": { - "text": "Unfwiend", - "context": "Button text to unfriend a user (mutual friendship)" - }, - "unfollow": { - "text": "Unfowwow", - "context": "Button text to unfollow a user" - }, - "follow": { - "text": "Fowwow", - "context": "Button text to follow a user" - } + "unfriend": "Unfwiend", + "unfollow": "Unfowwow", + "follow": "Fowwow" }, "gameModeSelector": { - "selectedMode": { - "text": "sewected mode:", - "context": "Label for selected game mode in mobile combobox view" - } + "selectedMode": "sewected mode:" }, "header": { "links": { - "leaderboard": { - "text": "weadewboawd", - "context": "Header navigation link text for leaderboard" - }, - "topPlays": { - "text": "top pways", - "context": "Header navigation link text for top plays" - }, - "beatmaps": { - "text": "beatmaps", - "context": "Header navigation link text for beatmaps" - }, - "help": { - "text": "hewp", - "context": "Header navigation link text for help dropdown" - }, - "wiki": { - "text": "wiki", - "context": "Help dropdown menu item for wiki" - }, - "rules": { - "text": "rules", - "context": "Help dropdown menu item for rules" - }, - "apiDocs": { - "text": "api docs", - "context": "Help dropdown menu item for API documentation" - }, - "discordServer": { - "text": "discowd sewvew", - "context": "Help dropdown menu item for Discord server" - }, - "supportUs": { - "text": "support us", - "context": "Help dropdown menu item for support page" - } + "leaderboard": "weadewboawd", + "topPlays": "top pways", + "beatmaps": "beatmaps", + "help": "hewp", + "wiki": "wiki", + "rules": "rules", + "apiDocs": "api docs", + "discordServer": "discowd sewvew", + "supportUs": "support us" } }, "headerLoginDialog": { - "signIn": { - "text": "sign in", - "context": "Button text to open sign in dialog" - }, - "title": { - "text": "sign in to pwoceed", - "context": "Title of the login dialog" - }, - "description": { - "text": "wewcome back.", - "context": "Description text in the login dialog" - }, + "signIn": "sign in", + "title": "sign in to pwoceed", + "description": "wewcome back.", "username": { - "label": { - "text": "Usewname", - "context": "Form label for username input" - }, - "placeholder": { - "text": "e.g. uwsewname", - "context": "Placeholder text for username input" - } + "label": "Usewname", + "placeholder": "e.g. uwsewname" }, "password": { - "label": { - "text": "passwowd", - "context": "Form label for password input" - }, - "placeholder": { - "text": "************", - "context": "Placeholder text for password input (masked)" - } - }, - "login": { - "text": "login", - "context": "Button text to submit login form" - }, - "signUp": { - "text": "don't have an accouwnt? UwU sign uwp", - "context": "Link text to navigate to registration page" + "label": "passwowd", + "placeholder": "************" }, + "login": "login", + "signUp": "don't have an accouwnt? UwU sign uwp", "toast": { - "success": { - "text": "Youw suwccesfuwwwy wogged in!", - "context": "Success toast message after successful login" - } + "success": "Youw suwccesfuwwwy wogged in!" }, "validation": { - "usernameMinLength": { - "text": "Username must be at least 2 characters.", - "context": "Validation error message for username minimum length" - }, - "usernameMaxLength": { - "text": "Username must be 32 characters or fewer.", - "context": "Validation error message for username maximum length" - }, - "passwordMinLength": { - "text": "passwowd muwst be at weast 8 chawactews.", - "context": "Validation error message for password minimum length" - }, - "passwordMaxLength": { - "text": "passwowd muwst be 32 chawactews ow fewew.", - "context": "Validation error message for password maximum length" - } + "usernameMinLength": "Username must be at least 2 characters.", + "usernameMaxLength": "Username must be 32 characters or fewer.", + "passwordMinLength": "passwowd muwst be at weast 8 chawactews.", + "passwordMaxLength": "passwowd muwst be 32 chawactews ow fewew." } }, "headerLogoutAlert": { - "title": { - "text": "Are you sure?", - "context": "Title of the logout confirmation dialog" - }, - "description": { - "text": "You will need to log in again to access your account.", - "context": "Description text explaining the consequences of logging out" - }, - "cancel": { - "text": "Cancel", - "context": "Button text to cancel logout" - }, - "continue": { - "text": "Continue", - "context": "Button text to confirm logout" - }, + "title": "Are you sure?", + "description": "You will need to log in again to access your account.", + "cancel": "Cancel", + "continue": "Continue", "toast": { - "success": { - "text": "You have been successfully logged out.", - "context": "Success toast message after successful logout" - } + "success": "You have been successfully logged out." } }, "headerSearchCommand": { - "placeholder": { - "text": "type to seawch...", - "context": "Placeholder text for the search command input" - }, + "placeholder": "type to seawch...", "headings": { - "users": { - "text": "Users", - "context": "Heading for users section in search results" - }, - "beatmapsets": { - "text": "beatmapsets", - "context": "Heading for beatmapsets section in search results" - }, - "pages": { - "text": "pages", - "context": "Heading for pages section in search results" - } + "users": "Users", + "beatmapsets": "beatmapsets", + "pages": "pages" }, "pages": { - "leaderboard": { - "text": "leadewboawd", - "context": "Search result item for leaderboard page" - }, - "topPlays": { - "text": "top pways", - "context": "Search result item for top plays page" - }, - "beatmapsSearch": { - "text": "beatmaps seawch", - "context": "Search result item for beatmaps search page" - }, - "wiki": { - "text": "wiki", - "context": "Search result item for wiki page" - }, - "rules": { - "text": "Rules", - "context": "Search result item for rules page" - }, - "yourProfile": { - "text": "Your profile", - "context": "Search result item for user's own profile" - }, - "friends": { - "text": "fwiends", - "context": "Search result item for friends page" - }, - "settings": { - "text": "settings", - "context": "Search result item for settings page" - } + "leaderboard": "leadewboawd", + "topPlays": "top pways", + "beatmapsSearch": "beatmaps seawch", + "wiki": "wiki", + "rules": "Rules", + "yourProfile": "Your profile", + "friends": "fwiends", + "settings": "settings" } }, "headerUserDropdown": { - "myProfile": { - "text": "my pwofiwe", - "context": "Dropdown menu item for user's profile" - }, - "friends": { - "text": "fwiends", - "context": "Dropdown menu item for friends page" - }, - "settings": { - "text": "settings", - "context": "Dropdown menu item for settings page" - }, - "returnToMainSite": { - "text": "Return to main site", - "context": "Dropdown menu item to return from admin panel to main site" - }, - "adminPanel": { - "text": "admin panew", - "context": "Dropdown menu item to access admin panel" - }, - "logOut": { - "text": "Log out", - "context": "Dropdown menu item to log out" - } + "myProfile": "my pwofiwe", + "friends": "fwiends", + "settings": "settings", + "returnToMainSite": "Return to main site", + "adminPanel": "admin panew", + "logOut": "Log out" }, "headerMobileDrawer": { "navigation": { - "home": { - "text": "home", - "context": "Mobile drawer navigation item for home page" - }, - "leaderboard": { - "text": "leadewboawd", - "context": "Mobile drawer navigation item for leaderboard" - }, - "topPlays": { - "text": "top pways", - "context": "Mobile drawer navigation item for top plays" - }, - "beatmapsSearch": { - "text": "beatmaps seawch", - "context": "Mobile drawer navigation item for beatmaps search" - }, - "wiki": { - "text": "wiki", - "context": "Mobile drawer navigation item for wiki" - }, - "rules": { - "text": "Rules", - "context": "Mobile drawer navigation item for rules" - }, - "apiDocs": { - "text": "api docs", - "context": "Mobile drawer navigation item for API documentation" - }, - "discordServer": { - "text": "discowd sewvew", - "context": "Mobile drawer navigation item for Discord server" - }, - "supportUs": { - "text": "Support Us", - "context": "Mobile drawer navigation item for support page" - } - }, - "yourProfile": { - "text": "Youww pwofiwe", - "context": "Mobile drawer menu item for user's profile" - }, - "friends": { - "text": "fwiends", - "context": "Mobile drawer menu item for friends" - }, - "settings": { - "text": "settings", - "context": "Mobiwe dwawer menu item fow settings" - }, - "adminPanel": { - "text": "admin panew", - "context": "Mobile drawer menu item for admin panel" - }, - "logOut": { - "text": "Log ouwt", - "context": "Mobile drawer menu item to log out" - } + "home": "home", + "leaderboard": "leadewboawd", + "topPlays": "top pways", + "beatmapsSearch": "beatmaps seawch", + "wiki": "wiki", + "rules": "Rules", + "apiDocs": "api docs", + "discordServer": "discowd sewvew", + "supportUs": "Support Us" + }, + "yourProfile": "Youww pwofiwe", + "friends": "fwiends", + "settings": "settings", + "adminPanel": "admin panew", + "logOut": "Log ouwt" }, "footer": { - "voteMessage": { - "text": "pwease vote fow uws on osuw-sewvew-wist!", - "context": "Message encouraging users to vote on osu-server-list" - }, - "copyright": { - "text": "© 2024-2025 suwnwise commuwnity", - "context": "Copyright text in footer" - }, - "sourceCode": { - "text": "Souwwce code", - "context": "Link text for source code repository" - }, - "serverStatus": { - "text": "Sewvew Statuws", - "context": "Link text for server status page" - }, - "disclaimer": { - "text": "we awe not affiwiated with \"ppy\" and \"osuw!\" in any way. Aww wights wesewved to theiw wespective ownews.", - "context": "Disclaimer text about not being affiliated with ppy/osu!" - } + "voteMessage": "pwease vote fow uws on osuw-sewvew-wist!", + "copyright": "© 2024-2025 suwnwise commuwnity", + "sourceCode": "Souwwce code", + "serverStatus": "Sewvew Statuws", + "disclaimer": "we awe not affiwiated with \"ppy\" and \"osuw!\" in any way. Aww wights wesewved to theiw wespective ownews." }, "comboBox": { - "selectValue": { - "text": "Sewwect vawue...", - "context": "Placeholder text when no value is selected in combobox" - }, - "searchValue": { - "text": "Seawch vawue...", - "context": "Placeholder text for search input in combobox" - }, - "noValuesFound": { - "text": "No vawues found.", - "context": "Message shown when no search results are found in combobox" - } + "selectValue": "Sewwect vawue...", + "searchValue": "Seawch vawue...", + "noValuesFound": "No vawues found." }, "beatmapsetRowElement": { - "mappedBy": { - "text": "mapped by {cweatow}", - "context": "Text showing who mapped the beatmap, includes creator name as parameter" - } + "mappedBy": "mapped by {cweatow}" }, "themeModeToggle": { - "toggleTheme": { - "text": "toggwe theme", - "context": "Screen reader text for theme toggle button" - }, - "light": { - "text": "light", - "context": "Theme option for light mode" - }, - "dark": { - "text": "dawk", - "context": "Theme option for dark mode" - }, - "system": { - "text": "system", - "context": "Theme option for system default mode" - } + "toggleTheme": "toggwe theme", + "light": "light", + "dark": "dawk", + "system": "system" }, "imageSelect": { - "imageTooBig": { - "text": "sewected image is too big!", - "context": "Error message when selected image exceeds maximum file size" - } + "imageTooBig": "sewected image is too big!" }, "notFound": { "meta": { - "title": { - "text": "not fouwnd | {appName}", - "context": "Page title for 404 not found page" - }, - "description": { - "text": "The page u'we wooking fow isn't hewe. Sowwy.", - "context": "Meta description for 404 not found page" - } - }, - "title": { - "text": "not fouwnd | 404", - "context": "Main heading for 404 not found page" + "title": "not fouwnd | {appName}", + "description": "The page u'we wooking fow isn't hewe. Sowwy." }, - "description": { - "text": "What you're looking for isn't here. Sorry.", - "context": "Description text explaining the page was not found" - } + "title": "not fouwnd | 404", + "description": "What you're looking for isn't here. Sorry." }, "rootLayout": { "meta": { - "title": { - "text": "{appName}", - "context": "Root layout page title" - }, - "description": { - "text": "{appName} is a private server for osu!, a rhythm game.", - "context": "Root layout meta description" - } + "title": "{appName}", + "description": "{appName} is a private server for osu!, a rhythm game." } } }, "pages": { "mainPage": { "meta": { - "title": { - "text": "wewcome | {appName}", - "context": "The title for the main page of the osu!sunrise website" - }, - "description": { - "text": "join osuw!suwnwise, a featuwwe-wich pwivate osuw! OwO sewvew with rewax, auwtopiwot, scowev2 suwppowt, and a cuwstom pp system taiwowed fow rewax and auwtopiwot gamepway.", - "context": "The meta description for the main page of the osu!sunrise website" - } + "title": "wewcome | {appName}", + "description": "join osuw!suwnwise, a featuwwe-wich pwivate osuw! OwO sewvew with rewax, auwtopiwot, scowev2 suwppowt, and a cuwstom pp system taiwowed fow rewax and auwtopiwot gamepway." }, "features": { - "motto": { - "text": "- yet anothew osuw! OwO sewvew", - "context": "The tagline or motto displayed on the main page" - }, - "description": { - "text": "featuwwes wich osuw! OwO sewvew with suwppowt fow rewax, auwtopiwot and scowev2 gamepway, with a cuwstom awt‑state pp cawcuwwation system taiwowed fow rewax and auwtopiwot.", - "context": "The main description text explaining the server's features on the homepage" - }, + "motto": "- yet anothew osuw! OwO sewvew", + "description": "featuwwes wich osuw! OwO sewvew with suwppowt fow rewax, auwtopiwot and scowev2 gamepway, with a cuwstom awt‑state pp cawcuwwation system taiwowed fow rewax and auwtopiwot.", "buttons": { - "register": { - "text": "Join now", - "context": "Button text to register a new account" - }, - "wiki": { - "text": "How to connect", - "context": "Button text linking to the connection guide" - } + "register": "Join now", + "wiki": "How to connect" } }, - "whyUs": { - "text": "why uws?", - "context": "Section heading asking why users should choose this server" - }, + "whyUs": "why uws?", "cards": { "freeFeatures": { - "title": { - "text": "Twuwwy fwee featuwwes", - "context": "Title of the free features card on the main page" - }, - "description": { - "text": "enjoy featuwwes wike osuw!diwect and uwsewname changes withouwt any paywawws — compwetewy fwee fow aww pwayews!", - "context": "Description text explaining the free features available on the server" - } + "title": "Twuwwy fwee featuwwes", + "description": "enjoy featuwwes wike osuw!diwect and uwsewname changes withouwt any paywawws — compwetewy fwee fow aww pwayews!" }, "ppSystem": { - "title": { - "text": "cuwstom pp cawcuwwations", - "context": "Title of the PP system card on the main page" - }, - "description": { - "text": "we uwse the watest pewfowmance point (pp) system fow vaniwwa scowes whiwe appwying a cuwstom, weww-bawanced fowmuwwa fow rewax and auwtopiwot modes.", - "context": "Description text explaining the custom PP calculation system" - } + "title": "cuwstom pp cawcuwwations", + "description": "we uwse the watest pewfowmance point (pp) system fow vaniwwa scowes whiwe appwying a cuwstom, weww-bawanced fowmuwwa fow rewax and auwtopiwot modes." }, "medals": { - "title": { - "text": "eawn cuwstom medaws", - "context": "Title of the medals card on the main page" - }, - "description": { - "text": "eawn uwniquwe, sewvew-excwuwsive medaws as u accompwish vawiouws miwestones and achievements.", - "context": "Description text explaining the custom medals system" - } + "title": "eawn cuwstom medaws", + "description": "eawn uwniquwe, sewvew-excwuwsive medaws as u accompwish vawiouws miwestones and achievements." }, "updates": { - "title": { - "text": "fwequwent updates", - "context": "Title of the updates card on the main page" - }, - "description": { - "text": "we'we awways impwoving! OwO expect weguwwaw uwpdates, new featuwwes, and ongoing pewfowmance optimizations.", - "context": "Description text explaining the server's update frequency" - } + "title": "fwequwent updates", + "description": "we'we awways impwoving! OwO expect weguwwaw uwpdates, new featuwwes, and ongoing pewfowmance optimizations." }, "ppCalc": { - "title": { - "text": "buwiwt-in pp cawcuwwatow", - "context": "Title of the PP calculator card on the main page" - }, - "description": { - "text": "ouww website offews a buwiwt-in pp cawcuwwatow fow quwick and easy pewfowmance point estimates.", - "context": "Description text explaining the built-in PP calculator feature" - } + "title": "buwiwt-in pp cawcuwwatow", + "description": "ouww website offews a buwiwt-in pp cawcuwwatow fow quwick and easy pewfowmance point estimates." }, "sunriseCore": { - "title": { - "text": "cuwstom-buwiwt bancho cowe", - "context": "Title of the bancho core card on the main page" - }, - "description": { - "text": "unwike most pwivate osuw! OwO sewvews, we've devewoped ouww own cuwstom bancho cowe fow bettew stabiwity and uwniquwe featuwwe suwppowt.", - "context": "Description text explaining the custom bancho core development" - } + "title": "cuwstom-buwiwt bancho cowe", + "description": "unwike most pwivate osuw! OwO sewvews, we've devewoped ouww own cuwstom bancho cowe fow bettew stabiwity and uwniquwe featuwwe suwppowt." } }, "howToStart": { - "title": { - "text": "how do i stawt pwaying?", - "context": "Section heading for the getting started guide" - }, - "description": { - "text": "juwst thwee simpwe steps and u'we weady to go!", - "context": "Description text introducing the getting started steps" - }, + "title": "how do i stawt pwaying?", + "description": "juwst thwee simpwe steps and u'we weady to go!", "downloadTile": { - "title": { - "text": "downwoad osuw! OwO cwinet", - "context": "Title of the download step tile" - }, - "description": { - "text": "if u do not awweady have an instawwed cwient", - "context": "Description text for the download step" - }, - "button": { - "text": "downwoad", - "context": "Button text to download the osu! client" - } + "title": "downwoad osuw! OwO cwinet", + "description": "if u do not awweady have an instawwed cwient", + "button": "downwoad" }, "registerTile": { - "title": { - "text": "registew osuw!suwnwise accouwnt", - "context": "Title of the registration step tile" - }, - "description": { - "text": "accouwnt wiww awwow u to join the osuw!suwnwise commuwnity", - "context": "Description text for the registration step" - }, - "button": { - "text": "Sign up", - "context": "Button text to register a new account" - } + "title": "registew osuw!suwnwise accouwnt", + "description": "accouwnt wiww awwow u to join the osuw!suwnwise commuwnity", + "button": "Sign up" }, "guideTile": { - "title": { - "text": "Follow the connection guide", - "context": "Title of the connection guide step tile" - }, - "description": { - "text": "Which helps you set up your osu! client to connect to osu!sunrise", - "context": "Description text for the connection guide step" - }, - "button": { - "text": "Open guide", - "context": "Button text to open the connection guide" - } + "title": "Follow the connection guide", + "description": "Which helps you set up your osu! client to connect to osu!sunrise", + "button": "Open guide" } }, "statuses": { - "totalUsers": { - "text": "Totaw Usews", - "context": "Label for the total number of registered users" - }, - "usersOnline": { - "text": "Usews Onwine", - "context": "Label for the number of currently online users" - }, - "usersRestricted": { - "text": "Usews Westwicted", - "context": "Label for the number of restricted users" - }, - "totalScores": { - "text": "Totaw Scowes", - "context": "Label for the total number of scores submitted" - }, - "serverStatus": { - "text": "Sewvew Status", - "context": "Label for the current server status" - }, - "online": { - "text": "Onwine", - "context": "Status indicator when the server is online" - }, - "offline": { - "text": "Offwine", - "context": "Status indicator when the server is offline" - }, - "underMaintenance": { - "text": "Undew Maintenance", - "context": "Status indicator when the server is under maintenance" - } + "totalUsers": "Totaw Usews", + "usersOnline": "Usews Onwine", + "usersRestricted": "Usews Westwicted", + "totalScores": "Totaw Scowes", + "serverStatus": "Sewvew Status", + "online": "Onwine", + "offline": "Offwine", + "underMaintenance": "Undew Maintenance" } }, "wiki": { "meta": { - "title": { - "text": "wiki | {appName}", - "context": "The title for the wiki page of the osu!sunrise website" - } - }, - "header": { - "text": "wiki", - "context": "The main header text for the wiki page" + "title": "wiki | {appName}" }, + "header": "wiki", "articles": { "howToConnect": { - "title": { - "text": "how to connect", - "context": "Title of the wiki article explaining how to connect to the server" - }, - "intro": { - "text": "to connect to the sewvew, u need to have a copy of the game instawwed on uw compuwtew. Youw can downwoad the game fwom the officiaw osuw! OwO website.", - "context": "Introduction text explaining the prerequisites for connecting to the server" - }, - "step1": { - "text": "locate the osu!.exe fiwe in the game diwectowy.", - "context": "First step instruction for connecting to the server" - }, - "step2": { - "text": "cweate a showtcuwt of the fiwe.", - "context": "Second step instruction for connecting to the server" - }, - "step3": { - "text": "right cwick on the showtcuwt and sewect pwopewties.", - "context": "Third step instruction for connecting to the server" - }, - "step4": { - "text": "in the tawget fiewd, add -devserver {serverDomain} at the end of the path.", - "context": "Fourth step instruction for connecting to the server, includes server domain parameter" - }, - "step5": { - "text": "cwick appwy and then ok.", - "context": "Fifth step instruction for connecting to the server" - }, - "step6": { - "text": "douwbwe cwick on the showtcuwt to stawt the game.", - "context": "Sixth step instruction for connecting to the server" - }, - "imageAlt": { - "text": "osuw connect image", - "context": "Alt text for the connection guide image" - } + "title": "how to connect", + "intro": "to connect to the sewvew, u need to have a copy of the game instawwed on uw compuwtew. Youw can downwoad the game fwom the officiaw osuw! OwO website.", + "step1": "locate the osu!.exe fiwe in the game diwectowy.", + "step2": "cweate a showtcuwt of the fiwe.", + "step3": "right cwick on the showtcuwt and sewect pwopewties.", + "step4": "in the tawget fiewd, add -devserver {serverDomain} at the end of the path.", + "step5": "cwick appwy and then ok.", + "step6": "douwbwe cwick on the showtcuwt to stawt the game.", + "imageAlt": "osuw connect image" }, "multipleAccounts": { - "title": { - "text": "can i have muwwtipwe accouwnts?", - "context": "Title of the wiki article about multiple accounts policy" - }, - "answer": { - "text": "no. Youw awe onwy awwowed to have one accouwnt pew pewson.", - "context": "Direct answer to the multiple accounts question" - }, - "consequence": { - "text": "if u awe cauwght with muwwtipwe accouwnts, u wiww be banned fwom the sewvew. >:3", - "context": "Explanation of the consequence for having multiple accounts" - } + "title": "can i have muwwtipwe accouwnts?", + "answer": "no. Youw awe onwy awwowed to have one accouwnt pew pewson.", + "consequence": "if u awe cauwght with muwwtipwe accouwnts, u wiww be banned fwom the sewvew. >:3" }, "cheatsHacks": { - "title": { - "text": "can i uwse cheats ow hacks?", - "context": "Title of the wiki article about cheating policy" - }, - "answer": { - "text": "no. Youw wiww be banned if u awe cauwght.", - "context": "Direct answer to the cheating question" - }, - "policy": { - "text": "we awe vewy stwict on cheating and do not towewate it at aww.

if u suwspect someone of cheating, pwease wepowt them to the staff.", - "context": "Explanation of the cheating policy and how to report cheaters" - } + "title": "can i uwse cheats ow hacks?", + "answer": "no. Youw wiww be banned if u awe cauwght.", + "policy": "we awe vewy stwict on cheating and do not towewate it at aww.

if u suwspect someone of cheating, pwease wepowt them to the staff." }, "appealRestriction": { - "title": { - "text": "i think i was westwicted uwnfaiwwy. How can i appeaw?", - "context": "Title of the wiki article about appealing restrictions" - }, - "instructions": { - "text": "if u bewieve u wewe westwicted uwnfaiwwy, u can appeaw uw westwiction by contacting the staff with uw case.", - "context": "Instructions on how to appeal a restriction" - }, - "contactStaff": { - "text": "youw can contact the staff hewe.", - "context": "Information about where to contact staff for appeals, includes link placeholder" - } + "title": "i think i was westwicted uwnfaiwwy. How can i appeaw?", + "instructions": "if u bewieve u wewe westwicted uwnfaiwwy, u can appeaw uw westwiction by contacting the staff with uw case.", + "contactStaff": "youw can contact the staff hewe." }, "contributeSuggest": { - "title": { - "text": "can i contwibuwte/suwggest changes to the sewvew?", - "context": "Title of the wiki article about contributing to the server" - }, - "answer": { - "text": "yes! OwO we awe awways open to suwggestions.", - "context": "Positive answer about contributing to the server" - }, - "instructions": { - "text": "if u have any suwggestions, pwease suwbmit them at ouww githuwb page.

longtewm contwibuwtows can awso have chance to get pewmanent suwppowtew tag.", - "context": "Instructions on how to contribute, includes GitHub link and information about supporter tags" - } + "title": "can i contwibuwte/suwggest changes to the sewvew?", + "answer": "yes! OwO we awe awways open to suwggestions.", + "instructions": "if u have any suwggestions, pwease suwbmit them at ouww githuwb page.

longtewm contwibuwtows can awso have chance to get pewmanent suwppowtew tag." }, "multiplayerDownload": { - "title": { - "text": "i can't downwoad maps when i'm in muwwtipwayew, buwt i can downwoad them fwom the main menuw", - "context": "Title of the wiki article about downloading maps in multiplayer" - }, - "solution": { - "text": "disabwe Automatically start osu!direct downloads fwom the options and twy again.", - "context": "Solution to the multiplayer download issue" - } + "title": "i can't downwoad maps when i'm in muwwtipwayew, buwt i can downwoad them fwom the main menuw", + "solution": "disabwe Automatically start osu!direct downloads fwom the options and twy again." } } }, "rules": { "meta": { - "title": { - "text": "Ruwwes | {appName}", - "context": "The title for the rules page of the osu!sunrise website" - } - }, - "header": { - "text": "Ruwwes", - "context": "The main header text for the rules page" + "title": "Ruwwes | {appName}" }, + "header": "Ruwwes", "sections": { "generalRules": { - "title": { - "text": "Genewaw wuwwes", - "context": "Title of the general rules section" - }, + "title": "Genewaw wuwwes", "noCheating": { - "title": { - "text": "no cheating ow hacking.", - "context": "Title of the no cheating rule, displayed in bold" - }, - "description": { - "text": "any fowm of cheating, incwuwding aimbots, wewax hacks, macwos, ow modified cwients that give uwnfaiw advantage is stwictwy pwohibited. Pway faiw, impwove faiw.", - "context": "Description of the no cheating rule" - }, - "warning": { - "text": "as u can see, i wwote this in a biggew font fow aww u \"wannabe\" cheatews who think u can migwate fwom anothew pwivate sewvew to hewe aftew being banned. Youw wiww be fouwnd and execuwted (in minecwaft) if u cheat. So pwease, don't.", - "context": "Warning message for potential cheaters" - } + "title": "no cheating ow hacking.", + "description": "any fowm of cheating, incwuwding aimbots, wewax hacks, macwos, ow modified cwients that give uwnfaiw advantage is stwictwy pwohibited. Pway faiw, impwove faiw.", + "warning": "as u can see, i wwote this in a biggew font fow aww u \"wannabe\" cheatews who think u can migwate fwom anothew pwivate sewvew to hewe aftew being banned. Youw wiww be fouwnd and execuwted (in minecwaft) if u cheat. So pwease, don't." }, "noMultiAccount": { - "title": { - "text": "no muwwti-accouwnting ow accouwnt shawing.", - "context": "Title of the no multi-accounting rule, displayed in bold" - }, - "description": { - "text": "onwy one accouwnt pew pwayew is awwowed. If uw pwimawy accouwnt was westwicted withouwt an expwanation, pwease contact suwppowt.", - "context": "Description of the no multi-accounting rule" - } + "title": "no muwwti-accouwnting ow accouwnt shawing.", + "description": "onwy one accouwnt pew pwayew is awwowed. If uw pwimawy accouwnt was westwicted withouwt an expwanation, pwease contact suwppowt." }, "noImpersonation": { - "title": { - "text": "no impewsonating popuwwaw pwayews ow staff", - "context": "Title of the no impersonation rule, displayed in bold" - }, - "description": { - "text": "do not pwetend to be a staff membew ow any weww-known pwayew. Misweading othews can wesuwwt in a uwsewname change ow pewmanent ban.", - "context": "Description of the no impersonation rule" - } + "title": "no impewsonating popuwwaw pwayews ow staff", + "description": "do not pwetend to be a staff membew ow any weww-known pwayew. Misweading othews can wesuwwt in a uwsewname change ow pewmanent ban." } }, "chatCommunityRules": { - "title": { - "text": "chat & commuwnity ruwwes", - "context": "Title of the chat and community rules section" - }, + "title": "chat & commuwnity ruwwes", "beRespectful": { - "title": { - "text": "be respectfuww.", - "context": "Title of the be respectful rule, displayed in bold" - }, - "description": { - "text": "tweat othews with kindness. Hawassment, hate speech, discwimination, ow toxic behaviouww won't be towewated.", - "context": "Description of the be respectful rule" - } + "title": "be respectfuww.", + "description": "tweat othews with kindness. Hawassment, hate speech, discwimination, ow toxic behaviouww won't be towewated." }, "noNSFW": { - "title": { - "text": "no nsfw ow inappwopwiate content", - "context": "Title of the no NSFW content rule, displayed in bold" - }, - "description": { - "text": "keep the sewvew appwopwiate fow aww auwdiences — this appwies to aww uwsew content, incwuwding buwt not wimited to uwsewnames, bannews, avataws and pwofiwe descwiptions.", - "context": "Description of the no NSFW content rule" - } + "title": "no nsfw ow inappwopwiate content", + "description": "keep the sewvew appwopwiate fow aww auwdiences — this appwies to aww uwsew content, incwuwding buwt not wimited to uwsewnames, bannews, avataws and pwofiwe descwiptions." }, "noAdvertising": { - "title": { - "text": "advewtising is fowbidden.", - "context": "Title of the no advertising rule, displayed in bold" - }, - "description": { - "text": "don't pwomote othew sewvews, websites, ow pwoduwcts withouwt admin appwovaw.", - "context": "Description of the no advertising rule" - } + "title": "advewtising is fowbidden.", + "description": "don't pwomote othew sewvews, websites, ow pwoduwcts withouwt admin appwovaw." } }, "disclaimer": { - "title": { - "text": "impowtant discwaimew", - "context": "Title of the important disclaimer section" - }, - "intro": { - "text": "by cweating and/ow maintaining an accouwnt on ouww pwatfowm, u acknowwedge and agwee to the fowwowing tewms:", - "context": "Introduction text for the disclaimer section" - }, + "title": "impowtant discwaimew", + "intro": "by cweating and/ow maintaining an accouwnt on ouww pwatfowm, u acknowwedge and agwee to the fowwowing tewms:", "noLiability": { - "title": { - "text": "No Liability.", - "context": "Title of the no liability disclaimer, displayed in bold" - }, - "description": { - "text": "You accept full responsibility for your participation in any services provided by Sunrise and acknowledge that you cannot hold the organization accountable for any consequences that may arise from your usage.", - "context": "Description of the no liability disclaimer" - } + "title": "No Liability.", + "description": "You accept full responsibility for your participation in any services provided by Sunrise and acknowledge that you cannot hold the organization accountable for any consequences that may arise from your usage." }, "accountRestrictions": { - "title": { - "text": "Account Restrictions.", - "context": "Title of the account restrictions disclaimer, displayed in bold" - }, - "description": { - "text": "The administration reserves the right to restrict or suspend any account, with or without prior notice, for violations of server rules or at their discretion.", - "context": "Description of the account restrictions disclaimer" - } + "title": "Account Restrictions.", + "description": "The administration reserves the right to restrict or suspend any account, with or without prior notice, for violations of server rules or at their discretion." }, "ruleChanges": { - "title": { - "text": "Rule Changes.", - "context": "Title of the rule changes disclaimer, displayed in bold" - }, - "description": { - "text": "The server rules are subject to change at any time. The administration may update, modify, or remove any rule with or without prior notification, and all players are expected to stay informed of any changes.", - "context": "Description of the rule changes disclaimer" - } + "title": "Rule Changes.", + "description": "The server rules are subject to change at any time. The administration may update, modify, or remove any rule with or without prior notification, and all players are expected to stay informed of any changes." }, "agreementByParticipation": { - "title": { - "text": "Agreement by Participation.", - "context": "Title of the agreement by participation disclaimer, displayed in bold" - }, - "description": { - "text": "By creating and/or maintaining an account on the server, you automatically agree to these terms and commit to adhering to the rules and guidelines in effect at the time.", - "context": "Description of the agreement by participation disclaimer" - } + "title": "Agreement by Participation.", + "description": "By creating and/or maintaining an account on the server, you automatically agree to these terms and commit to adhering to the rules and guidelines in effect at the time." } } } }, "register": { "meta": { - "title": { - "text": "registew | {appName}", - "context": "The title for the register page of the osu!sunrise website" - } - }, - "header": { - "text": "registew", - "context": "The main header text for the register page" + "title": "registew | {appName}" }, + "header": "registew", "welcome": { - "title": { - "text": "wewcome to the wegistwation page!", - "context": "Welcome title on the registration page" - }, - "description": { - "text": "hewwo! OwO pwease entew uw detaiws to cweate an accouwnt. If u awen't suwwe how to connect to the sewvew, ow if u have any othew quwestions, pwease visit ouww wiki page.", - "context": "Welcome description text with link to wiki page" - } + "title": "wewcome to the wegistwation page!", + "description": "hewwo! OwO pwease entew uw detaiws to cweate an accouwnt. If u awen't suwwe how to connect to the sewvew, ow if u have any othew quwestions, pwease visit ouww wiki page." }, "form": { - "title": { - "text": "entew uw detaiws", - "context": "Title for the registration form section" - }, + "title": "entew uw detaiws", "labels": { - "username": { - "text": "Username", - "context": "Label for the username input field" - }, - "email": { - "text": "emaiw", - "context": "Label for the email input field" - }, - "password": { - "text": "passwowd", - "context": "Label for the password input field" - }, - "confirmPassword": { - "text": "confiwm passwowd", - "context": "Label for the confirm password input field" - } + "username": "Username", + "email": "emaiw", + "password": "passwowd", + "confirmPassword": "confiwm passwowd" }, "placeholders": { - "username": { - "text": "e.g. username", - "context": "Placeholder text for the username input field" - }, - "email": { - "text": "e.g. uwsewname@maiw.com", - "context": "Placeholder text for the email input field" - }, - "password": { - "text": "************", - "context": "Placeholder text for the password input field" - } + "username": "e.g. username", + "email": "e.g. uwsewname@maiw.com", + "password": "************" }, "validation": { - "usernameMin": { - "text": "Username must be at least {min} characters.", - "context": "Validation error message when username is too short, includes minimum length parameter" - }, - "usernameMax": { - "text": "Username must be {max} characters or fewer.", - "context": "Validation error message when username is too long, includes maximum length parameter" - }, - "passwordMin": { - "text": "passwowd muwst be at weast {min} chawactews.", - "context": "Validation error message when password is too short, includes minimum length parameter" - }, - "passwordMax": { - "text": "passwowd muwst be {max} chawactews ow fewew.", - "context": "Validation error message when password is too long, includes maximum length parameter" - }, - "passwordsDoNotMatch": { - "text": "passwowds do not match", - "context": "Validation error message when password and confirm password do not match" - } + "usernameMin": "Username must be at least {min} characters.", + "usernameMax": "Username must be {max} characters or fewer.", + "passwordMin": "passwowd muwst be at weast {min} chawactews.", + "passwordMax": "passwowd muwst be {max} chawactews ow fewew.", + "passwordsDoNotMatch": "passwowds do not match" }, "error": { - "title": { - "text": "ewwow", - "context": "Title for the error alert" - }, - "unknown": { - "text": "Unknown error.", - "context": "Generic error message when an unknown error occurs" - } - }, - "submit": { - "text": "Register", - "context": "Text for the registration submit button" + "title": "ewwow", + "unknown": "Unknown error." }, - "terms": { - "text": "By signing up, you agree to the server rules", - "context": "Terms agreement text with link to rules page" - } + "submit": "Register", + "terms": "By signing up, you agree to the server rules" }, "success": { "dialog": { - "title": { - "text": "youw'we aww set!", - "context": "Title of the success dialog after registration" - }, - "description": { - "text": "youww accouwnt has been suwccessfuwwwy cweated.", - "context": "Description in the success dialog" - }, - "message": { - "text": "youw can now connect to the sewvew by fowwowing the guwide on ouww wiki page, ow cuwstomize uw pwofiwe by uwpdating uw avataw and bannew befowe u stawt pwaying!", - "context": "Success message with link to wiki page" - }, + "title": "youw'we aww set!", + "description": "youww accouwnt has been suwccessfuwwwy cweated.", + "message": "youw can now connect to the sewvew by fowwowing the guwide on ouww wiki page, ow cuwstomize uw pwofiwe by uwpdating uw avataw and bannew befowe u stawt pwaying!", "buttons": { - "viewWiki": { - "text": "view wiki guwide", - "context": "Button text to view the wiki guide" - }, - "goToProfile": { - "text": "go to pwofiwe", - "context": "Button text to go to user profile" - } + "viewWiki": "view wiki guwide", + "goToProfile": "go to pwofiwe" } }, - "toast": { - "text": "accouwnt suwccessfuwwwy cweated!", - "context": "Toast notification message when account is successfully created" - } + "toast": "accouwnt suwccessfuwwwy cweated!" } }, "support": { "meta": { - "title": { - "text": "Support Us | {appName}", - "context": "The title for the support page of the osu!sunrise website" - } - }, - "header": { - "text": "Support Us", - "context": "The main header text for the support page" + "title": "Support Us | {appName}" }, + "header": "Support Us", "section": { - "title": { - "text": "how youw can hewp us", - "context": "Title of the support section explaining how users can help" - }, - "intro": { - "text": "whiwe aww osuw!suwnwise featuwwes has awways been fwee, wuwnning and impwoving the sewvew wequwiwes wesouwwces, time, and effowt, whiwe being mainwy maintained by a singwe devewopew.



if u wove osuw!suwnwise and want to see it gwow even fuwwthew, hewe awe a few ways u can suwppowt uws:", - "context": "Introduction text explaining why support is needed, includes bold text for emphasis and line breaks" - }, + "title": "how youw can hewp us", + "intro": "whiwe aww osuw!suwnwise featuwwes has awways been fwee, wuwnning and impwoving the sewvew wequwiwes wesouwwces, time, and effowt, whiwe being mainwy maintained by a singwe devewopew.



if u wove osuw!suwnwise and want to see it gwow even fuwwthew, hewe awe a few ways u can suwppowt uws:", "donate": { - "title": { - "text": "Donate.", - "context": "Title of the donation option, displayed in bold" - }, - "description": { - "text": "youww genewouws donations hewp uws maintain and enhance the osuw! OwO sewvews. Evewy wittwe bit couwnts! OwO with uw suwppowt, we can covew hosting costs, impwement new featuwwes, and ensuwwe a smoothew expewience fow evewyone.", - "context": "Description text explaining how donations help the server" - }, + "title": "Donate.", + "description": "youww genewouws donations hewp uws maintain and enhance the osuw! OwO sewvews. Evewy wittwe bit couwnts! OwO with uw suwppowt, we can covew hosting costs, impwement new featuwwes, and ensuwwe a smoothew expewience fow evewyone.", "buttons": { - "kofi": { - "text": "Ko-fi", - "context": "Button text for Ko-fi donation platform" - }, - "boosty": { - "text": "Boosty", - "context": "Button text for Boosty donation platform" - } + "kofi": "Ko-fi", + "boosty": "Boosty" } }, "spreadTheWord": { - "title": { - "text": "Spread the Word.", - "context": "Title of the spread the word option, displayed in bold" - }, + "title": "Spread the Word.", "description": { "text": "the mowe peopwe who know abouwt osuw!suwnwise, the mowe vibwant and exciting ouww commuwnity wiww be. Teww uw fwiends, shawe on sociaw media, and invite new pwayews to join." } }, "justPlay": { - "title": { - "text": "Just Play on the Server.", - "context": "Title of the just play option, displayed in bold" - }, - "description": { - "text": "One of the easiest ways to support osu!sunrise is simply by playing on the server! The more players we have, the better the community and experience become. By joining in, you're helping to grow the server and keeping it active for all players.", - "context": "Description text explaining that playing on the server is a form of support" - } + "title": "Just Play on the Server.", + "description": "One of the easiest ways to support osu!sunrise is simply by playing on the server! The more players we have, the better the community and experience become. By joining in, you're helping to grow the server and keeping it active for all players." } } }, "topplays": { "meta": { - "title": { - "text": "top pways | {appName}", - "context": "The title for the top plays page of the osu!sunrise website" - } - }, - "header": { - "text": "top pways", - "context": "The main header text for the top plays page" - }, - "showMore": { - "text": "show mowe", - "context": "Button text to load more top plays" + "title": "top pways | {appName}" }, + "header": "top pways", + "showMore": "show mowe", "components": { "userScoreMinimal": { - "pp": { - "text": "pp", - "context": "Abbreviation for performance points, displayed after the PP value" - }, - "accuracy": { - "text": "acc:", - "context": "Abbreviation for accuracy, displayed before the accuracy percentage" - } + "pp": "pp", + "accuracy": "acc:" } } }, "score": { "meta": { - "title": { - "text": "{uwsewname} on {beatmaptitwe} [{beatmapvewsion}] | {appName}", - "context": "The title for the score page, includes username, beatmap title, version, and app name as parameters" - }, - "description": { - "text": "usew {uwsewname} has scowed {pp}pp on {beatmaptitwe} [{beatmapvewsion}] in {appName}.", - "context": "The meta description for the score page, includes username, PP value, beatmap title, version, and app name as parameters" - }, + "title": "{uwsewname} on {beatmaptitwe} [{beatmapvewsion}] | {appName}", + "description": "usew {uwsewname} has scowed {pp}pp on {beatmaptitwe} [{beatmapvewsion}] in {appName}.", "openGraph": { - "title": { - "text": "{uwsewname} on {beatmaptitwe} - {beatmapawtist} [{beatmapvewsion}] | {appName}", - "context": "The OpenGraph title for the score page, includes username, beatmap title, artist, version, and app name as parameters" - }, - "description": { - "text": "usew {uwsewname} has scowed {pp}pp on {beatmaptitwe} - {beatmapawtist} [{beatmapvewsion}] ★{stawrating} {mods} in {appName}.", - "context": "The OpenGraph description for the score page, includes username, PP value, beatmap title, artist, version, star rating, mods, and app name as parameters" - } + "title": "{uwsewname} on {beatmaptitwe} - {beatmapawtist} [{beatmapvewsion}] | {appName}", + "description": "usew {uwsewname} has scowed {pp}pp on {beatmaptitwe} - {beatmapawtist} [{beatmapvewsion}] ★{stawrating} {mods} in {appName}." } }, - "header": { - "text": "scowe pewfowmance", - "context": "The main header text for the score page" - }, + "header": "scowe pewfowmance", "beatmap": { - "versionUnknown": { - "text": "unknown", - "context": "Fallback text when beatmap version is not available" - }, - "mappedBy": { - "text": "mapped by", - "context": "Text displayed before the beatmap creator name" - }, - "creatorUnknown": { - "text": "unknown cweatow", - "context": "Fallback text when beatmap creator is not available" - } + "versionUnknown": "unknown", + "mappedBy": "mapped by", + "creatorUnknown": "unknown cweatow" }, "score": { - "submittedOn": { - "text": "Submitted on", - "context": "Text displayed before the score submission date" - }, - "playedBy": { - "text": "pwayed by", - "context": "Text displayed before the player username" - }, - "userUnknown": { - "text": "Unknown user", - "context": "Fallback text when user is not available" - } + "submittedOn": "Submitted on", + "playedBy": "pwayed by", + "userUnknown": "Unknown user" }, "actions": { - "downloadReplay": { - "text": "downwoad repway", - "context": "Button text to download the replay file" - }, - "openMenu": { - "text": "Open menu", - "context": "Screen reader text for the dropdown menu button" - } + "downloadReplay": "downwoad repway", + "openMenu": "Open menu" }, "error": { - "notFound": { - "text": "Score not found", - "context": "Error message when score cannot be found" - }, - "description": { - "text": "the scowe u awe wooking fow does not exist ow has been deweted.", - "context": "Error description explaining that the score doesn't exist or was deleted" - } + "notFound": "Score not found", + "description": "the scowe u awe wooking fow does not exist ow has been deweted." } }, "leaderboard": { "meta": { - "title": { - "text": "leadewboawd | {appName}", - "context": "The title for the leaderboard page of the osu!sunrise website" - } - }, - "header": { - "text": "leadewboawd", - "context": "The main header text for the leaderboard page" + "title": "leadewboawd | {appName}" }, + "header": "leadewboawd", "sortBy": { - "label": { - "text": "sowt by:", - "context": "Label for the sort by selector on mobile view" - }, - "performancePoints": { - "text": "pewfowmance points", - "context": "Button text for sorting by performance points" - }, - "rankedScore": { - "text": "ranked scowe", - "context": "Button text for sorting by ranked score" - }, - "performancePointsShort": { - "text": "pewf. points", - "context": "Short label for performance points in mobile combobox" - }, - "scoreShort": { - "text": "scowe", - "context": "Short label for score in mobile combobox" - } + "label": "sowt by:", + "performancePoints": "pewfowmance points", + "rankedScore": "ranked scowe", + "performancePointsShort": "pewf. points", + "scoreShort": "scowe" }, "table": { "columns": { - "rank": { - "text": "Rank", - "context": "Column header for user rank" - }, - "performance": { - "text": "Performance", - "context": "Column header for performance points" - }, - "rankedScore": { - "text": "Ranked Score", - "context": "Column header for ranked score" - }, - "accuracy": { - "text": "Accuracy", - "context": "Column header for accuracy" - }, - "playCount": { - "text": "Play count", - "context": "Column header for play count" - } + "rank": "Rank", + "performance": "Performance", + "rankedScore": "Ranked Score", + "accuracy": "Accuracy", + "playCount": "Play count" }, "actions": { - "openMenu": { - "text": "Open menu", - "context": "Screen reader text for the dropdown menu button" - }, - "viewUserProfile": { - "text": "view uwsew pwofiwe", - "context": "Dropdown menu item text to view user profile" - } - }, - "emptyState": { - "text": "no wesuwwts.", - "context": "Message displayed when there are no results in the table" + "openMenu": "Open menu", + "viewUserProfile": "view uwsew pwofiwe" }, + "emptyState": "no wesuwwts.", "pagination": { - "usersPerPage": { - "text": "users per page", - "context": "Label for the users per page selector" - }, - "showing": { - "text": "showing {stawt} - {end} of {totaw}", - "context": "Pagination text showing the range of displayed users, includes start, end, and total as parameters" - } + "usersPerPage": "users per page", + "showing": "showing {stawt} - {end} of {totaw}" } } }, "friends": { "meta": { - "title": { - "text": "youww fwiends | {appName}", - "context": "The title for the friends page of the osu!sunrise website" - } - }, - "header": { - "text": "youww connections", - "context": "The main header text for the friends page" + "title": "youww fwiends | {appName}" }, + "header": "youww connections", "tabs": { - "friends": { - "text": "fwiends", - "context": "Button text to show friends list" - }, - "followers": { - "text": "fowwowews", - "context": "Button text to show followers list" - } + "friends": "fwiends", + "followers": "fowwowews" }, "sorting": { - "label": { - "text": "sowt by:", - "context": "Label for the sort by selector" - }, - "username": { - "text": "Username", - "context": "Sorting option to sort by username" - }, - "recentlyActive": { - "text": "recentwy active", - "context": "Sorting option to sort by recently active users" - } + "label": "sowt by:", + "username": "Username", + "recentlyActive": "recentwy active" }, - "showMore": { - "text": "show mowe", - "context": "Button text to load more users" - }, - "emptyState": { - "text": "no uwsews fouwnd", - "context": "Message displayed when there are no users in the list" - } + "showMore": "show mowe", + "emptyState": "no uwsews fouwnd" }, "beatmaps": { "search": { "meta": { - "title": { - "text": "beatmaps seawch | {appName}", - "context": "The title for the beatmaps search page of the osu!sunrise website" - } + "title": "beatmaps seawch | {appName}" }, - "header": { - "text": "beatmaps seawch", - "context": "The main header text for the beatmaps search page" - } + "header": "beatmaps seawch" }, "detail": { "meta": { - "title": { - "text": "beatmap info | {appName}", - "context": "The title for the beatmap detail page of the osu!sunrise website" - } - }, - "header": { - "text": "beatmap info", - "context": "The header text for the beatmap detail page" + "title": "beatmap info | {appName}" }, + "header": "beatmap info", "notFound": { - "title": { - "text": "Beatmapset not found", - "context": "Title displayed when a beatmapset is not found" - }, - "description": { - "text": "The beatmapset you are looking for does not exist or has been deleted.", - "context": "Description message when a beatmapset is not found or has been deleted" - } + "title": "Beatmapset not found", + "description": "The beatmapset you are looking for does not exist or has been deleted." } }, "components": { "search": { - "searchPlaceholder": { - "text": "seawch beatmaps...", - "context": "Placeholder text for the beatmaps search input field" - }, - "filters": { - "text": "fiwtews", - "context": "Button text to toggle filters panel" - }, + "searchPlaceholder": "seawch beatmaps...", + "filters": "fiwtews", "viewMode": { - "grid": { - "text": "gwid", - "context": "Button text for grid view mode" - }, - "list": { - "text": "list", - "context": "Button text for list view mode" - } + "grid": "gwid", + "list": "list" }, - "showMore": { - "text": "show mowe", - "context": "Button text to load more beatmapsets" - } + "showMore": "show mowe" }, "filters": { "mode": { - "label": { - "text": "mode", - "context": "Label for the game mode filter selector" - }, - "any": { - "text": "any", - "context": "Option to select any game mode (no filter)" - }, - "standard": { - "text": "osuw!", - "context": "Game mode option for osu!standard" - }, - "taiko": { - "text": "osuw!taiko", - "context": "Game mode option for osu!taiko" - }, - "catch": { - "text": "osuw!catch", - "context": "Game mode option for osu!catch" - }, - "mania": { - "text": "osuw!mania", - "context": "Game mode option for osu!mania" - } + "label": "mode", + "any": "any", + "standard": "osuw!", + "taiko": "osuw!taiko", + "catch": "osuw!catch", + "mania": "osuw!mania" }, "status": { - "label": { - "text": "statuws", - "context": "Label for the beatmap status filter selector" - } + "label": "statuws" }, "searchByCustomStatus": { - "label": { - "text": "seawch by cuwstom statuws", - "context": "Label for the search by custom status toggle switch" - } + "label": "seawch by cuwstom statuws" }, - "applyFilters": { - "text": "appwy fiwtews", - "context": "Button text to apply the selected filters" - } + "applyFilters": "appwy fiwtews" } } }, "beatmapsets": { "meta": { - "title": { - "text": "{awtist} - {titwe} | {appName}", - "context": "The title for the beatmapset detail page, includes artist and title as parameters" - }, - "description": { - "text": "beatmapset info fow {titwe} by {awtist}", - "context": "The meta description for the beatmapset detail page, includes title and artist as parameters" - }, + "title": "{awtist} - {titwe} | {appName}", + "description": "beatmapset info fow {titwe} by {awtist}", "openGraph": { - "title": { - "text": "{awtist} - {titwe} | {appName}", - "context": "The OpenGraph title for the beatmapset detail page, includes artist and title as parameters" - }, - "description": { - "text": "beatmapset info fow {titwe} by {awtist} {difficuwwtyinfo}", - "context": "The OpenGraph description for the beatmapset detail page, includes title, artist, and optional difficulty info as parameters" - } + "title": "{awtist} - {titwe} | {appName}", + "description": "beatmapset info fow {titwe} by {awtist} {difficuwwtyinfo}" } }, - "header": { - "text": "beatmap info", - "context": "The main header text for the beatmapset detail page" - }, + "header": "beatmap info", "error": { "notFound": { - "title": { - "text": "Beatmapset not found", - "context": "Title displayed when a beatmapset is not found" - }, - "description": { - "text": "The beatmapset you are looking for does not exist or has been deleted.", - "context": "Description message when a beatmapset is not found or has been deleted" - } + "title": "Beatmapset not found", + "description": "The beatmapset you are looking for does not exist or has been deleted." } }, "submission": { - "submittedBy": { - "text": "submitted by", - "context": "Text label indicating who submitted the beatmapset" - }, - "submittedOn": { - "text": "submitted on", - "context": "Text label indicating when the beatmapset was submitted" - }, - "rankedOn": { - "text": "ranked on", - "context": "Text label indicating when the beatmapset was ranked" - }, - "statusBy": { - "text": "{status} by", - "context": "Text indicating the beatmap status and who set it, includes status as parameter" - } + "submittedBy": "submitted by", + "submittedOn": "submitted on", + "rankedOn": "ranked on", + "statusBy": "{status} by" }, "video": { - "tooltip": { - "text": "this beatmap contains video", - "context": "Tooltip text for the video icon indicating the beatmap has a video" - } + "tooltip": "this beatmap contains video" }, "description": { - "header": { - "text": "descwiption", - "context": "Header text for the beatmapset description section" - } + "header": "descwiption" }, "components": { "dropdown": { - "openMenu": { - "text": "Open menu", - "context": "Screen reader text for the dropdown menu button" - }, - "ppCalculator": { - "text": "pp cawcuwwatow", - "context": "Dropdown menu item text to open PP calculator" - }, - "openOnBancho": { - "text": "open on bancho", - "context": "Dropdown menu item text to open beatmap on official osu! website" - }, - "openWithAdminPanel": { - "text": "open with admin panew", - "context": "Dropdown menu item text to open beatmap in admin panel (for BAT users)" - } + "openMenu": "Open menu", + "ppCalculator": "pp cawcuwwatow", + "openOnBancho": "open on bancho", + "openWithAdminPanel": "open with admin panew" }, "infoAccordion": { - "communityHype": { - "text": "Community Hype", - "context": "Header text for the community hype section" - }, - "information": { - "text": "infowmation", - "context": "Header text for the beatmap information section" - }, + "communityHype": "Community Hype", + "information": "infowmation", "metadata": { - "genre": { - "text": "genwe", - "context": "Label for the beatmap genre" - }, - "language": { - "text": "Language", - "context": "Label for the beatmap language" - }, - "tags": { - "text": "tags", - "context": "Label for the beatmap tags" - } + "genre": "genwe", + "language": "Language", + "tags": "tags" } }, "downloadButtons": { - "download": { - "text": "Download", - "context": "Button text to download the beatmapset" - }, - "withVideo": { - "text": "with Video", - "context": "Text indicating the download includes video" - }, - "withoutVideo": { - "text": "without Video", - "context": "Text indicating the download excludes video" - }, - "osuDirect": { - "text": "osu!direct", - "context": "Button text for osu!direct download" - } + "download": "Download", + "withVideo": "with Video", + "withoutVideo": "without Video", + "osuDirect": "osu!direct" }, "difficultyInformation": { "tooltips": { - "totalLength": { - "text": "Total Length", - "context": "Tooltip text for the total length of the beatmap" - }, - "bpm": { - "text": "BPM", - "context": "Tooltip text for beats per minute" - }, - "starRating": { - "text": "Star Rating", - "context": "Tooltip text for the star rating" - } + "totalLength": "Total Length", + "bpm": "BPM", + "starRating": "Star Rating" }, "labels": { - "keyCount": { - "text": "Key Count:", - "context": "Label for the key count (mania mode)" - }, - "circleSize": { - "text": "Ciwcwe Size:", - "context": "Label for circle size (standard/catch mode)" - }, - "hpDrain": { - "text": "HP Dwain:", - "context": "Label for HP drain" - }, - "accuracy": { - "text": "Accuwwacy:", - "context": "Label for accuracy difficulty" - }, - "approachRate": { - "text": "Appwoach Wate:", - "context": "Label for approach rate (standard/catch mode)" - } + "keyCount": "Key Count:", + "circleSize": "Ciwcwe Size:", + "hpDrain": "HP Dwain:", + "accuracy": "Accuwwacy:", + "approachRate": "Appwoach Wate:" } }, "nomination": { - "description": { - "text": "hype this map if u enjoyed pwaying it to hewp it pwogwess to ranked statuws.", - "context": "Description text explaining the hype feature, includes bold tag for Ranked" - }, - "hypeProgress": { - "text": "hype pwogwess", - "context": "Label for the hype progress indicator" - }, - "hypeBeatmap": { - "text": "hype beatmap!", - "context": "Button text to hype a beatmap" - }, - "hypesRemaining": { - "text": "youw have {couwnt} hypes wemaining fow this week", - "context": "Text showing remaining hypes for the week, includes count as parameter and bold tag" - }, + "description": "hype this map if u enjoyed pwaying it to hewp it pwogwess to ranked statuws.", + "hypeProgress": "hype pwogwess", + "hypeBeatmap": "hype beatmap!", + "hypesRemaining": "youw have {couwnt} hypes wemaining fow this week", "toast": { - "success": { - "text": "Beatmap hyped successfully!", - "context": "Success toast message when a beatmap is hyped" - }, - "error": { - "text": "ewwow occuwwed whiwe hyping beatmapset!", - "context": "Error toast message when hyping fails" - } + "success": "Beatmap hyped successfully!", + "error": "ewwow occuwwed whiwe hyping beatmapset!" } }, "ppCalculator": { - "title": { - "text": "pp cawcuwwatow", - "context": "titwe of the pp cawcuwwatow diawog" - }, - "pp": { - "text": "PP: {value}", - "context": "Label for performance points" - }, - "totalLength": { - "text": "Total Length", - "context": "Tooltip text for total length in PP calculator" - }, + "title": "pp cawcuwwatow", + "pp": "PP: {value}", + "totalLength": "Total Length", "form": { "accuracy": { - "label": { - "text": "Accuracy", - "context": "Form label for accuracy input" - }, + "label": "Accuracy", "validation": { - "negative": { - "text": "Accuracy can't be negative", - "context": "Validation error message for negative accuracy" - }, - "tooHigh": { - "text": "Accuracy can't be greater that 100", - "context": "Validation error message for accuracy over 100" - } + "negative": "Accuracy can't be negative", + "tooHigh": "Accuracy can't be greater that 100" } }, "combo": { - "label": { - "text": "Combo", - "context": "Form label for combo input" - }, + "label": "Combo", "validation": { - "negative": { - "text": "Combo can't be negative", - "context": "Validation error message for negative combo" - } + "negative": "Combo can't be negative" } }, "misses": { - "label": { - "text": "Misses", - "context": "Form label for misses input" - }, + "label": "Misses", "validation": { - "negative": { - "text": "Misses can't be negative", - "context": "Validation error message for negative misses" - } + "negative": "Misses can't be negative" } }, - "calculate": { - "text": "Calculate", - "context": "Button text to calculate PP" - }, - "unknownError": { - "text": "Unknown error", - "context": "Generic error message for unknown errors" - } + "calculate": "Calculate", + "unknownError": "Unknown error" } }, "leaderboard": { "columns": { - "rank": { - "text": "Rank", - "context": "Column header for rank" - }, - "score": { - "text": "Scowe", - "context": "Column header for score" - }, - "accuracy": { - "text": "Accuwwacy", - "context": "Column header for accuracy" - }, - "player": { - "text": "Pwayew", - "context": "Column header for player" - }, - "maxCombo": { - "text": "Max Combo", - "context": "Column header for max combo" - }, - "perfect": { - "text": "Pewfect", - "context": "Column header for perfect hits (geki)" - }, - "great": { - "text": "Gweat", - "context": "Column header for great hits (300)" - }, - "good": { - "text": "Good", - "context": "Column header for good hits (katu)" - }, - "ok": { - "text": "Ok", - "context": "Column header for ok hits (100)" - }, - "lDrp": { - "text": "L DRP", - "context": "Column header for large droplets (catch mode, 100)" - }, - "meh": { - "text": "Meh", - "context": "Column header for meh hits (50)" - }, - "sDrp": { - "text": "S DRP", - "context": "Column header for small droplets (catch mode, 50)" - }, - "miss": { - "text": "Miss", - "context": "Column header for misses" - }, - "pp": { - "text": "PP", - "context": "Column header for performance points" - }, - "time": { - "text": "Time", - "context": "Column header for time played" - }, - "mods": { - "text": "Mods", - "context": "Column header for mods" - } + "rank": "Rank", + "score": "Scowe", + "accuracy": "Accuwwacy", + "player": "Pwayew", + "maxCombo": "Max Combo", + "perfect": "Pewfect", + "great": "Gweat", + "good": "Good", + "ok": "Ok", + "lDrp": "L DRP", + "meh": "Meh", + "sDrp": "S DRP", + "miss": "Miss", + "pp": "PP", + "time": "Time", + "mods": "Mods" }, "actions": { - "openMenu": { - "text": "Open menu", - "context": "Screen reader text for the dropdown menu button" - }, - "viewDetails": { - "text": "view detaiws", - "context": "Dropdown menu item text to view score details" - }, - "downloadReplay": { - "text": "downwoad repway", - "context": "Dropdown menu item text to download replay" - } + "openMenu": "Open menu", + "viewDetails": "view detaiws", + "downloadReplay": "downwoad repway" }, "table": { - "emptyState": { - "text": "no scowes fouwnd. Be the fiwst to suwbmit one!", - "context": "Message displayed when there are no scores in the leaderboard" - }, + "emptyState": "no scowes fouwnd. Be the fiwst to suwbmit one!", "pagination": { - "scoresPerPage": { - "text": "scowes pew page", - "context": "Label for the scores per page selector" - }, - "showing": { - "text": "showing {stawt} - {end} of {totaw}", - "context": "Pagination text showing the range of displayed scores, includes start, end, and total as parameters" - } + "scoresPerPage": "scowes pew page", + "showing": "showing {stawt} - {end} of {totaw}" } } } @@ -1765,96 +679,36 @@ }, "settings": { "meta": { - "title": { - "text": "settings | {appName}", - "context": "The title for the settings page of the osu!sunrise website" - } - }, - "header": { - "text": "settings", - "context": "The main header text for the settings page" - }, - "notLoggedIn": { - "text": "youw muwst be wogged in to view this page.", - "context": "Message displayed when user is not logged in" + "title": "settings | {appName}" }, + "header": "settings", + "notLoggedIn": "youw muwst be wogged in to view this page.", "sections": { - "changeAvatar": { - "text": "change avataw", - "context": "Title for the change avatar section" - }, - "changeBanner": { - "text": "change bannew", - "context": "Title for the change banner section" - }, - "changeDescription": { - "text": "change descwiption", - "context": "Title for the change description section" - }, - "socials": { - "text": "sociaws", - "context": "Title for the socials section" - }, - "playstyle": { - "text": "pwaystywe", - "context": "Title for the playstyle section" - }, - "options": { - "text": "options", - "context": "Title for the options section" - }, - "changePassword": { - "text": "change passwowd", - "context": "Title for the change password section" - }, - "changeUsername": { - "text": "change uwsewname", - "context": "Title for the change username section" - }, - "changeCountryFlag": { - "text": "Change country flag", - "context": "Title for the change country flag section" - } + "changeAvatar": "change avataw", + "changeBanner": "change bannew", + "changeDescription": "change descwiption", + "socials": "sociaws", + "playstyle": "pwaystywe", + "options": "options", + "changePassword": "change passwowd", + "changeUsername": "change uwsewname", + "changeCountryFlag": "Change country flag" }, "description": { - "reminder": { - "text": "* remindew: do not post any inappwopwiate content. Twy to keep it famiwy fwiendwy owo", - "context": "Reminder message for description field about keeping content appropriate" - } + "reminder": "* remindew: do not post any inappwopwiate content. Twy to keep it famiwy fwiendwy owo" }, "components": { "username": { - "label": { - "text": "New Username", - "context": "Form label for new username input" - }, - "placeholder": { - "text": "e.g. username", - "context": "Placeholder text for username input" - }, - "button": { - "text": "Change username", - "context": "Button text to change username" - }, + "label": "New Username", + "placeholder": "e.g. username", + "button": "Change username", "validation": { - "minLength": { - "text": "Username must be at least {min} characters.", - "context": "Validation error message for username minimum length, includes min as parameter" - }, - "maxLength": { - "text": "Username must be {max} characters or fewer.", - "context": "Validation error message for username maximum length, includes max as parameter" - } + "minLength": "Username must be at least {min} characters.", + "maxLength": "Username must be {max} characters or fewer." }, "toast": { - "success": { - "text": "Username changed successfully!", - "context": "Success toast message when username is changed" - }, - "error": { - "text": "Error occured while changing username!", - "context": "Error toast message when username change fails" - } + "success": "Username changed successfully!", + "error": "Error occured while changing username!" }, "reminder": { "text": "* remindew: pwease keep uw uwsewname famiwy fwiendwy, ow it wiww be changed fow u. Abuwsing this featuwwe wiww wesuwwt in a ban." @@ -1862,497 +716,188 @@ }, "password": { "labels": { - "current": { - "text": "Current Password", - "context": "Form label for current password input" - }, - "new": { - "text": "new passwowd", - "context": "Form label for new password input" - }, - "confirm": { - "text": "confiwm passwowd", - "context": "Form label for confirm password input" - } - }, - "placeholder": { - "text": "************", - "context": "Placeholder text for password inputs" - }, - "button": { - "text": "Change password", - "context": "Button text to change password" + "current": "Current Password", + "new": "new passwowd", + "confirm": "confiwm passwowd" }, + "placeholder": "************", + "button": "Change password", "validation": { - "minLength": { - "text": "passwowd muwst be at weast {min} chawactews.", - "context": "Validation error message for password minimum length, includes min as parameter" - }, - "maxLength": { - "text": "passwowd muwst be {max} chawactews ow fewew.", - "context": "Validation error message for password maximum length, includes max as parameter" - }, - "mismatch": { - "text": "passwowds do not match", - "context": "Validation error message when passwords don't match" - } + "minLength": "passwowd muwst be at weast {min} chawactews.", + "maxLength": "passwowd muwst be {max} chawactews ow fewew.", + "mismatch": "passwowds do not match" }, "toast": { - "success": { - "text": "Password changed successfully!", - "context": "Success toast message when password is changed" - }, - "error": { - "text": "ewwow occuwwed whiwe changing passwowd!", - "context": "Error toast message when password change fails" - } + "success": "Password changed successfully!", + "error": "ewwow occuwwed whiwe changing passwowd!" } }, "description": { "toast": { - "success": { - "text": "Description updated successfully!", - "context": "Success toast message when description is updated" - }, - "error": { - "text": "an uwnknown ewwow occuwwwed", - "context": "Generic error message for description update" - } + "success": "Description updated successfully!", + "error": "an uwnknown ewwow occuwwwed" } }, "country": { - "label": { - "text": "new couwntwy fwag", - "context": "Form label for country flag selector" - }, - "placeholder": { - "text": "sewect new couwntwy fwag", - "context": "Placeholder text for country flag selector" - }, - "button": { - "text": "Change country flag", - "context": "Button text to change country flag" - }, + "label": "new couwntwy fwag", + "placeholder": "sewect new couwntwy fwag", + "button": "Change country flag", "toast": { - "success": { - "text": "couwntwy fwag changed suwccessfuwwwy!", - "context": "Success toast message when country flag is changed" - }, - "error": { - "text": "ewwow occuwwed whiwe changing couwntwy fwag!", - "context": "Error toast message when country flag change fails" - } + "success": "couwntwy fwag changed suwccessfuwwwy!", + "error": "ewwow occuwwed whiwe changing couwntwy fwag!" } }, "socials": { "headings": { - "general": { - "text": "genewaw", - "context": "Heading for general information section in socials form" - }, - "socials": { - "text": "sociaws", - "context": "Heading for socials section in socials form" - } + "general": "genewaw", + "socials": "sociaws" }, "fields": { - "location": { - "text": "wocation", - "context": "Field label for location" - }, - "interest": { - "text": "intewest", - "context": "Field label for interest" - }, - "occupation": { - "text": "occupation", - "context": "Field label for occupation" - } - }, - "button": { - "text": "Update socials", - "context": "Button text to update socials" + "location": "wocation", + "interest": "intewest", + "occupation": "occupation" }, + "button": "Update socials", "toast": { - "success": { - "text": "Socials updated successfully!", - "context": "Success toast message when socials are updated" - }, - "error": { - "text": "ewwow occuwwed whiwe uwpdating sociaws!", - "context": "Error toast message when socials update fails" - } + "success": "Socials updated successfully!", + "error": "ewwow occuwwed whiwe uwpdating sociaws!" } }, "playstyle": { "options": { - "Mouse": { - "text": "Mouse", - "context": "Playstyle option for mouse input" - }, - "Keyboard": { - "text": "keyboawd", - "context": "Playstyle option for keyboard input" - }, - "Tablet": { - "text": "tabwet", - "context": "Playstyle option for tablet input" - }, - "TouchScreen": { - "text": "Touch Screen", - "context": "Playstyle option for touch screen input" - } + "Mouse": "Mouse", + "Keyboard": "keyboawd", + "Tablet": "tabwet", + "TouchScreen": "Touch Screen" }, "toast": { - "success": { - "text": "Playstyle updated successfully!", - "context": "Success toast message when playstyle is updated" - }, - "error": { - "text": "ewwow occuwwed whiwe uwpdating pwaystywe!", - "context": "Error toast message when playstyle update fails" - } + "success": "Playstyle updated successfully!", + "error": "ewwow occuwwed whiwe uwpdating pwaystywe!" } }, "uploadImage": { "types": { - "avatar": { - "text": "avatar", - "context": "The word 'avatar' for image upload type" - }, - "banner": { - "text": "banner", - "context": "The word 'banner' for image upload type" - } - }, - "button": { - "text": "Upload {type}", - "context": "Button text to upload image, includes type (avatar/banner) as parameter" + "avatar": "avatar", + "banner": "banner" }, + "button": "Upload {type}", "toast": { - "success": { - "text": "{type} updated successfully!", - "context": "Success toast message when image is uploaded, includes type (avatar/banner) as parameter" - }, - "error": { - "text": "an uwnknown ewwow occuwwwed", - "context": "Generic error message for image upload" - } + "success": "{type} updated successfully!", + "error": "an uwnknown ewwow occuwwwed" }, - "note": { - "text": "* note: {type}s awe wimited to 5mb in size", - "context": "Note about file size limit, includes type (avatar/banner) as parameter" - } + "note": "* note: {type}s awe wimited to 5mb in size" }, "siteOptions": { - "includeBanchoButton": { - "text": "incwuwde \"open on bancho\" buwtton in beatmap page", - "context": "Label for toggle to include Open on Bancho button" - }, - "useSpaciousUI": { - "text": "use spaciouws ui (incwease spacing between ewements)", - "context": "Label for toggle to use spacious UI" - } + "includeBanchoButton": "incwuwde \"open on bancho\" buwtton in beatmap page", + "useSpaciousUI": "use spaciouws ui (incwease spacing between ewements)" } }, "common": { - "unknownError": { - "text": "Unknown error.", - "context": "Generic error message for unknown errors" - } + "unknownError": "Unknown error." } }, "user": { "meta": { - "title": { - "text": "{username} · User Profile | {appName}", - "context": "Page title for user profile page, includes username and app name as parameters" - }, - "description": { - "text": "We don't know much about them, but we're sure {username} is great.", - "context": "Meta description for user profile page, includes username as parameter" - } - }, - "header": { - "text": "Player info", - "context": "Header text for the user profile page" + "title": "{username} · User Profile | {appName}", + "description": "We don't know much about them, but we're sure {username} is great." }, + "header": "Player info", "tabs": { - "general": { - "text": "genewaw", - "context": "Tab name for general information" - }, - "bestScores": { - "text": "Best scowes", - "context": "Tab name for best scores" - }, - "recentScores": { - "text": "Recent scowes", - "context": "Tab name for recent scores" - }, - "firstPlaces": { - "text": "Fiwst pwaces", - "context": "Tab name for first place scores" - }, - "beatmaps": { - "text": "Beatmaps", - "context": "Tab name for beatmaps" - }, - "medals": { - "text": "Medaws", - "context": "Tab name for medals" - } + "general": "genewaw", + "bestScores": "Best scowes", + "recentScores": "Recent scowes", + "firstPlaces": "Fiwst pwaces", + "beatmaps": "Beatmaps", + "medals": "Medaws" }, "buttons": { - "editProfile": { - "text": "Edit pwofiwe", - "context": "Button text to edit user profile" - }, - "setDefaultGamemode": { - "text": "Set {gamemode} {flag} as pwofiwe defauwt game mode", - "context": "Button text to set default gamemode, includes gamemode name and flag emoji as parameters" - } + "editProfile": "Edit pwofiwe", + "setDefaultGamemode": "Set {gamemode} {flag} as pwofiwe defauwt game mode" }, "errors": { - "userNotFound": { - "text": "Usew not found ow an ewwow occuwwwed.", - "context": "Error message when user is not found or an error occurs" - }, - "restricted": { - "text": "This means that the usew viowated the sewvew wuwes and has been westwicted. :<", - "context": "Explanation message when user has been restricted" - }, - "userDeleted": { - "text": "The usew may have been deweted ow does not exist.", - "context": "Error message when user may have been deleted or doesn't exist" - } + "userNotFound": "Usew not found ow an ewwow occuwwwed.", + "restricted": "This means that the usew viowated the sewvew wuwes and has been westwicted. :<", + "userDeleted": "The usew may have been deweted ow does not exist." }, "components": { "generalTab": { - "info": { - "text": "Info", - "context": "Section header for user information" - }, - "rankedScore": { - "text": "Ranked Scowe", - "context": "Label for ranked score statistic" - }, - "hitAccuracy": { - "text": "Hit Accuwacy", - "context": "Label for hit accuracy statistic" - }, - "playcount": { - "text": "Pway couwnt", - "context": "Label for playcount statistic" - }, - "totalScore": { - "text": "Total Scowe", - "context": "Label for total score statistic" - }, - "maximumCombo": { - "text": "Maximuwm Combo", - "context": "Label for maximum combo statistic" - }, - "playtime": { - "text": "Pwaytime", - "context": "Label for playtime statistic" - }, - "performance": { - "text": "Pewfowmance", - "context": "Section header for performance information" - }, - "showByRank": { - "text": "Show by rank", - "context": "Button text to show chart by rank" - }, - "showByPp": { - "text": "Show by pp", - "context": "Button text to show chart by performance points" - }, - "aboutMe": { - "text": "Abouwt me", - "context": "Section header for user description/about section" - } + "info": "Info", + "rankedScore": "Ranked Scowe", + "hitAccuracy": "Hit Accuwacy", + "playcount": "Pway couwnt", + "totalScore": "Total Scowe", + "maximumCombo": "Maximuwm Combo", + "playtime": "Pwaytime", + "performance": "Pewfowmance", + "showByRank": "Show by rank", + "showByPp": "Show by pp", + "aboutMe": "Abouwt me" }, "scoresTab": { - "bestScores": { - "text": "Best scowes", - "context": "Header for best scores section" - }, - "recentScores": { - "text": "Recent scowes", - "context": "Header for recent scores section" - }, - "firstPlaces": { - "text": "Fiwst pwaces", - "context": "Header for first places section" - }, - "noScores": { - "text": "Usew has no {type} scowes", - "context": "Message when user has no scores of the specified type, includes type as parameter" - }, - "showMore": { - "text": "Show mowe", - "context": "Button text to load more scores" - } + "bestScores": "Best scowes", + "recentScores": "Recent scowes", + "firstPlaces": "Fiwst pwaces", + "noScores": "Usew has no {type} scowes", + "showMore": "Show mowe" }, "beatmapsTab": { - "mostPlayed": { - "text": "Most pwayed", - "context": "Header for most played beatmaps section" - }, - "noMostPlayed": { - "text": "Usew has no most pwayed beatmaps", - "context": "Message when user has no most played beatmaps" - }, - "favouriteBeatmaps": { - "text": "Favouwwite Beatmaps", - "context": "Headew for favouwwite beatmaps section" - }, - "noFavourites": { - "text": "Usew has no favouwwite beatmaps", - "context": "Message when user has no favouwwite beatmaps" - }, - "showMore": { - "text": "Show more", - "context": "Button text to load more beatmaps" - } + "mostPlayed": "Most pwayed", + "noMostPlayed": "Usew has no most pwayed beatmaps", + "favouriteBeatmaps": "Favouwwite Beatmaps", + "noFavourites": "Usew has no favouwwite beatmaps", + "showMore": "Show more" }, "medalsTab": { - "medals": { - "text": "Medals", - "context": "Header for medals section" - }, - "latest": { - "text": "Latest", - "context": "Header for latest medals section" - }, + "medals": "Medals", + "latest": "Latest", "categories": { - "hushHush": { - "text": "Hush hush", - "context": "Medal category name" - }, - "beatmapHunt": { - "text": "Beatmap hunt", - "context": "Medal category name" - }, - "modIntroduction": { - "text": "Mod introduction", - "context": "Medal category name" - }, - "skill": { - "text": "Skill", - "context": "Medal category name" - } + "hushHush": "Hush hush", + "beatmapHunt": "Beatmap hunt", + "modIntroduction": "Mod introduction", + "skill": "Skill" }, - "achievedOn": { - "text": "achieved on", - "context": "Text shown before the date when a medal was achieved" - }, - "notAchieved": { - "text": "Not achieved", - "context": "Text shown when a medal has not been achieved" - } + "achievedOn": "achieved on", + "notAchieved": "Not achieved" }, "generalInformation": { - "joined": { - "text": "Joined {time}", - "context": "Text showing when user joined, includes time as parameter" - }, - "followers": { - "text": "{count} Fowwowews", - "context": "Text showing followers count, includes count as parameter" - }, - "following": { - "text": "{count} Fowwowing", - "context": "Text showing following count, includes count as parameter" - }, - "playsWith": { - "text": "Pways wif {playstyle}", - "context": "Text showing playstyle, includes playstyle as parameter with formatting" - } + "joined": "Joined {time}", + "followers": "{count} Fowwowews", + "following": "{count} Fowwowing", + "playsWith": "Pways wif {playstyle}" }, "statusText": { - "lastSeenOn": { - "text": ", wast seen on {date}", - "context": "Text shown before the last seen date when user is offline, includes date as parameter" - } + "lastSeenOn": ", wast seen on {date}" }, "ranks": { - "highestRank": { - "text": "Highest wank {rank} on {date}", - "context": "Tooltip text for highest rank achieved, includes rank number and date as parameters, rank is formatted" - } + "highestRank": "Highest wank {rank} on {date}" }, "previousUsernames": { - "previouslyKnownAs": { - "text": "This usew was pweviouswy known as:", - "context": "Tooltip text explaining that the user had previous usernames" - } + "previouslyKnownAs": "This usew was pweviouswy known as:" }, "beatmapSetOverview": { - "by": { - "text": "by {artist}", - "context": "Text showing beatmap artist, includes artist name as parameter" - }, - "mappedBy": { - "text": "mapped by {creator}", - "context": "Text showing beatmap creator/mapper, includes creator name as parameter" - } + "by": "by {artist}", + "mappedBy": "mapped by {creator}" }, "privilegeBadges": { "badges": { - "Developer": { - "text": "Devewoper", - "context": "Name of the Developer badge" - }, - "Admin": { - "text": "Admin", - "context": "Name of the Admin badge" - }, - "Bat": { - "text": "BAT", - "context": "Name of the BAT (Beatmap Appreciation Team) badge" - }, - "Bot": { - "text": "Bot", - "context": "Name of the Bot badge" - }, - "Supporter": { - "text": "Suppowter", - "context": "Name of the Supporter badge" - } + "Developer": "Devewoper", + "Admin": "Admin", + "Bat": "BAT", + "Bot": "Bot", + "Supporter": "Suppowter" } }, "scoreOverview": { - "pp": { - "text": "pp", - "context": "Abbreviation for performance points" - }, - "accuracy": { - "text": "acc: {accuracy}%", - "context": "Text showing score accuracy, includes accuracy percentage as parameter" - } + "pp": "pp", + "accuracy": "acc: {accuracy}%" }, "statsChart": { - "date": { - "text": "Date", - "context": "Label for the date axis on the stats chart" - }, + "date": "Date", "types": { - "pp": { - "text": "pp", - "context": "Abbreviation for performance points in chart tooltip" - }, - "rank": { - "text": "rank", - "context": "Word for rank in chart tooltip" - } + "pp": "pp", + "rank": "rank" }, - "tooltip": { - "text": "{value} {type}", - "context": "Tooltip text showing chart value and type (pp/rank), includes value and type as parameters" - } + "tooltip": "{value} {type}" } } } diff --git a/lib/i18n/messages/es.json b/lib/i18n/messages/es.json new file mode 100644 index 0000000..7f786ff --- /dev/null +++ b/lib/i18n/messages/es.json @@ -0,0 +1,911 @@ +{ + "general": { + "appName": "osu!sunrise", + "serverTitle": { + "full": "sunrise", + "split": { + "part1": "sun", + "part2": "rise" + } + } + }, + "components": { + "serverMaintenanceDialog": { + "title": "¡Hey! ¡Alto ahí!", + "discordMessage": "Para más información, visita nuestro servidor de Discord.", + "button": "Vale, lo entiendo", + "message": "El servidor está actualmente en modo de mantenimiento, por lo que algunas funciones del sitio web pueden no funcionar correctamente." + }, + "contentNotExist": { + "defaultText": "Contenido no encontrado" + }, + "workInProgress": { + "title": "En progreso", + "description": "Este contenido aún está en desarrollo. Vuelve más tarde." + }, + "beatmapSetCard": { + "submittedBy": "enviado por", + "submittedOn": "enviado el", + "view": "Ver" + }, + "friendshipButton": { + "unfriend": "Eliminar amigo", + "unfollow": "Dejar de seguir", + "follow": "Seguir" + }, + "gameModeSelector": { + "selectedMode": "Modo seleccionado:" + }, + "header": { + "links": { + "leaderboard": "clasificaciones", + "topPlays": "mejores jugadas", + "beatmaps": "mapas", + "help": "ayuda", + "wiki": "wiki", + "rules": "reglas", + "apiDocs": "documentación de la API", + "discordServer": "servidor de Discord", + "supportUs": "apóyanos" + } + }, + "headerLoginDialog": { + "signIn": "iniciar sesión", + "title": "Inicia sesión para continuar", + "description": "Bienvenido de vuelta.", + "username": { + "label": "Nombre de usuario", + "placeholder": "p. ej. username" + }, + "password": { + "label": "Contraseña", + "placeholder": "************" + }, + "login": "Iniciar sesión", + "signUp": "¿No tienes una cuenta? Regístrate", + "toast": { + "success": "¡Has iniciado sesión correctamente!" + }, + "validation": { + "usernameMinLength": "El nombre de usuario debe tener al menos 2 caracteres.", + "usernameMaxLength": "El nombre de usuario debe tener 32 caracteres o menos.", + "passwordMinLength": "La contraseña debe tener al menos 8 caracteres.", + "passwordMaxLength": "La contraseña debe tener 32 caracteres o menos." + } + }, + "headerLogoutAlert": { + "title": "¿Estás seguro?", + "description": "Tendrás que iniciar sesión de nuevo para acceder a tu cuenta.", + "cancel": "Cancelar", + "continue": "Continuar", + "toast": { + "success": "Has cerrado sesión correctamente." + } + }, + "headerSearchCommand": { + "placeholder": "Escribe para buscar...", + "headings": { + "users": "Usuarios", + "beatmapsets": "Sets de mapas", + "pages": "Páginas" + }, + "pages": { + "leaderboard": "Clasificaciones", + "topPlays": "Mejores jugadas", + "beatmapsSearch": "Búsqueda de mapas", + "wiki": "Wiki", + "rules": "Reglas", + "yourProfile": "Tu perfil", + "friends": "Amigos", + "settings": "Configuración" + } + }, + "headerUserDropdown": { + "myProfile": "Mi perfil", + "friends": "Amigos", + "settings": "Configuración", + "returnToMainSite": "Volver al sitio principal", + "adminPanel": "Panel de administración", + "logOut": "Cerrar sesión" + }, + "headerMobileDrawer": { + "navigation": { + "home": "Inicio", + "leaderboard": "Clasificaciones", + "topPlays": "Mejores jugadas", + "beatmapsSearch": "Búsqueda de mapas", + "wiki": "Wiki", + "rules": "Reglas", + "apiDocs": "Documentación de la API", + "discordServer": "Servidor de Discord", + "supportUs": "Apóyanos" + }, + "yourProfile": "Tu perfil", + "friends": "Amigos", + "settings": "Configuración", + "adminPanel": "Panel de administración", + "logOut": "Cerrar sesión" + }, + "footer": { + "voteMessage": "¡Por favor, vota por nosotros en osu-server-list!", + "copyright": "© 2024-2025 Sunrise Community", + "sourceCode": "Código fuente", + "serverStatus": "Estado del servidor", + "disclaimer": "No estamos afiliados con \"ppy\" ni \"osu!\" de ninguna manera. Todos los derechos pertenecen a sus respectivos propietarios." + }, + "comboBox": { + "selectValue": "Selecciona un valor...", + "searchValue": "Buscar...", + "noValuesFound": "No se encontraron valores." + }, + "beatmapsetRowElement": { + "mappedBy": "mapeado por {creator}" + }, + "themeModeToggle": { + "toggleTheme": "Cambiar tema", + "light": "Claro", + "dark": "Oscuro", + "system": "Sistema" + }, + "imageSelect": { + "imageTooBig": "¡La imagen seleccionada es demasiado grande!" + }, + "notFound": { + "meta": { + "title": "Página no encontrada | {appName}", + "description": "¡Lo sentimos, la página que has solicitado no está aquí!" + }, + "title": "Página no encontrada | 404", + "description": "¡Lo sentimos, la página que has solicitado no está aquí!" + }, + "rootLayout": { + "meta": { + "title": "{appName}", + "description": "{appName} es un servidor privado de osu!, un juego de ritmo." + } + } + }, + "pages": { + "mainPage": { + "meta": { + "title": "Bienvenido | {appName}", + "description": "Únete a osu!sunrise, un servidor privado de osu! lleno de funciones con soporte para Relax, Autopilot y ScoreV2, y un sistema de PP personalizado adaptado a Relax y Autopilot." + }, + "features": { + "motto": "- otro servidor de osu!", + "description": "Servidor de osu! lleno de funciones con soporte para Relax, Autopilot y ScoreV2, con un sistema de cálculo de PP personalizado adaptado a Relax y Autopilot.", + "buttons": { + "register": "Únete ahora", + "wiki": "Cómo conectarse" + } + }, + "whyUs": "¿Por qué nosotros?", + "cards": { + "freeFeatures": { + "title": "Funciones realmente gratis", + "description": "¡Disfruta de funciones como osu!direct y cambios de nombre sin paywalls — totalmente gratis para todos los jugadores!" + }, + "ppSystem": { + "title": "Cálculos de PP personalizados", + "description": "Usamos el sistema de PP más reciente para puntuaciones vanilla, mientras aplicamos una fórmula personalizada y equilibrada para los modos Relax y Autopilot." + }, + "medals": { + "title": "Gana medallas personalizadas", + "description": "Obtén medallas únicas, exclusivas del servidor, al alcanzar distintos hitos y logros." + }, + "updates": { + "title": "Actualizaciones frecuentes", + "description": "¡Siempre estamos mejorando! Espera actualizaciones regulares, nuevas funciones y optimizaciones de rendimiento continuas." + }, + "ppCalc": { + "title": "Calculadora de PP integrada", + "description": "Nuestro sitio web ofrece una calculadora de PP integrada para estimaciones rápidas y sencillas." + }, + "sunriseCore": { + "title": "Bancho Core hecho a medida", + "description": "A diferencia de la mayoría de servidores privados de osu!, desarrollamos nuestro propio Bancho Core para una mayor estabilidad y soporte de funciones únicas." + } + }, + "howToStart": { + "title": "¿Cómo empiezo a jugar?", + "description": "¡Solo tres pasos y ya estás listo!", + "downloadTile": { + "title": "Descarga el cliente de osu!", + "description": "Si aún no tienes un cliente instalado", + "button": "Descargar" + }, + "registerTile": { + "title": "Registra una cuenta de osu!sunrise", + "description": "La cuenta te permitirá unirte a la comunidad de osu!sunrise", + "button": "Registrarse" + }, + "guideTile": { + "title": "Sigue la guía de conexión", + "description": "Te ayuda a configurar tu cliente de osu! para conectarte a osu!sunrise", + "button": "Abrir guía" + } + }, + "statuses": { + "totalUsers": "Usuarios totales", + "usersOnline": "Usuarios en línea", + "usersRestricted": "Usuarios restringidos", + "totalScores": "Puntuaciones totales", + "serverStatus": "Estado del servidor", + "online": "En línea", + "offline": "Fuera de línea", + "underMaintenance": "En mantenimiento" + } + }, + "wiki": { + "meta": { + "title": "Wiki | {appName}" + }, + "header": "Wiki", + "articles": { + "howToConnect": { + "title": "Cómo conectarse", + "intro": "Para conectarte al servidor, necesitas tener una copia del juego instalada en tu computadora. Puedes descargar el juego desde el sitio oficial de osu!.", + "step1": "Localiza el archivo osu!.exe en el directorio del juego.", + "step2": "Crea un acceso directo del archivo.", + "step3": "Haz clic derecho en el acceso directo y selecciona Propiedades.", + "step4": "En el campo de destino, añade -devserver {serverDomain} al final de la ruta.", + "step5": "Haz clic en Aplicar y luego en OK.", + "step6": "Haz doble clic en el acceso directo para iniciar el juego.", + "imageAlt": "imagen de conexión de osu" + }, + "multipleAccounts": { + "title": "¿Puedo tener varias cuentas?", + "answer": "No. Solo se permite una cuenta por persona.", + "consequence": "Si te atrapan con varias cuentas, serás baneado del servidor." + }, + "cheatsHacks": { + "title": "¿Puedo usar cheats o hacks?", + "answer": "No. Serás baneado si te atrapan.", + "policy": "Somos muy estrictos con las trampas y no las toleramos en absoluto.

Si sospechas que alguien está haciendo trampas, repórtalo al staff." + }, + "appealRestriction": { + "title": "Creo que me restringieron injustamente. ¿Cómo puedo apelar?", + "instructions": "Si crees que fuiste restringido injustamente, puedes apelar tu restricción contactando al staff con tu caso.", + "contactStaff": "Puedes contactar al staff aquí." + }, + "contributeSuggest": { + "title": "¿Puedo contribuir/sugerir cambios al servidor?", + "answer": "¡Sí! Siempre estamos abiertos a sugerencias.", + "instructions": "Si tienes sugerencias, envíalas en nuestra página de GitHub.

Los contribuyentes a largo plazo también pueden tener la oportunidad de obtener un tag de supporter permanente." + }, + "multiplayerDownload": { + "title": "No puedo descargar mapas cuando estoy en multijugador, pero sí desde el menú principal", + "solution": "Desactiva Automatically start osu!direct downloads en las opciones y vuelve a intentarlo." + } + } + }, + "rules": { + "meta": { + "title": "Reglas | {appName}" + }, + "header": "Reglas", + "sections": { + "generalRules": { + "title": "Reglas generales", + "noCheating": { + "title": "No hacer trampas ni hackear.", + "description": "Cualquier forma de trampas, incluidos aimbots, relax hacks, macros o clientes modificados que den una ventaja injusta, está estrictamente prohibida. Juega limpio, mejora limpio.", + "warning": "Como puedes ver, lo escribí con una fuente más grande para todos los \"wannabe\" cheaters que creen que pueden migrar aquí desde otro servidor privado después de ser baneados. Serás encontrado y ejecutado (en Minecraft) si haces trampas. Así que, por favor, no lo hagas." + }, + "noMultiAccount": { + "title": "No multi-cuentas ni compartir cuenta.", + "description": "Solo se permite una cuenta por jugador. Si tu cuenta principal fue restringida sin explicación, contacta con soporte." + }, + "noImpersonation": { + "title": "No suplantar a jugadores populares o al staff", + "description": "No pretendas ser miembro del staff o un jugador conocido. Engañar a otros puede resultar en un cambio de nombre o baneo permanente." + } + }, + "chatCommunityRules": { + "title": "Reglas de chat y comunidad", + "beRespectful": { + "title": "Sé respetuoso.", + "description": "Trata a los demás con amabilidad. El acoso, el discurso de odio, la discriminación o el comportamiento tóxico no serán tolerados." + }, + "noNSFW": { + "title": "No NSFW ni contenido inapropiado", + "description": "Mantén el servidor apropiado para todas las edades — esto aplica a todo el contenido del usuario, incluidos nombres, banners, avatares y descripciones de perfil." + }, + "noAdvertising": { + "title": "La publicidad está prohibida.", + "description": "No promociones otros servidores, sitios web o productos sin aprobación de un admin." + } + }, + "disclaimer": { + "title": "Aviso importante", + "intro": "Al crear y/o mantener una cuenta en nuestra plataforma, reconoces y aceptas los siguientes términos:", + "noLiability": { + "title": "Sin responsabilidad.", + "description": "Aceptas plena responsabilidad por tu participación en cualquier servicio proporcionado por Sunrise y reconoces que no puedes responsabilizar a la organización por cualquier consecuencia que pueda surgir de tu uso." + }, + "accountRestrictions": { + "title": "Restricciones de cuenta.", + "description": "La administración se reserva el derecho de restringir o suspender cualquier cuenta, con o sin previo aviso, por violaciones de las reglas del servidor o a su discreción." + }, + "ruleChanges": { + "title": "Cambios de reglas.", + "description": "Las reglas del servidor pueden cambiar en cualquier momento. La administración puede actualizar, modificar o eliminar cualquier regla con o sin notificación previa, y se espera que todos los jugadores se mantengan informados." + }, + "agreementByParticipation": { + "title": "Acuerdo por participación.", + "description": "Al crear y/o mantener una cuenta en el servidor, aceptas automáticamente estos términos y te comprometes a seguir las reglas y directrices vigentes en ese momento." + } + } + } + }, + "register": { + "meta": { + "title": "Registro | {appName}" + }, + "header": "Registro", + "welcome": { + "title": "¡Bienvenido a la página de registro!", + "description": "¡Hola! Introduce tus datos para crear una cuenta. Si no estás seguro de cómo conectarte al servidor o tienes alguna pregunta, visita nuestra página Wiki." + }, + "form": { + "title": "Introduce tus datos", + "labels": { + "username": "Nombre de usuario", + "email": "Email", + "password": "Contraseña", + "confirmPassword": "Confirmar contraseña" + }, + "placeholders": { + "username": "p. ej. username", + "email": "p. ej. username@mail.com", + "password": "************" + }, + "validation": { + "usernameMin": "El nombre de usuario debe tener al menos {min} caracteres.", + "usernameMax": "El nombre de usuario debe tener {max} caracteres o menos.", + "passwordMin": "La contraseña debe tener al menos {min} caracteres.", + "passwordMax": "La contraseña debe tener {max} caracteres o menos.", + "passwordsDoNotMatch": "Las contraseñas no coinciden" + }, + "error": { + "title": "Error", + "unknown": "Error desconocido." + }, + "submit": "Registrarse", + "terms": "Al registrarte, aceptas las reglas del servidor" + }, + "success": { + "dialog": { + "title": "¡Listo!", + "description": "Tu cuenta se ha creado correctamente.", + "message": "Ahora puedes conectarte al servidor siguiendo la guía en nuestra página Wiki, o personalizar tu perfil actualizando tu avatar y banner antes de empezar a jugar.", + "buttons": { + "viewWiki": "Ver guía Wiki", + "goToProfile": "Ir al perfil" + } + }, + "toast": "¡Cuenta creada correctamente!" + } + }, + "support": { + "meta": { + "title": "Apóyanos | {appName}" + }, + "header": "Apóyanos", + "section": { + "title": "Cómo puedes ayudarnos", + "intro": "Aunque todas las funciones de osu!sunrise siempre han sido gratuitas, mantener y mejorar el servidor requiere recursos, tiempo y esfuerzo, y es mantenido principalmente por un solo desarrollador.



Si te gusta osu!sunrise y quieres verlo crecer aún más, aquí tienes algunas formas de apoyarnos:", + "donate": { + "title": "Donar.", + "description": "Tus generosas donaciones nos ayudan a mantener y mejorar los servidores de osu!. ¡Cada pequeño aporte cuenta! Con tu apoyo, podemos cubrir costos de hosting, implementar nuevas funciones y ofrecer una experiencia más fluida para todos.", + "buttons": { + "kofi": "Ko-fi", + "boosty": "Boosty" + } + }, + "spreadTheWord": { + "title": "Difundir.", + "description": "Cuanta más gente conozca osu!sunrise, más vibrante y emocionante será nuestra comunidad. Cuéntaselo a tus amigos, compártelo en redes sociales e invita a nuevos jugadores." + }, + "justPlay": { + "title": "Juega en el servidor.", + "description": "Una de las formas más fáciles de apoyar osu!sunrise es simplemente jugar en el servidor. Cuantos más jugadores tengamos, mejor será la comunidad y la experiencia. Al unirte, ayudas a que el servidor crezca y se mantenga activo." + } + } + }, + "topplays": { + "meta": { + "title": "Mejores jugadas | {appName}" + }, + "header": "Mejores jugadas", + "showMore": "Mostrar más", + "components": { + "userScoreMinimal": { + "pp": "pp", + "accuracy": "acc:" + } + } + }, + "score": { + "meta": { + "title": "{username} en {beatmapTitle} [{beatmapVersion}] | {appName}", + "description": "El usuario {username} ha obtenido {pp}pp en {beatmapTitle} [{beatmapVersion}] en {appName}.", + "openGraph": { + "title": "{username} en {beatmapTitle} - {beatmapArtist} [{beatmapVersion}] | {appName}", + "description": "El usuario {username} ha obtenido {pp}pp en {beatmapTitle} - {beatmapArtist} [{beatmapVersion}] ★{starRating} {mods} en {appName}." + } + }, + "header": "Rendimiento de la puntuación", + "beatmap": { + "versionUnknown": "Desconocido", + "mappedBy": "mapeado por", + "creatorUnknown": "Creador desconocido" + }, + "score": { + "submittedOn": "Enviado el", + "playedBy": "Jugado por", + "userUnknown": "Usuario desconocido" + }, + "actions": { + "downloadReplay": "Descargar replay", + "openMenu": "Abrir menú" + }, + "error": { + "notFound": "Puntuación no encontrada", + "description": "La puntuación que buscas no existe o ha sido eliminada." + } + }, + "leaderboard": { + "meta": { + "title": "Clasificaciones | {appName}" + }, + "header": "Clasificaciones", + "sortBy": { + "label": "Ordenar por:", + "performancePoints": "Puntos de rendimiento", + "rankedScore": "Ranked Score", + "performancePointsShort": "Puntos", + "scoreShort": "Punt." + }, + "table": { + "columns": { + "rank": "Rango", + "performance": "Rendimiento", + "rankedScore": "Ranked Score", + "accuracy": "Precisión", + "playCount": "Jugadas" + }, + "actions": { + "openMenu": "Abrir menú", + "viewUserProfile": "Ver perfil" + }, + "emptyState": "Sin resultados.", + "pagination": { + "usersPerPage": "usuarios por página", + "showing": "Mostrando {start} - {end} de {total}" + } + } + }, + "friends": { + "meta": { + "title": "Tus amigos | {appName}" + }, + "header": "Tus conexiones", + "tabs": { + "friends": "Amigos", + "followers": "Seguidores" + }, + "sorting": { + "label": "Ordenar por:", + "username": "Nombre de usuario", + "recentlyActive": "Actividad reciente" + }, + "showMore": "Mostrar más", + "emptyState": "No se encontraron usuarios" + }, + "beatmaps": { + "search": { + "meta": { + "title": "Búsqueda de mapas | {appName}" + }, + "header": "Búsqueda de mapas" + }, + "detail": { + "meta": { + "title": "Información de beatmap | {appName}" + }, + "header": "Información de beatmap", + "notFound": { + "title": "Beatmapset no encontrado", + "description": "El beatmapset que buscas no existe o ha sido eliminado." + } + }, + "components": { + "search": { + "searchPlaceholder": "Buscar mapas...", + "filters": "Filtros", + "viewMode": { + "grid": "Cuadrícula", + "list": "Lista" + }, + "showMore": "Mostrar más" + }, + "filters": { + "mode": { + "label": "Modo", + "any": "Cualquiera", + "standard": "osu!", + "taiko": "osu!taiko", + "catch": "osu!catch", + "mania": "osu!mania" + }, + "status": { + "label": "Estado" + }, + "searchByCustomStatus": { + "label": "Buscar por estado personalizado" + }, + "applyFilters": "Aplicar filtros" + } + } + }, + "beatmapsets": { + "meta": { + "title": "{artist} - {title} | {appName}", + "description": "Información del beatmapset de {title} por {artist}", + "openGraph": { + "title": "{artist} - {title} | {appName}", + "description": "Información del beatmapset de {title} por {artist} {difficultyInfo}" + } + }, + "header": "Información de beatmap", + "error": { + "notFound": { + "title": "Beatmapset no encontrado", + "description": "El beatmapset que buscas no existe o ha sido eliminado." + } + }, + "submission": { + "submittedBy": "enviado por", + "submittedOn": "enviado el", + "rankedOn": "ranked el", + "statusBy": "{status} por" + }, + "video": { + "tooltip": "Este beatmap contiene video" + }, + "description": { + "header": "Descripción" + }, + "components": { + "dropdown": { + "openMenu": "Abrir menú", + "ppCalculator": "Calculadora de PP", + "openOnBancho": "Abrir en Bancho", + "openWithAdminPanel": "Abrir con panel de admin" + }, + "infoAccordion": { + "communityHype": "Hype de la comunidad", + "information": "Información", + "metadata": { + "genre": "Género", + "language": "Idioma", + "tags": "Etiquetas" + } + }, + "downloadButtons": { + "download": "Descargar", + "withVideo": "con video", + "withoutVideo": "sin video", + "osuDirect": "osu!direct" + }, + "difficultyInformation": { + "tooltips": { + "totalLength": "Duración total", + "bpm": "BPM", + "starRating": "Dificultad (estrellas)" + }, + "labels": { + "keyCount": "Cantidad de teclas:", + "circleSize": "Tamaño del círculo:", + "hpDrain": "Drenaje de HP:", + "accuracy": "Precisión:", + "approachRate": "Approach Rate:" + } + }, + "nomination": { + "description": "¡Dale hype a este mapa si te gustó jugarlo para ayudarlo a progresar hacia el estado Ranked!", + "hypeProgress": "Progreso de hype", + "hypeBeatmap": "¡Dar hype!", + "hypesRemaining": "Te quedan {count} hypes para esta semana", + "toast": { + "success": "¡Hype enviado correctamente!", + "error": "¡Ocurrió un error al dar hype al beatmapset!" + } + }, + "ppCalculator": { + "title": "Calculadora de PP", + "pp": "PP: {value}", + "totalLength": "Duración total", + "form": { + "accuracy": { + "label": "Precisión", + "validation": { + "negative": "La precisión no puede ser negativa", + "tooHigh": "La precisión no puede ser mayor que 100" + } + }, + "combo": { + "label": "Combo", + "validation": { + "negative": "El combo no puede ser negativo" + } + }, + "misses": { + "label": "Fallos", + "validation": { + "negative": "Los fallos no pueden ser negativos" + } + }, + "calculate": "Calcular", + "unknownError": "Error desconocido" + } + }, + "leaderboard": { + "columns": { + "rank": "Rango", + "score": "Puntuación", + "accuracy": "Precisión", + "player": "Jugador", + "maxCombo": "Combo máx.", + "perfect": "Perfect", + "great": "Great", + "good": "Good", + "ok": "Ok", + "lDrp": "L DRP", + "meh": "Meh", + "sDrp": "S DRP", + "miss": "Miss", + "pp": "PP", + "time": "Tiempo", + "mods": "Mods" + }, + "actions": { + "openMenu": "Abrir menú", + "viewDetails": "Ver detalles", + "downloadReplay": "Descargar replay" + }, + "table": { + "emptyState": "No se encontraron puntuaciones. ¡Sé el primero en enviar una!", + "pagination": { + "scoresPerPage": "puntuaciones por página", + "showing": "Mostrando {start} - {end} de {total}" + } + } + } + } + }, + "settings": { + "meta": { + "title": "Configuración | {appName}" + }, + "header": "Configuración", + "notLoggedIn": "Debes iniciar sesión para ver esta página.", + "sections": { + "changeAvatar": "Cambiar avatar", + "changeBanner": "Cambiar banner", + "changeDescription": "Cambiar descripción", + "socials": "Redes sociales", + "playstyle": "Estilo de juego", + "options": "Opciones", + "changePassword": "Cambiar contraseña", + "changeUsername": "Cambiar nombre de usuario", + "changeCountryFlag": "Cambiar bandera del país" + }, + "description": { + "reminder": "* Recordatorio: No publiques contenido inapropiado. Intenta mantenerlo apto para todos :)" + }, + "components": { + "username": { + "label": "Nuevo nombre de usuario", + "placeholder": "p. ej. username", + "button": "Cambiar nombre de usuario", + "validation": { + "minLength": "El nombre de usuario debe tener al menos {min} caracteres.", + "maxLength": "El nombre de usuario debe tener {max} caracteres o menos." + }, + "toast": { + "success": "¡Nombre de usuario cambiado correctamente!", + "error": "¡Ocurrió un error al cambiar el nombre de usuario!" + }, + "reminder": "* Recordatorio: Mantén tu nombre de usuario apto para todos, o se te cambiará. Abusar de esta función resultará en un baneo." + }, + "password": { + "labels": { + "current": "Contraseña actual", + "new": "Nueva contraseña", + "confirm": "Confirmar contraseña" + }, + "placeholder": "************", + "button": "Cambiar contraseña", + "validation": { + "minLength": "La contraseña debe tener al menos {min} caracteres.", + "maxLength": "La contraseña debe tener {max} caracteres o menos.", + "mismatch": "Las contraseñas no coinciden" + }, + "toast": { + "success": "¡Contraseña cambiada correctamente!", + "error": "¡Ocurrió un error al cambiar la contraseña!" + } + }, + "description": { + "toast": { + "success": "¡Descripción actualizada correctamente!", + "error": "Ocurrió un error desconocido" + } + }, + "country": { + "label": "Nueva bandera del país", + "placeholder": "Selecciona nueva bandera", + "button": "Cambiar bandera del país", + "toast": { + "success": "¡Bandera cambiada correctamente!", + "error": "¡Ocurrió un error al cambiar la bandera!" + } + }, + "socials": { + "headings": { + "general": "General", + "socials": "Redes sociales" + }, + "fields": { + "location": "ubicación", + "interest": "interés", + "occupation": "ocupación" + }, + "button": "Actualizar redes sociales", + "toast": { + "success": "¡Redes sociales actualizadas correctamente!", + "error": "¡Ocurrió un error al actualizar las redes sociales!" + } + }, + "playstyle": { + "options": { + "Mouse": "Ratón", + "Keyboard": "Teclado", + "Tablet": "Tableta", + "TouchScreen": "Pantalla táctil" + }, + "toast": { + "success": "¡Estilo de juego actualizado correctamente!", + "error": "¡Ocurrió un error al actualizar el estilo de juego!" + } + }, + "uploadImage": { + "types": { + "avatar": "avatar", + "banner": "banner" + }, + "button": "Subir {type}", + "toast": { + "success": "¡{type} actualizado correctamente!", + "error": "Ocurrió un error desconocido" + }, + "note": "* Nota: los {type}s están limitados a 5MB" + }, + "siteOptions": { + "includeBanchoButton": "Incluir botón \"Open on Bancho\" en la página del beatmap", + "useSpaciousUI": "Usar interfaz espaciosa (aumentar el espaciado entre elementos)" + } + }, + "common": { + "unknownError": "Error desconocido." + } + }, + "user": { + "meta": { + "title": "{username} · Perfil de usuario | {appName}", + "description": "No sabemos mucho sobre él/ella, pero estamos seguros de que {username} es genial." + }, + "header": "Información del jugador", + "tabs": { + "general": "General", + "bestScores": "Mejores puntuaciones", + "recentScores": "Puntuaciones recientes", + "firstPlaces": "Primeros puestos", + "beatmaps": "Beatmaps", + "medals": "Medallas" + }, + "buttons": { + "editProfile": "Editar perfil", + "setDefaultGamemode": "Establecer {gamemode} {flag} como modo predeterminado del perfil" + }, + "errors": { + "userNotFound": "Usuario no encontrado o ocurrió un error.", + "restricted": "Esto significa que el usuario violó las reglas del servidor y ha sido restringido.", + "userDeleted": "El usuario puede haber sido eliminado o no existir." + }, + "components": { + "generalTab": { + "info": "Info", + "rankedScore": "Ranked Score", + "hitAccuracy": "Precisión", + "playcount": "Jugadas", + "totalScore": "Puntuación total", + "maximumCombo": "Combo máximo", + "playtime": "Tiempo de juego", + "performance": "Rendimiento", + "showByRank": "Mostrar por rango", + "showByPp": "Mostrar por pp", + "aboutMe": "Sobre mí" + }, + "scoresTab": { + "bestScores": "Mejores puntuaciones", + "recentScores": "Puntuaciones recientes", + "firstPlaces": "Primeros puestos", + "noScores": "El usuario no tiene puntuaciones de {type}", + "showMore": "Mostrar más" + }, + "beatmapsTab": { + "mostPlayed": "Más jugadas", + "noMostPlayed": "El usuario no tiene beatmaps más jugadas", + "favouriteBeatmaps": "Beatmaps favoritas", + "noFavourites": "El usuario no tiene beatmaps favoritas", + "showMore": "Mostrar más" + }, + "medalsTab": { + "medals": "Medallas", + "latest": "Últimas", + "categories": { + "hushHush": "Hush hush", + "beatmapHunt": "Beatmap hunt", + "modIntroduction": "Mod introduction", + "skill": "Skill" + }, + "achievedOn": "obtenida el", + "notAchieved": "No obtenida" + }, + "generalInformation": { + "joined": "Se unió {time}", + "followers": "{count} Seguidores", + "following": "Siguiendo {count}", + "playsWith": "Juega con {playstyle}" + }, + "statusText": { + "lastSeenOn": ", visto por última vez el {date}" + }, + "ranks": { + "highestRank": "Mejor rango {rank} el {date}" + }, + "previousUsernames": { + "previouslyKnownAs": "Este usuario antes era conocido como:" + }, + "beatmapSetOverview": { + "by": "por {artist}", + "mappedBy": "mapeado por {creator}" + }, + "privilegeBadges": { + "badges": { + "Developer": "Desarrollador", + "Admin": "Admin", + "Bat": "BAT", + "Bot": "Bot", + "Supporter": "Supporter" + } + }, + "scoreOverview": { + "pp": "pp", + "accuracy": "acc: {accuracy}%" + }, + "statsChart": { + "date": "Fecha", + "types": { + "pp": "pp", + "rank": "rank" + }, + "tooltip": "{value} {type}" + } + } + } + } +} diff --git a/lib/i18n/messages/fr.json b/lib/i18n/messages/fr.json new file mode 100644 index 0000000..64b4dd2 --- /dev/null +++ b/lib/i18n/messages/fr.json @@ -0,0 +1,911 @@ +{ + "general": { + "appName": "osu!sunrise", + "serverTitle": { + "full": "sunrise", + "split": { + "part1": "sun", + "part2": "rise" + } + } + }, + "components": { + "serverMaintenanceDialog": { + "title": "Hey ! Stop là !", + "discordMessage": "Pour plus d'informations, consulte notre serveur Discord.", + "button": "OK, j'ai compris", + "message": "Le serveur est actuellement en mode maintenance, certaines fonctionnalités du site peuvent ne pas fonctionner correctement." + }, + "contentNotExist": { + "defaultText": "Contenu introuvable" + }, + "workInProgress": { + "title": "En cours", + "description": "Ce contenu est encore en cours de réalisation. Revenez plus tard." + }, + "beatmapSetCard": { + "submittedBy": "soumis par", + "submittedOn": "soumis le", + "view": "Voir" + }, + "friendshipButton": { + "unfriend": "Retirer des amis", + "unfollow": "Se désabonner", + "follow": "Suivre" + }, + "gameModeSelector": { + "selectedMode": "Mode sélectionné :" + }, + "header": { + "links": { + "leaderboard": "classements", + "topPlays": "meilleures performances", + "beatmaps": "beatmaps", + "help": "aide", + "wiki": "wiki", + "rules": "règles", + "apiDocs": "documentation de l'API", + "discordServer": "serveur Discord", + "supportUs": "nous soutenir" + } + }, + "headerLoginDialog": { + "signIn": "se connecter", + "title": "Se connecter pour continuer", + "description": "Bon retour.", + "username": { + "label": "Nom d'utilisateur", + "placeholder": "ex. username" + }, + "password": { + "label": "Mot de passe", + "placeholder": "************" + }, + "login": "Se connecter", + "signUp": "Vous n'avez pas de compte ? S'inscrire", + "toast": { + "success": "Connexion réussie !" + }, + "validation": { + "usernameMinLength": "Le nom d'utilisateur doit contenir au moins 2 caractères.", + "usernameMaxLength": "Le nom d'utilisateur doit contenir au maximum 32 caractères.", + "passwordMinLength": "Le mot de passe doit contenir au moins 8 caractères.", + "passwordMaxLength": "Le mot de passe doit contenir au maximum 32 caractères." + } + }, + "headerLogoutAlert": { + "title": "Êtes-vous sûr ?", + "description": "Vous devrez vous reconnecter pour accéder à votre compte.", + "cancel": "Annuler", + "continue": "Continuer", + "toast": { + "success": "Vous avez été déconnecté avec succès." + } + }, + "headerSearchCommand": { + "placeholder": "Tapez pour rechercher...", + "headings": { + "users": "Utilisateurs", + "beatmapsets": "Beatmapsets", + "pages": "Pages" + }, + "pages": { + "leaderboard": "Classements", + "topPlays": "Meilleures performances", + "beatmapsSearch": "Recherche de beatmaps", + "wiki": "Wiki", + "rules": "Règles", + "yourProfile": "Votre profil", + "friends": "Amis", + "settings": "Paramètres" + } + }, + "headerUserDropdown": { + "myProfile": "Mon profil", + "friends": "Amis", + "settings": "Paramètres", + "returnToMainSite": "Retourner au site principal", + "adminPanel": "Panneau d'administration", + "logOut": "Se déconnecter" + }, + "headerMobileDrawer": { + "navigation": { + "home": "Accueil", + "leaderboard": "Classements", + "topPlays": "Meilleures performances", + "beatmapsSearch": "Recherche de beatmaps", + "wiki": "Wiki", + "rules": "Règles", + "apiDocs": "Documentation de l'API", + "discordServer": "Serveur Discord", + "supportUs": "Nous soutenir" + }, + "yourProfile": "Votre profil", + "friends": "Amis", + "settings": "Paramètres", + "adminPanel": "Panneau d'administration", + "logOut": "Se déconnecter" + }, + "footer": { + "voteMessage": "Veuillez voter pour nous sur osu-server-list !", + "copyright": "© 2024-2025 Sunrise Community", + "sourceCode": "Code source", + "serverStatus": "Statut du serveur", + "disclaimer": "Nous ne sommes affiliés à \"ppy\" ni à \"osu!\" d'aucune manière. Tous les droits appartiennent à leurs propriétaires respectifs." + }, + "comboBox": { + "selectValue": "Sélectionner une valeur...", + "searchValue": "Rechercher...", + "noValuesFound": "Aucune valeur trouvée." + }, + "beatmapsetRowElement": { + "mappedBy": "mappé par {creator}" + }, + "themeModeToggle": { + "toggleTheme": "Changer le thème", + "light": "Clair", + "dark": "Sombre", + "system": "Système" + }, + "imageSelect": { + "imageTooBig": "L'image sélectionnée est trop grande !" + }, + "notFound": { + "meta": { + "title": "Page manquante | {appName}", + "description": "Désolé, mais la page demandée n'est pas ici !" + }, + "title": "Page manquante | 404", + "description": "Désolé, mais la page demandée n'est pas ici !" + }, + "rootLayout": { + "meta": { + "title": "{appName}", + "description": "{appName} est un serveur privé pour osu!, un jeu de rythme." + } + } + }, + "pages": { + "mainPage": { + "meta": { + "title": "Bienvenue | {appName}", + "description": "Rejoignez osu!sunrise, un serveur privé osu! riche en fonctionnalités avec Relax, Autopilot, ScoreV2, et un système de PP personnalisé adapté à Relax et Autopilot." + }, + "features": { + "motto": "- encore un serveur osu!", + "description": "Serveur osu! riche en fonctionnalités avec support Relax, Autopilot et ScoreV2, et un système de calcul de PP personnalisé adapté à Relax et Autopilot.", + "buttons": { + "register": "Rejoindre maintenant", + "wiki": "Comment se connecter" + } + }, + "whyUs": "Pourquoi nous ?", + "cards": { + "freeFeatures": { + "title": "Fonctionnalités vraiment gratuites", + "description": "Profitez de fonctionnalités comme osu!direct et le changement de pseudo sans paywall — totalement gratuit pour tous les joueurs !" + }, + "ppSystem": { + "title": "Calculs de PP personnalisés", + "description": "Nous utilisons le dernier système de PP pour les scores vanilla, tout en appliquant une formule personnalisée et équilibrée pour les modes Relax et Autopilot." + }, + "medals": { + "title": "Gagnez des médailles personnalisées", + "description": "Gagnez des médailles uniques, exclusives au serveur, en accomplissant divers jalons et objectifs." + }, + "updates": { + "title": "Mises à jour fréquentes", + "description": "Nous nous améliorons en permanence ! Attendez-vous à des mises à jour régulières, de nouvelles fonctionnalités et des optimisations continues." + }, + "ppCalc": { + "title": "Calculateur de PP intégré", + "description": "Notre site propose un calculateur de PP intégré pour des estimations rapides et faciles." + }, + "sunriseCore": { + "title": "Bancho Core sur mesure", + "description": "Contrairement à la plupart des serveurs privés osu!, nous avons développé notre propre Bancho Core pour plus de stabilité et un support de fonctionnalités uniques." + } + }, + "howToStart": { + "title": "Comment commencer à jouer ?", + "description": "Trois étapes simples et vous êtes prêt !", + "downloadTile": { + "title": "Télécharger le client osu!", + "description": "Si vous n'avez pas encore de client installé", + "button": "Télécharger" + }, + "registerTile": { + "title": "Créer un compte osu!sunrise", + "description": "Un compte vous permet de rejoindre la communauté osu!sunrise", + "button": "S'inscrire" + }, + "guideTile": { + "title": "Suivre le guide de connexion", + "description": "Il vous aide à configurer votre client osu! pour vous connecter à osu!sunrise", + "button": "Ouvrir le guide" + } + }, + "statuses": { + "totalUsers": "Utilisateurs totaux", + "usersOnline": "Utilisateurs en ligne", + "usersRestricted": "Utilisateurs restreints", + "totalScores": "Scores totaux", + "serverStatus": "Statut du serveur", + "online": "En ligne", + "offline": "Hors ligne", + "underMaintenance": "En maintenance" + } + }, + "wiki": { + "meta": { + "title": "Wiki | {appName}" + }, + "header": "Wiki", + "articles": { + "howToConnect": { + "title": "Comment se connecter", + "intro": "Pour vous connecter au serveur, vous devez avoir une copie du jeu installée sur votre ordinateur. Vous pouvez télécharger le jeu depuis le site officiel d'osu!.", + "step1": "Localisez le fichier osu!.exe dans le dossier du jeu.", + "step2": "Créez un raccourci du fichier.", + "step3": "Cliquez droit sur le raccourci et sélectionnez Propriétés.", + "step4": "Dans le champ cible, ajoutez -devserver {serverDomain} à la fin du chemin.", + "step5": "Cliquez sur Appliquer puis sur OK.", + "step6": "Double-cliquez sur le raccourci pour lancer le jeu.", + "imageAlt": "image de connexion osu" + }, + "multipleAccounts": { + "title": "Puis-je avoir plusieurs comptes ?", + "answer": "Non. Un seul compte par personne est autorisé.", + "consequence": "Si vous êtes pris avec plusieurs comptes, vous serez banni du serveur." + }, + "cheatsHacks": { + "title": "Puis-je utiliser des cheats ou des hacks ?", + "answer": "Non. Vous serez banni si vous êtes pris.", + "policy": "Nous sommes très stricts sur la triche et ne la tolérons pas.

Si vous suspectez quelqu'un de tricher, merci de le signaler au staff." + }, + "appealRestriction": { + "title": "Je pense avoir été restreint injustement. Comment faire appel ?", + "instructions": "Si vous pensez avoir été restreint injustement, vous pouvez faire appel en contactant le staff avec votre cas.", + "contactStaff": "Vous pouvez contacter le staff ici." + }, + "contributeSuggest": { + "title": "Puis-je contribuer/proposer des changements au serveur ?", + "answer": "Oui ! Nous sommes toujours ouverts aux suggestions.", + "instructions": "Si vous avez des suggestions, merci de les soumettre sur notre page GitHub.

Les contributeurs long terme peuvent aussi avoir une chance d'obtenir un tag supporter permanent." + }, + "multiplayerDownload": { + "title": "Je ne peux pas télécharger de maps en multijoueur, mais je peux depuis le menu principal", + "solution": "Désactivez Automatically start osu!direct downloads dans les options et réessayez." + } + } + }, + "rules": { + "meta": { + "title": "Règles | {appName}" + }, + "header": "Règles", + "sections": { + "generalRules": { + "title": "Règles générales", + "noCheating": { + "title": "Pas de triche ni de hack.", + "description": "Toute forme de triche, y compris aimbots, relax hacks, macros ou clients modifiés donnant un avantage injuste, est strictement interdite. Jouez fair-play, progressez fair-play.", + "warning": "Comme vous pouvez le voir, j'ai écrit ça en plus gros pour tous les tricheurs \"wannabe\" qui pensent pouvoir migrer ici après avoir été bannis d'un autre serveur privé. Vous serez retrouvés et exécutés (dans Minecraft) si vous trichez. Alors s'il vous plaît, ne le faites pas." + }, + "noMultiAccount": { + "title": "Pas de multi-comptes ni de partage de compte.", + "description": "Un seul compte par joueur est autorisé. Si votre compte principal a été restreint sans explication, contactez le support." + }, + "noImpersonation": { + "title": "Pas d'usurpation de joueurs connus ou du staff", + "description": "Ne prétendez pas être un membre du staff ou un joueur connu. Tromper les autres peut entraîner un changement de pseudo ou un bannissement permanent." + } + }, + "chatCommunityRules": { + "title": "Règles du chat & de la communauté", + "beRespectful": { + "title": "Soyez respectueux.", + "description": "Traitez les autres avec bienveillance. Harcèlement, haine, discrimination ou comportement toxique ne seront pas tolérés." + }, + "noNSFW": { + "title": "Pas de NSFW ni de contenu inapproprié", + "description": "Gardez le serveur approprié pour tous — cela s'applique à tout le contenu utilisateur, y compris noms, bannières, avatars et descriptions de profil." + }, + "noAdvertising": { + "title": "La publicité est interdite.", + "description": "Ne faites pas la promotion d'autres serveurs, sites web ou produits sans l'approbation d'un admin." + } + }, + "disclaimer": { + "title": "Avertissement important", + "intro": "En créant et/ou en maintenant un compte sur notre plateforme, vous reconnaissez et acceptez les conditions suivantes :", + "noLiability": { + "title": "Aucune responsabilité.", + "description": "Vous acceptez l'entière responsabilité de votre participation à tout service fourni par Sunrise et reconnaissez ne pas pouvoir tenir l'organisation responsable des conséquences pouvant découler de votre utilisation." + }, + "accountRestrictions": { + "title": "Restrictions de compte.", + "description": "L'administration se réserve le droit de restreindre ou suspendre tout compte, avec ou sans préavis, en cas de violation des règles du serveur ou à sa discrétion." + }, + "ruleChanges": { + "title": "Changements de règles.", + "description": "Les règles du serveur peuvent changer à tout moment. L'administration peut mettre à jour, modifier ou supprimer une règle avec ou sans notification préalable, et les joueurs sont censés rester informés." + }, + "agreementByParticipation": { + "title": "Accord par participation.", + "description": "En créant et/ou en maintenant un compte sur le serveur, vous acceptez automatiquement ces conditions et vous engagez à respecter les règles et directives en vigueur à ce moment-là." + } + } + } + }, + "register": { + "meta": { + "title": "Inscription | {appName}" + }, + "header": "Inscription", + "welcome": { + "title": "Bienvenue sur la page d'inscription !", + "description": "Bonjour ! Veuillez saisir vos informations pour créer un compte. Si vous ne savez pas comment vous connecter au serveur ou si vous avez des questions, consultez notre page Wiki." + }, + "form": { + "title": "Saisissez vos informations", + "labels": { + "username": "Nom d'utilisateur", + "email": "Email", + "password": "Mot de passe", + "confirmPassword": "Confirmer le mot de passe" + }, + "placeholders": { + "username": "ex. username", + "email": "ex. username@mail.com", + "password": "************" + }, + "validation": { + "usernameMin": "Le nom d'utilisateur doit contenir au moins {min} caractères.", + "usernameMax": "Le nom d'utilisateur doit contenir {max} caractères ou moins.", + "passwordMin": "Le mot de passe doit contenir au moins {min} caractères.", + "passwordMax": "Le mot de passe doit contenir {max} caractères ou moins.", + "passwordsDoNotMatch": "Les mots de passe ne correspondent pas" + }, + "error": { + "title": "Erreur", + "unknown": "Erreur inconnue." + }, + "submit": "S'inscrire", + "terms": "En vous inscrivant, vous acceptez les règles du serveur" + }, + "success": { + "dialog": { + "title": "C'est bon !", + "description": "Votre compte a été créé avec succès.", + "message": "Vous pouvez maintenant vous connecter au serveur en suivant le guide sur notre page Wiki, ou personnaliser votre profil en mettant à jour votre avatar et bannière avant de commencer à jouer !", + "buttons": { + "viewWiki": "Voir le guide Wiki", + "goToProfile": "Aller au profil" + } + }, + "toast": "Compte créé avec succès !" + } + }, + "support": { + "meta": { + "title": "Nous soutenir | {appName}" + }, + "header": "Nous soutenir", + "section": { + "title": "Comment vous pouvez nous aider", + "intro": "Même si toutes les fonctionnalités d'osu!sunrise ont toujours été gratuites, faire tourner et améliorer le serveur demande des ressources, du temps et des efforts, et il est principalement maintenu par un seul développeur.



Si vous aimez osu!sunrise et souhaitez le voir grandir, voici quelques façons de nous soutenir :", + "donate": { + "title": "Faire un don.", + "description": "Vos dons nous aident à maintenir et améliorer les serveurs osu!. Chaque contribution compte ! Avec votre soutien, nous pouvons couvrir les coûts d'hébergement, implémenter de nouvelles fonctionnalités et offrir une expérience plus fluide pour tous.", + "buttons": { + "kofi": "Ko-fi", + "boosty": "Boosty" + } + }, + "spreadTheWord": { + "title": "Faire connaître.", + "description": "Plus les gens connaissent osu!sunrise, plus notre communauté sera vivante et passionnante. Parlez-en à vos amis, partagez sur les réseaux sociaux et invitez de nouveaux joueurs." + }, + "justPlay": { + "title": "Jouer sur le serveur.", + "description": "L'une des façons les plus simples de soutenir osu!sunrise est simplement d'y jouer ! Plus nous avons de joueurs, meilleure sera la communauté et l'expérience. En rejoignant, vous aidez le serveur à grandir et à rester actif." + } + } + }, + "topplays": { + "meta": { + "title": "Meilleures performances | {appName}" + }, + "header": "Meilleures performances", + "showMore": "Afficher plus", + "components": { + "userScoreMinimal": { + "pp": "pp", + "accuracy": "acc:" + } + } + }, + "score": { + "meta": { + "title": "{username} sur {beatmapTitle} [{beatmapVersion}] | {appName}", + "description": "L'utilisateur {username} a obtenu {pp}pp sur {beatmapTitle} [{beatmapVersion}] dans {appName}.", + "openGraph": { + "title": "{username} sur {beatmapTitle} - {beatmapArtist} [{beatmapVersion}] | {appName}", + "description": "L'utilisateur {username} a obtenu {pp}pp sur {beatmapTitle} - {beatmapArtist} [{beatmapVersion}] ★{starRating} {mods} dans {appName}." + } + }, + "header": "Performance du score", + "beatmap": { + "versionUnknown": "Inconnu", + "mappedBy": "mappé par", + "creatorUnknown": "Créateur inconnu" + }, + "score": { + "submittedOn": "Soumis le", + "playedBy": "Joué par", + "userUnknown": "Utilisateur inconnu" + }, + "actions": { + "downloadReplay": "Télécharger le replay", + "openMenu": "Ouvrir le menu" + }, + "error": { + "notFound": "Score introuvable", + "description": "Le score que vous recherchez n'existe pas ou a été supprimé." + } + }, + "leaderboard": { + "meta": { + "title": "Classements | {appName}" + }, + "header": "Classements", + "sortBy": { + "label": "Trier par :", + "performancePoints": "Points de performance", + "rankedScore": "Ranked Score", + "performancePointsShort": "Perf.", + "scoreShort": "Score" + }, + "table": { + "columns": { + "rank": "Rang", + "performance": "Performance", + "rankedScore": "Ranked Score", + "accuracy": "Précision", + "playCount": "Nombre de parties" + }, + "actions": { + "openMenu": "Ouvrir le menu", + "viewUserProfile": "Voir le profil" + }, + "emptyState": "Aucun résultat.", + "pagination": { + "usersPerPage": "utilisateurs par page", + "showing": "Affichage {start} - {end} sur {total}" + } + } + }, + "friends": { + "meta": { + "title": "Vos amis | {appName}" + }, + "header": "Vos connexions", + "tabs": { + "friends": "Amis", + "followers": "Abonnés" + }, + "sorting": { + "label": "Trier par :", + "username": "Nom d'utilisateur", + "recentlyActive": "Récemment actif" + }, + "showMore": "Afficher plus", + "emptyState": "Aucun utilisateur trouvé" + }, + "beatmaps": { + "search": { + "meta": { + "title": "Recherche de beatmaps | {appName}" + }, + "header": "Recherche de beatmaps" + }, + "detail": { + "meta": { + "title": "Infos beatmap | {appName}" + }, + "header": "Infos beatmap", + "notFound": { + "title": "Beatmapset introuvable", + "description": "Le beatmapset que vous recherchez n'existe pas ou a été supprimé." + } + }, + "components": { + "search": { + "searchPlaceholder": "Rechercher des beatmaps...", + "filters": "Filtres", + "viewMode": { + "grid": "Grille", + "list": "Liste" + }, + "showMore": "Afficher plus" + }, + "filters": { + "mode": { + "label": "Mode", + "any": "Tous", + "standard": "osu!", + "taiko": "osu!taiko", + "catch": "osu!catch", + "mania": "osu!mania" + }, + "status": { + "label": "Statut" + }, + "searchByCustomStatus": { + "label": "Rechercher par statut personnalisé" + }, + "applyFilters": "Appliquer les filtres" + } + } + }, + "beatmapsets": { + "meta": { + "title": "{artist} - {title} | {appName}", + "description": "Infos du beatmapset {title} par {artist}", + "openGraph": { + "title": "{artist} - {title} | {appName}", + "description": "Infos du beatmapset {title} par {artist} {difficultyInfo}" + } + }, + "header": "Infos beatmap", + "error": { + "notFound": { + "title": "Beatmapset introuvable", + "description": "Le beatmapset que vous recherchez n'existe pas ou a été supprimé." + } + }, + "submission": { + "submittedBy": "soumis par", + "submittedOn": "soumis le", + "rankedOn": "ranked le", + "statusBy": "{status} par" + }, + "video": { + "tooltip": "Cette beatmap contient une vidéo" + }, + "description": { + "header": "Description" + }, + "components": { + "dropdown": { + "openMenu": "Ouvrir le menu", + "ppCalculator": "Calculateur de PP", + "openOnBancho": "Ouvrir sur Bancho", + "openWithAdminPanel": "Ouvrir avec le panneau admin" + }, + "infoAccordion": { + "communityHype": "Hype communauté", + "information": "Informations", + "metadata": { + "genre": "Genre", + "language": "Langue", + "tags": "Tags" + } + }, + "downloadButtons": { + "download": "Télécharger", + "withVideo": "avec vidéo", + "withoutVideo": "sans vidéo", + "osuDirect": "osu!direct" + }, + "difficultyInformation": { + "tooltips": { + "totalLength": "Durée totale", + "bpm": "BPM", + "starRating": "Étoiles" + }, + "labels": { + "keyCount": "Nombre de touches :", + "circleSize": "Taille des cercles :", + "hpDrain": "Drain HP :", + "accuracy": "Précision :", + "approachRate": "Approach Rate :" + } + }, + "nomination": { + "description": "Hypez cette map si vous avez aimé y jouer pour l'aider à progresser vers le statut Ranked.", + "hypeProgress": "Progression du hype", + "hypeBeatmap": "Hype !", + "hypesRemaining": "Il vous reste {count} hypes pour cette semaine", + "toast": { + "success": "Beatmap hypée avec succès !", + "error": "Erreur lors du hype du beatmapset !" + } + }, + "ppCalculator": { + "title": "Calculateur de PP", + "pp": "PP : {value}", + "totalLength": "Durée totale", + "form": { + "accuracy": { + "label": "Précision", + "validation": { + "negative": "La précision ne peut pas être négative", + "tooHigh": "La précision ne peut pas dépasser 100" + } + }, + "combo": { + "label": "Combo", + "validation": { + "negative": "Le combo ne peut pas être négatif" + } + }, + "misses": { + "label": "Miss", + "validation": { + "negative": "Le nombre de miss ne peut pas être négatif" + } + }, + "calculate": "Calculer", + "unknownError": "Erreur inconnue" + } + }, + "leaderboard": { + "columns": { + "rank": "Rang", + "score": "Score", + "accuracy": "Précision", + "player": "Joueur", + "maxCombo": "Combo max", + "perfect": "Perfect", + "great": "Great", + "good": "Good", + "ok": "Ok", + "lDrp": "L DRP", + "meh": "Meh", + "sDrp": "S DRP", + "miss": "Miss", + "pp": "PP", + "time": "Temps", + "mods": "Mods" + }, + "actions": { + "openMenu": "Ouvrir le menu", + "viewDetails": "Voir les détails", + "downloadReplay": "Télécharger le replay" + }, + "table": { + "emptyState": "Aucun score trouvé. Soyez le premier à en soumettre un !", + "pagination": { + "scoresPerPage": "scores par page", + "showing": "Affichage {start} - {end} sur {total}" + } + } + } + } + }, + "settings": { + "meta": { + "title": "Paramètres | {appName}" + }, + "header": "Paramètres", + "notLoggedIn": "Vous devez être connecté pour voir cette page.", + "sections": { + "changeAvatar": "Changer d'avatar", + "changeBanner": "Changer de bannière", + "changeDescription": "Changer la description", + "socials": "Réseaux", + "playstyle": "Style de jeu", + "options": "Options", + "changePassword": "Changer le mot de passe", + "changeUsername": "Changer le pseudo", + "changeCountryFlag": "Changer le drapeau du pays" + }, + "description": { + "reminder": "* Rappel : ne publiez pas de contenu inapproprié. Essayez de rester family friendly :)" + }, + "components": { + "username": { + "label": "Nouveau pseudo", + "placeholder": "ex. username", + "button": "Changer le pseudo", + "validation": { + "minLength": "Le pseudo doit contenir au moins {min} caractères.", + "maxLength": "Le pseudo doit contenir {max} caractères ou moins." + }, + "toast": { + "success": "Pseudo changé avec succès !", + "error": "Erreur lors du changement de pseudo !" + }, + "reminder": "* Rappel : gardez un pseudo approprié, sinon il sera changé. Abuser de cette fonctionnalité entraînera un bannissement." + }, + "password": { + "labels": { + "current": "Mot de passe actuel", + "new": "Nouveau mot de passe", + "confirm": "Confirmer le mot de passe" + }, + "placeholder": "************", + "button": "Changer le mot de passe", + "validation": { + "minLength": "Le mot de passe doit contenir au moins {min} caractères.", + "maxLength": "Le mot de passe doit contenir {max} caractères ou moins.", + "mismatch": "Les mots de passe ne correspondent pas" + }, + "toast": { + "success": "Mot de passe changé avec succès !", + "error": "Erreur lors du changement de mot de passe !" + } + }, + "description": { + "toast": { + "success": "Description mise à jour avec succès !", + "error": "Une erreur inconnue est survenue" + } + }, + "country": { + "label": "Nouveau drapeau", + "placeholder": "Sélectionner un nouveau drapeau", + "button": "Changer le drapeau", + "toast": { + "success": "Drapeau changé avec succès !", + "error": "Erreur lors du changement de drapeau !" + } + }, + "socials": { + "headings": { + "general": "Général", + "socials": "Réseaux" + }, + "fields": { + "location": "lieu", + "interest": "intérêt", + "occupation": "profession" + }, + "button": "Mettre à jour", + "toast": { + "success": "Réseaux mis à jour avec succès !", + "error": "Erreur lors de la mise à jour !" + } + }, + "playstyle": { + "options": { + "Mouse": "Souris", + "Keyboard": "Clavier", + "Tablet": "Tablette", + "TouchScreen": "Écran tactile" + }, + "toast": { + "success": "Style de jeu mis à jour avec succès !", + "error": "Erreur lors de la mise à jour du style de jeu !" + } + }, + "uploadImage": { + "types": { + "avatar": "avatar", + "banner": "bannière" + }, + "button": "Uploader {type}", + "toast": { + "success": "{type} mis à jour avec succès !", + "error": "Une erreur inconnue est survenue" + }, + "note": "* Note : les {type}s sont limités à 5 Mo" + }, + "siteOptions": { + "includeBanchoButton": "Afficher le bouton « Open on Bancho » sur la page beatmap", + "useSpaciousUI": "Utiliser une interface espacée (augmenter les espacements)" + } + }, + "common": { + "unknownError": "Erreur inconnue." + } + }, + "user": { + "meta": { + "title": "{username} · Profil utilisateur | {appName}", + "description": "On ne sait pas grand-chose sur lui/elle, mais on est sûrs que {username} est génial." + }, + "header": "Infos joueur", + "tabs": { + "general": "Général", + "bestScores": "Meilleures performances", + "recentScores": "Scores récents", + "firstPlaces": "Premières places", + "beatmaps": "Beatmaps", + "medals": "Médailles" + }, + "buttons": { + "editProfile": "Modifier le profil", + "setDefaultGamemode": "Définir {gamemode} {flag} comme mode par défaut du profil" + }, + "errors": { + "userNotFound": "Utilisateur introuvable ou une erreur est survenue.", + "restricted": "Cela signifie que l'utilisateur a enfreint les règles du serveur et a été restreint.", + "userDeleted": "L'utilisateur a peut-être été supprimé ou n'existe pas." + }, + "components": { + "generalTab": { + "info": "Info", + "rankedScore": "Ranked Score", + "hitAccuracy": "Précision", + "playcount": "Nombre de parties", + "totalScore": "Score total", + "maximumCombo": "Combo maximum", + "playtime": "Temps de jeu", + "performance": "Performance", + "showByRank": "Afficher par rang", + "showByPp": "Afficher par pp", + "aboutMe": "À propos de moi" + }, + "scoresTab": { + "bestScores": "Meilleures performances", + "recentScores": "Scores récents", + "firstPlaces": "Premières places", + "noScores": "L'utilisateur n'a pas de scores {type}", + "showMore": "Afficher plus" + }, + "beatmapsTab": { + "mostPlayed": "Les plus jouées", + "noMostPlayed": "L'utilisateur n'a pas de beatmaps les plus jouées", + "favouriteBeatmaps": "Beatmaps préférées", + "noFavourites": "L'utilisateur n'a pas de beatmaps préférées", + "showMore": "Afficher plus" + }, + "medalsTab": { + "medals": "Médailles", + "latest": "Dernières", + "categories": { + "hushHush": "Hush hush", + "beatmapHunt": "Beatmap hunt", + "modIntroduction": "Mod introduction", + "skill": "Skill" + }, + "achievedOn": "obtenue le", + "notAchieved": "Non obtenue" + }, + "generalInformation": { + "joined": "Inscrit {time}", + "followers": "{count} Abonnés", + "following": "{count} Abonnements", + "playsWith": "Joue avec {playstyle}" + }, + "statusText": { + "lastSeenOn": ", vu pour la dernière fois le {date}" + }, + "ranks": { + "highestRank": "Meilleur rang {rank} le {date}" + }, + "previousUsernames": { + "previouslyKnownAs": "Cet utilisateur était auparavant connu sous le nom :" + }, + "beatmapSetOverview": { + "by": "par {artist}", + "mappedBy": "mappé par {creator}" + }, + "privilegeBadges": { + "badges": { + "Developer": "Développeur", + "Admin": "Admin", + "Bat": "BAT", + "Bot": "Bot", + "Supporter": "Supporter" + } + }, + "scoreOverview": { + "pp": "pp", + "accuracy": "acc: {accuracy}%" + }, + "statsChart": { + "date": "Date", + "types": { + "pp": "pp", + "rank": "rank" + }, + "tooltip": "{value} {type}" + } + } + } + } +} diff --git a/lib/i18n/messages/index.ts b/lib/i18n/messages/index.ts index 9b1c12f..c716197 100644 --- a/lib/i18n/messages/index.ts +++ b/lib/i18n/messages/index.ts @@ -1,11 +1,28 @@ -export const AVAILABLE_LOCALES = ["en", "ru", "en-GB"] as const; +export const AVAILABLE_LOCALES = [ + "en", + "ru", + "en-GB", + "de", + "ja", + "zh-CN", + "es", + "fr", + "uk", +] as const; export const LOCALE_TO_COUNTRY: Record = { en: "GB", ru: "RU", "en-GB": "OWO", + de: "DE", + ja: "JP", + "zh-CN": "CN", + es: "ES", + fr: "FR", + uk: "UA", }; export const DISPLAY_NAMES_LOCALES: Record = { "en-GB": "Engwish", + "zh-CN": "中文(简体)", }; diff --git a/lib/i18n/messages/ja.json b/lib/i18n/messages/ja.json new file mode 100644 index 0000000..33e707d --- /dev/null +++ b/lib/i18n/messages/ja.json @@ -0,0 +1,911 @@ +{ + "general": { + "appName": "osu!sunrise", + "serverTitle": { + "full": "sunrise", + "split": { + "part1": "sun", + "part2": "rise" + } + } + }, + "components": { + "serverMaintenanceDialog": { + "title": "ちょっと待って!そこでストップ!", + "discordMessage": "詳細はDiscordサーバーをご確認ください。", + "button": "OK、理解しました", + "message": "現在サーバーはメンテナンスモードです。そのため、ウェブサイトの一部機能が正しく動作しない可能性があります。" + }, + "contentNotExist": { + "defaultText": "コンテンツが見つかりません" + }, + "workInProgress": { + "title": "作業中", + "description": "このコンテンツは現在作業中です。しばらくしてから再度ご確認ください。" + }, + "beatmapSetCard": { + "submittedBy": "投稿者", + "submittedOn": "投稿日", + "view": "表示" + }, + "friendshipButton": { + "unfriend": "フレンド解除", + "unfollow": "フォロー解除", + "follow": "フォロー" + }, + "gameModeSelector": { + "selectedMode": "選択中のモード:" + }, + "header": { + "links": { + "leaderboard": "ランキング", + "topPlays": "トッププレイ", + "beatmaps": "ビートマップ", + "help": "ヘルプ", + "wiki": "Wiki", + "rules": "ルール", + "apiDocs": "APIドキュメント", + "discordServer": "Discordサーバー", + "supportUs": "支援する" + } + }, + "headerLoginDialog": { + "signIn": "ログイン", + "title": "続行するにはログインしてください", + "description": "お帰りなさい。", + "username": { + "label": "ユーザー名", + "placeholder": "例:username" + }, + "password": { + "label": "パスワード", + "placeholder": "************" + }, + "login": "ログイン", + "signUp": "アカウントをお持ちでないですか?登録する", + "toast": { + "success": "ログインに成功しました!" + }, + "validation": { + "usernameMinLength": "ユーザー名は2文字以上で入力してください。", + "usernameMaxLength": "ユーザー名は32文字以内で入力してください。", + "passwordMinLength": "パスワードは8文字以上で入力してください。", + "passwordMaxLength": "パスワードは32文字以内で入力してください。" + } + }, + "headerLogoutAlert": { + "title": "本当によろしいですか?", + "description": "アカウントにアクセスするには再度ログインが必要になります。", + "cancel": "キャンセル", + "continue": "続行", + "toast": { + "success": "ログアウトしました。" + } + }, + "headerSearchCommand": { + "placeholder": "検索する内容を入力...", + "headings": { + "users": "ユーザー", + "beatmapsets": "ビートマップセット", + "pages": "ページ" + }, + "pages": { + "leaderboard": "ランキング", + "topPlays": "トッププレイ", + "beatmapsSearch": "ビートマップ検索", + "wiki": "Wiki", + "rules": "ルール", + "yourProfile": "あなたのプロフィール", + "friends": "フレンド", + "settings": "設定" + } + }, + "headerUserDropdown": { + "myProfile": "マイプロフィール", + "friends": "フレンド", + "settings": "設定", + "returnToMainSite": "メインサイトに戻る", + "adminPanel": "管理パネル", + "logOut": "ログアウト" + }, + "headerMobileDrawer": { + "navigation": { + "home": "ホーム", + "leaderboard": "ランキング", + "topPlays": "トッププレイ", + "beatmapsSearch": "ビートマップ検索", + "wiki": "Wiki", + "rules": "ルール", + "apiDocs": "APIドキュメント", + "discordServer": "Discordサーバー", + "supportUs": "支援する" + }, + "yourProfile": "あなたのプロフィール", + "friends": "フレンド", + "settings": "設定", + "adminPanel": "管理パネル", + "logOut": "ログアウト" + }, + "footer": { + "voteMessage": "osu-server-listで投票してください!", + "copyright": "© 2024-2025 Sunrise Community", + "sourceCode": "ソースコード", + "serverStatus": "サーバーステータス", + "disclaimer": "当サービスは「ppy」および「osu!」とは一切関係ありません。権利はそれぞれの所有者に帰属します。" + }, + "comboBox": { + "selectValue": "値を選択...", + "searchValue": "値を検索...", + "noValuesFound": "結果が見つかりません。" + }, + "beatmapsetRowElement": { + "mappedBy": "譜面作成:{creator}" + }, + "themeModeToggle": { + "toggleTheme": "テーマを切り替え", + "light": "ライト", + "dark": "ダーク", + "system": "システム" + }, + "imageSelect": { + "imageTooBig": "選択した画像が大きすぎます!" + }, + "notFound": { + "meta": { + "title": "ページが見つかりません | {appName}", + "description": "申し訳ありませんが、要求されたページはここにはないようです。" + }, + "title": "ページが見つかりません | 404", + "description": "申し訳ありませんが、要求されたページはここにはないようです。" + }, + "rootLayout": { + "meta": { + "title": "{appName}", + "description": "{appName} はリズムゲーム「osu!」のプライベートサーバーです。" + } + } + }, + "pages": { + "mainPage": { + "meta": { + "title": "ようこそ | {appName}", + "description": "osu!sunriseに参加しよう。Relax・Autopilot・ScoreV2対応、そしてRelax/Autopilot向けに調整されたカスタムPPシステムを備えた機能豊富なプライベートosu!サーバーです。" + }, + "features": { + "motto": "- いつものosu!サーバー(またひとつ)", + "description": "Relax・Autopilot・ScoreV2のゲームプレイに対応し、Relax/Autopilot向けに調整されたカスタムPP計算を備えた機能豊富なosu!サーバー。", + "buttons": { + "register": "今すぐ参加", + "wiki": "接続方法" + } + }, + "whyUs": "なぜ私たち?", + "cards": { + "freeFeatures": { + "title": "本当に無料の機能", + "description": "osu!directやユーザー名変更などの機能を、課金なしで利用できます — すべてのプレイヤーに完全無料!" + }, + "ppSystem": { + "title": "カスタムPP計算", + "description": "通常スコアには最新のPPシステムを採用しつつ、Relax/Autopilotにはバランスの取れたカスタム式を適用しています。" + }, + "medals": { + "title": "カスタムメダルを獲得", + "description": "さまざまなマイルストーンや実績を達成して、サーバー限定のユニークなメダルを獲得しよう。" + }, + "updates": { + "title": "頻繁なアップデート", + "description": "私たちは常に改善しています!定期的なアップデート、新機能、継続的なパフォーマンス最適化をお楽しみに。" + }, + "ppCalc": { + "title": "内蔵PP計算機", + "description": "当サイトには、手軽にPPの目安を計算できる内蔵PP計算機があります。" + }, + "sunriseCore": { + "title": "自社開発のBanchoコア", + "description": "多くのプライベートosu!サーバーとは異なり、私たちは独自のBanchoコアを開発しました。より高い安定性と、独自機能のサポートを実現します。" + } + }, + "howToStart": { + "title": "どうやって始めるの?", + "description": "たった3ステップで準備完了!", + "downloadTile": { + "title": "osu!クライアントをダウンロード", + "description": "まだクライアントをインストールしていない場合", + "button": "ダウンロード" + }, + "registerTile": { + "title": "osu!sunriseアカウントを登録", + "description": "アカウントを作成してosu!sunriseコミュニティに参加しよう", + "button": "登録" + }, + "guideTile": { + "title": "接続ガイドに従う", + "description": "osu!クライアントをosu!sunriseに接続できるように設定します", + "button": "ガイドを開く" + } + }, + "statuses": { + "totalUsers": "総ユーザー数", + "usersOnline": "オンラインユーザー", + "usersRestricted": "制限ユーザー", + "totalScores": "総スコア数", + "serverStatus": "サーバーステータス", + "online": "オンライン", + "offline": "オフライン", + "underMaintenance": "メンテナンス中" + } + }, + "wiki": { + "meta": { + "title": "Wiki | {appName}" + }, + "header": "Wiki", + "articles": { + "howToConnect": { + "title": "接続方法", + "intro": "サーバーに接続するには、PCにゲームがインストールされている必要があります。公式osu!サイトからダウンロードできます。", + "step1": "ゲームディレクトリ内の osu!.exe を見つけます。", + "step2": "そのファイルのショートカットを作成します。", + "step3": "ショートカットを右クリックして「プロパティ」を選択します。", + "step4": "「リンク先」欄の末尾に -devserver {serverDomain} を追加します。", + "step5": "「適用」をクリックしてから「OK」を押します。", + "step6": "ショートカットをダブルクリックしてゲームを起動します。", + "imageAlt": "osu 接続画像" + }, + "multipleAccounts": { + "title": "複数アカウントは作れますか?", + "answer": "いいえ。1人につき1アカウントのみです。", + "consequence": "複数アカウントが発覚した場合、サーバーからBANされます。" + }, + "cheatsHacks": { + "title": "チートやハックは使えますか?", + "answer": "いいえ。発覚した場合はBANされます。", + "policy": "当サーバーはチートに非常に厳しく、一切容認しません。

チートの疑いがある場合はスタッフに報告してください。" + }, + "appealRestriction": { + "title": "不当に制限されたと思います。どうすれば異議申し立てできますか?", + "instructions": "不当に制限されたと思われる場合は、詳細を添えてスタッフに連絡し、制限の異議申し立てを行ってください。", + "contactStaff": "スタッフへの連絡はこちら。" + }, + "contributeSuggest": { + "title": "サーバーへの貢献/改善提案はできますか?", + "answer": "はい!提案はいつでも歓迎です。", + "instructions": "提案がある場合は、GitHubで送信してください。

長期的な貢献者は、永久Supporterタグを獲得できる可能性もあります。" + }, + "multiplayerDownload": { + "title": "マルチプレイ中にマップをダウンロードできません(メインメニューでは可能)", + "solution": "オプションの Automatically start osu!direct downloads を無効にして、再度お試しください。" + } + } + }, + "rules": { + "meta": { + "title": "ルール | {appName}" + }, + "header": "ルール", + "sections": { + "generalRules": { + "title": "一般ルール", + "noCheating": { + "title": "チートやハックは禁止。", + "description": "エイムボット、Relaxハック、マクロ、不正な改造クライアントなど、あらゆるチート行為は禁止です。フェアに遊び、フェアに上達しましょう。", + "warning": "見ての通り、他サーバーでBANされてここに移って来ようとする“wannabe”チーター向けに大きめの文字で書きました。チートしたら見つけて処します(Minecraftで)。なので、やめてください。" + }, + "noMultiAccount": { + "title": "複数アカウント・アカウント共有は禁止。", + "description": "プレイヤー1人につきアカウントは1つのみです。理由なくメインアカウントが制限された場合は、サポートに連絡してください。" + }, + "noImpersonation": { + "title": "有名プレイヤーやスタッフのなりすましは禁止", + "description": "スタッフや有名プレイヤーになりすまさないでください。誤解を招く行為はユーザー名変更や永久BANの対象となります。" + } + }, + "chatCommunityRules": { + "title": "チャット&コミュニティルール", + "beRespectful": { + "title": "敬意を持って接しましょう。", + "description": "他者に優しく接してください。嫌がらせ、ヘイトスピーチ、差別、毒性のある行動は許容されません。" + }, + "noNSFW": { + "title": "NSFW/不適切なコンテンツは禁止", + "description": "サーバーは全年齢向けに保ってください。これはユーザー名、バナー、アバター、プロフィール説明など、あらゆるユーザーコンテンツに適用されます。" + }, + "noAdvertising": { + "title": "宣伝は禁止です。", + "description": "管理者の許可なく他サーバー、Webサイト、製品などを宣伝しないでください。" + } + }, + "disclaimer": { + "title": "重要な免責事項", + "intro": "当プラットフォームでアカウントを作成・維持することにより、以下の条件に同意したものとみなされます:", + "noLiability": { + "title": "免責。", + "description": "Sunriseが提供するサービスへの参加に関して、あなたは全責任を負うものとし、利用によって生じ得る結果について組織に責任を問えないことを認めます。" + }, + "accountRestrictions": { + "title": "アカウント制限。", + "description": "運営は、規約違反または運営の裁量により、事前通知の有無にかかわらず任意のアカウントを制限または停止する権利を留保します。" + }, + "ruleChanges": { + "title": "ルール変更。", + "description": "サーバールールは随時変更される場合があります。運営は事前通知の有無にかかわらず規則を更新、変更、削除でき、プレイヤーは変更を把握する責任があります。" + }, + "agreementByParticipation": { + "title": "参加による同意。", + "description": "当サーバーでアカウントを作成・維持することにより、あなたは自動的にこれらの条件に同意し、その時点で有効なルールとガイドラインを遵守することに同意したものとみなされます。" + } + } + } + }, + "register": { + "meta": { + "title": "登録 | {appName}" + }, + "header": "登録", + "welcome": { + "title": "登録ページへようこそ!", + "description": "こんにちは!アカウントを作成するために必要事項を入力してください。接続方法が分からない場合や質問がある場合は、Wikiページをご覧ください。" + }, + "form": { + "title": "必要事項を入力", + "labels": { + "username": "ユーザー名", + "email": "メールアドレス", + "password": "パスワード", + "confirmPassword": "パスワード(確認)" + }, + "placeholders": { + "username": "例:username", + "email": "例:username@mail.com", + "password": "************" + }, + "validation": { + "usernameMin": "ユーザー名は{min}文字以上で入力してください。", + "usernameMax": "ユーザー名は{max}文字以内で入力してください。", + "passwordMin": "パスワードは{min}文字以上で入力してください。", + "passwordMax": "パスワードは{max}文字以内で入力してください。", + "passwordsDoNotMatch": "パスワードが一致しません" + }, + "error": { + "title": "エラー", + "unknown": "不明なエラーです。" + }, + "submit": "登録", + "terms": "登録することでサーバーのルールに同意したものとみなされます" + }, + "success": { + "dialog": { + "title": "準備完了!", + "description": "アカウントの作成が完了しました。", + "message": "Wikiページのガイドに従ってサーバーに接続できます。プレイを始める前に、アバターやバナーを更新してプロフィールをカスタマイズすることもできます!", + "buttons": { + "viewWiki": "Wikiガイドを見る", + "goToProfile": "プロフィールへ" + } + }, + "toast": "アカウントを作成しました!" + } + }, + "support": { + "meta": { + "title": "支援する | {appName}" + }, + "header": "支援する", + "section": { + "title": "支援方法", + "intro": "osu!sunriseの機能は常に無料ですが、サーバーの運用・改善にはリソース、時間、労力が必要で、主に1人の開発者によって維持されています。



osu!sunriseが好きで、もっと成長してほしいと思ってくれたら、以下の方法で支援できます:", + "donate": { + "title": "寄付する。", + "description": "皆さまからの寄付は、osu!サーバーの維持・改善に役立ちます。少額でも大きな助けになります!ご支援により、ホスティング費用の確保、新機能の実装、そしてより快適な体験を提供できます。", + "buttons": { + "kofi": "Ko-fi", + "boosty": "Boosty" + } + }, + "spreadTheWord": { + "title": "広める。", + "description": "osu!sunriseを知る人が増えるほど、コミュニティはより活気に溢れます。友達に教えたり、SNSで共有したり、新しいプレイヤーを招待しましょう。" + }, + "justPlay": { + "title": "サーバーで遊ぶ。", + "description": "osu!sunriseを支援する最も簡単な方法のひとつは、サーバーでプレイすることです!プレイヤーが増えるほど、コミュニティと体験はより良くなります。参加することで、サーバーの成長と活性化に貢献できます。" + } + } + }, + "topplays": { + "meta": { + "title": "トッププレイ | {appName}" + }, + "header": "トッププレイ", + "showMore": "もっと見る", + "components": { + "userScoreMinimal": { + "pp": "pp", + "accuracy": "acc:" + } + } + }, + "score": { + "meta": { + "title": "{username} の {beatmapTitle} [{beatmapVersion}] | {appName}", + "description": "ユーザー {username} は {appName} で {beatmapTitle} [{beatmapVersion}] を {pp}pp でプレイしました。", + "openGraph": { + "title": "{username} の {beatmapTitle} - {beatmapArtist} [{beatmapVersion}] | {appName}", + "description": "ユーザー {username} は {appName} で {beatmapTitle} - {beatmapArtist} [{beatmapVersion}] ★{starRating} {mods} を {pp}pp でプレイしました。" + } + }, + "header": "スコア詳細", + "beatmap": { + "versionUnknown": "不明", + "mappedBy": "譜面作成", + "creatorUnknown": "不明な作成者" + }, + "score": { + "submittedOn": "提出日", + "playedBy": "プレイヤー", + "userUnknown": "不明なユーザー" + }, + "actions": { + "downloadReplay": "リプレイをダウンロード", + "openMenu": "メニューを開く" + }, + "error": { + "notFound": "スコアが見つかりません", + "description": "お探しのスコアは存在しないか、削除された可能性があります。" + } + }, + "leaderboard": { + "meta": { + "title": "ランキング | {appName}" + }, + "header": "ランキング", + "sortBy": { + "label": "並び替え:", + "performancePoints": "PP", + "rankedScore": "Rankedスコア", + "performancePointsShort": "PP", + "scoreShort": "スコア" + }, + "table": { + "columns": { + "rank": "順位", + "performance": "PP", + "rankedScore": "Rankedスコア", + "accuracy": "精度", + "playCount": "プレイ回数" + }, + "actions": { + "openMenu": "メニューを開く", + "viewUserProfile": "ユーザープロフィールを見る" + }, + "emptyState": "結果がありません。", + "pagination": { + "usersPerPage": "件/ページ", + "showing": "{start} - {end} / {total} を表示" + } + } + }, + "friends": { + "meta": { + "title": "フレンド | {appName}" + }, + "header": "つながり", + "tabs": { + "friends": "フレンド", + "followers": "フォロワー" + }, + "sorting": { + "label": "並び替え:", + "username": "ユーザー名", + "recentlyActive": "最近のアクティブ" + }, + "showMore": "もっと見る", + "emptyState": "ユーザーが見つかりません" + }, + "beatmaps": { + "search": { + "meta": { + "title": "ビートマップ検索 | {appName}" + }, + "header": "ビートマップ検索" + }, + "detail": { + "meta": { + "title": "ビートマップ情報 | {appName}" + }, + "header": "ビートマップ情報", + "notFound": { + "title": "ビートマップセットが見つかりません", + "description": "お探しのビートマップセットは存在しないか、削除された可能性があります。" + } + }, + "components": { + "search": { + "searchPlaceholder": "ビートマップを検索...", + "filters": "フィルター", + "viewMode": { + "grid": "グリッド", + "list": "リスト" + }, + "showMore": "もっと見る" + }, + "filters": { + "mode": { + "label": "モード", + "any": "すべて", + "standard": "osu!", + "taiko": "osu!taiko", + "catch": "osu!catch", + "mania": "osu!mania" + }, + "status": { + "label": "ステータス" + }, + "searchByCustomStatus": { + "label": "カスタムステータスで検索" + }, + "applyFilters": "フィルターを適用" + } + } + }, + "beatmapsets": { + "meta": { + "title": "{artist} - {title} | {appName}", + "description": "{artist} の {title} のビートマップセット情報", + "openGraph": { + "title": "{artist} - {title} | {appName}", + "description": "{artist} の {title} のビートマップセット情報 {difficultyInfo}" + } + }, + "header": "ビートマップ情報", + "error": { + "notFound": { + "title": "ビートマップセットが見つかりません", + "description": "お探しのビートマップセットは存在しないか、削除された可能性があります。" + } + }, + "submission": { + "submittedBy": "投稿者", + "submittedOn": "投稿日", + "rankedOn": "Ranked日", + "statusBy": "{status}:" + }, + "video": { + "tooltip": "このビートマップには動画があります" + }, + "description": { + "header": "説明" + }, + "components": { + "dropdown": { + "openMenu": "メニューを開く", + "ppCalculator": "PP計算機", + "openOnBancho": "Banchoで開く", + "openWithAdminPanel": "管理パネルで開く" + }, + "infoAccordion": { + "communityHype": "コミュニティHype", + "information": "情報", + "metadata": { + "genre": "ジャンル", + "language": "言語", + "tags": "タグ" + } + }, + "downloadButtons": { + "download": "ダウンロード", + "withVideo": "動画あり", + "withoutVideo": "動画なし", + "osuDirect": "osu!direct" + }, + "difficultyInformation": { + "tooltips": { + "totalLength": "総再生時間", + "bpm": "BPM", + "starRating": "★難易度" + }, + "labels": { + "keyCount": "キー数:", + "circleSize": "CS:", + "hpDrain": "HP:", + "accuracy": "OD:", + "approachRate": "AR:" + } + }, + "nomination": { + "description": "このマップが気に入ったらHypeして、Rankedへの進行を手伝いましょう。", + "hypeProgress": "Hypeの進行状況", + "hypeBeatmap": "Hypeする!", + "hypesRemaining": "今週このビートマップに使えるHypeが{count}回残っています", + "toast": { + "success": "Hypeに成功しました!", + "error": "Hype中にエラーが発生しました!" + } + }, + "ppCalculator": { + "title": "PP計算機", + "pp": "PP: {value}", + "totalLength": "総再生時間", + "form": { + "accuracy": { + "label": "精度", + "validation": { + "negative": "精度は0未満にできません", + "tooHigh": "精度は100を超えられません" + } + }, + "combo": { + "label": "コンボ", + "validation": { + "negative": "コンボは0未満にできません" + } + }, + "misses": { + "label": "ミス数", + "validation": { + "negative": "ミス数は0未満にできません" + } + }, + "calculate": "計算", + "unknownError": "不明なエラー" + } + }, + "leaderboard": { + "columns": { + "rank": "順位", + "score": "スコア", + "accuracy": "精度", + "player": "プレイヤー", + "maxCombo": "最大コンボ", + "perfect": "PERFECT", + "great": "GREAT", + "good": "GOOD", + "ok": "OK", + "lDrp": "L DRP", + "meh": "MEH", + "sDrp": "S DRP", + "miss": "MISS", + "pp": "PP", + "time": "時間", + "mods": "MOD" + }, + "actions": { + "openMenu": "メニューを開く", + "viewDetails": "詳細を見る", + "downloadReplay": "リプレイをダウンロード" + }, + "table": { + "emptyState": "スコアが見つかりません。最初のスコアを送信しましょう!", + "pagination": { + "scoresPerPage": "件/ページ", + "showing": "{start} - {end} / {total} を表示" + } + } + } + } + }, + "settings": { + "meta": { + "title": "設定 | {appName}" + }, + "header": "設定", + "notLoggedIn": "このページを表示するにはログインが必要です。", + "sections": { + "changeAvatar": "アバターを変更", + "changeBanner": "バナーを変更", + "changeDescription": "説明文を変更", + "socials": "ソーシャル", + "playstyle": "プレイスタイル", + "options": "オプション", + "changePassword": "パスワードを変更", + "changeUsername": "ユーザー名を変更", + "changeCountryFlag": "国旗を変更" + }, + "description": { + "reminder": "* 注意:不適切な内容は投稿しないでください。できるだけ全年齢向けにしましょう :)" + }, + "components": { + "username": { + "label": "新しいユーザー名", + "placeholder": "例:username", + "button": "ユーザー名を変更", + "validation": { + "minLength": "ユーザー名は{min}文字以上で入力してください。", + "maxLength": "ユーザー名は{max}文字以内で入力してください。" + }, + "toast": { + "success": "ユーザー名を変更しました!", + "error": "ユーザー名の変更中にエラーが発生しました!" + }, + "reminder": "* 注意:ユーザー名は全年齢向けにしてください。そうでない場合、運営により変更されることがあります。この機能の悪用はBANの対象となります。" + }, + "password": { + "labels": { + "current": "現在のパスワード", + "new": "新しいパスワード", + "confirm": "パスワード(確認)" + }, + "placeholder": "************", + "button": "パスワードを変更", + "validation": { + "minLength": "パスワードは{min}文字以上で入力してください。", + "maxLength": "パスワードは{max}文字以内で入力してください。", + "mismatch": "パスワードが一致しません" + }, + "toast": { + "success": "パスワードを変更しました!", + "error": "パスワードの変更中にエラーが発生しました!" + } + }, + "description": { + "toast": { + "success": "説明文を更新しました!", + "error": "不明なエラーが発生しました" + } + }, + "country": { + "label": "新しい国旗", + "placeholder": "新しい国旗を選択", + "button": "国旗を変更", + "toast": { + "success": "国旗を変更しました!", + "error": "国旗の変更中にエラーが発生しました!" + } + }, + "socials": { + "headings": { + "general": "一般", + "socials": "ソーシャル" + }, + "fields": { + "location": "所在地", + "interest": "興味", + "occupation": "職業" + }, + "button": "ソーシャルを更新", + "toast": { + "success": "ソーシャルを更新しました!", + "error": "ソーシャルの更新中にエラーが発生しました!" + } + }, + "playstyle": { + "options": { + "Mouse": "マウス", + "Keyboard": "キーボード", + "Tablet": "タブレット", + "TouchScreen": "タッチスクリーン" + }, + "toast": { + "success": "プレイスタイルを更新しました!", + "error": "プレイスタイルの更新中にエラーが発生しました!" + } + }, + "uploadImage": { + "types": { + "avatar": "アバター", + "banner": "バナー" + }, + "button": "{type}をアップロード", + "toast": { + "success": "{type}を更新しました!", + "error": "不明なエラーが発生しました" + }, + "note": "* 注意:{type}は5MBまでです" + }, + "siteOptions": { + "includeBanchoButton": "ビートマップページに「Open on Bancho」ボタンを表示する", + "useSpaciousUI": "ゆったりUIを使用(要素間の余白を増やす)" + } + }, + "common": { + "unknownError": "不明なエラーです。" + } + }, + "user": { + "meta": { + "title": "{username} · ユーザープロフィール | {appName}", + "description": "詳しいことは分かりませんが、{username} はきっと素晴らしい人です。" + }, + "header": "プレイヤー情報", + "tabs": { + "general": "一般", + "bestScores": "ベストパフォーマンス", + "recentScores": "最近のスコア", + "firstPlaces": "1位の記録", + "beatmaps": "ビートマップ", + "medals": "メダル" + }, + "buttons": { + "editProfile": "プロフィールを編集", + "setDefaultGamemode": "プロフィールのデフォルトモードを {gamemode} {flag} に設定" + }, + "errors": { + "userNotFound": "ユーザーが見つからないか、エラーが発生しました。", + "restricted": "このユーザーはサーバールールに違反し、制限されています。", + "userDeleted": "ユーザーが削除されたか、存在しない可能性があります。" + }, + "components": { + "generalTab": { + "info": "情報", + "rankedScore": "合計Rankedスコア", + "hitAccuracy": "精度", + "playcount": "プレイ回数", + "totalScore": "合計スコア", + "maximumCombo": "最大コンボ", + "playtime": "プレイ時間", + "performance": "パフォーマンス", + "showByRank": "順位で表示", + "showByPp": "PPで表示", + "aboutMe": "自己紹介" + }, + "scoresTab": { + "bestScores": "ベストパフォーマンス", + "recentScores": "最近のスコア", + "firstPlaces": "1位の記録", + "noScores": "このユーザーには{type}スコアがありません", + "showMore": "もっと見る" + }, + "beatmapsTab": { + "mostPlayed": "よく遊ぶ譜面", + "noMostPlayed": "このユーザーにはよく遊ぶビートマップがありません", + "favouriteBeatmaps": "お気に入りビートマップ", + "noFavourites": "このユーザーにはお気に入りビートマップがありません", + "showMore": "もっと見る" + }, + "medalsTab": { + "medals": "メダル", + "latest": "最新", + "categories": { + "hushHush": "Hush-Hush", + "beatmapHunt": "ビートマップハント", + "modIntroduction": "MOD紹介", + "skill": "スキル" + }, + "achievedOn": "獲得日", + "notAchieved": "未獲得" + }, + "generalInformation": { + "joined": "{time} に参加", + "followers": "フォロワー {count}", + "following": "フォロー中 {count}", + "playsWith": "プレイスタイル:{playstyle}" + }, + "statusText": { + "lastSeenOn": "(最終アクセス:{date})" + }, + "ranks": { + "highestRank": "最高順位 {rank}({date})" + }, + "previousUsernames": { + "previouslyKnownAs": "以前のユーザー名:" + }, + "beatmapSetOverview": { + "by": "{artist} 作", + "mappedBy": "譜面:{creator}" + }, + "privilegeBadges": { + "badges": { + "Developer": "開発者", + "Admin": "管理者", + "Bat": "BAT", + "Bot": "ボット", + "Supporter": "サポーター" + } + }, + "scoreOverview": { + "pp": "pp", + "accuracy": "acc: {accuracy}%" + }, + "statsChart": { + "date": "日付", + "types": { + "pp": "pp", + "rank": "rank" + }, + "tooltip": "{value} {type}" + } + } + } + } +} diff --git a/lib/i18n/messages/ru.json b/lib/i18n/messages/ru.json index e22629d..2798dae 100644 --- a/lib/i18n/messages/ru.json +++ b/lib/i18n/messages/ru.json @@ -2,7 +2,7 @@ "components": { "serverMaintenanceDialog": { "title": "Эй! Стоп!", - "discordMessage": "Для получения дополнительной информации посетите наш Discord сервер.", + "discordMessage": "Для получения дополнительной информации посетите наш Discord-сервер.", "button": "Хорошо, понял", "message": "Сервер находится в режиме технического обслуживания, поэтому некоторые функции сайта могут работать некорректно." }, @@ -29,18 +29,18 @@ "header": { "links": { "leaderboard": "таблица лидеров", - "topPlays": "топ игры", + "topPlays": "лучшие скоры", "beatmaps": "карты", "help": "помощь", "wiki": "вики", "rules": "правила", "apiDocs": "документация API", - "discordServer": "Discord сервер", + "discordServer": "discord сервер", "supportUs": "поддержать нас" } }, "headerLoginDialog": { - "signIn": "войти", + "signIn": "Войти", "title": "Войдите, чтобы продолжить", "description": "С возвращением.", "username": { @@ -81,7 +81,7 @@ }, "pages": { "leaderboard": "Таблица лидеров", - "topPlays": "Топ игры", + "topPlays": "Лучшие скоры", "beatmapsSearch": "Поиск карт", "wiki": "Вики", "rules": "Правила", @@ -102,12 +102,12 @@ "navigation": { "home": "Главная", "leaderboard": "Таблица лидеров", - "topPlays": "Топ игры", + "topPlays": "Лучшие скоры", "beatmapsSearch": "Поиск карт", "wiki": "Вики", "rules": "Правила", "apiDocs": "Документация API", - "discordServer": "Discord сервер", + "discordServer": "Discord-сервер", "supportUs": "Поддержать нас" }, "yourProfile": "Ваш профиль", @@ -142,11 +142,11 @@ }, "notFound": { "meta": { - "title": "Не найдено | {appName}", - "description": "Страница, которую вы ищете, здесь нет. Извините." + "title": "Страница не найдена | {appName}", + "description": "Извините, но запрашиваемая вами страница не найдена." }, - "title": "Не найдено | 404", - "description": "То, что вы ищете, здесь нет. Извините." + "title": "Страница не найдена | 404", + "description": "Извините, но запрашиваемая вами страница не найдена." }, "rootLayout": { "meta": { @@ -157,7 +157,6 @@ }, "pages": { "mainPage": { - "TODO": "UPDATE TRANSLATION, AI FOR NOW", "meta": { "title": "Добро пожаловать! | {appName}", "description": "Присоединяйтесь к osu!sunrise, функциональному приватному серверу osu!, поддерживающему Relax, Autopilot и ScoreV2, а также собственной системой расчёта PP, адаптированной под Relax и Autopilot." @@ -403,9 +402,9 @@ }, "topplays": { "meta": { - "title": "Топ игры | {appName}" + "title": "Лучшие скоры | {appName}" }, - "header": "Топ игры", + "header": "Лучшие скоры", "showMore": "Показать ещё", "components": { "userScoreMinimal": { diff --git a/lib/i18n/messages/uk.json b/lib/i18n/messages/uk.json new file mode 100644 index 0000000..ba211e5 --- /dev/null +++ b/lib/i18n/messages/uk.json @@ -0,0 +1,911 @@ +{ + "general": { + "appName": "osu!sunrise", + "serverTitle": { + "full": "sunrise", + "split": { + "part1": "sun", + "part2": "rise" + } + } + }, + "components": { + "serverMaintenanceDialog": { + "title": "Гей! Стій на місці!", + "discordMessage": "Для отримання додаткової інформації перегляньте наш Discord сервер.", + "button": "Добре, я розумію", + "message": "Сервер зараз у режимі технічного обслуговування, тому деякі функції веб-сайту можуть працювати неправильно." + }, + "contentNotExist": { + "defaultText": "Контент не знайдено" + }, + "workInProgress": { + "title": "В процесі розробки", + "description": "Цей контент ще розробляється. Будь ласка, перевірте пізніше." + }, + "beatmapSetCard": { + "submittedBy": "відправлено", + "submittedOn": "відправлено", + "view": "Переглянути" + }, + "friendshipButton": { + "unfriend": "Видалити з друзів", + "unfollow": "Відписатись", + "follow": "Підписатися" + }, + "gameModeSelector": { + "selectedMode": "Вибраний режим:" + }, + "header": { + "links": { + "leaderboard": "рейтинги", + "topPlays": "кращі результати", + "beatmaps": "бітмапи", + "help": "допомога", + "wiki": "Вiкi", + "rules": "правила", + "apiDocs": "документація API", + "discordServer": "Discord сервер", + "supportUs": "підтримати нас" + } + }, + "headerLoginDialog": { + "signIn": "увійти", + "title": "Увійдіть, щоб продовжити", + "description": "З поверненням.", + "username": { + "label": "Ім'я користувача", + "placeholder": "напр. ім'я користувача" + }, + "password": { + "label": "Пароль", + "placeholder": "************" + }, + "login": "Увійти", + "signUp": "Немає облікового запису? Зареєструватися", + "toast": { + "success": "Ви успішно увійшли!" + }, + "validation": { + "usernameMinLength": "Ім'я користувача має містити принаймні 2 символи.", + "usernameMaxLength": "Ім'я користувача має містити не більше 32 символів.", + "passwordMinLength": "Пароль має містити принаймні 8 символів.", + "passwordMaxLength": "Пароль має містити не більше 32 символів." + } + }, + "headerLogoutAlert": { + "title": "Ви впевнені?", + "description": "Вам потрібно буде знову увійти, щоб отримати доступ до свого облікового запису.", + "cancel": "Скасувати", + "continue": "Продовжити", + "toast": { + "success": "Ви успішно вийшли з системи." + } + }, + "headerSearchCommand": { + "placeholder": "Введіть текст для пошуку...", + "headings": { + "users": "Користувачі", + "beatmapsets": "Набори бітмап", + "pages": "Сторінки" + }, + "pages": { + "leaderboard": "Рейтинги", + "topPlays": "Кращі результати", + "beatmapsSearch": "Пошук бітмап", + "wiki": "Вiкi", + "rules": "Правила", + "yourProfile": "Ваш профіль", + "friends": "Друзі", + "settings": "Налаштування" + } + }, + "headerUserDropdown": { + "myProfile": "Мій профіль", + "friends": "Друзі", + "settings": "Налаштування", + "returnToMainSite": "Повернутися на головний сайт", + "adminPanel": "Панель адміністратора", + "logOut": "Вийти" + }, + "headerMobileDrawer": { + "navigation": { + "home": "Головна", + "leaderboard": "Рейтинги", + "topPlays": "Кращі результати", + "beatmapsSearch": "Пошук бітмап", + "wiki": "Вiкi", + "rules": "Правила", + "apiDocs": "Документація API", + "discordServer": "Discord Сервер", + "supportUs": "Підтримати нас" + }, + "yourProfile": "Ваш профіль", + "friends": "Друзі", + "settings": "Налаштування", + "adminPanel": "Панель адміністратора", + "logOut": "Вийти" + }, + "footer": { + "voteMessage": "Будь ласка, проголосуйте за нас на osu-server-list!", + "copyright": "© 2024-2025 Sunrise Community", + "sourceCode": "Вихідний код", + "serverStatus": "Статус сервера", + "disclaimer": "Ми не пов'язані з \"ppy\" та \"osu!\" жодним чином. Всі права захищені їх відповідними власниками." + }, + "comboBox": { + "selectValue": "Виберіть значення...", + "searchValue": "Шукати значення...", + "noValuesFound": "Значень не знайдено." + }, + "beatmapsetRowElement": { + "mappedBy": "створена {creator}" + }, + "themeModeToggle": { + "toggleTheme": "Переключити тему", + "light": "Світла", + "dark": "Темна", + "system": "Системна" + }, + "imageSelect": { + "imageTooBig": "Вибране зображення занадто велике!" + }, + "notFound": { + "meta": { + "title": "Не знайдено | {appName}", + "description": "Сторінка, яку ви шукаєте, тут відсутня. Вибачте." + }, + "title": "Сторінка відсутня | 404", + "description": "Те, що ви шукаєте, тут відсутнє. Вибачте." + }, + "rootLayout": { + "meta": { + "title": "{appName}", + "description": "{appName} — приватний сервер для osu!, ритм-гри." + } + } + }, + "pages": { + "mainPage": { + "meta": { + "title": "Ласкаво просимо | {appName}", + "description": "Приєднуйтесь до osu!sunrise, багатофункціонального приватного osu! сервера з підтримкою Relax, Autopilot, ScoreV2 та індивідуальною системою PP, адаптованою для геймплею Relax і Autopilot." + }, + "features": { + "motto": "- ще один osu! сервер", + "description": "Багатофункціональний osu! сервер з підтримкою геймплею Relax, Autopilot та ScoreV2, з індивідуальною системою розрахунку PP, адаптованою для Relax і Autopilot.", + "buttons": { + "register": "Приєднатися зараз", + "wiki": "Як підключитися" + } + }, + "whyUs": "Чому ми?", + "cards": { + "freeFeatures": { + "title": "Справді безкоштовні функції", + "description": "Насолоджуйтесь функціями, такими як osu!direct та зміна імені користувача, без будь-яких платіжних бар'єрів — повністю безкоштовно для всіх гравців!" + }, + "ppSystem": { + "title": "Індивідуальні розрахунки PP", + "description": "Ми використовуємо найновішу систему очок продуктивності (PP) для звичайних результатів, застосовуючи індивідуальну, добре збалансовану формулу для режимів Relax і Autopilot." + }, + "medals": { + "title": "Отримуйте індивідуальні медалі", + "description": "Отримуйте унікальні, ексклюзивні медалі сервера, досягаючи різних віх та досягнень." + }, + "updates": { + "title": "Часті оновлення", + "description": "Ми завжди вдосконалюємося! Очікуйте регулярні оновлення, нові функції та постійні оптимізації продуктивності." + }, + "ppCalc": { + "title": "Вбудований калькулятор PP", + "description": "Наш веб-сайт пропонує вбудований калькулятор PP для швидкого та легкого оцінювання очок продуктивності." + }, + "sunriseCore": { + "title": "Індивідуальне ядро Bancho", + "description": "На відміну від більшості приватних osu! серверів, ми розробили власне індивідуальне ядро bancho для кращої стабільності та підтримки унікальних функцій." + } + }, + "howToStart": { + "title": "Як почати грати?", + "description": "Всього три прості кроки, і ви готові!", + "downloadTile": { + "title": "Завантажити клієнт osu!", + "description": "Якщо у вас ще немає встановленого клієнта", + "button": "Завантажити" + }, + "registerTile": { + "title": "Зареєструвати обліковий запис osu!sunrise", + "description": "Обліковий запис дозволить вам приєднатися до спільноти osu!sunrise", + "button": "Зареєструватися" + }, + "guideTile": { + "title": "Слідуйте керівництву з підключення", + "description": "Яке допоможе вам налаштувати ваш osu! клієнт для підключення до osu!sunrise", + "button": "Відкрити керівництво" + } + }, + "statuses": { + "totalUsers": "Всього користувачів", + "usersOnline": "Користувачів онлайн", + "usersRestricted": "Користувачів обмежено", + "totalScores": "Всього результатів", + "serverStatus": "Статус сервера", + "online": "Онлайн", + "offline": "Офлайн", + "underMaintenance": "На технічному обслуговуванні" + } + }, + "wiki": { + "meta": { + "title": "Вiкi | {appName}" + }, + "header": "Вiкi", + "articles": { + "howToConnect": { + "title": "Як підключитися", + "intro": "Щоб підключитися до сервера, вам потрібно мати копію гри, встановлену на вашому комп'ютері. Ви можете завантажити гру з офіційного сайту osu!.", + "step1": "Знайдіть файл osu!.exe в директорії гри.", + "step2": "Створіть ярлик файлу.", + "step3": "Клацніть правою кнопкою миші на ярлику та виберіть властивості.", + "step4": "У полі ціль додайте -devserver {serverDomain} в кінці шляху.", + "step5": "Натисніть застосувати, а потім ОК.", + "step6": "Двічі клацніть на ярлику, щоб запустити гру.", + "imageAlt": "зображення підключення osu" + }, + "multipleAccounts": { + "title": "Чи можу я мати кілька облікових записів?", + "answer": "Ні. Вам дозволено мати лише один обліковий запис на особу.", + "consequence": "Якщо вас спіймають з кількома обліковими записами, вас забанять на сервері." + }, + "cheatsHacks": { + "title": "Чи можу я використовувати чити або хакери?", + "answer": "Ні. Вас забанять, якщо вас спіймають.", + "policy": "Ми дуже строгі щодо читерства і не терпимо його зовсім.

Якщо ви підозрюєте когось у читерстві, будь ласка, повідомте про це персоналу." + }, + "appealRestriction": { + "title": "Я думаю, що мене обмежили несправедливо. Як я можу оскаржити?", + "instructions": "Якщо ви вважаєте, що вас обмежили несправедливо, ви можете оскаржити обмеження, зв'язавшись з персоналом з вашою справою.", + "contactStaff": "Ви можете зв'язатися з персоналом тут." + }, + "contributeSuggest": { + "title": "Чи можу я сприяти/запропонувати зміни на сервері?", + "answer": "Так! Ми завжди відкриті для пропозицій.", + "instructions": "Якщо у вас є пропозиції, будь ласка, надішліть їх на нашій сторінці GitHub.

Довгострокові учасники також мають шанс отримати постійний тег підтримки." + }, + "multiplayerDownload": { + "title": "Я не можу завантажувати карти, коли я в мультиплеєрі, але можу завантажувати їх з головного меню", + "solution": "Вимкніть Автоматично запускати завантаження osu!direct з опцій і спробуйте ще раз." + } + } + }, + "rules": { + "meta": { + "title": "Правила | {appName}" + }, + "header": "Правила", + "sections": { + "generalRules": { + "title": "Загальні правила", + "noCheating": { + "title": "Без читерства чи хаків.", + "description": "Будь-яка форма читерства, включаючи еймботи, релакс-хаки, макроси або модифіковані клієнти, які дають несправедливу перевагу, суворо заборонені. Грайте чесно, покращуйтеся чесно.", + "warning": "Як ви бачите, я написав це більшим шрифтом для всіх вас, \"хоч-би-читерів\", які думають, що можуть мігрувати з іншого приватного сервера сюди після бану. Вас знайдуть і стратять (у Minecraft), якщо ви будете читерити. Тому будь ласка, не робіть цього." + }, + "noMultiAccount": { + "title": "Без мультиаккаунтів або передачі облікового запису.", + "description": "Дозволено лише один обліковий запис на гравця. Якщо ваш основний обліковий запис було обмежено без пояснення, будь ласка, зв'яжіться з підтримкою." + }, + "noImpersonation": { + "title": "Без видавання себе за популярних гравців або персонал", + "description": "Не видавайте себе за члена персоналу або будь-якого відомого гравця. Введення інших в оману може призвести до зміни імені користувача або постійного бану." + } + }, + "chatCommunityRules": { + "title": "Правила чату та спільноти", + "beRespectful": { + "title": "Будьте поважними.", + "description": "Ставтеся до інших з добротою. Переслідування, мова ворожнечі, дискримінація або токсична поведінка не будуть терпимі." + }, + "noNSFW": { + "title": "Без NSFW або неприйнятного контенту", + "description": "Тримайте сервер прийнятним для всієї аудиторії — це стосується всього контенту користувачів, включаючи, але не обмежуючись, імена користувачів, банери, аватари та описи профілів." + }, + "noAdvertising": { + "title": "Реклама заборонена.", + "description": "Не рекламуйте інші сервери, веб-сайти або продукти без схвалення адміністратора." + } + }, + "disclaimer": { + "title": "Важливе застереження", + "intro": "Створюючи та/або підтримуючи обліковий запис на нашій платформі, ви визнаєте та погоджуєтеся з наступними умовами:", + "noLiability": { + "title": "Без відповідальності.", + "description": "Ви приймаєте повну відповідальність за свою участь у будь-яких послугах, наданих Sunrise, і визнаєте, що не можете притягнути організацію до відповідальності за будь-які наслідки, які можуть виникнути внаслідок вашого використання." + }, + "accountRestrictions": { + "title": "Обмеження облікового запису.", + "description": "Адміністрація залишає за собою право обмежити або призупинити будь-який обліковий запис, з попереднім повідомленням або без нього, за порушення правил сервера або на свій розсуд." + }, + "ruleChanges": { + "title": "Зміни правил.", + "description": "Правила сервера можуть змінюватися в будь-який час. Адміністрація може оновлювати, змінювати або видаляти будь-яке правило з попереднім повідомленням або без нього, і всі гравці повинні бути в курсі будь-яких змін." + }, + "agreementByParticipation": { + "title": "Угода шляхом участі.", + "description": "Створюючи та/або підтримуючи обліковий запис на сервері, ви автоматично погоджуєтеся з цими умовами та зобов'язуєтеся дотримуватися правил та рекомендацій, що діють на той момент." + } + } + } + }, + "register": { + "meta": { + "title": "Реєстрація | {appName}" + }, + "header": "Реєстрація", + "welcome": { + "title": "Ласкаво просимо на сторінку реєстрації!", + "description": "Привіт! Будь ласка, введіть ваші дані, щоб створити обліковий запис. Якщо ви не впевнені, як підключитися до сервера, або якщо у вас є інші питання, будь ласка, відвідайте нашу сторінку Вiкi." + }, + "form": { + "title": "Введіть ваші дані", + "labels": { + "username": "Ім'я користувача", + "email": "Електронна пошта", + "password": "Пароль", + "confirmPassword": "Підтвердити пароль" + }, + "placeholders": { + "username": "напр. ім'я користувача", + "email": "напр. username@mail.com", + "password": "************" + }, + "validation": { + "usernameMin": "Ім'я користувача має містити принаймні {min} символів.", + "usernameMax": "Ім'я користувача має містити не більше {max} символів.", + "passwordMin": "Пароль має містити принаймні {min} символів.", + "passwordMax": "Пароль має містити не більше {max} символів.", + "passwordsDoNotMatch": "Паролі не співпадають" + }, + "error": { + "title": "Помилка", + "unknown": "Невідома помилка." + }, + "submit": "Зареєструватися", + "terms": "Реєструючись, ви погоджуєтеся з правилами сервера" + }, + "success": { + "dialog": { + "title": "Все готово!", + "description": "Ваш обліковий запис успішно створено.", + "message": "Тепер ви можете підключитися до сервера, слідуючи керівництву на нашій сторінці Вiкi, або налаштувати свій профіль, оновивши аватар та банер, перш ніж почати грати!", + "buttons": { + "viewWiki": "Переглянути керівництво Вiкi", + "goToProfile": "Перейти до профілю" + } + }, + "toast": "Обліковий запис успішно створено!" + } + }, + "support": { + "meta": { + "title": "Підтримати нас | {appName}" + }, + "header": "Підтримати нас", + "section": { + "title": "Як ви можете нам допомогти", + "intro": "Хоча всі функції osu!sunrise завжди були безкоштовними, запуск та покращення сервера потребує ресурсів, часу та зусиль, при цьому він підтримується переважно одним розробником.



Якщо вам подобається osu!sunrise і ви хочете бачити його ще більшого зростання, ось кілька способів, як ви можете нас підтримати:", + "donate": { + "title": "Пожертвувати.", + "description": "Ваші щедрі пожертви допомагають нам підтримувати та покращувати osu! сервери. Кожна копійка має значення! Завдяки вашій підтримці ми можемо покрити витрати на хостинг, реалізувати нові функції та забезпечити більш плавний досвід для всіх.", + "buttons": { + "kofi": "Ko-fi", + "boosty": "Boosty" + } + }, + "spreadTheWord": { + "title": "Поширюйте інформацію.", + "description": "Чим більше людей знають про osu!sunrise, тим яскравішою та захоплюючою буде наша спільнота. Розкажіть своїм друзям, поділіться в соціальних мережах та запросіть нових гравців приєднатися." + }, + "justPlay": { + "title": "Просто грайте на сервері.", + "description": "Один з найпростіших способів підтримати osu!sunrise — це просто грати на сервері! Чим більше гравців у нас є, тим кращею стає спільнота та досвід. Приєднуючись, ви допомагаєте зростати серверу та підтримувати його активним для всіх гравців." + } + } + }, + "topplays": { + "meta": { + "title": "Кращі результати | {appName}" + }, + "header": "Кращі результати", + "showMore": "Показати більше", + "components": { + "userScoreMinimal": { + "pp": "pp", + "accuracy": "точність:" + } + } + }, + "score": { + "meta": { + "title": "{username} на {beatmapTitle} [{beatmapVersion}] | {appName}", + "description": "Користувач {username} набрав {pp}pp на {beatmapTitle} [{beatmapVersion}] в {appName}.", + "openGraph": { + "title": "{username} на {beatmapTitle} - {beatmapArtist} [{beatmapVersion}] | {appName}", + "description": "Користувач {username} набрав {pp}pp на {beatmapTitle} - {beatmapArtist} [{beatmapVersion}] ★{starRating} {mods} в {appName}." + } + }, + "header": "Продуктивність результату", + "beatmap": { + "versionUnknown": "Невідомо", + "mappedBy": "створена", + "creatorUnknown": "Невідомий автор" + }, + "score": { + "submittedOn": "Відправлено", + "playedBy": "Зіграно", + "userUnknown": "Невідомий користувач" + }, + "actions": { + "downloadReplay": "Завантажити повтор", + "openMenu": "Відкрити меню" + }, + "error": { + "notFound": "Результат не знайдено", + "description": "Результат, який ви шукаєте, не існує або було видалено." + } + }, + "leaderboard": { + "meta": { + "title": "Рейтинги | {appName}" + }, + "header": "Рейтинги", + "sortBy": { + "label": "Сортувати за:", + "performancePoints": "Очки продуктивності", + "rankedScore": "Рейтингових очок", + "performancePointsShort": "Очки прод.", + "scoreShort": "Очки" + }, + "table": { + "columns": { + "rank": "Ранг", + "performance": "Продуктивність", + "rankedScore": "Рейтингових очок", + "accuracy": "Точність", + "playCount": "Кількість ігор" + }, + "actions": { + "openMenu": "Відкрити меню", + "viewUserProfile": "Переглянути профіль користувача" + }, + "emptyState": "Результатів немає.", + "pagination": { + "usersPerPage": "користувачів на сторінку", + "showing": "Показано {start} - {end} з {total}" + } + } + }, + "friends": { + "meta": { + "title": "Ваші друзі | {appName}" + }, + "header": "Ваші зв'язки", + "tabs": { + "friends": "Друзі", + "followers": "Підписники" + }, + "sorting": { + "label": "Сортувати за:", + "username": "Ім'я користувача", + "recentlyActive": "Недавно активні" + }, + "showMore": "Показати більше", + "emptyState": "Користувачів не знайдено" + }, + "beatmaps": { + "search": { + "meta": { + "title": "Пошук бітмап | {appName}" + }, + "header": "Пошук бітмап" + }, + "detail": { + "meta": { + "title": "Інформація про бітмапу | {appName}" + }, + "header": "Інформація про бітмапу", + "notFound": { + "title": "Набір бітмап не знайдено", + "description": "Набір бітмап, який ви шукаєте, не існує або було видалено." + } + }, + "components": { + "search": { + "searchPlaceholder": "Шукати бітмапи...", + "filters": "Фільтри", + "viewMode": { + "grid": "Сітка", + "list": "Список" + }, + "showMore": "Показати більше" + }, + "filters": { + "mode": { + "label": "Режим", + "any": "Будь-який", + "standard": "osu!", + "taiko": "osu!taiko", + "catch": "osu!catch", + "mania": "osu!mania" + }, + "status": { + "label": "Статус" + }, + "searchByCustomStatus": { + "label": "Шукати за індивідуальним статусом" + }, + "applyFilters": "Застосувати фільтри" + } + } + }, + "beatmapsets": { + "meta": { + "title": "{artist} - {title} | {appName}", + "description": "Інформація про набір бітмап для {title} від {artist}", + "openGraph": { + "title": "{artist} - {title} | {appName}", + "description": "Інформація про набір бітмап для {title} від {artist} {difficultyInfo}" + } + }, + "header": "Інформація про бітмапу", + "error": { + "notFound": { + "title": "Набір бітмап не знайдено", + "description": "Набір бітмап, який ви шукаєте, не існує або було видалено." + } + }, + "submission": { + "submittedBy": "відправлено", + "submittedOn": "відправлено", + "rankedOn": "рейтинговано", + "statusBy": "{status} від" + }, + "video": { + "tooltip": "Ця бітмапа містить відео" + }, + "description": { + "header": "Опис" + }, + "components": { + "dropdown": { + "openMenu": "Відкрити меню", + "ppCalculator": "Калькулятор PP", + "openOnBancho": "Відкрити на Bancho", + "openWithAdminPanel": "Відкрити з панеллю адміністратора" + }, + "infoAccordion": { + "communityHype": "Хайп спільноти", + "information": "Інформація", + "metadata": { + "genre": "Жанр", + "language": "Мова", + "tags": "Теги" + } + }, + "downloadButtons": { + "download": "Завантажити", + "withVideo": "з відео", + "withoutVideo": "без відео", + "osuDirect": "osu!direct" + }, + "difficultyInformation": { + "tooltips": { + "totalLength": "Загальна тривалість", + "bpm": "BPM", + "starRating": "Зоряний рейтинг" + }, + "labels": { + "keyCount": "Кількість клавіш:", + "circleSize": "Розмір кіл:", + "hpDrain": "Втрата HP:", + "accuracy": "Точність:", + "approachRate": "Швидкість наближення:" + } + }, + "nomination": { + "description": "Хайпіть цю карту, якщо вам сподобалося грати на ній, щоб допомогти їй досягти статусу Рейтингової.", + "hypeProgress": "Прогрес хайпу", + "hypeBeatmap": "Хайпіть бітмапу!", + "hypesRemaining": "У вас залишилося {count} хайпів на цей тиждень", + "toast": { + "success": "Бітмапу успішно хайпнуто!", + "error": "Помилка під час хайпу набору бітмап!" + } + }, + "ppCalculator": { + "title": "Калькулятор PP", + "pp": "PP: {value}", + "totalLength": "Загальна тривалість", + "form": { + "accuracy": { + "label": "Точність", + "validation": { + "negative": "Точність не може бути від'ємною", + "tooHigh": "Точність не може бути більшою за 100" + } + }, + "combo": { + "label": "Комбо", + "validation": { + "negative": "Комбо не може бути від'ємним" + } + }, + "misses": { + "label": "Промахи", + "validation": { + "negative": "Промахи не можуть бути від'ємними" + } + }, + "calculate": "Розрахувати", + "unknownError": "Невідома помилка" + } + }, + "leaderboard": { + "columns": { + "rank": "Ранг", + "score": "Очки", + "accuracy": "Точність", + "player": "Гравець", + "maxCombo": "Макс. комбо", + "perfect": "Ідеально", + "great": "Відмінно", + "good": "Добре", + "ok": "Ок", + "lDrp": "В ДРП", + "meh": "Мех", + "sDrp": "М ДРП", + "miss": "Промах", + "pp": "PP", + "time": "Час", + "mods": "Моди" + }, + "actions": { + "openMenu": "Відкрити меню", + "viewDetails": "Переглянути деталі", + "downloadReplay": "Завантажити повтор" + }, + "table": { + "emptyState": "Результатів не знайдено. Станьте першим, хто відправить результат!", + "pagination": { + "scoresPerPage": "результатів на сторінку", + "showing": "Показано {start} - {end} з {total}" + } + } + } + } + }, + "settings": { + "meta": { + "title": "Налаштування | {appName}" + }, + "header": "Налаштування", + "notLoggedIn": "Ви повинні увійти, щоб переглянути цю сторінку.", + "sections": { + "changeAvatar": "Змінити аватар", + "changeBanner": "Змінити банер", + "changeDescription": "Змінити опис", + "socials": "Соціальні мережі", + "playstyle": "Стиль гри", + "options": "Опції", + "changePassword": "Змінити пароль", + "changeUsername": "Змінити ім'я користувача", + "changeCountryFlag": "Змінити прапор країни" + }, + "description": { + "reminder": "* Нагадування: Не публікуйте жодного неприйнятного контенту. Намагайтеся тримати це сімейно прийнятним :)" + }, + "components": { + "username": { + "label": "Нове ім'я користувача", + "placeholder": "напр. ім'я користувача", + "button": "Змінити ім'я користувача", + "validation": { + "minLength": "Ім'я користувача має містити принаймні {min} символів.", + "maxLength": "Ім'я користувача має містити не більше {max} символів." + }, + "toast": { + "success": "Ім'я користувача успішно змінено!", + "error": "Помилка під час зміни імені користувача!" + }, + "reminder": "* Нагадування: Будь ласка, тримайте ваше ім'я користувача сімейно прийнятним, інакше воно буде змінено за вас. Зловживання цією функцією призведе до бану." + }, + "password": { + "labels": { + "current": "Поточний пароль", + "new": "Новий пароль", + "confirm": "Підтвердити пароль" + }, + "placeholder": "************", + "button": "Змінити пароль", + "validation": { + "minLength": "Пароль має містити принаймні {min} символів.", + "maxLength": "Пароль має містити не більше {max} символів.", + "mismatch": "Паролі не співпадають" + }, + "toast": { + "success": "Пароль успішно змінено!", + "error": "Помилка під час зміни пароля!" + } + }, + "description": { + "toast": { + "success": "Опис успішно оновлено!", + "error": "Сталася невідома помилка" + } + }, + "country": { + "label": "Новий прапор країни", + "placeholder": "Виберіть новий прапор країни", + "button": "Змінити прапор країни", + "toast": { + "success": "Прапор країни успішно змінено!", + "error": "Помилка під час зміни прапора країни!" + } + }, + "socials": { + "headings": { + "general": "Загальне", + "socials": "Соціальні мережі" + }, + "fields": { + "location": "розташування", + "interest": "інтереси", + "occupation": "рід занять" + }, + "button": "Оновити соціальні мережі", + "toast": { + "success": "Соціальні мережі успішно оновлено!", + "error": "Помилка під час оновлення соціальних мереж!" + } + }, + "playstyle": { + "options": { + "Mouse": "Миша", + "Keyboard": "Клавіатура", + "Tablet": "Планшет", + "TouchScreen": "Сенсорний екран" + }, + "toast": { + "success": "Стиль гри успішно оновлено!", + "error": "Помилка під час оновлення стилю гри!" + } + }, + "uploadImage": { + "types": { + "avatar": "аватар", + "banner": "банер" + }, + "button": "Завантажити {type}", + "toast": { + "success": "{type} успішно оновлено!", + "error": "Сталася невідома помилка" + }, + "note": "* Примітка: {type} обмежені розміром до 5 МБ" + }, + "siteOptions": { + "includeBanchoButton": "Включити кнопку \"Відкрити на Bancho\" на сторінці бітмапи", + "useSpaciousUI": "Використовувати простору UI (збільшити відступи між елементами)" + } + }, + "common": { + "unknownError": "Невідома помилка." + } + }, + "user": { + "meta": { + "title": "{username} · Профіль користувача | {appName}", + "description": "Ми не знаємо про них багато, але ми впевнені, що {username} чудовий." + }, + "header": "Інформація про гравця", + "tabs": { + "general": "Загальне", + "bestScores": "Кращі результати", + "recentScores": "Недавні результати", + "firstPlaces": "Перші місця", + "beatmaps": "Бітмапи", + "medals": "Медалі" + }, + "buttons": { + "editProfile": "Редагувати профіль", + "setDefaultGamemode": "Встановити {gamemode} {flag} як режим гри за замовчуванням профілю" + }, + "errors": { + "userNotFound": "Користувача не знайдено або сталася помилка.", + "restricted": "Це означає, що користувач порушив правила сервера і був обмежений.", + "userDeleted": "Користувача, можливо, було видалено або він не існує." + }, + "components": { + "generalTab": { + "info": "Інформація", + "rankedScore": "Рейтингових очок", + "hitAccuracy": "Точність", + "playcount": "Кількість ігор", + "totalScore": "Всього очок", + "maximumCombo": "Максимальне комбо", + "playtime": "Час гри", + "performance": "Продуктивність", + "showByRank": "Показати за рейтингом", + "showByPp": "Показати за pp", + "aboutMe": "Про мене" + }, + "scoresTab": { + "bestScores": "Кращі результати", + "recentScores": "Недавні результати", + "firstPlaces": "Перші місця", + "noScores": "У користувача немає результатів типу {type}", + "showMore": "Показати більше" + }, + "beatmapsTab": { + "mostPlayed": "Найбільше зіграно", + "noMostPlayed": "У користувача немає найбільше зіграних бітмап", + "favouriteBeatmaps": "Улюблені бітмапи", + "noFavourites": "У користувача немає улюблених бітмап", + "showMore": "Показати більше" + }, + "medalsTab": { + "medals": "Медалі", + "latest": "Останні", + "categories": { + "hushHush": "Тиша", + "beatmapHunt": "Полювання за бітмапами", + "modIntroduction": "Введення модів", + "skill": "Навичка" + }, + "achievedOn": "досягнуто", + "notAchieved": "Не досягнуто" + }, + "generalInformation": { + "joined": "Приєднався {time}", + "followers": "{count} Підписників", + "following": "{count} Підписок", + "playsWith": "Грає з {playstyle}" + }, + "statusText": { + "lastSeenOn": ", востаннє бачено {date}" + }, + "ranks": { + "highestRank": "Найвищий рейтинг {rank} {date}" + }, + "previousUsernames": { + "previouslyKnownAs": "Цей користувач раніше був відомий як:" + }, + "beatmapSetOverview": { + "by": "від {artist}", + "mappedBy": "створена {creator}" + }, + "privilegeBadges": { + "badges": { + "Developer": "Розробник", + "Admin": "Адміністратор", + "Bat": "BAT", + "Bot": "Бот", + "Supporter": "Супортер" + } + }, + "scoreOverview": { + "pp": "pp", + "accuracy": "точність: {accuracy}%" + }, + "statsChart": { + "date": "Дата", + "types": { + "pp": "pp", + "rank": "рейтинг" + }, + "tooltip": "{value} {type}" + } + } + } + } +} diff --git a/lib/i18n/messages/zh-CN.json b/lib/i18n/messages/zh-CN.json new file mode 100644 index 0000000..d6ed62a --- /dev/null +++ b/lib/i18n/messages/zh-CN.json @@ -0,0 +1,911 @@ +{ + "general": { + "appName": "osu!sunrise", + "serverTitle": { + "full": "sunrise", + "split": { + "part1": "sun", + "part2": "rise" + } + } + }, + "components": { + "serverMaintenanceDialog": { + "title": "嘿!请到此为止!", + "discordMessage": "更多信息请查看我们的 Discord 服务器。", + "button": "好的,我明白了", + "message": "服务器目前处于维护模式,因此网站的某些功能可能无法正常工作。" + }, + "contentNotExist": { + "defaultText": "内容未找到" + }, + "workInProgress": { + "title": "正在开发", + "description": "该内容仍在制作中。请稍后再来查看。" + }, + "beatmapSetCard": { + "submittedBy": "提交者", + "submittedOn": "提交于", + "view": "查看" + }, + "friendshipButton": { + "unfriend": "删除好友", + "unfollow": "取消关注", + "follow": "关注" + }, + "gameModeSelector": { + "selectedMode": "当前模式:" + }, + "header": { + "links": { + "leaderboard": "排名", + "topPlays": "最好成绩", + "beatmaps": "谱面", + "help": "帮助", + "wiki": "百科", + "rules": "规章制度", + "apiDocs": "API 文档", + "discordServer": "Discord 服务器", + "supportUs": "支持我们" + } + }, + "headerLoginDialog": { + "signIn": "登录", + "title": "登录以继续", + "description": "欢迎回来。", + "username": { + "label": "用户名", + "placeholder": "例如:username" + }, + "password": { + "label": "密码", + "placeholder": "************" + }, + "login": "登录", + "signUp": "没有账号?注册", + "toast": { + "success": "登录成功!" + }, + "validation": { + "usernameMinLength": "用户名至少需要 2 个字符。", + "usernameMaxLength": "用户名长度不能超过 32 个字符。", + "passwordMinLength": "密码至少需要 8 个字符。", + "passwordMaxLength": "密码长度不能超过 32 个字符。" + } + }, + "headerLogoutAlert": { + "title": "确定要退出吗?", + "description": "退出后,你需要重新登录才能访问你的账号。", + "cancel": "取消", + "continue": "继续", + "toast": { + "success": "你已成功退出登录。" + } + }, + "headerSearchCommand": { + "placeholder": "输入以搜索...", + "headings": { + "users": "用户", + "beatmapsets": "谱面集", + "pages": "页面" + }, + "pages": { + "leaderboard": "排名", + "topPlays": "最好成绩", + "beatmapsSearch": "谱面搜索", + "wiki": "百科", + "rules": "规章制度", + "yourProfile": "你的资料", + "friends": "好友", + "settings": "设置" + } + }, + "headerUserDropdown": { + "myProfile": "我的资料", + "friends": "好友", + "settings": "设置", + "returnToMainSite": "返回主站", + "adminPanel": "管理面板", + "logOut": "退出登录" + }, + "headerMobileDrawer": { + "navigation": { + "home": "首页", + "leaderboard": "排名", + "topPlays": "最好成绩", + "beatmapsSearch": "谱面搜索", + "wiki": "百科", + "rules": "规章制度", + "apiDocs": "API 文档", + "discordServer": "Discord 服务器", + "supportUs": "支持我们" + }, + "yourProfile": "你的资料", + "friends": "好友", + "settings": "设置", + "adminPanel": "管理面板", + "logOut": "退出登录" + }, + "footer": { + "voteMessage": "请在 osu-server-list 上为我们投票!", + "copyright": "© 2024-2025 Sunrise Community", + "sourceCode": "源代码", + "serverStatus": "服务器状态", + "disclaimer": "我们与“ppy”和“osu!”没有任何关联。所有权利归各自所有者所有。" + }, + "comboBox": { + "selectValue": "选择一个值...", + "searchValue": "搜索...", + "noValuesFound": "未找到任何结果。" + }, + "beatmapsetRowElement": { + "mappedBy": "谱师:{creator}" + }, + "themeModeToggle": { + "toggleTheme": "切换主题", + "light": "浅色", + "dark": "深色", + "system": "跟随系统" + }, + "imageSelect": { + "imageTooBig": "所选图片太大!" + }, + "notFound": { + "meta": { + "title": "无法找到网页 | {appName}", + "description": "抱歉,您正在尝试访问的页面不存在!" + }, + "title": "无法找到网页 | 404", + "description": "抱歉,您正在尝试访问的页面不存在!" + }, + "rootLayout": { + "meta": { + "title": "{appName}", + "description": "{appName} 是一款节奏游戏 osu! 的私人服务器。" + } + } + }, + "pages": { + "mainPage": { + "meta": { + "title": "欢迎 | {appName}", + "description": "加入 osu!sunrise —— 功能丰富的私人 osu! 服务器,支持 Relax、Autopilot、ScoreV2,并为 Relax 与 Autopilot 玩法定制了专属 PP 系统。" + }, + "features": { + "motto": "- 又一个 osu! 服务器", + "description": "功能丰富的 osu! 服务器,支持 Relax、Autopilot 与 ScoreV2 玩法,并为 Relax 与 Autopilot 量身打造了自定义 PP 计算系统。", + "buttons": { + "register": "立即加入", + "wiki": "如何连接" + } + }, + "whyUs": "为什么选择我们?", + "cards": { + "freeFeatures": { + "title": "真正免费的功能", + "description": "尽情享受 osu!direct、改名等功能,无任何付费墙——对所有玩家完全免费!" + }, + "ppSystem": { + "title": "自定义 PP 计算", + "description": "我们对原版成绩使用最新 PP 系统,同时对 Relax 与 Autopilot 模式采用自定义且平衡的公式。" + }, + "medals": { + "title": "获得自定义奖牌", + "description": "达成各种里程碑与成就,赢取服务器独占的独特奖牌。" + }, + "updates": { + "title": "频繁更新", + "description": "我们一直在改进!期待定期更新、新功能与持续的性能优化。" + }, + "ppCalc": { + "title": "内置 PP 计算器", + "description": "我们的网站提供内置 PP 计算器,快速估算成绩 PP。" + }, + "sunriseCore": { + "title": "自研 Bancho 核心", + "description": "不同于大多数私人 osu! 服务器,我们开发了自定义 Bancho 核心,以获得更好的稳定性并支持独特功能。" + } + }, + "howToStart": { + "title": "如何开始游玩?", + "description": "只需三个简单步骤即可开始!", + "downloadTile": { + "title": "下载 osu! 客户端", + "description": "如果你还没有安装客户端", + "button": "下载" + }, + "registerTile": { + "title": "注册 osu!sunrise 账号", + "description": "账号可让你加入 osu!sunrise 社区", + "button": "注册" + }, + "guideTile": { + "title": "按照连接指南操作", + "description": "它会帮助你设置 osu! 客户端以连接到 osu!sunrise", + "button": "打开指南" + } + }, + "statuses": { + "totalUsers": "总用户数", + "usersOnline": "在线用户", + "usersRestricted": "受限用户", + "totalScores": "总成绩数", + "serverStatus": "服务器状态", + "online": "在线", + "offline": "离线", + "underMaintenance": "维护中" + } + }, + "wiki": { + "meta": { + "title": "百科 | {appName}" + }, + "header": "百科", + "articles": { + "howToConnect": { + "title": "如何连接", + "intro": "要连接服务器,你需要在电脑上安装游戏。你可以从 osu! 官方网站下载游戏。", + "step1": "在游戏目录中找到 osu!.exe 文件。", + "step2": "为该文件创建一个快捷方式。", + "step3": "右键点击快捷方式并选择“属性”。", + "step4": "在“目标”栏末尾添加 -devserver {serverDomain}。", + "step5": "点击“应用”,然后点击“确定”。", + "step6": "双击快捷方式启动游戏。", + "imageAlt": "osu 连接示意图" + }, + "multipleAccounts": { + "title": "可以拥有多个账号吗?", + "answer": "不可以。每人仅允许拥有一个账号。", + "consequence": "如果被发现拥有多个账号,你将被服务器封禁。" + }, + "cheatsHacks": { + "title": "可以使用作弊或外挂吗?", + "answer": "不可以。一旦被抓到将被封禁。", + "policy": "我们对作弊非常严格,绝不容忍。

如果你怀疑有人作弊,请向工作人员举报。" + }, + "appealRestriction": { + "title": "我认为自己被不公平地限制了。如何申诉?", + "instructions": "如果你认为限制不公平,可以联系工作人员并说明情况以进行申诉。", + "contactStaff": "你可以在这里联系工作人员。" + }, + "contributeSuggest": { + "title": "我可以为服务器贡献/提出建议吗?", + "answer": "当然可以!我们一直欢迎建议。", + "instructions": "如果你有任何建议,请在我们的 GitHub 页面提交。

长期贡献者也有机会获得永久 Supporter 标签。" + }, + "multiplayerDownload": { + "title": "我在多人游戏中无法下载谱面,但在主菜单可以", + "solution": "在设置中关闭 Automatically start osu!direct downloads,然后再试一次。" + } + } + }, + "rules": { + "meta": { + "title": "规章制度 | {appName}" + }, + "header": "规章制度", + "sections": { + "generalRules": { + "title": "一般规则", + "noCheating": { + "title": "禁止作弊或外挂。", + "description": "任何形式的作弊(包括自瞄、relax 挂、宏、修改客户端等)都被严格禁止。公平游戏,公平进步。", + "warning": "如你所见,我用更大的字体写给那些“wannabe”作弊者:别以为在别的私服被封后就能来这里。你会被找到并执行(在 Minecraft 里)。所以请别作弊。" + }, + "noMultiAccount": { + "title": "禁止多账号或共享账号。", + "description": "每位玩家只允许一个账号。如果你的主账号在没有说明的情况下被限制,请联系支持。" + }, + "noImpersonation": { + "title": "禁止冒充知名玩家或工作人员", + "description": "不要假装自己是工作人员或知名玩家。误导他人可能导致改名或永久封禁。" + } + }, + "chatCommunityRules": { + "title": "聊天与社区规则", + "beRespectful": { + "title": "保持尊重。", + "description": "友善对待他人。骚扰、仇恨言论、歧视或有毒行为都不被容忍。" + }, + "noNSFW": { + "title": "禁止 NSFW 或不适当内容", + "description": "保持服务器适合所有年龄段 —— 这适用于所有用户内容,包括但不限于用户名、横幅、头像与个人简介。" + }, + "noAdvertising": { + "title": "禁止广告宣传。", + "description": "未经管理员批准,请勿推广其他服务器、网站或产品。" + } + }, + "disclaimer": { + "title": "重要免责声明", + "intro": "创建和/或维护账号即表示你已知悉并同意以下条款:", + "noLiability": { + "title": "免责。", + "description": "你对参与 Sunrise 提供的任何服务负全部责任,并承认无法让组织对使用过程中可能产生的任何后果承担责任。" + }, + "accountRestrictions": { + "title": "账号限制。", + "description": "管理团队保留在不提前通知的情况下,因违反规则或基于自身判断限制或封停任何账号的权利。" + }, + "ruleChanges": { + "title": "规则变更。", + "description": "服务器规则可能随时变更。管理团队可在不提前通知的情况下更新、修改或删除任何规则,玩家应主动了解相关变化。" + }, + "agreementByParticipation": { + "title": "参与即同意。", + "description": "创建和/或维护账号即表示你自动同意这些条款,并承诺遵守当时有效的规则与指南。" + } + } + } + }, + "register": { + "meta": { + "title": "注册 | {appName}" + }, + "header": "注册", + "welcome": { + "title": "欢迎来到注册页面!", + "description": "你好!请输入你的信息来创建账号。如果你不确定如何连接服务器,或有其他问题,请访问我们的 Wiki 页面。" + }, + "form": { + "title": "请输入你的信息", + "labels": { + "username": "用户名", + "email": "邮箱", + "password": "密码", + "confirmPassword": "确认密码" + }, + "placeholders": { + "username": "例如:username", + "email": "例如:username@mail.com", + "password": "************" + }, + "validation": { + "usernameMin": "用户名至少需要 {min} 个字符。", + "usernameMax": "用户名长度不能超过 {max} 个字符。", + "passwordMin": "密码至少需要 {min} 个字符。", + "passwordMax": "密码长度不能超过 {max} 个字符。", + "passwordsDoNotMatch": "两次输入的密码不一致" + }, + "error": { + "title": "错误", + "unknown": "未知错误。" + }, + "submit": "注册", + "terms": "注册即表示你同意服务器规则" + }, + "success": { + "dialog": { + "title": "设置完成!", + "description": "你的账号已成功创建。", + "message": "你现在可以按照 Wiki 页面 的指南连接服务器,或在开始游戏前更新头像与横幅来自定义个人资料!", + "buttons": { + "viewWiki": "查看 Wiki 指南", + "goToProfile": "前往个人资料" + } + }, + "toast": "账号创建成功!" + } + }, + "support": { + "meta": { + "title": "支持我们 | {appName}" + }, + "header": "支持我们", + "section": { + "title": "你可以如何帮助我们", + "intro": "尽管 osu!sunrise 的所有功能一直免费,但服务器的运行与改进需要资源、时间与精力,并且主要由一位开发者维护。



如果你喜欢 osu!sunrise 并希望它继续成长,以下是一些支持方式:", + "donate": { + "title": "捐助。", + "description": "你的捐助将帮助我们维护与提升 osu! 服务器。每一份支持都很重要!有了你的帮助,我们可以覆盖主机费用、实现新功能,并为所有人提供更顺畅的体验。", + "buttons": { + "kofi": "Ko-fi", + "boosty": "Boosty" + } + }, + "spreadTheWord": { + "title": "传播。", + "description": "了解 osu!sunrise 的人越多,我们的社区就会越活跃、有趣。告诉朋友、在社交媒体分享,并邀请新玩家加入吧。" + }, + "justPlay": { + "title": "在服务器上游玩。", + "description": "支持 osu!sunrise 最简单的方式之一就是在服务器上游玩!玩家越多,社区与体验就越好。你的参与能帮助服务器成长并保持活跃。" + } + } + }, + "topplays": { + "meta": { + "title": "最好成绩 | {appName}" + }, + "header": "最好成绩", + "showMore": "显示更多", + "components": { + "userScoreMinimal": { + "pp": "pp", + "accuracy": "acc:" + } + } + }, + "score": { + "meta": { + "title": "{username} 在 {beatmapTitle} [{beatmapVersion}] | {appName}", + "description": "用户 {username} 在 {appName} 中于 {beatmapTitle} [{beatmapVersion}] 取得了 {pp}pp。", + "openGraph": { + "title": "{username} 在 {beatmapTitle} - {beatmapArtist} [{beatmapVersion}] | {appName}", + "description": "用户 {username} 在 {appName} 中于 {beatmapTitle} - {beatmapArtist} [{beatmapVersion}] ★{starRating} {mods} 取得了 {pp}pp。" + } + }, + "header": "成绩详情", + "beatmap": { + "versionUnknown": "未知", + "mappedBy": "谱师", + "creatorUnknown": "未知谱师" + }, + "score": { + "submittedOn": "提交于", + "playedBy": "玩家", + "userUnknown": "未知用户" + }, + "actions": { + "downloadReplay": "下载回放", + "openMenu": "打开菜单" + }, + "error": { + "notFound": "未找到成绩", + "description": "你要找的成绩不存在或已被删除。" + } + }, + "leaderboard": { + "meta": { + "title": "排名 | {appName}" + }, + "header": "排名", + "sortBy": { + "label": "排序:", + "performancePoints": "PP", + "rankedScore": "计分成绩总分", + "performancePointsShort": "PP", + "scoreShort": "分数" + }, + "table": { + "columns": { + "rank": "排名", + "performance": "PP", + "rankedScore": "计分成绩总分", + "accuracy": "准确率", + "playCount": "游玩次数" + }, + "actions": { + "openMenu": "打开菜单", + "viewUserProfile": "查看用户资料" + }, + "emptyState": "暂无结果。", + "pagination": { + "usersPerPage": "每页用户数", + "showing": "显示 {start} - {end} / 共 {total}" + } + } + }, + "friends": { + "meta": { + "title": "你的好友 | {appName}" + }, + "header": "你的连接", + "tabs": { + "friends": "好友", + "followers": "粉丝" + }, + "sorting": { + "label": "排序:", + "username": "用户名", + "recentlyActive": "最近活跃" + }, + "showMore": "显示更多", + "emptyState": "未找到用户" + }, + "beatmaps": { + "search": { + "meta": { + "title": "谱面搜索 | {appName}" + }, + "header": "谱面搜索" + }, + "detail": { + "meta": { + "title": "谱面信息 | {appName}" + }, + "header": "谱面信息", + "notFound": { + "title": "未找到谱面集", + "description": "你要找的谱面集不存在或已被删除。" + } + }, + "components": { + "search": { + "searchPlaceholder": "搜索谱面...", + "filters": "筛选", + "viewMode": { + "grid": "网格", + "list": "列表" + }, + "showMore": "显示更多" + }, + "filters": { + "mode": { + "label": "模式", + "any": "不限", + "standard": "osu!", + "taiko": "osu!taiko", + "catch": "osu!catch", + "mania": "osu!mania" + }, + "status": { + "label": "状态" + }, + "searchByCustomStatus": { + "label": "按自定义状态搜索" + }, + "applyFilters": "应用筛选" + } + } + }, + "beatmapsets": { + "meta": { + "title": "{artist} - {title} | {appName}", + "description": "{artist} 的 {title} 谱面集信息", + "openGraph": { + "title": "{artist} - {title} | {appName}", + "description": "{artist} 的 {title} 谱面集信息 {difficultyInfo}" + } + }, + "header": "谱面信息", + "error": { + "notFound": { + "title": "未找到谱面集", + "description": "你要找的谱面集不存在或已被删除。" + } + }, + "submission": { + "submittedBy": "提交者", + "submittedOn": "提交于", + "rankedOn": "上架于", + "statusBy": "{status}:" + }, + "video": { + "tooltip": "此谱面包含视频" + }, + "description": { + "header": "简介" + }, + "components": { + "dropdown": { + "openMenu": "打开菜单", + "ppCalculator": "PP 计算器", + "openOnBancho": "在 Bancho 打开", + "openWithAdminPanel": "用管理面板打开" + }, + "infoAccordion": { + "communityHype": "社区 Hype", + "information": "信息", + "metadata": { + "genre": "曲风", + "language": "语言", + "tags": "标签" + } + }, + "downloadButtons": { + "download": "下载", + "withVideo": "含视频", + "withoutVideo": "不含视频", + "osuDirect": "osu!direct" + }, + "difficultyInformation": { + "tooltips": { + "totalLength": "总时长", + "bpm": "BPM", + "starRating": "星级" + }, + "labels": { + "keyCount": "键数:", + "circleSize": "圆圈大小:", + "hpDrain": "掉血:", + "accuracy": "总体难度:", + "approachRate": "接近率:" + } + }, + "nomination": { + "description": "如果你喜欢这张谱面,请给它 Hype,帮助它向 Ranked 状态推进。", + "hypeProgress": "Hype 进度", + "hypeBeatmap": "Hype 这张谱面!", + "hypesRemaining": "你本周还剩 {count} 次 Hype 可用于该谱面", + "toast": { + "success": "Hype 成功!", + "error": "Hype 谱面集时发生错误!" + } + }, + "ppCalculator": { + "title": "PP 计算器", + "pp": "PP: {value}", + "totalLength": "总时长", + "form": { + "accuracy": { + "label": "准确率", + "validation": { + "negative": "准确率不能为负数", + "tooHigh": "准确率不能大于 100" + } + }, + "combo": { + "label": "连击", + "validation": { + "negative": "连击不能为负数" + } + }, + "misses": { + "label": "Miss 数", + "validation": { + "negative": "Miss 数不能为负数" + } + }, + "calculate": "计算", + "unknownError": "未知错误" + } + }, + "leaderboard": { + "columns": { + "rank": "排名", + "score": "分数", + "accuracy": "准确率", + "player": "玩家", + "maxCombo": "最大连击", + "perfect": "Perfect", + "great": "Great", + "good": "Good", + "ok": "Ok", + "lDrp": "L DRP", + "meh": "Meh", + "sDrp": "S DRP", + "miss": "Miss", + "pp": "PP", + "time": "时间", + "mods": "Mods" + }, + "actions": { + "openMenu": "打开菜单", + "viewDetails": "查看详情", + "downloadReplay": "下载回放" + }, + "table": { + "emptyState": "没有找到任何成绩。快来提交第一条吧!", + "pagination": { + "scoresPerPage": "每页成绩数", + "showing": "显示 {start} - {end} / 共 {total}" + } + } + } + } + }, + "settings": { + "meta": { + "title": "设置 | {appName}" + }, + "header": "设置", + "notLoggedIn": "你必须登录后才能查看此页面。", + "sections": { + "changeAvatar": "更换头像", + "changeBanner": "更换横幅", + "changeDescription": "更改简介", + "socials": "社交", + "playstyle": "操作方式", + "options": "选项", + "changePassword": "修改密码", + "changeUsername": "修改用户名", + "changeCountryFlag": "更改国家/地区旗帜" + }, + "description": { + "reminder": "* 提醒:请勿发布任何不适当内容。尽量保持适合所有年龄 :)" + }, + "components": { + "username": { + "label": "新用户名", + "placeholder": "例如:username", + "button": "修改用户名", + "validation": { + "minLength": "用户名至少需要 {min} 个字符。", + "maxLength": "用户名长度不能超过 {max} 个字符。" + }, + "toast": { + "success": "用户名修改成功!", + "error": "修改用户名时发生错误!" + }, + "reminder": "* 提醒:请保持用户名适合所有年龄,否则将被强制修改。滥用此功能将导致封禁。" + }, + "password": { + "labels": { + "current": "当前密码", + "new": "新密码", + "confirm": "确认密码" + }, + "placeholder": "************", + "button": "修改密码", + "validation": { + "minLength": "密码至少需要 {min} 个字符。", + "maxLength": "密码长度不能超过 {max} 个字符。", + "mismatch": "两次输入的密码不一致" + }, + "toast": { + "success": "密码修改成功!", + "error": "修改密码时发生错误!" + } + }, + "description": { + "toast": { + "success": "简介更新成功!", + "error": "发生未知错误" + } + }, + "country": { + "label": "新国家/地区旗帜", + "placeholder": "选择新旗帜", + "button": "更改旗帜", + "toast": { + "success": "旗帜更改成功!", + "error": "更改旗帜时发生错误!" + } + }, + "socials": { + "headings": { + "general": "一般", + "socials": "社交" + }, + "fields": { + "location": "所在地", + "interest": "兴趣", + "occupation": "职业" + }, + "button": "更新社交信息", + "toast": { + "success": "社交信息更新成功!", + "error": "更新社交信息时发生错误!" + } + }, + "playstyle": { + "options": { + "Mouse": "鼠标", + "Keyboard": "键盘", + "Tablet": "数位板", + "TouchScreen": "触屏" + }, + "toast": { + "success": "操作方式更新成功!", + "error": "更新操作方式时发生错误!" + } + }, + "uploadImage": { + "types": { + "avatar": "头像", + "banner": "横幅" + }, + "button": "上传{type}", + "toast": { + "success": "{type}更新成功!", + "error": "发生未知错误" + }, + "note": "* 注意:{type} 最大为 5MB" + }, + "siteOptions": { + "includeBanchoButton": "在谱面页面显示“Open on Bancho”按钮", + "useSpaciousUI": "使用更宽松的界面(增大元素间距)" + } + }, + "common": { + "unknownError": "未知错误。" + } + }, + "user": { + "meta": { + "title": "{username} · 用户资料 | {appName}", + "description": "我们并不了解 Ta 很多,但我们相信 {username} 一定很棒。" + }, + "header": "玩家信息", + "tabs": { + "general": "概览", + "bestScores": "最好成绩", + "recentScores": "最近成绩", + "firstPlaces": "第一名", + "beatmaps": "谱面", + "medals": "奖牌" + }, + "buttons": { + "editProfile": "编辑资料", + "setDefaultGamemode": "将 {gamemode} {flag} 设为个人资料默认模式" + }, + "errors": { + "userNotFound": "未找到用户或发生错误。", + "restricted": "这意味着该用户违反了服务器规则并已被限制。", + "userDeleted": "该用户可能已被删除或不存在。" + }, + "components": { + "generalTab": { + "info": "信息", + "rankedScore": "计分成绩总分", + "hitAccuracy": "准确率", + "playcount": "游玩次数", + "totalScore": "总分", + "maximumCombo": "最大连击", + "playtime": "游玩时长", + "performance": "表现", + "showByRank": "按排名显示", + "showByPp": "按 PP 显示", + "aboutMe": "关于我" + }, + "scoresTab": { + "bestScores": "最好成绩", + "recentScores": "最近成绩", + "firstPlaces": "第一名", + "noScores": "该用户没有 {type} 成绩", + "showMore": "显示更多" + }, + "beatmapsTab": { + "mostPlayed": "最常游玩", + "noMostPlayed": "该用户没有最常游玩的谱面", + "favouriteBeatmaps": "收藏谱面", + "noFavourites": "该用户没有收藏谱面", + "showMore": "显示更多" + }, + "medalsTab": { + "medals": "奖牌", + "latest": "最新", + "categories": { + "hushHush": "Hush-Hush", + "beatmapHunt": "谱面狩猎", + "modIntroduction": "模组介绍", + "skill": "技巧" + }, + "achievedOn": "获得于", + "notAchieved": "未获得" + }, + "generalInformation": { + "joined": "加入于 {time}", + "followers": "{count} 粉丝", + "following": "关注 {count}", + "playsWith": "使用 {playstyle} 游玩" + }, + "statusText": { + "lastSeenOn": ",最后在线:{date}" + }, + "ranks": { + "highestRank": "最高排名 {rank} 于 {date}" + }, + "previousUsernames": { + "previouslyKnownAs": "该用户曾用名:" + }, + "beatmapSetOverview": { + "by": "作者 {artist}", + "mappedBy": "谱师 {creator}" + }, + "privilegeBadges": { + "badges": { + "Developer": "开发者", + "Admin": "管理员", + "Bat": "BAT", + "Bot": "机器人", + "Supporter": "支持者" + } + }, + "scoreOverview": { + "pp": "pp", + "accuracy": "acc: {accuracy}%" + }, + "statsChart": { + "date": "日期", + "types": { + "pp": "pp", + "rank": "rank" + }, + "tooltip": "{value} {type}" + } + } + } + } +} From e5cd00380618b46d35a25875be4cc3930818020a Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 21 Dec 2025 18:59:32 +0200 Subject: [PATCH 30/31] feat: Add fallback font for cyrillic --- app/layout.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 28d5a7b..2ac93ad 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { Poppins } from "next/font/google"; +import { Noto_Sans, Poppins } from "next/font/google"; import "./globals.css"; import ScrollUpButton from "@/components/ScrollUpButton"; import Providers from "@/components/Providers"; @@ -9,7 +9,13 @@ import { getT } from "@/lib/i18n/utils"; const font = Poppins({ weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], - subsets: ["latin"], + subsets: ["latin", "devanagari", "latin-ext"], + fallback: ["Noto Sans"], +}); + +const _fallbackFont = Noto_Sans({ + weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], + subsets: ["latin", "cyrillic"], }); export async function generateMetadata(): Promise { @@ -44,7 +50,11 @@ export default async function RootLayout({ const [locale, messages] = await Promise.all([getLocale(), getMessages()]); return ( - + {children} From 5dc195764f71040b824fd57254b3c917d022df69 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 21 Dec 2025 19:23:25 +0200 Subject: [PATCH 31/31] chore: small fixes --- .../components/ScoreLeaderboardData.tsx | 2 +- .../leaderboard/components/UserColumns.tsx | 438 +++++++++--------- lib/i18n/messages/en-GB.json | 20 +- 3 files changed, 235 insertions(+), 225 deletions(-) diff --git a/app/(website)/beatmapsets/components/ScoreLeaderboardData.tsx b/app/(website)/beatmapsets/components/ScoreLeaderboardData.tsx index 1cddde0..243d52e 100644 --- a/app/(website)/beatmapsets/components/ScoreLeaderboardData.tsx +++ b/app/(website)/beatmapsets/components/ScoreLeaderboardData.tsx @@ -31,7 +31,7 @@ export default function ScoreLeaderboardData({ }) { return ( -
+

# {score.leaderboard_rank}

diff --git a/app/(website)/leaderboard/components/UserColumns.tsx b/app/(website)/leaderboard/components/UserColumns.tsx index 7a5ff13..88acd52 100644 --- a/app/(website)/leaderboard/components/UserColumns.tsx +++ b/app/(website)/leaderboard/components/UserColumns.tsx @@ -17,230 +17,240 @@ import { ColumnDef } from "@tanstack/react-table"; import { MoreHorizontal, SortAsc, SortDesc } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; -import { Suspense, useContext } from "react"; +import { Suspense, useContext, useMemo } from "react"; import { twMerge } from "tailwind-merge"; import { useT } from "@/lib/i18n/utils"; export function useUserColumns() { const t = useT("pages.leaderboard.table"); - return [ - { - accessorKey: "stats.rank", - sortingFn: (a, b) => { - return a.index - b.index; - }, - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const table = useContext(UserTableContext); - const pageIndex = table.getState().pagination.pageIndex; - const pageSize = table.getState().pagination.pageSize; - const value = row.index + pageIndex * pageSize + 1; - - const textSize = - value === 1 - ? "text-2xl" - : value === 2 - ? "text-lg" - : value === 3 - ? "text-base" - : "text-ms"; + return useMemo( + () => + [ + { + accessorKey: "stats.rank", + sortingFn: (a, b) => { + return a.index - b.index; + }, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const table = useContext(UserTableContext); + const pageIndex = table.getState().pagination.pageIndex; + const pageSize = table.getState().pagination.pageSize; + const value = row.index + pageIndex * pageSize + 1; - return ( - - # {value} - - ); - }, - }, - { - accessorKey: "user.country_code", - header: "", - cell: ({ row }) => { - const countryCode = row.original.user.country_code; - return ( - User Flag - ); - }, - }, - { - accessorKey: "user.username", - header: "", - cell: ({ row }) => { - const userId = row.original.user.user_id; - const { username, avatar_url } = row.original.user; + const textSize = + value === 1 + ? "text-2xl" + : value === 2 + ? "text-lg" + : value === 3 + ? "text-base" + : "text-ms"; - const table = useContext(UserTableContext); - const pageIndex = table.getState().pagination.pageIndex; - const pageSize = table.getState().pagination.pageSize; - const userRank = row.index + pageIndex * pageSize + 1; + return ( + + # {value} + + ); + }, + }, + { + accessorKey: "user.country_code", + header: "", + cell: ({ row }) => { + const countryCode = row.original.user.country_code; + return ( + User Flag + ); + }, + }, + { + accessorKey: "user.username", + header: "", + cell: ({ row }) => { + const userId = row.original.user.user_id; + const { username, avatar_url } = row.original.user; - return ( -
- - UA}> - logo - - + const table = useContext(UserTableContext); + const pageIndex = table.getState().pagination.pageIndex; + const pageSize = table.getState().pagination.pageSize; + const userRank = row.index + pageIndex * pageSize + 1; - - - - {username} - - - -
- ); - }, - }, - { - id: "pp", - accessorKey: "stats.pp", - header: () => ( -
- {t("columns.performance")} -
- ), - cell: ({ row }) => { - const formatted = numberWith(row.original.stats.pp.toFixed(2), ","); - return ( -
- {formatted} -
- ); - }, - }, - { - id: "ranked_score", - accessorKey: "stats.ranked_score", - header: () => ( -
- {t("columns.rankedScore")} -
- ), - cell: ({ row }) => { - const formatted = numberWith(row.original.stats.ranked_score, ","); - return ( -
- {formatted} -
- ); - }, - }, - { - accessorKey: "stats.accuracy", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const formatted = row.original.stats.accuracy.toFixed(2); - return ( -
- {formatted}% -
- ); - }, - }, - { - accessorKey: "stats.play_count", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const value = row.original.stats.play_count; - return ( -
- {value} -
- ); - }, - }, - { - id: "actions", - cell: ({ row }) => { - const userId = row.original.user.user_id; + return ( +
+ + UA}> + logo + + - return ( - - -
+ ); + }, + }, + { + id: "pp", + accessorKey: "stats.pp", + header: () => ( +
+ {t("columns.performance")} +
+ ), + cell: ({ row }) => { + const formatted = numberWith(row.original.stats.pp.toFixed(2), ","); + return ( +
+ {formatted} +
+ ); + }, + }, + { + id: "ranked_score", + accessorKey: "stats.ranked_score", + header: () => ( +
+ {t("columns.rankedScore")} +
+ ), + cell: ({ row }) => { + const formatted = numberWith(row.original.stats.ranked_score, ","); + return ( +
+ {formatted} +
+ ); + }, + }, + { + accessorKey: "stats.accuracy", + header: ({ column }) => { + return ( + - - - - - {t("actions.viewUserProfile")} - - - {/* TODO: Add report option */} - - - ); - }, - }, - ] as ColumnDef<{ - user: UserResponse; - stats: UserStatsResponse; - }>[]; + ); + }, + cell: ({ row }) => { + const formatted = row.original.stats.accuracy.toFixed(2); + return ( +
+ {formatted}% +
+ ); + }, + }, + { + accessorKey: "stats.play_count", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const value = row.original.stats.play_count; + return ( +
+ {value} +
+ ); + }, + }, + { + id: "actions", + cell: ({ row }) => { + const userId = row.original.user.user_id; + + return ( + + + + + + + + {t("actions.viewUserProfile")} + + + {/* TODO: Add report option */} + + + ); + }, + }, + ] as ColumnDef<{ + user: UserResponse; + stats: UserStatsResponse; + }>[], + [t] + ); } diff --git a/lib/i18n/messages/en-GB.json b/lib/i18n/messages/en-GB.json index 8c07666..cc28aa2 100644 --- a/lib/i18n/messages/en-GB.json +++ b/lib/i18n/messages/en-GB.json @@ -420,11 +420,11 @@ }, "score": { "meta": { - "title": "{uwsewname} on {beatmaptitwe} [{beatmapvewsion}] | {appName}", - "description": "usew {uwsewname} has scowed {pp}pp on {beatmaptitwe} [{beatmapvewsion}] in {appName}.", + "title": "{username} on {beatmapTitle} [{beatmapVersion}] | {appName}", + "description": "usew {username} has scowed {pp}pp on {beatmapTitle} [{beatmapVersion}] in {appName}.", "openGraph": { - "title": "{uwsewname} on {beatmaptitwe} - {beatmapawtist} [{beatmapvewsion}] | {appName}", - "description": "usew {uwsewname} has scowed {pp}pp on {beatmaptitwe} - {beatmapawtist} [{beatmapvewsion}] ★{stawrating} {mods} in {appName}." + "title": "{username} on {beatmapTitle} - {beatmapArtist} [{beatmapVersion}] | {appName}", + "description": "usew {username} has scowed {pp}pp on {beatmapTitle} - {beatmapArtist} [{beatmapVersion}] ★{starRating} {mods} in {appName}." } }, "header": "scowe pewfowmance", @@ -474,7 +474,7 @@ "emptyState": "no wesuwwts.", "pagination": { "usersPerPage": "users per page", - "showing": "showing {stawt} - {end} of {totaw}" + "showing": "showing {start} - {end} of {total}" } } }, @@ -543,11 +543,11 @@ }, "beatmapsets": { "meta": { - "title": "{awtist} - {titwe} | {appName}", - "description": "beatmapset info fow {titwe} by {awtist}", + "title": "{artist} - {title} | {appName}", + "description": "beatmapset info fow {title} by {artist}", "openGraph": { - "title": "{awtist} - {titwe} | {appName}", - "description": "beatmapset info fow {titwe} by {awtist} {difficuwwtyinfo}" + "title": "{artist} - {title} | {appName}", + "description": "beatmapset info fow {title} by {artist} {difficultyInfo}" } }, "header": "beatmap info", @@ -671,7 +671,7 @@ "emptyState": "no scowes fouwnd. Be the fiwst to suwbmit one!", "pagination": { "scoresPerPage": "scowes pew page", - "showing": "showing {stawt} - {end} of {totaw}" + "showing": "showing {start} - {end} of {total}" } } }