feat(i18n) : add personal languages and compact weapon exports
CI / Verify (push) Successful in 1m18s
CI / Build immutable images and deploy (push) Successful in 1m57s

This commit is contained in:
2026-09-14 16:44:44 +07:00 Unverified
parent f214d330f0
commit e0794822b1
72 changed files with 4199 additions and 1011 deletions
+35
View File
@@ -0,0 +1,35 @@
"use client";
import Image from "next/image";
import { ChevronDownIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuRadioGroup, DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
import { languages, isLocale } from "@/lib/i18n/locales";
import { useTranslation } from "./locale-provider";
export function LanguageDropdown() {
const { locale, setLocale, t } = useTranslation();
const selected = languages.find((language) => language.code === locale)!;
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="sm" />} aria-label={t("Language")}>
<Image src={selected.flag} alt="" aria-hidden width={24} height={16} />
<span lang={selected.code}>{selected.name}</span>
<ChevronDownIcon data-icon="inline-end" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={locale} onValueChange={(value) => { if (isLocale(value)) setLocale(value); }} aria-label={t("Language")}>
{languages.map((language) => (
<DropdownMenuRadioItem key={language.code} value={language.code}>
<Image src={language.flag} alt="" aria-hidden width={24} height={16} />
<span lang={language.code}>{language.name}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useSyncExternalStore, type ReactNode } from "react";
import { createLocaleStore } from "@/lib/i18n/locale-store";
import { defaultLocale, type Locale } from "@/lib/i18n/locales";
import { translate, type MessageParams } from "@/lib/i18n/messages";
import { exportText, type ExportMessageKey } from "@/lib/guides/export-text";
// This store contains a browser preference only; server rendering always uses Thai.
const store = createLocaleStore(() => typeof window === "undefined" ? undefined : window.localStorage);
const LocaleContext = createContext<Locale>(defaultLocale);
export function LocaleProvider({ children }: { children: ReactNode }) {
const locale = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);
useEffect(() => { document.documentElement.lang = locale; }, [locale]);
return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>;
}
export function LocaleBoundary({ locale, children }: { locale: Locale; children: ReactNode }) {
return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>;
}
export function useTranslation() {
const locale = useContext(LocaleContext);
const t = useCallback((message: string, params?: MessageParams) => translate(message, params, locale), [locale]);
const e = useCallback((key: ExportMessageKey, params?: MessageParams) => exportText(key, params, locale), [locale]);
return {
locale,
setLocale: store.setLocale,
t,
e,
};
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { cloneElement, type ReactElement } from "react";
import { useTranslation } from "./locale-provider";
import { exportStatLabel, type ExportMessageKey } from "@/lib/guides/export-text";
import type { MessageParams } from "@/lib/i18n/messages";
export function Text({ message, params }: { message: string; params?: MessageParams }) {
const { t } = useTranslation();
return t(message, params);
}
export function ExportText({ message, params }: { message: ExportMessageKey; params?: MessageParams }) {
const { e } = useTranslation();
return e(message, params);
}
export function StatText({ value }: { value: string }) {
const { locale } = useTranslation();
return exportStatLabel(value, locale);
}
export function Localized({ children, attributes }: {
children: ReactElement;
attributes: Record<string, string | { message: string; params: MessageParams }>;
}) {
const { t } = useTranslation();
const props = Object.fromEntries(Object.entries(attributes).map(([key, value]) => [
key, typeof value === "string" ? t(value) : t(value.message, value.params),
]));
return cloneElement(children, props);
}
export function MetricText({ value }: { value: string }) {
const { locale } = useTranslation();
return `${Number(value).toLocaleString(locale, { maximumFractionDigits: 2 })}%`;
}
export function SectionTitle({ slug, fallback }: { slug: string; fallback: string }) {
const { t } = useTranslation();
const titles: Record<string, string> = { overview: "Overview", weapon: "Weapons", artifact: "Artifacts & Stats", constellations: "Constellations", team: "Team Calc" };
return titles[slug] ? t(titles[slug]) : fallback;
}
export function ConditionLabel({ name, index }: { name: string | null; index: number }) {
const { e } = useTranslation();
return name || e("condition", { index });
}