feat: localization

This commit is contained in:
2026-03-21 17:27:32 +07:00 Unverified
parent 83047fb0ad
commit 588056403c
5 changed files with 116 additions and 903 deletions
+23
View File
@@ -0,0 +1,23 @@
export const cardErrorLocs = {
en: {
private: "The showcase for this UID is private",
notInShowcase: "Character not found in showcase",
unsupported:
"Unsupported character, avatar store doesn't have this character",
uidFormat: "Invalid UID format",
notFound: "UID not found on enka.network",
maintenance: "enka.network is under maintenance",
ratelimit: "Too many requests to enka.network, please try again later",
internal: "Internal server error",
},
th: {
private: "โปรไฟล์ UID นี้ตั้งค่าเป็นส่วนตัว",
notInShowcase: "ไม่พบตัวละครในตั้งโชว์",
unsupported: "ไม่รองรับตัวละครนี้",
uidFormat: "รูปแบบ UID ไม่ถูกต้อง",
notFound: "ไม่พบ UID บน enka.network",
maintenance: "enka.network กำลังปิดปรับปรุง",
ratelimit: "เรียก API enka.network มากเกินไป กรุณาลองใหม่ภายหลัง",
internal: "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์",
},
} as const;
+3 -3
View File
@@ -1,5 +1,5 @@
import { AvatarElement, ResolvedAvatarInfo } from "enka.ts";
import { EnkaLocs } from "@/type/enka";
import { EnkaLocs, EnkaLocsLanguage } from "@/type/enka";
import { FightPropMapEnum, PercentageFightProp } from "@/type/fightPropMap";
// Types
@@ -141,7 +141,7 @@ function formatValue(
*/
export function buildDisplayRows(
apiPayload: ResolvedAvatarInfo,
locs: EnkaLocs,
locs: EnkaLocsLanguage,
opts: BuildDisplayRowsOptions = {},
): DisplayRow[] {
const enumMap = invertEnum(FightPropMapEnum);
@@ -195,7 +195,7 @@ export function buildDisplayRows(
rows.push({
id,
key: keyName as keyof typeof FightPropMapEnum,
name: locs.en[keyName].replace("Max ", ""),
name: locs[keyName].replace("Max ", ""),
value: Number(value),
formatted: formatValue(keyName, value, PercentageFightProp),
base: computedBase,
+20 -3
View File
@@ -103,9 +103,26 @@ export interface RelicStore {
Sets: RelicSetMap;
}
export interface EnkaLocs {
en: Record<string, string>;
}
export type EnkaLocsLanguage = Record<string, string>;
export type EnkaLocs = {
[k in
| "zh-cn"
| "zh-tw"
| "de"
| "en"
| "es"
| "fr"
| "id"
| "ja"
| "ko"
| "pt"
| "ru"
| "th"
| "vi"
| "tr"
| "it"]: EnkaLocsLanguage;
};
export const SubstatOrder: Record<string, number> = {
FIGHT_PROP_CRITICAL: 1,
+70 -45
View File
@@ -24,6 +24,7 @@ import { WeaponGlazeSvg } from "@/assets/weapon-glaze";
import type { App } from "@/index";
import { AssetManager, getEnkaStores } from "@/lib/assets";
import { Deduplicator } from "@/lib/dedupe";
import { cardErrorLocs } from "@/lib/locs";
import { createLogger } from "@/lib/logger";
import { mockData } from "@/lib/mock";
import { buildDisplayRows } from "@/lib/rows";
@@ -102,13 +103,14 @@ export function card(app: App) {
async ({
params: { uid, char: charId },
logger,
query: { debug, h: height = 1024, error: errorType },
query: { debug, h: heightRaw = 1024, w, error: errorType, lang },
}) => {
logger`Rendering ${uid}/${charId}`;
const error =
errorType === "card"
? (_: number, message: string) => ErrorPage(message)
: status;
const errorLoc = cardErrorLocs[lang === "th" ? "th" : "en"];
try {
// #region Data fetching
const [[avatarStore, weaponStore, relicStore, locs], data] =
@@ -124,19 +126,18 @@ export function card(app: App) {
)
: mockData,
]);
const enkaLoc = locs[lang ?? "en"];
if (typeof data.avatarInfoList === "undefined")
return error(400, "The showcase for this UID is private");
return error(400, errorLoc.private);
const char = data.avatarInfoList.find(
(c) => c.avatar.avatarId === Number(charId),
);
if (!char) return error(400, "Character not found in showcase");
if (!char) return error(400, errorLoc.notInShowcase);
const avatar = avatarStore[char.avatar.avatarId];
if (!avatar)
return error(
400,
"Unsupported character, avatar store doesn't have this character",
);
if (!avatar) return error(400, errorLoc.unsupported);
if (!char.equipList)
return error(501, "INTERNAL: No equipList provided");
@@ -147,9 +148,12 @@ export function card(app: App) {
store: weaponStore[weaponRaw.itemId ?? "Unknown"],
};
const cacheKey = hash(JSON.stringify({ data, charId })).toString(
36,
);
const height =
typeof w === "number" ? w / (1218 / 508) : heightRaw;
const cacheKey = hash(
JSON.stringify({ data, charId, lang, height }),
).toString(36);
if (feature("API")) {
if (!debug) {
const cached = cache.get<ImageResponse>(cacheKey);
@@ -167,7 +171,7 @@ export function card(app: App) {
20 +
(charLvl > 90 ? 10 : 0);
const statRows = buildDisplayRows(char, locs);
const statRows = buildDisplayRows(char, enkaLoc);
const artifacts = (
Object.keys(EquipType) as (keyof typeof EquipType)[]
@@ -198,7 +202,7 @@ export function card(app: App) {
const artifactSets = Object.entries(setCounts)
.filter(([, count]) => count >= 2)
.map(([set, count]) => ({
set: locs.en[relicStore.Sets[set].Name],
set: enkaLoc[relicStore.Sets[set].Name],
count,
}));
@@ -313,7 +317,7 @@ export function card(app: App) {
<img
src="/avatar"
alt="char"
tw="object-cover h-full aspect-[1024/1124] absolute top-0 -left-[2.5em] scale-x-[1.1] scale-y-[1.15]"
tw="object-cover h-full aspect-[1024/1124] absolute top-0 -left-[2.0em] scale-x-[1.1] scale-y-[1.15]"
/>
{/* left section */}
@@ -482,7 +486,7 @@ export function card(app: App) {
"0 .08em .1em rgba(0,0,0,.4),0 .1em .3em rgba(0,0,0,.4)",
}}
>
<span>{locs.en[weapon.store.NameTextMapHash]}</span>
<span>{enkaLoc[weapon.store.NameTextMapHash]}</span>
</div>
{/* stats */}
<div tw="pt-[3%] pb-[2%] flex gap-[.5em]">
@@ -700,18 +704,15 @@ export function card(app: App) {
return res.clone();
} catch (err) {
if (err instanceof InvalidFormatError)
return error(400, "Invalid UID format");
return error(400, errorLoc.uidFormat);
else if (err instanceof NotFoundError)
return error(404, "UID not found on enka.network");
return error(404, errorLoc.notFound);
else if (err instanceof MaintenanceError)
return error(424, "enka.network is under maintenance");
return error(424, errorLoc.maintenance);
else if (err instanceof RatelimitError)
return error(
429,
"Too many requests to enka.network, please try again later",
);
return error(429, errorLoc.ratelimit);
else if (err instanceof InternalError)
return status(502, "Internal server error");
return status(502, errorLoc.internal);
console.error(err);
try {
return error(500, inspect(err) as "");
@@ -727,28 +728,14 @@ export function card(app: App) {
description: "Genshin Impact UID",
}),
char: t.Number({
examples: ["10000120"],
examples: [10000120],
description: "Character ID",
}),
}),
query: t.Object(
{
debug: t.Optional(
t.Boolean({
description: "Return debug information instead of card.",
}),
),
h: t.Optional(
t.Number({ default: 1024, description: "Height of card." }),
),
w: t.Optional(
t.Number({
description:
"Target width of card, used to calculate height. Has higher priority than `h`.",
}),
),
error: t.Optional(
t.Enum(
query: t.Partial(
t.Object(
{
error: t.Enum(
{ http: "http", card: "card" },
{
default: "http",
@@ -756,9 +743,47 @@ export function card(app: App) {
"Type of error to return when card fails to render.",
},
),
),
},
{ additionalProperties: true },
lang: t.Enum(
Object.fromEntries(
(
[
"zh-cn",
"zh-tw",
"de",
"en",
"es",
"fr",
"id",
"ja",
"ko",
"pt",
"ru",
"th",
"vi",
"tr",
"it",
] as const
).map((v) => [v, v]),
),
{
default: "en",
},
),
h: t.Number({
default: 1024,
description: "Height of card.",
}),
w: t.Number({
description:
"Target width of card, used to calculate height. Has higher priority than `h`.",
}),
debug: t.Boolean({
description: "Return debug information instead of card.",
default: false,
}),
},
{ additionalProperties: true },
),
),
detail: {
tags: ["Card"],