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:
@@ -1,6 +1,8 @@
|
||||
import { file } from "bun";
|
||||
import sansFontFile from "./open-sans.woff2" with { type: "file" };
|
||||
import iconFontFile from "./shicon.woff" with { type: "file" };
|
||||
import fontFile from "./shinshin.woff2" with { type: "file" };
|
||||
|
||||
export const font = file(fontFile);
|
||||
export const iconFont = file(iconFontFile);
|
||||
export const sansFont = file(sansFontFile);
|
||||
|
||||
Binary file not shown.
+42
-55
@@ -3,7 +3,14 @@ import { mkdir, readdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { file, hash, write } from "bun";
|
||||
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 { createLogger } from "./logger";
|
||||
|
||||
@@ -17,20 +24,24 @@ export class AssetManager {
|
||||
private approximateSize = 0;
|
||||
|
||||
private async getAssetList() {
|
||||
const [avatars, weapons, relics] = await Promise.all([
|
||||
getEnkaAvatarStore(),
|
||||
getEnkaWeaponStore(),
|
||||
getEnkaRelicStore(),
|
||||
]);
|
||||
const [avatars, weapons, relics, pfps, namecards] = await batchStore(
|
||||
"avatars",
|
||||
"weapons",
|
||||
"relics",
|
||||
"pfps",
|
||||
"namecards",
|
||||
);
|
||||
return [
|
||||
...Object.values(avatars).flatMap((a) => [
|
||||
...a.Consts,
|
||||
...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"),
|
||||
]),
|
||||
...Object.values(weapons).flatMap((w) => [w.Icon, w.AwakenIcon]),
|
||||
...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() {
|
||||
return Promise.all([
|
||||
getEnkaAvatarStore(),
|
||||
getEnkaWeaponStore(),
|
||||
getEnkaRelicStore(),
|
||||
getEnkaLocs(),
|
||||
]);
|
||||
export const assets = new AssetManager();
|
||||
|
||||
export async function batchStore<K extends readonly (keyof StoreTypeMap)[]>(
|
||||
...keys: K
|
||||
) {
|
||||
return Promise.all(keys.map(getEnkaStore)) as unknown as Promise<{
|
||||
[I in keyof K]: StoreTypeMap[K[I]];
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function getEnkaAvatarStore(): Promise<AvatarStore> {
|
||||
const cached = cache.get("enkaAvatarStore");
|
||||
if (cached) return cached as AvatarStore;
|
||||
type StoreTypeMap = {
|
||||
avatars: 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(
|
||||
"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();
|
||||
cache.set("enkaAvatarStore", 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
|
||||
cache.set(`store.${key}`, data, 86400); // 24 hours
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from "elysia";
|
||||
|
||||
export type AvatarStore = Record<number, Avatar>;
|
||||
|
||||
export interface Avatar {
|
||||
@@ -148,3 +150,37 @@ export const SubstatOrder: Record<string, number> = {
|
||||
FIGHT_PROP_HP: 9,
|
||||
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
@@ -22,17 +22,17 @@ import { star } from "@/assets/stars";
|
||||
import { TalentSvg } from "@/assets/talent";
|
||||
import { WeaponGlazeSvg } from "@/assets/weapon-glaze";
|
||||
import type { App } from "@/index";
|
||||
import { AssetManager, getEnkaStores } from "@/lib/assets";
|
||||
import { assets, batchStore } 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";
|
||||
import { ElementList, SubstatOrder } from "@/type/enka";
|
||||
import { ElementList, Locale, SubstatOrder } from "@/type/enka";
|
||||
import { PercentageFightProp } from "@/type/fightPropMap";
|
||||
import { ErrorPage } from "./error";
|
||||
import { ListPage } from "./list";
|
||||
|
||||
const assets = new AssetManager();
|
||||
const cache = new TTLCache();
|
||||
const deduper = new Deduplicator();
|
||||
|
||||
@@ -61,7 +61,7 @@ export function card(app: App) {
|
||||
.group("/card", (app) =>
|
||||
app
|
||||
.derive(() => ({
|
||||
logger: createLogger(),
|
||||
log: createLogger(),
|
||||
}))
|
||||
// FE still uses this endpoint for some reason(aka lazy to change)
|
||||
.head("/genshin/:uid/:char/preload", async ({ params: { uid } }) =>
|
||||
@@ -69,15 +69,21 @@ export function card(app: App) {
|
||||
)
|
||||
.head(
|
||||
"/genshin/:uid/preload",
|
||||
async ({ params: { uid }, logger }) => {
|
||||
logger`Preload ${uid}`;
|
||||
async ({ params: { uid }, log }) => {
|
||||
log`Preload ${uid}`;
|
||||
try {
|
||||
await Promise.all([
|
||||
deduper.acquire("stores", () =>
|
||||
getEnkaStores().finally(() => logger`Stores loaded`),
|
||||
batchStore(
|
||||
"avatars",
|
||||
"locs",
|
||||
"relics",
|
||||
"weapons",
|
||||
"pfps",
|
||||
).finally(() => log`Stores loaded`),
|
||||
),
|
||||
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"],
|
||||
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(
|
||||
"/genshin/:uid/:char",
|
||||
async ({
|
||||
params: { uid, char: charId },
|
||||
logger,
|
||||
log,
|
||||
query: { debug, h: heightRaw = 1024, w, error: errorType, lang },
|
||||
}) => {
|
||||
logger`Rendering ${uid}/${charId}`;
|
||||
log`Rendering ${uid}/${charId}`;
|
||||
const error =
|
||||
errorType === "card"
|
||||
? (_: number, message: string) => ErrorPage(message)
|
||||
@@ -116,12 +183,14 @@ export function card(app: App) {
|
||||
const [[avatarStore, weaponStore, relicStore, locs], data] =
|
||||
await Promise.all([
|
||||
deduper.acquire("stores", () =>
|
||||
getEnkaStores().finally(() => logger`Stores loaded`),
|
||||
batchStore("avatars", "weapons", "relics", "locs").finally(
|
||||
() => log`Stores loaded`,
|
||||
),
|
||||
),
|
||||
feature("API") || uid !== "775956514"
|
||||
? deduper.acquire(`enka-${uid}`, () =>
|
||||
fetchEnka(uid, false).finally(
|
||||
() => logger`Enka data loaded`,
|
||||
() => log`Enka data loaded`,
|
||||
),
|
||||
)
|
||||
: mockData,
|
||||
@@ -285,10 +354,10 @@ export function card(app: App) {
|
||||
src: string;
|
||||
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) {
|
||||
logger`Debug endpoint, returning JSON`;
|
||||
log`Debug endpoint, returning JSON`;
|
||||
return JSON.stringify(
|
||||
{
|
||||
charLvl,
|
||||
@@ -757,32 +826,7 @@ export function card(app: App) {
|
||||
"Type of error to return when card fails to render.",
|
||||
},
|
||||
),
|
||||
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",
|
||||
},
|
||||
),
|
||||
lang: Locale,
|
||||
h: t.Number({
|
||||
default: 1024,
|
||||
description: "Height of card.",
|
||||
@@ -815,7 +859,7 @@ export function card(app: App) {
|
||||
}
|
||||
}
|
||||
},
|
||||
afterResponse: ({ logger }) => logger`Rendering completed`,
|
||||
afterResponse: ({ log }) => log`Rendering completed`,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
@@ -1,27 +1,43 @@
|
||||
import ImageResponse from "@takumi-rs/image-response";
|
||||
import { MaybePromise } from "bun";
|
||||
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(
|
||||
<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}
|
||||
<div tw="grow" />
|
||||
<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 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>
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
fonts: [await font.arrayBuffer(), await iconFont.arrayBuffer()],
|
||||
fonts: [
|
||||
await font.arrayBuffer(),
|
||||
await sansFont.arrayBuffer(),
|
||||
await iconFont.arrayBuffer(),
|
||||
],
|
||||
format: "webp",
|
||||
// same ratio as card
|
||||
width: 1228,
|
||||
fetchedResources: await Promise.all(assets).then((list) =>
|
||||
Promise.all(
|
||||
list.map(async (a) => ({ src: a.src, data: await a.data })),
|
||||
),
|
||||
),
|
||||
height: 512,
|
||||
// drawDebugBorder: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user