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) { @@ -148,9 +150,9 @@ export default function Home() { serverStatus ? serverStatus.is_online ? serverStatus.is_on_maintenance - ? "Under Maintenance" - : "Online" - : "Offline" + ? t("statuses.underMaintenance") + : t("statuses.online") + : t("statuses.offline") : undefined } /> @@ -172,7 +174,7 @@ export default function Home() {
-

Why us?

+

{t("whyUs")}

@@ -186,15 +188,17 @@ export default function Home() {
{card.title}
-

{card.title}

+

+ {t(card.titleKey)} +

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

@@ -210,52 +214,55 @@ export default function Home() {

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

-

- Just three simple steps and you're ready to go! -

+

{t("howToStart.description")}

} className="rounded-lg">
-

Download osu! client

+

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

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

} className="rounded-lg">
-

Register osu!sunrise account

+

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

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

} className="rounded-lg">
-

Follow the connection guide

+

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

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

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 efa6304..823ab2f 100644 --- a/app/(website)/beatmaps/[id]/page.tsx +++ b/app/(website)/beatmaps/[id]/page.tsx @@ -7,14 +7,17 @@ import PrettyHeader from "@/components/General/PrettyHeader"; 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: number }>; + params: Promise<{ id: string }>; } export default function BeatmapsRedirect(props: BeatmapsProps) { + const t = useT("pages.beatmaps.detail"); const params = use(props.params); - const beatmapQuery = useBeatmap(params.id); + const beatmapQuery = useBeatmap(tryParseNumber(params.id) ?? 0); const beatmap = beatmapQuery.data; if (beatmap) { @@ -26,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/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 ff9ff8f..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?, string?] }>; + 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..243d52e 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/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/app/(website)/leaderboard/components/UserColumns.tsx b/app/(website)/leaderboard/components/UserColumns.tsx index a253b8b..88acd52 100644 --- a/app/(website)/leaderboard/components/UserColumns.tsx +++ b/app/(website)/leaderboard/components/UserColumns.tsx @@ -17,219 +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 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"); - const textSize = - value === 1 - ? "text-2xl" - : value === 2 - ? "text-lg" - : value === 3 - ? "text-base" - : "text-ms"; - - 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 table = useContext(UserTableContext); - const pageIndex = table.getState().pagination.pageIndex; - const pageSize = table.getState().pagination.pageSize; - const userRank = row.index + pageIndex * pageSize + 1; + 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 ( -
- - UA}> - logo - - + const textSize = + value === 1 + ? "text-2xl" + : value === 2 + ? "text-lg" + : value === 3 + ? "text-base" + : "text-ms"; - - + return ( - {username} + # {value} - - -
- ); - }, - }, - { - id: "pp", - accessorKey: "stats.pp", - header: () => ( -
- Performance -
- ), - cell: ({ row }) => { - const formatted = numberWith(row.original.stats.pp.toFixed(2), ","); - return ( -
{formatted}
- ); - }, - }, - { - id: "ranked_score", - accessorKey: "stats.ranked_score", - header: () => ( -
- Ranked Score -
- ), - 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; + ); + }, + }, + { + 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 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 + + + + + + + {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 ( - - - - - - - View user profile - - {/* TODO: Add report option */} - - - ); - }, - }, -]; + return ( + + + + + + + + {t("actions.viewUserProfile")} + + + {/* TODO: Add report option */} + + + ); + }, + }, + ] as ColumnDef<{ + user: UserResponse; + stats: UserStatsResponse; + }>[], + [t] + ); +} 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/app/(website)/register/layout.tsx b/app/(website)/register/layout.tsx index b40e6bf..b04ff14 100644 --- a/app/(website)/register/layout.tsx +++ b/app/(website)/register/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: "Register | osu!sunrise", - openGraph: { - title: "Register | osu!sunrise", - }, -}; +export async function generateMetadata(): Promise { + const t = await getT("pages.register.meta"); + return { + title: t("title"), + openGraph: { + title: t("title"), + }, + }; +} export default Page; diff --git a/app/(website)/register/page.tsx b/app/(website)/register/page.tsx index 82be29a..1df9c25 100644 --- a/app/(website)/register/page.tsx +++ b/app/(website)/register/page.tsx @@ -3,7 +3,7 @@ import { AlertCircle, DoorOpen } from "lucide-react"; import PrettyHeader from "@/components/General/PrettyHeader"; import RoundedContent from "@/components/General/RoundedContent"; import Image from "next/image"; -import { useState } from "react"; +import { useState, useMemo, useCallback, useRef } from "react"; import { useRegister } from "@/lib/hooks/api/auth/useRegister"; import Cookies from "js-cookie"; import useSelf from "@/lib/hooks/useSelf"; @@ -31,51 +31,57 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; - -let password = ""; - -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.", - }), - email: z.string(), - password: z - .string() - .min(8, { - message: "Password must be at least 8 characters.", - }) - .max(32, { - message: "Password must be 32 characters or fewer.", - }) - .refine((value) => { - password = value; - return true; - }), - confirmPassword: z - .string() - .min(8, { - message: "Password must be at least 8 characters.", - }) - .max(32, { - message: "Password must be 32 characters or fewer.", - }) - .refine((value) => value === password, "Passwords do not match"), -}); +import { useT } from "@/lib/i18n/utils"; export default function Register() { const [isSuccessfulDialogOpen, setIsSuccessfulDialogOpen] = useState(false); const [error, setError] = useState(null); + const passwordRef = useRef(""); const { trigger } = useRegister(); - const { self, revalidate } = useSelf(); - const { toast } = useToast(); + const t = useT("pages.register"); + + const formSchema = useMemo( + () => + z.object({ + username: z + .string() + .min(2, { + message: t("form.validation.usernameMin", { min: 2 }), + }) + .max(32, { + message: t("form.validation.usernameMax", { max: 32 }), + }), + email: z.string(), + password: z + .string() + .min(8, { + message: t("form.validation.passwordMin", { min: 8 }), + }) + .max(32, { + message: t("form.validation.passwordMax", { max: 32 }), + }) + .refine((value) => { + passwordRef.current = value; + return true; + }), + confirmPassword: z + .string() + .min(8, { + message: t("form.validation.passwordMin", { min: 8 }), + }) + .max(32, { + message: t("form.validation.passwordMax", { max: 32 }), + }) + .refine( + (value) => value === passwordRef.current, + t("form.validation.passwordsDoNotMatch") + ), + }), + [t] + ); const form = useForm>({ resolver: zodResolver(formSchema), @@ -87,61 +93,95 @@ export default function Register() { }, }); - function onSubmit(values: z.infer) { - setError(null); + const onSubmit = useCallback( + (values: z.infer) => { + setError(null); - const { username, email, password } = values; + const { username, email, password } = values; - trigger( - { - email, - username, - password, - }, - { - onSuccess: (data) => { - Cookies.set("session_token", data.token, { - expires: new Date(Date.now() + data.expires_in), - }); + trigger( + { + email, + username, + password, + }, + { + onSuccess: (data) => { + Cookies.set("session_token", data.token, { + expires: new Date(Date.now() + data.expires_in), + }); - Cookies.set("refresh_token", data.refresh_token, { - expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), - }); + Cookies.set("refresh_token", data.refresh_token, { + expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }); - form.reset(); + form.reset(); - revalidate(); + revalidate(); - toast({ title: "Account successfully created!" }); + toast({ title: t("success.toast") }); - setIsSuccessfulDialogOpen(true); - }, - onError(err) { - setError(err.message ?? "Unknown error."); - }, - } - ); - } + setIsSuccessfulDialogOpen(true); + }, + onError(err) { + setError(err.message ?? t("form.error.unknown")); + }, + } + ); + }, + [trigger, form, revalidate, toast, t] + ); + + const welcomeDescription = useMemo( + () => + t.rich("welcome.description", { + a: (chunks) => ( + + {chunks} + + ), + }), + [t] + ); + + const termsText = useMemo( + () => + t.rich("form.terms", { + a: (chunks) => ( + + {chunks} + + ), + }), + [t] + ); + + const successMessage = useMemo( + () => + t.rich("success.dialog.message", { + a: (chunks) => ( + + {chunks} + + ), + }), + [t] + ); return (
- } roundBottom={true} /> + } roundBottom={true} />
-

Welcome to the registration page!

-

- Hello! Please enter your details to create an account. If you - don't sure how to connect to the server, or if you have any other - questions, please visit our{" "} - - Wiki page - - . -

+

{t("welcome.title")}

