feat: add ListPage component for player profile display

- Implemented ListPage component to render player information including profile picture, namecard, nickname, level, world level, and signature.
- Integrated asset loading for profile picture and namecard using assets.load.
- Utilized Tailwind CSS for styling the layout and components.
This commit is contained in:
2026-03-21 23:33:36 +07:00 Unverified
parent b144018947
commit 29a583fcf2
7 changed files with 243 additions and 103 deletions
+2
View File
@@ -1,6 +1,8 @@
import { file } from "bun"; import { file } from "bun";
import sansFontFile from "./open-sans.woff2" with { type: "file" };
import iconFontFile from "./shicon.woff" with { type: "file" }; import iconFontFile from "./shicon.woff" with { type: "file" };
import fontFile from "./shinshin.woff2" with { type: "file" }; import fontFile from "./shinshin.woff2" with { type: "file" };
export const font = file(fontFile); export const font = file(fontFile);
export const iconFont = file(iconFontFile); export const iconFont = file(iconFontFile);
export const sansFont = file(sansFontFile);
Binary file not shown.
+42 -55
View File
@@ -3,7 +3,14 @@ import { mkdir, readdir, rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { file, hash, write } from "bun"; import { file, hash, write } from "bun";
import { TTLCache } from "enka.ts"; import { TTLCache } from "enka.ts";
import { AvatarStore, EnkaLocs, RelicStore, WeaponStore } from "@/type/enka"; import {
AvatarStore,
EnkaLocs,
EnkaNamecards,
EnkaPfps,
RelicStore,
WeaponStore,
} from "@/type/enka";
import { Deduplicator } from "./dedupe"; import { Deduplicator } from "./dedupe";
import { createLogger } from "./logger"; import { createLogger } from "./logger";
@@ -17,20 +24,24 @@ export class AssetManager {
private approximateSize = 0; private approximateSize = 0;
private async getAssetList() { private async getAssetList() {
const [avatars, weapons, relics] = await Promise.all([ const [avatars, weapons, relics, pfps, namecards] = await batchStore(
getEnkaAvatarStore(), "avatars",
getEnkaWeaponStore(), "weapons",
getEnkaRelicStore(), "relics",
]); "pfps",
"namecards",
);
return [ return [
...Object.values(avatars).flatMap((a) => [ ...Object.values(avatars).flatMap((a) => [
...a.Consts, ...a.Consts,
...Object.values(a.Skills), ...Object.values(a.Skills),
...Object.values(a.Costumes ?? {}).map((c) => c.Art), ...Object.values(a.Costumes ?? {}).flatMap((c) => [c.Art, c.SideIcon]),
a.SideIconName.replace("AvatarIcon_Side", "Gacha_AvatarImg"), a.SideIconName.replace("AvatarIcon_Side", "Gacha_AvatarImg"),
]), ]),
...Object.values(weapons).flatMap((w) => [w.Icon, w.AwakenIcon]), ...Object.values(weapons).flatMap((w) => [w.Icon, w.AwakenIcon]),
...Object.values(relics.Items).map((r) => r.Icon), ...Object.values(relics.Items).map((r) => r.Icon),
...Object.values(pfps).map((p) => p.IconPath),
...Object.values(namecards).map((p) => p.Icon),
]; ];
} }
@@ -155,59 +166,35 @@ export class AssetManager {
} }
} }
export async function getEnkaStores() { export const assets = new AssetManager();
return Promise.all([
getEnkaAvatarStore(), export async function batchStore<K extends readonly (keyof StoreTypeMap)[]>(
getEnkaWeaponStore(), ...keys: K
getEnkaRelicStore(), ) {
getEnkaLocs(), return Promise.all(keys.map(getEnkaStore)) as unknown as Promise<{
]); [I in keyof K]: StoreTypeMap[K[I]];
}>;
} }
export async function getEnkaAvatarStore(): Promise<AvatarStore> { type StoreTypeMap = {
const cached = cache.get("enkaAvatarStore"); avatars: AvatarStore;
if (cached) return cached as AvatarStore; weapons: WeaponStore;
relics: RelicStore;
locs: EnkaLocs;
pfps: EnkaPfps;
namecards: EnkaNamecards;
};
export async function getEnkaStore<K extends keyof StoreTypeMap>(
key: K,
): Promise<StoreTypeMap[K]> {
const cached = cache.get(`store.${key}`);
if (cached) return cached as StoreTypeMap[K];
const response = await fetch( const response = await fetch(
"https://github.com/EnkaNetwork/API-docs/raw/refs/heads/master/store/gi/avatars.json", `https://github.com/EnkaNetwork/API-docs/raw/refs/heads/master/store/gi/${key}.json`,
); );
const data = await response.json(); const data = await response.json();
cache.set("enkaAvatarStore", data, 86400); // 24 hours cache.set(`store.${key}`, data, 86400); // 24 hours
return data;
}
export async function getEnkaWeaponStore(): Promise<WeaponStore> {
const cached = cache.get("enkaWeaponStore");
if (cached) return cached as WeaponStore;
const response = await fetch(
"https://github.com/EnkaNetwork/API-docs/raw/refs/heads/master/store/gi/weapons.json",
);
const data = await response.json();
cache.set("enkaWeaponStore", data, 86400); // 24 hours
return data;
}
export async function getEnkaRelicStore(): Promise<RelicStore> {
const cached = cache.get("enkaRelicStore");
if (cached) return cached as RelicStore;
const response = await fetch(
"https://github.com/EnkaNetwork/API-docs/raw/refs/heads/master/store/gi/relics.json",
);
const data = await response.json();
cache.set("enkaRelicStore", data, 86400); // 24 hours
return data;
}
export async function getEnkaLocs(): Promise<EnkaLocs> {
const cached = cache.get("enkaLocs");
if (cached) return cached as EnkaLocs;
const response = await fetch(
"https://github.com/EnkaNetwork/API-docs/raw/refs/heads/master/store/gi/locs.json",
);
const data = await response.json();
cache.set("enkaLocs", data, 86400); // 24 hours
return data; return data;
} }
+36
View File
@@ -1,3 +1,5 @@
import { t } from "elysia";
export type AvatarStore = Record<number, Avatar>; export type AvatarStore = Record<number, Avatar>;
export interface Avatar { export interface Avatar {
@@ -148,3 +150,37 @@ export const SubstatOrder: Record<string, number> = {
FIGHT_PROP_HP: 9, FIGHT_PROP_HP: 9,
FIGHT_PROP_DEFENSE: 10, FIGHT_PROP_DEFENSE: 10,
}; };
export type NumberLike = `${number}`;
export type CdnPath = `/ui/UI_${string}.jpg`;
export type EnkaPfps = Record<NumberLike, { IconPath: CdnPath }>;
export type EnkaNamecards = Record<NumberLike, { Icon: CdnPath }>;
export const Locale = 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",
},
);
+86 -42
View File
@@ -22,17 +22,17 @@ import { star } from "@/assets/stars";
import { TalentSvg } from "@/assets/talent"; import { TalentSvg } from "@/assets/talent";
import { WeaponGlazeSvg } from "@/assets/weapon-glaze"; import { WeaponGlazeSvg } from "@/assets/weapon-glaze";
import type { App } from "@/index"; import type { App } from "@/index";
import { AssetManager, getEnkaStores } from "@/lib/assets"; import { assets, batchStore } from "@/lib/assets";
import { Deduplicator } from "@/lib/dedupe"; import { Deduplicator } from "@/lib/dedupe";
import { cardErrorLocs } from "@/lib/locs"; import { cardErrorLocs } from "@/lib/locs";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { mockData } from "@/lib/mock"; import { mockData } from "@/lib/mock";
import { buildDisplayRows } from "@/lib/rows"; import { buildDisplayRows } from "@/lib/rows";
import { ElementList, SubstatOrder } from "@/type/enka"; import { ElementList, Locale, SubstatOrder } from "@/type/enka";
import { PercentageFightProp } from "@/type/fightPropMap"; import { PercentageFightProp } from "@/type/fightPropMap";
import { ErrorPage } from "./error"; import { ErrorPage } from "./error";
import { ListPage } from "./list";
const assets = new AssetManager();
const cache = new TTLCache(); const cache = new TTLCache();
const deduper = new Deduplicator(); const deduper = new Deduplicator();
@@ -61,7 +61,7 @@ export function card(app: App) {
.group("/card", (app) => .group("/card", (app) =>
app app
.derive(() => ({ .derive(() => ({
logger: createLogger(), log: createLogger(),
})) }))
// FE still uses this endpoint for some reason(aka lazy to change) // FE still uses this endpoint for some reason(aka lazy to change)
.head("/genshin/:uid/:char/preload", async ({ params: { uid } }) => .head("/genshin/:uid/:char/preload", async ({ params: { uid } }) =>
@@ -69,15 +69,21 @@ export function card(app: App) {
) )
.head( .head(
"/genshin/:uid/preload", "/genshin/:uid/preload",
async ({ params: { uid }, logger }) => { async ({ params: { uid }, log }) => {
logger`Preload ${uid}`; log`Preload ${uid}`;
try { try {
await Promise.all([ await Promise.all([
deduper.acquire("stores", () => deduper.acquire("stores", () =>
getEnkaStores().finally(() => logger`Stores loaded`), batchStore(
"avatars",
"locs",
"relics",
"weapons",
"pfps",
).finally(() => log`Stores loaded`),
), ),
deduper.acquire(`enka-${uid}`, () => deduper.acquire(`enka-${uid}`, () =>
fetchEnka(uid, false).finally(() => logger`Enka data loaded`), fetchEnka(uid, false).finally(() => log`Enka data loaded`),
), ),
]); ]);
@@ -95,17 +101,78 @@ export function card(app: App) {
tags: ["Card"], tags: ["Card"],
description: "Preload data required to render a card.", description: "Preload data required to render a card.",
}, },
afterResponse: ({ logger }) => logger`Data preload complete`, afterResponse: ({ log }) => log`Data preload complete`,
},
)
.get(
"/genshin/:uid",
async ({ params: { uid }, query: { debug }, log }) => {
const [[avatarStore, locs, pfps, namecards], data] =
await Promise.all([
deduper.acquire("stores", () =>
batchStore("avatars", "locs", "pfps", "namecards").finally(
() => log`Stores loaded`,
),
),
feature("API") || uid !== "775956514"
? deduper.acquire(`enka-${uid}`, () =>
fetchEnka(uid, true).finally(() => log`Enka data loaded`),
)
: mockData,
]);
if (debug) return { data };
return ListPage(
data,
pfps[
`${data.playerInfo.profilePicture.id ?? 1}`
].IconPath.replace("_Circle", ""),
namecards[`${data.playerInfo.namecardId ?? 210001}`].Icon,
);
},
{
params: t.Object({
uid: t.String({
examples: ["775956514"],
description: "Genshin Impact UID",
}),
}),
query: t.Partial(
t.Object(
{
error: t.Enum(
{ http: "http", card: "card" },
{
default: "http",
description:
"Type of error to return when card fails to render.",
},
),
lang: Locale,
debug: t.Boolean({
description: "Return debug information instead of card.",
default: false,
}),
},
{ additionalProperties: true },
),
),
detail: {
tags: ["Card"],
description:
"Generate a Genshin Impact character list card image from a UID.",
},
}, },
) )
.get( .get(
"/genshin/:uid/:char", "/genshin/:uid/:char",
async ({ async ({
params: { uid, char: charId }, params: { uid, char: charId },
logger, log,
query: { debug, h: heightRaw = 1024, w, error: errorType, lang }, query: { debug, h: heightRaw = 1024, w, error: errorType, lang },
}) => { }) => {
logger`Rendering ${uid}/${charId}`; log`Rendering ${uid}/${charId}`;
const error = const error =
errorType === "card" errorType === "card"
? (_: number, message: string) => ErrorPage(message) ? (_: number, message: string) => ErrorPage(message)
@@ -116,12 +183,14 @@ export function card(app: App) {
const [[avatarStore, weaponStore, relicStore, locs], data] = const [[avatarStore, weaponStore, relicStore, locs], data] =
await Promise.all([ await Promise.all([
deduper.acquire("stores", () => deduper.acquire("stores", () =>
getEnkaStores().finally(() => logger`Stores loaded`), batchStore("avatars", "weapons", "relics", "locs").finally(
() => log`Stores loaded`,
),
), ),
feature("API") || uid !== "775956514" feature("API") || uid !== "775956514"
? deduper.acquire(`enka-${uid}`, () => ? deduper.acquire(`enka-${uid}`, () =>
fetchEnka(uid, false).finally( fetchEnka(uid, false).finally(
() => logger`Enka data loaded`, () => log`Enka data loaded`,
), ),
) )
: mockData, : mockData,
@@ -285,10 +354,10 @@ export function card(app: App) {
src: string; src: string;
data: ArrayBuffer | Buffer; data: ArrayBuffer | Buffer;
}>[]); }>[]);
logger`Image resources loaded\n${resources.map((r) => `- ${r.src} ${(r.data.byteLength / 1000).toFixed(2)}Kb`).join("\n")}`; log`Image resources loaded\n${resources.map((r) => `- ${r.src} ${(r.data.byteLength / 1000).toFixed(2)}Kb`).join("\n")}`;
if (debug) { if (debug) {
logger`Debug endpoint, returning JSON`; log`Debug endpoint, returning JSON`;
return JSON.stringify( return JSON.stringify(
{ {
charLvl, charLvl,
@@ -757,32 +826,7 @@ export function card(app: App) {
"Type of error to return when card fails to render.", "Type of error to return when card fails to render.",
}, },
), ),
lang: t.Enum( lang: Locale,
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({ h: t.Number({
default: 1024, default: 1024,
description: "Height of card.", description: "Height of card.",
@@ -815,7 +859,7 @@ export function card(app: App) {
} }
} }
}, },
afterResponse: ({ logger }) => logger`Rendering completed`, afterResponse: ({ log }) => log`Rendering completed`,
}, },
), ),
) )
+55
View File
@@ -0,0 +1,55 @@
import { ResolvedRoot } from "enka.ts";
import { assets } from "@/lib/assets";
import { CardResponse } from "./response";
export async function ListPage(
{ playerInfo: p, uid }: ResolvedRoot,
pfp: string,
namecard: string,
) {
return CardResponse(
<div tw="flex font-[Open_Sans]">
{/* left bar */}
<div tw="flex flex-col justify-between p-[1em] w-[37.5%]">
{/* profile */}
<div tw="flex gap-[1em] relative">
<div tw="absolute -top-1 -left-1 -bottom-1 -right-1 overflow-hidden rounded-[.3em] opacity-50">
<img src="/namecard" alt="namecard" tw="object-cover" />
</div>
<div tw="overflow-hidden rounded-[.7em] bg-black/25 shrink-0">
<img
src="/pfp"
alt="pfp"
tw="object-cover scale-120 w-[10em] h-[10em]"
/>
</div>
<div tw="flex flex-col justify-evenly py-[.5em] min-w-0">
<div tw="text-[1.4em] font-[ShinShin]">{p.nickname}</div>
<div tw="flex items-center gap-[.4em] opacity-70 font-bold text-[1.2em] tracking-widest">
AR {p.level}
<svg
width="16"
height="16"
viewBox="0 0 100 100"
tw="h-[.75em] w-[.75em]"
>
<title>doritos</title>
<polygon points="50,10 90,90 10,90" fill="currentColor" />
</svg>
WL {p.worldLevel}
</div>
<div tw="grow">
<div tw="opacity-70 pt-[.5em] text-[1.1em] leading-none">
{p.signature}
</div>
</div>
</div>
</div>
</div>
</div>,
[
{ src: "/pfp", data: assets.load(pfp) },
{ src: "/namecard", data: assets.load(namecard) },
],
);
}
+22 -6
View File
@@ -1,27 +1,43 @@
import ImageResponse from "@takumi-rs/image-response"; import ImageResponse from "@takumi-rs/image-response";
import { MaybePromise } from "bun";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { font, iconFont } from "@/assets/fonts"; import { font, iconFont, sansFont } from "@/assets/fonts";
export async function CardResponse(children: ReactNode) { export async function CardResponse(
children: ReactNode,
assets: MaybePromise<{
src: string;
data: MaybePromise<ArrayBuffer | Buffer>;
}>[] = [],
) {
return new ImageResponse( return new ImageResponse(
<div tw="text-white bg-[#2f3136] w-full h-full flex flex-col"> <div tw="text-white bg-[#131416] w-full h-full flex flex-col rounded-[.5em]">
{children} {children}
<div tw="grow" /> <div tw="grow" />
<div tw="ml-[.5em] flex items-center"> <div tw="ml-[.5em] flex items-center">
<div tw="text-[2em] font-[shicon] pb-[.1em] -mr-[.3em] text-black/50"> <div tw="text-[2em] font-[shicon] pb-[.1em] -mr-[.3em] text-white/25">
</div> </div>
<div tw="flex flex-col"> <div tw="flex flex-col">
<div tw="leading-none text-white/50">Powered By</div> <div tw="leading-none text-white/75">Powered By</div>
<div tw="text-[1.5em] leading-[.8]">Enka.Network</div> <div tw="text-[1.5em] leading-[.8]">Enka.Network</div>
</div> </div>
</div> </div>
</div>, </div>,
{ {
fonts: [await font.arrayBuffer(), await iconFont.arrayBuffer()], fonts: [
await font.arrayBuffer(),
await sansFont.arrayBuffer(),
await iconFont.arrayBuffer(),
],
format: "webp", format: "webp",
// same ratio as card // same ratio as card
width: 1228, width: 1228,
fetchedResources: await Promise.all(assets).then((list) =>
Promise.all(
list.map(async (a) => ({ src: a.src, data: await a.data })),
),
),
height: 512, height: 512,
// drawDebugBorder: true, // drawDebugBorder: true,
}, },