+

{welcomeDescription}

-

Enter your details

+

{t("form.title")}

( - Username + {t("form.labels.username")} @@ -170,12 +210,12 @@ export default function Register() { name="email" render={({ field }) => ( - Email + {t("form.labels.email")} @@ -189,11 +229,11 @@ export default function Register() { name="password" render={({ field }) => ( - Password + {t("form.labels.password")} @@ -206,11 +246,13 @@ export default function Register() { name="confirmPassword" render={({ field }) => ( - Confirm Password + + {t("form.labels.confirmPassword")} + @@ -222,22 +264,16 @@ export default function Register() { {error && ( - Error + {t("form.error.title")} {error} )} - 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/app/(website)/rules/layout.tsx b/app/(website)/rules/layout.tsx index b5c2e61..98dac9b 100644 --- a/app/(website)/rules/layout.tsx +++ b/app/(website)/rules/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: "Rules | osu!sunrise", - openGraph: { - title: "Rules | osu!sunrise", - }, -}; +export async function generateMetadata(): Promise { + const t = await getT("pages.rules.meta"); + return { + title: t("title"), + openGraph: { + title: t("title"), + }, + }; +} export default Page; diff --git a/app/(website)/rules/page.tsx b/app/(website)/rules/page.tsx index 2312c17..b7a40ca 100644 --- a/app/(website)/rules/page.tsx +++ b/app/(website)/rules/page.tsx @@ -1,115 +1,115 @@ +"use client"; + import { BookCopy } from "lucide-react"; import PrettyHeader from "@/components/General/PrettyHeader"; import RoundedContent from "@/components/General/RoundedContent"; +import { useT } from "@/lib/i18n/utils"; +import { useMemo } from "react"; + +export default function Rules() { + const t = useT("pages.rules"); + const tGeneral = useT("pages.rules.sections.generalRules"); + const tChat = useT("pages.rules.sections.chatCommunityRules"); + const tDisclaimer = useT("pages.rules.sections.disclaimer"); + + const generalRulesContent = useMemo( + () => ( + +
+
    +
  1. + {tGeneral.rich("noCheating.title")}{" "} + {tGeneral("noCheating.description")} +
  2. + + {tGeneral("noCheating.warning")} + +
  3. + {tGeneral.rich("noMultiAccount.title")}{" "} + {tGeneral("noMultiAccount.description")} +
  4. +
  5. + {tGeneral.rich("noImpersonation.title")}{" "} + {tGeneral("noImpersonation.description")} +
  6. +
+
+
+ ), + [tGeneral] + ); + + const chatCommunityRulesContent = useMemo( + () => ( + +
+
    +
  1. + {tChat.rich("beRespectful.title")}{" "} + {tChat("beRespectful.description")} +
  2. +
  3. + {tChat.rich("noNSFW.title")}{" "} + {tChat("noNSFW.description")} +
  4. +
  5. + {tChat.rich("noAdvertising.title")}{" "} + {tChat("noAdvertising.description")} +
  6. +
+
+
+ ), + [tChat] + ); + + const disclaimerContent = useMemo( + () => ( + +

{tDisclaimer("intro")}

+ +
+
    +
  1. + {tDisclaimer.rich("noLiability.title")}{" "} + {tDisclaimer("noLiability.description")} +
  2. +
  3. + {tDisclaimer.rich("accountRestrictions.title")}{" "} + {tDisclaimer("accountRestrictions.description")} +
  4. +
  5. + {tDisclaimer.rich("ruleChanges.title")}{" "} + {tDisclaimer("ruleChanges.description")} +
  6. +
  7. + {tDisclaimer.rich("agreementByParticipation.title")}{" "} + {tDisclaimer("agreementByParticipation.description")} +
  8. +
+
+
+ ), + [tDisclaimer] + ); -export default function Wiki() { return (
- } roundBottom={true} /> + } roundBottom={true} />
- - -
-
    -
  1. - No Cheating or Hacking. Any - form of cheating, including aimbots, relax hacks, macros, or - modified clients that give unfair advantage is strictly - prohibited. Play fair, improve fair. -
  2. - - As you can see, I wrote this in a bigger font for all you - "wannabe" cheaters who think you can migrate from another - private server to here after being banned. You will be found and - executed (in Minecraft) if you cheat. So please, don’t. - -
  3. - - No Multi-Accounting or Account Sharing.{" "} - - Only one account per player is allowed. If your primary account - was restricted without an explanation, please contact support. -
  4. -
  5. - - No Impersonating Popular Players or Staff{" "} - - Do not pretend to be a staff member or any well-known player. - Misleading others can result in a username change or permanent - ban. -
  6. -
-
-
+ + {generalRulesContent}
- - -
-
    -
  1. - Be Respectful. Treat others - with kindness. Harassment, hate speech, discrimination, or toxic - behaviour won’t be tolerated. -
  2. -
  3. - - No NSFW or Inappropriate Content{" "} - - Keep the server appropriate for all audiences — this applies to - all user content, including but not limited to usernames, - banners, avatars and profile descriptions. -
  4. -
  5. - Advertising is forbidden. - Don’t promote other servers, websites, or products without admin - approval. -
  6. -
-
-
+ + {chatCommunityRulesContent}
- - -

- By creating and/or maintaining an account on our platform, you - acknowledge and agree to the following terms: -

- -
-
    -
  1. - No Liability. 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. -
  2. -
  3. - Account Restrictions. - 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. -
  4. -
  5. - Rule Changes. - 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. -
  6. -
  7. - Agreement by Participation. - 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. -
  8. -
-
-
+ + {disclaimerContent}
); diff --git a/app/(website)/score/[id]/layout.tsx b/app/(website)/score/[id]/layout.tsx index e249fc0..5d115d0 100644 --- a/app/(website)/score/[id]/layout.tsx +++ b/app/(website)/score/[id]/layout.tsx @@ -4,11 +4,12 @@ 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; export async function generateMetadata(props: { - params: Promise<{ id: number }>; + params: Promise<{ id: string }>; }): Promise { const params = await props.params; const score = await fetcher(`score/${params.id}`); @@ -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 c7f6324..48b4f87 100644 --- a/app/(website)/score/[id]/page.tsx +++ b/app/(website)/score/[id]/page.tsx @@ -33,9 +33,14 @@ import Link from "next/link"; 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: number }> }) { +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(); const [useSpaciousUI] = useState(() => { @@ -43,11 +48,10 @@ export default function Score(props: { params: Promise<{ id: number }> }) { return localStorage.getItem("useSpaciousUI") === "true"; }); - const { isLoading: isReplayLoading, downloadReplay } = useDownloadReplay( - params.id - ); + const { isLoading: isReplayLoading, downloadReplay } = + useDownloadReplay(paramsId); - const scoreQuery = useScore(params.id); + const scoreQuery = useScore(paramsId); const score = scoreQuery.data; @@ -68,15 +72,11 @@ export default function Score(props: { params: Promise<{ id: number }> }) { scoreQuery.error?.message ?? userQuery?.error?.message ?? beatmapQuery?.error?.message ?? - "Score not found"; + t("error.notFound"); return (
- } - /> + } /> {score && user && beatmap ? ( <> @@ -135,10 +135,11 @@ export default function Score(props: { params: Promise<{ id: number }> }) { {beatmap && getBeatmapStarRating(beatmap).toFixed(2)}{" "}

- [ + [
- {beatmap?.version || "Unknown"} + {beatmap?.version || + t("beatmap.versionUnknown")}
] @@ -148,7 +149,8 @@ export default function Score(props: { params: Promise<{ id: number }> }) {

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

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

-

Submitted on 

+

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

}) {

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

@@ -179,12 +184,14 @@ export default function Score(props: { params: Promise<{ id: number }> }) { variant="secondary" > - Download Replay + {t("actions.downloadReplay")} @@ -225,7 +232,9 @@ export default function Score(props: { params: Promise<{ id: number }> }) { {useSpaciousUI &&
} -
+
@@ -234,10 +243,7 @@ export default function Score(props: { params: Promise<{ id: number }> }) {

{errorMessage}

-

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

+

{t("error.description")}

(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/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/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/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..621951e 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,8 +111,8 @@ 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 9dbc138..53af1d5 100644 --- a/app/(website)/user/[id]/layout.tsx +++ b/app/(website)/user/[id]/layout.tsx @@ -3,12 +3,12 @@ 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; export async function generateMetadata(props: { - params: Promise<{ id: number }>; + params: Promise<{ id: string }>; }): Promise { const params = await props.params; const user = await fetcher(`user/${params.id}`); @@ -17,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 d3987ae..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"; @@ -34,7 +34,7 @@ import { UserResponse, UserStatsResponse, } from "@/lib/types/api"; -import { isInstance } from "@/lib/utils/type.util"; +import { isInstance, tryParseNumber } from "@/lib/utils/type.util"; import { SetDefaultGamemodeButton } from "@/app/(website)/user/[id]/components/SetDefaultGamemodeButton"; import useSelf from "@/lib/hooks/useSelf"; import UserGeneralInformation from "@/app/(website)/user/[id]/components/UserGeneralInformation"; @@ -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"; +import { useT } from "@/lib/i18n/utils"; -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 ( - - ); - } -}; - -export default function UserPage(props: { params: Promise<{ id: number }> }) { +export default function UserPage(props: { params: Promise<{ id: string }> }) { const params = use(props.params); - const userId = Number(params.id); + 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: number }> }) { 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: number }> }) { 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: number }> }) { 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: number }> }) { ); } - 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: number }> }) { 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: number }> }) { className="w-9 md:w-auto" > - Edit profile + + {t("buttons.editProfile")} + ) : ( <> @@ -314,7 +325,7 @@ export default function UserPage(props: { params: Promise<{ id: number }> }) { }`} onClick={() => setActiveTab(tab)} > - {tab} + {t(tab)} ))}
@@ -328,12 +339,9 @@ export default function UserPage(props: { params: Promise<{ id: number }> }) {

{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 t = await getT("pages.wiki.meta"); + return { + title: t("title"), + openGraph: { + title: t("title"), + }, + }; +} export default Page; diff --git a/app/(website)/wiki/page.tsx b/app/(website)/wiki/page.tsx index 5017e0d..2dabc91 100644 --- a/app/(website)/wiki/page.tsx +++ b/app/(website)/wiki/page.tsx @@ -10,160 +10,148 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; +import { useEffect, useState, useMemo } from "react"; import { tryParseNumber } from "@/lib/utils/type.util"; - -const wikiContent = [ - { - title: "How to connect", - content: ( - -
-

- To connect to the server, you need to have a copy of the game - installed on your computer. You can download the game from the - official osu! website. -

-
    -
  1. - Locale the osu!.exe file - on in game directory. -
  2. -
  3. Create a shortcut of the file.
  4. -
  5. Right click on the shortcut and select properties.
  6. -
  7. - In the target field, add{" "} - - -devserver {process.env.NEXT_PUBLIC_SERVER_DOMAIN} - {" "} - at the end of the path. -
  8. -
  9. Click apply and then OK.
  10. -
  11. Double click on the shortcut to start the game.
  12. -
- osu connect image -
-
- ), - }, - { - title: "Can I have multiple accounts?", - content: ( - -
-

- No. You are only allowed to have one account per person. -

-

- If you are caught with multiple accounts, you will be banned from - the server. -

-
-
- ), - }, - { - title: "Can I use cheats or hacks?", - content: ( - -
-

No. You will be banned if you are caught.

-

- We are very strict on cheating and do not tolerate it at all. -
- If you suspect someone of cheating, please report them to the staff. -

-
-
- ), - }, - { - title: "I think I was restricted unfairly. How can I appeal?", - content: ( - -
-

- If you believe you were restricted unfairly, you can appeal your - restriction by contacting the staff with your case. - {process.env.NEXT_PUBLIC_DISCORD_LINK && ( - - {" "} - You can contact the staff{" "} - - here - - . - - )} -

-
-
- - ), - }, - { - title: "Can I contribute/suggest changes to the server?", - content: ( - -
-

Yes! We are always open to suggestions.

-

- If you have any suggestions, please submit them at our{" "} - - GitHub - {" "} - page.
- Longterm contributors can also have chance to get permanent - supporter tag. -

-
-
- ), - }, - { - title: - "I can’t download maps when I’m in multiplayer, but I can download them from the main menu", - content: ( - -
-

- Disable{" "} - - Automatically start osu!direct downloads - {" "} - from the options and try again. -

-
-
- ), - }, -]; +import { useT } from "@/lib/i18n/utils"; +import Link from "next/link"; export default function Wiki() { - const router = useRouter(); const pathname = usePathname(); + const t = useT("pages.wiki.articles"); + const tHeader = useT("pages.wiki"); const [value, setValue] = useState(null); + const wikiContent = useMemo( + () => [ + { + tag: "How to connect", + title: t("howToConnect.title"), + content: ( + +
+

{t("howToConnect.intro")}

+
    +
  1. {t.rich("howToConnect.step1")}
  2. +
  3. {t("howToConnect.step2")}
  4. +
  5. {t("howToConnect.step3")}
  6. +
  7. + {t.rich("howToConnect.step4", { + serverDomain: process.env.NEXT_PUBLIC_SERVER_DOMAIN || "", + })} +
  8. +
  9. {t("howToConnect.step5")}
  10. +
  11. {t("howToConnect.step6")}
  12. +
+ {t("howToConnect.imageAlt")} +
+
+ ), + }, + { + tag: "Can I have multiple accounts?", + title: t("multipleAccounts.title"), + content: ( + +
+

{t("multipleAccounts.answer")}

+

{t("multipleAccounts.consequence")}

+
+
+ ), + }, + { + tag: "Can I use cheats or hacks?", + title: t("cheatsHacks.title"), + content: ( + +
+

{t("cheatsHacks.answer")}

+

{t.rich("cheatsHacks.policy")}

+
+
+ ), + }, + { + tag: "I think I was restricted unfairly. How can I appeal?", + title: t("appealRestriction.title"), + content: ( + +
+

+ {t("appealRestriction.instructions")} + {process.env.NEXT_PUBLIC_DISCORD_LINK && ( + + {" "} + {t.rich("appealRestriction.contactStaff", { + a: (chunks) => ( + + {chunks} + + ), + })} + + )} +

+
+
+ + ), + }, + { + tag: "Can I contribute/suggest changes to the server?", + title: t("contributeSuggest.title"), + content: ( + +
+

{t("contributeSuggest.answer")}

+

+ {t.rich("contributeSuggest.instructions", { + a: (chunks) => ( + + {chunks} + + ), + })} +

+
+
+ ), + }, + { + tag: "I can’t download maps when I’m in multiplayer, but I can download them from the main menu", + title: t("multiplayerDownload.title"), + content: ( + +
+

+ {t.rich("multiplayerDownload.solution")} +

+
+
+ ), + }, + ], + [t] + ); + useEffect(() => { if (typeof window !== "undefined") { - const element = wikiContent.find( - ({ title: t }) => - t === decodeURIComponent(window.location.hash).slice(1) - ); + const hash = decodeURIComponent(window.location.hash).slice(1); + const element = wikiContent.find(({ tag }) => tag === hash); if (element) { const index = wikiContent.indexOf(element).toString(); @@ -181,13 +169,17 @@ export default function Wiki() { window.history.replaceState( null, "", - pathname + (element ? "#" + encodeURIComponent(element.title) : "") + pathname + (element ? "#" + encodeURIComponent(element.tag) : "") ); - }, [value]); + }, [value, pathname, wikiContent]); return (
- } roundBottom={true} /> + } + roundBottom={true} + /> { + 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 function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const [locale, messages] = await Promise.all([getLocale(), getMessages()]); + return ( - + - + {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/BBCode/BBCodeReactParser.tsx b/components/BBCode/BBCodeReactParser.tsx index 1418574..022e0c4 100644 --- a/components/BBCode/BBCodeReactParser.tsx +++ b/components/BBCode/BBCodeReactParser.tsx @@ -12,10 +12,14 @@ import Link from "next/link"; import React, { useEffect, useState } from "react"; import { useLayoutEffect, useRef } from "react"; import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { useLocale, useMessages } from "next-intl"; export const BBCodeReactParser = React.memo( ({ textHtml }: { textHtml: string }) => { 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/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/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/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/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/components/Header/Header.tsx b/components/Header/Header.tsx index 131065d..2a1e656 100644 --- a/components/Header/Header.tsx +++ b/components/Header/Header.tsx @@ -16,8 +16,11 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Brand } from "@/components/Brand"; +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); useEffect(() => { @@ -49,36 +52,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")} )} @@ -86,7 +89,7 @@ export default function Header() { {(process.env.NEXT_PUBLIC_KOFI_LINK || process.env.NEXT_PUBLIC_BOOSTY_LINK) && ( - support us + {t("links.supportUs")} )} @@ -98,6 +101,7 @@ export default function Header() {
+
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..4debb69 100644 --- a/components/Header/HeaderMobileDrawer.tsx +++ b/components/Header/HeaderMobileDrawer.tsx @@ -35,75 +35,86 @@ 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"; +import { LanguageSelector } from "@/components/Header/LanguageSelector"; 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 ( @@ -141,6 +152,7 @@ export default function HeaderMobileDrawer() {
+
@@ -156,21 +168,21 @@ export default function HeaderMobileDrawer() { className="flex space-x-2" > -

Your profile

+

{t("yourProfile")}

-

Friends

+

{t("friends")}

-

Settings

+

{t("settings")}

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

Admin panel

+

{t("adminPanel")}

@@ -189,7 +201,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/LanguageSelector.tsx b/components/Header/LanguageSelector.tsx new file mode 100644 index 0000000..4c1babf --- /dev/null +++ b/components/Header/LanguageSelector.tsx @@ -0,0 +1,109 @@ +"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, DISPLAY_NAMES_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 = + DISPLAY_NAMES_LOCALES[locale] || + displayNames.of(locale) || + locale.toUpperCase(); + return name; + } catch { + return DISPLAY_NAMES_LOCALES[locale] || locale.toUpperCase(); + } + }, + [currentLocale] + ); + + const languages = useMemo(() => { + return AVAILABLE_LOCALES.map((localeCode) => ({ + code: localeCode, + countryCode: getCountryCodeForLocale(localeCode), + nativeName: getLanguageName(localeCode, localeCode), + })); + }, [getLanguageName]); + + return ( + + + + + + {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 && ( + + )} + + ); + })} + + + ); +} 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/Providers.tsx b/components/Providers.tsx index 3279094..9c82c0d 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/components/ServerMaintenanceDialog.tsx b/components/ServerMaintenanceDialog.tsx index 288b069..ddeade5 100644 --- a/components/ServerMaintenanceDialog.tsx +++ b/components/ServerMaintenanceDialog.tsx @@ -8,6 +8,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { useT } from "@/lib/i18n/utils"; import Image from "next/image"; export default function ServerMaintenanceDialog({ @@ -17,21 +18,22 @@ export default function ServerMaintenanceDialog({ open: boolean; setOpen: (e: boolean) => void; }) { + const t = useT("components.serverMaintenanceDialog"); + return ( - Hey! Stop right there! + {t("title")}

- The server is currently in maintenance mode, so some - features of the website may not function correctly. + {t.rich("message")} {process.env.NEXT_PUBLIC_DISCORD_LINK && (

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

@@ -51,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/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 new file mode 100644 index 0000000..cc28aa2 --- /dev/null +++ b/lib/i18n/messages/en-GB.json @@ -0,0 +1,905 @@ +{ + "components": { + "serverMaintenanceDialog": { + "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": "Content not found" + }, + "workInProgress": { + "title": "wowk in pwogwess", + "description": "this content is stiww being wowked on. Pwease check back watew." + }, + "beatmapSetCard": { + "submittedBy": "suwbmitted by", + "submittedOn": "suwbmitted on", + "view": "view" + }, + "friendshipButton": { + "unfriend": "Unfwiend", + "unfollow": "Unfowwow", + "follow": "Fowwow" + }, + "gameModeSelector": { + "selectedMode": "sewected mode:" + }, + "header": { + "links": { + "leaderboard": "weadewboawd", + "topPlays": "top pways", + "beatmaps": "beatmaps", + "help": "hewp", + "wiki": "wiki", + "rules": "rules", + "apiDocs": "api docs", + "discordServer": "discowd sewvew", + "supportUs": "support us" + } + }, + "headerLoginDialog": { + "signIn": "sign in", + "title": "sign in to pwoceed", + "description": "wewcome back.", + "username": { + "label": "Usewname", + "placeholder": "e.g. uwsewname" + }, + "password": { + "label": "passwowd", + "placeholder": "************" + }, + "login": "login", + "signUp": "don't have an accouwnt? UwU sign uwp", + "toast": { + "success": "Youw suwccesfuwwwy wogged in!" + }, + "validation": { + "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": "Are you sure?", + "description": "You will need to log in again to access your account.", + "cancel": "Cancel", + "continue": "Continue", + "toast": { + "success": "You have been successfully logged out." + } + }, + "headerSearchCommand": { + "placeholder": "type to seawch...", + "headings": { + "users": "Users", + "beatmapsets": "beatmapsets", + "pages": "pages" + }, + "pages": { + "leaderboard": "leadewboawd", + "topPlays": "top pways", + "beatmapsSearch": "beatmaps seawch", + "wiki": "wiki", + "rules": "Rules", + "yourProfile": "Your profile", + "friends": "fwiends", + "settings": "settings" + } + }, + "headerUserDropdown": { + "myProfile": "my pwofiwe", + "friends": "fwiends", + "settings": "settings", + "returnToMainSite": "Return to main site", + "adminPanel": "admin panew", + "logOut": "Log out" + }, + "headerMobileDrawer": { + "navigation": { + "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": "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": "Sewwect vawue...", + "searchValue": "Seawch vawue...", + "noValuesFound": "No vawues found." + }, + "beatmapsetRowElement": { + "mappedBy": "mapped by {cweatow}" + }, + "themeModeToggle": { + "toggleTheme": "toggwe theme", + "light": "light", + "dark": "dawk", + "system": "system" + }, + "imageSelect": { + "imageTooBig": "sewected image is too big!" + }, + "notFound": { + "meta": { + "title": "not fouwnd | {appName}", + "description": "The page u'we wooking fow isn't hewe. Sowwy." + }, + "title": "not fouwnd | 404", + "description": "What you're looking for isn't here. Sorry." + }, + "rootLayout": { + "meta": { + "title": "{appName}", + "description": "{appName} is a private server for osu!, a rhythm game." + } + } + }, + "pages": { + "mainPage": { + "meta": { + "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": "- 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": "Join now", + "wiki": "How to connect" + } + }, + "whyUs": "why uws?", + "cards": { + "freeFeatures": { + "title": "Twuwwy fwee featuwwes", + "description": "enjoy featuwwes wike osuw!diwect and uwsewname changes withouwt any paywawws — compwetewy fwee fow aww pwayews!" + }, + "ppSystem": { + "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": "eawn cuwstom medaws", + "description": "eawn uwniquwe, sewvew-excwuwsive medaws as u accompwish vawiouws miwestones and achievements." + }, + "updates": { + "title": "fwequwent updates", + "description": "we'we awways impwoving! OwO expect weguwwaw uwpdates, new featuwwes, and ongoing pewfowmance optimizations." + }, + "ppCalc": { + "title": "buwiwt-in pp cawcuwwatow", + "description": "ouww website offews a buwiwt-in pp cawcuwwatow fow quwick and easy pewfowmance point estimates." + }, + "sunriseCore": { + "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": "how do i stawt pwaying?", + "description": "juwst thwee simpwe steps and u'we weady to go!", + "downloadTile": { + "title": "downwoad osuw! OwO cwinet", + "description": "if u do not awweady have an instawwed cwient", + "button": "downwoad" + }, + "registerTile": { + "title": "registew osuw!suwnwise accouwnt", + "description": "accouwnt wiww awwow u to join the osuw!suwnwise commuwnity", + "button": "Sign up" + }, + "guideTile": { + "title": "Follow the connection guide", + "description": "Which helps you set up your osu! client to connect to osu!sunrise", + "button": "Open guide" + } + }, + "statuses": { + "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": "wiki | {appName}" + }, + "header": "wiki", + "articles": { + "howToConnect": { + "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": "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": "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": "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": "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": "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": "Ruwwes | {appName}" + }, + "header": "Ruwwes", + "sections": { + "generalRules": { + "title": "Genewaw wuwwes", + "noCheating": { + "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": "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": "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": "chat & commuwnity ruwwes", + "beRespectful": { + "title": "be respectfuww.", + "description": "tweat othews with kindness. Hawassment, hate speech, discwimination, ow toxic behaviouww won't be towewated." + }, + "noNSFW": { + "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": "advewtising is fowbidden.", + "description": "don't pwomote othew sewvews, websites, ow pwoduwcts withouwt admin appwovaw." + } + }, + "disclaimer": { + "title": "impowtant discwaimew", + "intro": "by cweating and/ow maintaining an accouwnt on ouww pwatfowm, u acknowwedge and agwee to the fowwowing tewms:", + "noLiability": { + "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": "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": "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": "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": "registew | {appName}" + }, + "header": "registew", + "welcome": { + "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": "entew uw detaiws", + "labels": { + "username": "Username", + "email": "emaiw", + "password": "passwowd", + "confirmPassword": "confiwm passwowd" + }, + "placeholders": { + "username": "e.g. username", + "email": "e.g. uwsewname@maiw.com", + "password": "************" + }, + "validation": { + "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": "ewwow", + "unknown": "Unknown error." + }, + "submit": "Register", + "terms": "By signing up, you agree to the server rules" + }, + "success": { + "dialog": { + "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": "view wiki guwide", + "goToProfile": "go to pwofiwe" + } + }, + "toast": "accouwnt suwccessfuwwwy cweated!" + } + }, + "support": { + "meta": { + "title": "Support Us | {appName}" + }, + "header": "Support Us", + "section": { + "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": "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": "Ko-fi", + "boosty": "Boosty" + } + }, + "spreadTheWord": { + "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": "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": "top pways | {appName}" + }, + "header": "top pways", + "showMore": "show mowe", + "components": { + "userScoreMinimal": { + "pp": "pp", + "accuracy": "acc:" + } + } + }, + "score": { + "meta": { + "title": "{username} on {beatmapTitle} [{beatmapVersion}] | {appName}", + "description": "usew {username} has scowed {pp}pp on {beatmapTitle} [{beatmapVersion}] in {appName}.", + "openGraph": { + "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", + "beatmap": { + "versionUnknown": "unknown", + "mappedBy": "mapped by", + "creatorUnknown": "unknown cweatow" + }, + "score": { + "submittedOn": "Submitted on", + "playedBy": "pwayed by", + "userUnknown": "Unknown user" + }, + "actions": { + "downloadReplay": "downwoad repway", + "openMenu": "Open menu" + }, + "error": { + "notFound": "Score not found", + "description": "the scowe u awe wooking fow does not exist ow has been deweted." + } + }, + "leaderboard": { + "meta": { + "title": "leadewboawd | {appName}" + }, + "header": "leadewboawd", + "sortBy": { + "label": "sowt by:", + "performancePoints": "pewfowmance points", + "rankedScore": "ranked scowe", + "performancePointsShort": "pewf. points", + "scoreShort": "scowe" + }, + "table": { + "columns": { + "rank": "Rank", + "performance": "Performance", + "rankedScore": "Ranked Score", + "accuracy": "Accuracy", + "playCount": "Play count" + }, + "actions": { + "openMenu": "Open menu", + "viewUserProfile": "view uwsew pwofiwe" + }, + "emptyState": "no wesuwwts.", + "pagination": { + "usersPerPage": "users per page", + "showing": "showing {start} - {end} of {total}" + } + } + }, + "friends": { + "meta": { + "title": "youww fwiends | {appName}" + }, + "header": "youww connections", + "tabs": { + "friends": "fwiends", + "followers": "fowwowews" + }, + "sorting": { + "label": "sowt by:", + "username": "Username", + "recentlyActive": "recentwy active" + }, + "showMore": "show mowe", + "emptyState": "no uwsews fouwnd" + }, + "beatmaps": { + "search": { + "meta": { + "title": "beatmaps seawch | {appName}" + }, + "header": "beatmaps seawch" + }, + "detail": { + "meta": { + "title": "beatmap info | {appName}" + }, + "header": "beatmap info", + "notFound": { + "title": "Beatmapset not found", + "description": "The beatmapset you are looking for does not exist or has been deleted." + } + }, + "components": { + "search": { + "searchPlaceholder": "seawch beatmaps...", + "filters": "fiwtews", + "viewMode": { + "grid": "gwid", + "list": "list" + }, + "showMore": "show mowe" + }, + "filters": { + "mode": { + "label": "mode", + "any": "any", + "standard": "osuw!", + "taiko": "osuw!taiko", + "catch": "osuw!catch", + "mania": "osuw!mania" + }, + "status": { + "label": "statuws" + }, + "searchByCustomStatus": { + "label": "seawch by cuwstom statuws" + }, + "applyFilters": "appwy fiwtews" + } + } + }, + "beatmapsets": { + "meta": { + "title": "{artist} - {title} | {appName}", + "description": "beatmapset info fow {title} by {artist}", + "openGraph": { + "title": "{artist} - {title} | {appName}", + "description": "beatmapset info fow {title} by {artist} {difficultyInfo}" + } + }, + "header": "beatmap info", + "error": { + "notFound": { + "title": "Beatmapset not found", + "description": "The beatmapset you are looking for does not exist or has been deleted." + } + }, + "submission": { + "submittedBy": "submitted by", + "submittedOn": "submitted on", + "rankedOn": "ranked on", + "statusBy": "{status} by" + }, + "video": { + "tooltip": "this beatmap contains video" + }, + "description": { + "header": "descwiption" + }, + "components": { + "dropdown": { + "openMenu": "Open menu", + "ppCalculator": "pp cawcuwwatow", + "openOnBancho": "open on bancho", + "openWithAdminPanel": "open with admin panew" + }, + "infoAccordion": { + "communityHype": "Community Hype", + "information": "infowmation", + "metadata": { + "genre": "genwe", + "language": "Language", + "tags": "tags" + } + }, + "downloadButtons": { + "download": "Download", + "withVideo": "with Video", + "withoutVideo": "without Video", + "osuDirect": "osu!direct" + }, + "difficultyInformation": { + "tooltips": { + "totalLength": "Total Length", + "bpm": "BPM", + "starRating": "Star Rating" + }, + "labels": { + "keyCount": "Key Count:", + "circleSize": "Ciwcwe Size:", + "hpDrain": "HP Dwain:", + "accuracy": "Accuwwacy:", + "approachRate": "Appwoach Wate:" + } + }, + "nomination": { + "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": "Beatmap hyped successfully!", + "error": "ewwow occuwwed whiwe hyping beatmapset!" + } + }, + "ppCalculator": { + "title": "pp cawcuwwatow", + "pp": "PP: {value}", + "totalLength": "Total Length", + "form": { + "accuracy": { + "label": "Accuracy", + "validation": { + "negative": "Accuracy can't be negative", + "tooHigh": "Accuracy can't be greater that 100" + } + }, + "combo": { + "label": "Combo", + "validation": { + "negative": "Combo can't be negative" + } + }, + "misses": { + "label": "Misses", + "validation": { + "negative": "Misses can't be negative" + } + }, + "calculate": "Calculate", + "unknownError": "Unknown error" + } + }, + "leaderboard": { + "columns": { + "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": "Open menu", + "viewDetails": "view detaiws", + "downloadReplay": "downwoad repway" + }, + "table": { + "emptyState": "no scowes fouwnd. Be the fiwst to suwbmit one!", + "pagination": { + "scoresPerPage": "scowes pew page", + "showing": "showing {start} - {end} of {total}" + } + } + } + } + }, + "settings": { + "meta": { + "title": "settings | {appName}" + }, + "header": "settings", + "notLoggedIn": "youw muwst be wogged in to view this page.", + "sections": { + "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": "* remindew: do not post any inappwopwiate content. Twy to keep it famiwy fwiendwy owo" + }, + "components": { + "username": { + "label": "New Username", + "placeholder": "e.g. username", + "button": "Change username", + "validation": { + "minLength": "Username must be at least {min} characters.", + "maxLength": "Username must be {max} characters or fewer." + }, + "toast": { + "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." + } + }, + "password": { + "labels": { + "current": "Current Password", + "new": "new passwowd", + "confirm": "confiwm passwowd" + }, + "placeholder": "************", + "button": "Change password", + "validation": { + "minLength": "passwowd muwst be at weast {min} chawactews.", + "maxLength": "passwowd muwst be {max} chawactews ow fewew.", + "mismatch": "passwowds do not match" + }, + "toast": { + "success": "Password changed successfully!", + "error": "ewwow occuwwed whiwe changing passwowd!" + } + }, + "description": { + "toast": { + "success": "Description updated successfully!", + "error": "an uwnknown ewwow occuwwwed" + } + }, + "country": { + "label": "new couwntwy fwag", + "placeholder": "sewect new couwntwy fwag", + "button": "Change country flag", + "toast": { + "success": "couwntwy fwag changed suwccessfuwwwy!", + "error": "ewwow occuwwed whiwe changing couwntwy fwag!" + } + }, + "socials": { + "headings": { + "general": "genewaw", + "socials": "sociaws" + }, + "fields": { + "location": "wocation", + "interest": "intewest", + "occupation": "occupation" + }, + "button": "Update socials", + "toast": { + "success": "Socials updated successfully!", + "error": "ewwow occuwwed whiwe uwpdating sociaws!" + } + }, + "playstyle": { + "options": { + "Mouse": "Mouse", + "Keyboard": "keyboawd", + "Tablet": "tabwet", + "TouchScreen": "Touch Screen" + }, + "toast": { + "success": "Playstyle updated successfully!", + "error": "ewwow occuwwed whiwe uwpdating pwaystywe!" + } + }, + "uploadImage": { + "types": { + "avatar": "avatar", + "banner": "banner" + }, + "button": "Upload {type}", + "toast": { + "success": "{type} updated successfully!", + "error": "an uwnknown ewwow occuwwwed" + }, + "note": "* note: {type}s awe wimited to 5mb in size" + }, + "siteOptions": { + "includeBanchoButton": "incwuwde \"open on bancho\" buwtton in beatmap page", + "useSpaciousUI": "use spaciouws ui (incwease spacing between ewements)" + } + }, + "common": { + "unknownError": "Unknown error." + } + }, + "user": { + "meta": { + "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": "genewaw", + "bestScores": "Best scowes", + "recentScores": "Recent scowes", + "firstPlaces": "Fiwst pwaces", + "beatmaps": "Beatmaps", + "medals": "Medaws" + }, + "buttons": { + "editProfile": "Edit pwofiwe", + "setDefaultGamemode": "Set {gamemode} {flag} as pwofiwe defauwt game mode" + }, + "errors": { + "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": "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": "Best scowes", + "recentScores": "Recent scowes", + "firstPlaces": "Fiwst pwaces", + "noScores": "Usew has no {type} scowes", + "showMore": "Show mowe" + }, + "beatmapsTab": { + "mostPlayed": "Most pwayed", + "noMostPlayed": "Usew has no most pwayed beatmaps", + "favouriteBeatmaps": "Favouwwite Beatmaps", + "noFavourites": "Usew has no favouwwite beatmaps", + "showMore": "Show more" + }, + "medalsTab": { + "medals": "Medals", + "latest": "Latest", + "categories": { + "hushHush": "Hush hush", + "beatmapHunt": "Beatmap hunt", + "modIntroduction": "Mod introduction", + "skill": "Skill" + }, + "achievedOn": "achieved on", + "notAchieved": "Not achieved" + }, + "generalInformation": { + "joined": "Joined {time}", + "followers": "{count} Fowwowews", + "following": "{count} Fowwowing", + "playsWith": "Pways wif {playstyle}" + }, + "statusText": { + "lastSeenOn": ", wast seen on {date}" + }, + "ranks": { + "highestRank": "Highest wank {rank} on {date}" + }, + "previousUsernames": { + "previouslyKnownAs": "This usew was pweviouswy known as:" + }, + "beatmapSetOverview": { + "by": "by {artist}", + "mappedBy": "mapped by {creator}" + }, + "privilegeBadges": { + "badges": { + "Developer": "Devewoper", + "Admin": "Admin", + "Bat": "BAT", + "Bot": "Bot", + "Supporter": "Suppowter" + } + }, + "scoreOverview": { + "pp": "pp", + "accuracy": "acc: {accuracy}%" + }, + "statsChart": { + "date": "Date", + "types": { + "pp": "pp", + "rank": "rank" + }, + "tooltip": "{value} {type}" + } + } + } + } +} diff --git a/lib/i18n/messages/en.json b/lib/i18n/messages/en.json new file mode 100644 index 0000000..ca61029 --- /dev/null +++ b/lib/i18n/messages/en.json @@ -0,0 +1,2384 @@ +{ + "general": { + "appName": { + "text": "osu!sunrise", + "context": "The name of the application, will be inclided in various places such as the title meta tag" + }, + "serverTitle": { + "full": { + "text": "sunrise", + "context": "The full name of the server" + }, + "split": { + "part1": { + "text": "sun", + "context": "The first part of the split server name" + }, + "part2": { + "text": "rise", + "context": "The second part of the split server name" + } + } + } + }, + "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": { + "mainPage": { + "meta": { + "title": { + "text": "Welcome | {appName}", + "context": "The title for the main page of the osu!sunrise website" + }, + "description": { + "text": "Join osu!sunrise, a feature-rich private osu! server with Relax, Autopilot, ScoreV2 support, and a custom PP system tailored for Relax and Autopilot gameplay.", + "context": "The meta description for the main page of the osu!sunrise website" + } + }, + "features": { + "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": { + "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 us?", + "context": "Section heading asking why users should choose this server" + }, + "cards": { + "freeFeatures": { + "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" + } + }, + "ppSystem": { + "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": { + "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": { + "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" + } + }, + "ppCalc": { + "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" + } + }, + "sunriseCore": { + "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" + } + } + }, + "howToStart": { + "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" + }, + "downloadTile": { + "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" + } + }, + "registerTile": { + "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" + } + }, + "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": "Total Users", + "context": "Label for the total number of registered users" + }, + "usersOnline": { + "text": "Users Online", + "context": "Label for the number of currently online users" + }, + "usersRestricted": { + "text": "Users Restricted", + "context": "Label for the number of restricted users" + }, + "totalScores": { + "text": "Total Scores", + "context": "Label for the total number of scores submitted" + }, + "serverStatus": { + "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" + }, + "underMaintenance": { + "text": "Under 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 server, you need to have a copy of the game installed on your computer. You can download the game from the official osu! website.", + "context": "Introduction text explaining the prerequisites for connecting to the server" + }, + "step1": { + "text": "Locate the osu!.exe file in the game directory.", + "context": "First step instruction for connecting to the server" + }, + "step2": { + "text": "Create a shortcut of the file.", + "context": "Second step instruction for connecting to the server" + }, + "step3": { + "text": "Right click on the shortcut and select properties.", + "context": "Third step instruction for connecting to the server" + }, + "step4": { + "text": "In the target field, add -devserver {serverDomain} at the end of the path.", + "context": "Fourth step instruction for connecting to the server, includes server domain parameter" + }, + "step5": { + "text": "Click apply and then OK.", + "context": "Fifth step instruction for connecting to the server" + }, + "step6": { + "text": "Double click on the shortcut to start the game.", + "context": "Sixth step instruction for connecting to the server" + }, + "imageAlt": { + "text": "osu connect image", + "context": "Alt text for the connection guide image" + } + }, + "multipleAccounts": { + "title": { + "text": "Can I have multiple accounts?", + "context": "Title of the wiki article about multiple accounts policy" + }, + "answer": { + "text": "No. You are only allowed to have one account per person.", + "context": "Direct answer to the multiple accounts question" + }, + "consequence": { + "text": "If you are caught with multiple accounts, you will be banned from the server.", + "context": "Explanation of the consequence for having multiple accounts" + } + }, + "cheatsHacks": { + "title": { + "text": "Can I use cheats or hacks?", + "context": "Title of the wiki article about cheating policy" + }, + "answer": { + "text": "No. You will be banned if you are caught.", + "context": "Direct answer to the cheating question" + }, + "policy": { + "text": "We are very strict on cheating and do not tolerate it at all.

If you suspect someone of cheating, please report them to the staff.", + "context": "Explanation of the cheating policy and how to report cheaters" + } + }, + "appealRestriction": { + "title": { + "text": "I think I was restricted unfairly. How can I appeal?", + "context": "Title of the wiki article about appealing restrictions" + }, + "instructions": { + "text": "If you believe you were restricted unfairly, you can appeal your restriction by contacting the staff with your case.", + "context": "Instructions on how to appeal a restriction" + }, + "contactStaff": { + "text": "You can contact the staff here.", + "context": "Information about where to contact staff for appeals, includes link placeholder" + } + }, + "contributeSuggest": { + "title": { + "text": "Can I contribute/suggest changes to the server?", + "context": "Title of the wiki article about contributing to the server" + }, + "answer": { + "text": "Yes! We are always open to suggestions.", + "context": "Positive answer about contributing to the server" + }, + "instructions": { + "text": "If you have any suggestions, please submit them at our GitHub page.

Longterm contributors can also have chance to get permanent supporter tag.", + "context": "Instructions on how to contribute, includes GitHub link and information about supporter tags" + } + }, + "multiplayerDownload": { + "title": { + "text": "I can't download maps when I'm in multiplayer, but I can download them from the main menu", + "context": "Title of the wiki article about downloading maps in multiplayer" + }, + "solution": { + "text": "Disable Automatically start osu!direct downloads from the options and try again.", + "context": "Solution to the multiplayer download issue" + } + } + } + }, + "rules": { + "meta": { + "title": { + "text": "Rules | {appName}", + "context": "The title for the rules page of the osu!sunrise website" + } + }, + "header": { + "text": "Rules", + "context": "The main header text for the rules page" + }, + "sections": { + "generalRules": { + "title": { + "text": "General rules", + "context": "Title of the general rules section" + }, + "noCheating": { + "title": { + "text": "No Cheating or Hacking.", + "context": "Title of the no cheating rule, displayed in bold" + }, + "description": { + "text": "Any form of cheating, including aimbots, relax hacks, macros, or modified clients that give unfair advantage is strictly prohibited. Play fair, improve fair.", + "context": "Description of the no cheating rule" + }, + "warning": { + "text": "As you can see, I wrote this in a bigger font for all you \"wannabe\" cheaters who think you can migrate from another private server to here after being banned. You will be found and executed (in Minecraft) if you cheat. So please, don't.", + "context": "Warning message for potential cheaters" + } + }, + "noMultiAccount": { + "title": { + "text": "No Multi-Accounting or Account Sharing.", + "context": "Title of the no multi-accounting rule, displayed in bold" + }, + "description": { + "text": "Only one account per player is allowed. If your primary account was restricted without an explanation, please contact support.", + "context": "Description of the no multi-accounting rule" + } + }, + "noImpersonation": { + "title": { + "text": "No Impersonating Popular Players or Staff", + "context": "Title of the no impersonation rule, displayed in bold" + }, + "description": { + "text": "Do not pretend to be a staff member or any well-known player. Misleading others can result in a username change or permanent ban.", + "context": "Description of the no impersonation rule" + } + } + }, + "chatCommunityRules": { + "title": { + "text": "Chat & Community Rules", + "context": "Title of the chat and community rules section" + }, + "beRespectful": { + "title": { + "text": "Be Respectful.", + "context": "Title of the be respectful rule, displayed in bold" + }, + "description": { + "text": "Treat others with kindness. Harassment, hate speech, discrimination, or toxic behaviour won't be tolerated.", + "context": "Description of the be respectful rule" + } + }, + "noNSFW": { + "title": { + "text": "No NSFW or Inappropriate Content", + "context": "Title of the no NSFW content rule, displayed in bold" + }, + "description": { + "text": "Keep the server appropriate for all audiences — this applies to all user content, including but not limited to usernames, banners, avatars and profile descriptions.", + "context": "Description of the no NSFW content rule" + } + }, + "noAdvertising": { + "title": { + "text": "Advertising is forbidden.", + "context": "Title of the no advertising rule, displayed in bold" + }, + "description": { + "text": "Don't promote other servers, websites, or products without admin approval.", + "context": "Description of the no advertising rule" + } + } + }, + "disclaimer": { + "title": { + "text": "Important Disclaimer", + "context": "Title of the important disclaimer section" + }, + "intro": { + "text": "By creating and/or maintaining an account on our platform, you acknowledge and agree to the following terms:", + "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": "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" + } + } + }, + "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" + } + } + } + }, + "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" + } + } + } + }, + "score": { + "meta": { + "title": { + "text": "{username} on {beatmapTitle} [{beatmapVersion}] | {appName}", + "context": "The title for the score page, includes username, beatmap title, version, and app name as parameters" + }, + "description": { + "text": "User {username} has scored {pp}pp on {beatmapTitle} [{beatmapVersion}] 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": "{username} on {beatmapTitle} - {beatmapArtist} [{beatmapVersion}] | {appName}", + "context": "The OpenGraph title for the score page, includes username, beatmap title, artist, version, and app name as parameters" + }, + "description": { + "text": "User {username} has scored {pp}pp on {beatmapTitle} - {beatmapArtist} [{beatmapVersion}] ★{starRating} {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": "Score Performance", + "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 Creator", + "context": "Fallback text when beatmap creator is not available" + } + }, + "score": { + "submittedOn": { + "text": "Submitted on", + "context": "Text displayed before the score submission date" + }, + "playedBy": { + "text": "Played by", + "context": "Text displayed before the player username" + }, + "userUnknown": { + "text": "Unknown user", + "context": "Fallback text when user is not available" + } + }, + "actions": { + "downloadReplay": { + "text": "Download Replay", + "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 score you are looking for does not exist or has been deleted.", + "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" + } + } + } + }, + "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" + } + }, + "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" + } + } + } + }, + "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" + } + } + } + } + } + }, + "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" + } + } + }, + "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/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 new file mode 100644 index 0000000..c716197 --- /dev/null +++ b/lib/i18n/messages/index.ts @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..2798dae --- /dev/null +++ b/lib/i18n/messages/ru.json @@ -0,0 +1,898 @@ +{ + "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, а также собственной системой расчёта 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": "Мы используем современную систему performance point (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": "Статус сервера" + } + }, + "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.

Долгосрочные контрибьюторы также могут получить шанс получить постоянный тег сторонника." + }, + "multiplayerDownload": { + "title": "Я не могу скачивать карты, когда я в мультиплеере, но могу скачивать их из главного меню", + "solution": "Отключите Автоматически запускать загрузки osu!direct в настройках и попробуйте снова." + } + } + }, + "rules": { + "meta": { + "title": "Правила | {appName}" + }, + "header": "Правила", + "sections": { + "generalRules": { + "title": "Общие правила", + "noCheating": { + "title": "Запрещено читерство и использование читов.", + "description": "Любая форма читерства, включая аимботы, читы для Relax, макросы или модифицированные клиенты, дающие нечестное преимущество, строго запрещены. Играйте честно, улучшайтесь честно.", + "warning": "Как вы можете видеть, я написал это крупным шрифтом для всех вас, \"хочу-быть\" читеров, которые думают, что могут переехать с другого приватного сервера сюда после бана. Вас найдут и казнят (в Minecraft), если вы будете читерить. Так что, пожалуйста, не делайте этого." + }, + "noMultiAccount": { + "title": "Запрещены множественные аккаунты и передача аккаунтов.", + "description": "Разрешён только один аккаунт на игрока. Если ваш основной аккаунт был ограничен без объяснения, пожалуйста, свяжитесь с поддержкой." + }, + "noImpersonation": { + "title": "Запрещена имитация популярных игроков или администрации", + "description": "Не притворяйтесь членом администрации или известным игроком. Введение других в заблуждение может привести к смене имени пользователя или постоянному бану." + } + }, + "chatCommunityRules": { + "title": "Правила чата и сообщества", + "beRespectful": { + "title": "Будьте уважительны.", + "description": "Относитесь к другим с добротой. Домогательства, разжигание ненависти, дискриминация или токсичное поведение не будут терпимы." + }, + "noNSFW": { + "title": "Запрещён контент для взрослых или неприемлемый контент", + "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": "Привет! Пожалуйста, введите свои данные для создания аккаунта. Если вы не уверены, как подключиться к серверу, или у вас есть другие вопросы, пожалуйста, посетите нашу страницу Вики." + }, + "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": "Аккаунт успешно создан!" + } + }, + "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": "например, 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": "Неизвестная ошибка." + } + }, + "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/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}" + } + } + } + } +} diff --git a/lib/i18n/request.ts b/lib/i18n/request.ts new file mode 100644 index 0000000..e6815e1 --- /dev/null +++ b/lib/i18n/request.ts @@ -0,0 +1,40 @@ +import { getRequestConfig } from "next-intl/server"; +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 rawMessages = merge( + {}, + defaultMessages, + (await import(`./messages/${selectedLocale}.json`)).default + ); + + const messages = extractTextFromMessages(rawMessages); + + return { + locale: selectedLocale, + messages, + }; +}); diff --git a/lib/i18n/utils.tsx b/lib/i18n/utils.tsx new file mode 100644 index 0000000..ec071c6 --- /dev/null +++ b/lib/i18n/utils.tsx @@ -0,0 +1,57 @@ +import { + RichTranslationValues, + TranslationValues, + useTranslations, +} from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { ReactNode } from "react"; +import { LOCALE_TO_COUNTRY } from "./messages"; + +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")("appName"); + + 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; +} + +export async function getT(namespace?: string) { + const t = await getTranslations(namespace); + + const appName = await getTranslations("general").then((t) => t("appName")); + + 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; +} + +export const getCountryCodeForLocale = (locale: string) => { + return LOCALE_TO_COUNTRY[locale] || locale.toUpperCase(); +}; + +export const getLanguageName = (locale: string) => { + return locale.toUpperCase(); +}; 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/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); +}; 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); } diff --git a/next.config.mjs b/next.config.mjs index 831064a..2befc7b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,14 @@ -/** @type {import('next').NextConfig} */ +import createNextIntlPlugin from "next-intl/plugin"; + +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({ @@ -9,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; }, @@ -67,4 +80,5 @@ const nextConfig = { reactStrictMode: false, }; -export default nextConfig; +const withNextIntl = createNextIntlPlugin("./lib/i18n/request.ts"); +export default withNextIntl(nextConfig); diff --git a/package-lock.json b/package-lock.json index 5d90cff..6d05066 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,8 +40,10 @@ "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.4.8", + "next": "^15.5.9", + "next-intl": "^4.4.0", "next-themes": "^0.4.6", "react": "19.1.0", "react-dom": "19.1.0", @@ -58,12 +60,13 @@ "@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", "dotenv-cli": "^8.0.0", "eslint": "^8", - "eslint-config-next": "15.3.0", + "eslint-config-next": "^15.5.9", "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5" @@ -1832,7 +1835,6 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", - "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -1928,6 +1930,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", @@ -2038,7 +2100,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", - "license": "MIT", "optional": true, "engines": { "node": ">=18" @@ -2178,6 +2239,21 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", @@ -2287,7 +2363,6 @@ "cpu": [ "ppc64" ], - "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -2309,7 +2384,6 @@ "cpu": [ "riscv64" ], - "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -2433,7 +2507,6 @@ "cpu": [ "arm64" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" @@ -2574,15 +2647,14 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" }, "node_modules/@next/env": { - "version": "15.4.8", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.8.tgz", - "integrity": "sha512-LydLa2MDI1NMrOFSkO54mTc8iIHSttj6R6dthITky9ylXV2gCGi0bHQjVCtLGRshdRPjyh2kXbxJukDtBWQZtQ==", - "license": "MIT" + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", + "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==" }, "node_modules/@next/eslint-plugin-next": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.3.0.tgz", - "integrity": "sha512-511UUcpWw5GWTyKfzW58U2F/bYJyjLE9e3SlnGK/zSXq7RqLlqFO8B9bitJjumLpj317fycC96KZ2RZsjGNfBw==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.9.tgz", + "integrity": "sha512-kUzXx0iFiXw27cQAViE1yKWnz/nF8JzRmwgMRTMh8qMY90crNsdXJRh2e+R0vBpFR3kk1yvAR7wev7+fCCb79Q==", "dev": true, "dependencies": { "fast-glob": "3.3.1" @@ -2617,9 +2689,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.4.8", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.8.tgz", - "integrity": "sha512-Pf6zXp7yyQEn7sqMxur6+kYcywx5up1J849psyET7/8pG2gQTVMjU3NzgIt8SeEP5to3If/SaWmaA6H6ysBr1A==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", "cpu": [ "arm64" ], @@ -2633,9 +2705,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.4.8", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.8.tgz", - "integrity": "sha512-xla6AOfz68a6kq3gRQccWEvFC/VRGJmA/QuSLENSO7CZX5WIEkSz7r1FdXUjtGCQ1c2M+ndUAH7opdfLK1PQbw==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", "cpu": [ "x64" ], @@ -2649,9 +2721,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.4.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.8.tgz", - "integrity": "sha512-y3fmp+1Px/SJD+5ntve5QLZnGLycsxsVPkTzAc3zUiXYSOlTPqT8ynfmt6tt4fSo1tAhDPmryXpYKEAcoAPDJw==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", "cpu": [ "arm64" ], @@ -2665,9 +2737,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.4.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.8.tgz", - "integrity": "sha512-DX/L8VHzrr1CfwaVjBQr3GWCqNNFgyWJbeQ10Lx/phzbQo3JNAxUok1DZ8JHRGcL6PgMRgj6HylnLNndxn4Z6A==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", "cpu": [ "arm64" ], @@ -2681,9 +2753,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.4.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.8.tgz", - "integrity": "sha512-9fLAAXKAL3xEIFdKdzG5rUSvSiZTLLTCc6JKq1z04DR4zY7DbAPcRvNm3K1inVhTiQCs19ZRAgUerHiVKMZZIA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", "cpu": [ "x64" ], @@ -2697,9 +2769,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.4.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.8.tgz", - "integrity": "sha512-s45V7nfb5g7dbS7JK6XZDcapicVrMMvX2uYgOHP16QuKH/JA285oy6HcxlKqwUNaFY/UC6EvQ8QZUOo19cBKSA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", "cpu": [ "x64" ], @@ -2713,9 +2785,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.4.8", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.8.tgz", - "integrity": "sha512-KjgeQyOAq7t/HzAJcWPGA8X+4WY03uSCZ2Ekk98S9OgCFsb6lfBE3dbUzUuEQAN2THbwYgFfxX2yFTCMm8Kehw==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", "cpu": [ "arm64" ], @@ -2729,9 +2801,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.4.8", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.8.tgz", - "integrity": "sha512-Exsmf/+42fWVnLMaZHzshukTBxZrSwuuLKFvqhGHJ+mC1AokqieLY/XzAl3jc/CqhXLqLY3RRjkKJ9YnLPcRWg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", "cpu": [ "x64" ], @@ -5015,6 +5087,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", @@ -5397,6 +5475,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", @@ -6727,6 +6812,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", @@ -6804,7 +6895,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", "engines": { "node": ">=8" } @@ -7283,12 +7373,12 @@ } }, "node_modules/eslint-config-next": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.3.0.tgz", - "integrity": "sha512-+Z3M1W9MnJjX3W4vI9CHfKlEyhTWOUHvc5dB89FyRnzPsUkJlLWZOi8+1pInuVcSztSM4MwBFB0hIHf4Rbwu4g==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.9.tgz", + "integrity": "sha512-852JYI3NkFNzW8CqsMhI0K2CDRxTObdZ2jQJj5CtpEaOkYHn13107tHpNuD/h0WRpU4FAbCdUaxQsrfBtNK9Kw==", "dev": true, "dependencies": { - "@next/eslint-plugin-next": "15.3.0", + "@next/eslint-plugin-next": "15.5.9", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", @@ -8272,6 +8362,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", @@ -8877,7 +8979,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", @@ -9083,18 +9186,26 @@ "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", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "node_modules/next": { - "version": "15.4.8", - "resolved": "https://registry.npmjs.org/next/-/next-15.4.8.tgz", - "integrity": "sha512-jwOXTz/bo0Pvlf20FSb6VXVeWRssA2vbvq9SdrOPEg9x8E1B27C2rQtvriAn600o9hH61kjrVRexEffv3JybuA==", - "license": "MIT", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", + "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "dependencies": { - "@next/env": "15.4.8", + "@next/env": "15.5.9", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -9107,14 +9218,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.4.8", - "@next/swc-darwin-x64": "15.4.8", - "@next/swc-linux-arm64-gnu": "15.4.8", - "@next/swc-linux-arm64-musl": "15.4.8", - "@next/swc-linux-x64-gnu": "15.4.8", - "@next/swc-linux-x64-musl": "15.4.8", - "@next/swc-win32-arm64-msvc": "15.4.8", - "@next/swc-win32-x64-msvc": "15.4.8", + "@next/swc-darwin-arm64": "15.5.7", + "@next/swc-darwin-x64": "15.5.7", + "@next/swc-linux-arm64-gnu": "15.5.7", + "@next/swc-linux-arm64-musl": "15.5.7", + "@next/swc-linux-x64-gnu": "15.5.7", + "@next/swc-linux-x64-musl": "15.5.7", + "@next/swc-win32-arm64-msvc": "15.5.7", + "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { @@ -9140,6 +9251,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", @@ -10666,7 +10804,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -11661,6 +11798,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 6cfa458..0e8c25d 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,10 @@ "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.4.8", + "next": "^15.5.9", + "next-intl": "^4.4.0", "next-themes": "^0.4.6", "react": "19.1.0", "react-dom": "19.1.0", @@ -60,12 +62,13 @@ "@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", "dotenv-cli": "^8.0.0", "eslint": "^8", - "eslint-config-next": "15.3.0", + "eslint-config-next": "^15.5.9", "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5" diff --git a/public/images/flags/OWO.png b/public/images/flags/OWO.png new file mode 100644 index 0000000..919b032 Binary files /dev/null and b/public/images/flags/OWO.png differ 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" },