feat: new character list card

This commit is contained in:
2026-05-08 16:51:01 +07:00 Unverified
parent b5d0eafac5
commit 1d066600a6
29 changed files with 3340 additions and 2294 deletions
Vendored
+5
View File
@@ -22,3 +22,8 @@ declare module "*.png" {
const file: string;
export default file;
}
declare module "*.webp" {
const file: string;
export default file;
}
+20 -8
View File
@@ -31,8 +31,6 @@
canvas {
display: block;
width: 1870px;
height: 778px;
margin: 12px auto;
background: #000;
}
@@ -64,14 +62,28 @@
<canvas id="canvas" width="1870" height="778"></canvas>
<script>
const MINE = "/v1/card/genshin/775956514/ororon";
const ORIG = "/v1/card/genshin/diff/ororo.png";
const urlParams = new URLSearchParams(window.location.search);
const type = urlParams.get('type');
let MINE, ORIG, W, H;
if (type === 'list') {
MINE = "/v1/card/genshin/775956514";
ORIG = "/v1/card/genshin/diff/listoriginal.webp";
W = 1200;
H = 732;
} else {
MINE = "/v1/card/genshin/775956514/ororon";
ORIG = "/v1/card/genshin/diff/ororo.png";
W = 1870;
H = 778;
}
const W = 1870, H = 778;
/**
* @type {HTMLCanvasElement}
*/
const canvas = document.getElementById("canvas");
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const mode = document.getElementById("mode");
@@ -231,9 +243,9 @@
}
});
// window.addEventListener("focus", () => {
// refresh();
// });
// window.addEventListener("focus", () => {
// refresh();
// });
</script>
</body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+45
View File
@@ -0,0 +1,45 @@
import { file } from "bun";
import ElementElectric from "./elements/Electric.webp";
import ElementFire from "./elements/Fire.webp";
import ElementGrass from "./elements/Grass.webp";
import ElementIce from "./elements/Ice.webp";
import ElementRock from "./elements/Rock.webp";
import ElementWater from "./elements/Water.webp";
import ElementWind from "./elements/Wind.webp";
import BadgeAbyssStar from "./listbadge/abyss-star.png";
import BadgeAbyss from "./listbadge/abyss.png";
import BadgeAchievements from "./listbadge/achievements.png";
import BadgeFren from "./listbadge/fren.png";
import BadgeStygian from "./listbadge/stygian.png";
import BadgeTheaterStar from "./listbadge/theater-star.png";
import BadgeTheater from "./listbadge/theater.png";
import ListBg from "./listbg.webp";
import ListNcFrame from "./listframe.png";
import ListRtop from "./listrtop.png";
import PaimonSmug from "./listsmug.webp";
export const listBg = file(ListBg);
export const listNcFrame = file(ListNcFrame);
export const listRtop = file(ListRtop);
export const smug = file(PaimonSmug);
export const listBadges = {
abyss: file(BadgeAbyss),
achievements: file(BadgeAchievements),
fren: file(BadgeFren),
stygian: file(BadgeStygian),
theater: file(BadgeTheater),
abyssStar: file(BadgeAbyssStar),
theaterStar: file(BadgeTheaterStar),
};
export const elements = {
electric: file(ElementElectric),
fire: file(ElementFire),
grass: file(ElementGrass),
ice: file(ElementIce),
rock: file(ElementRock),
water: file(ElementWater),
wind: file(ElementWind),
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
export function EmbedTemplate({ children }: { children?: ReactNode }) {
return (
<div
tw="text-white bg-[#131416aa] w-full h-full flex flex-col rounded-[.5em] border-solid"
style={{
borderColor: "#2D2E32",
borderWidth: "1px",
borderLeftColor: "#323337",
borderLeftWidth: "4px",
}}
>
{children}
<div tw="absolute bottom-0 left-0 ml-[.5em] flex items-center">
<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/75">Powered By</div>
<div tw="text-[1.3em] leading-[1] font-[ShinShin]">Enka.Network</div>
</div>
</div>
</div>
);
}
+2887 -2216
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
export function arrRandom<T>(arr: readonly T[]): T | undefined {
return arr[Math.floor(Math.random() * arr.length)];
}
+4 -4
View File
@@ -20,6 +20,7 @@ import { featherImage } from "@/assets/feather";
import { font, iconFont, sansFont } from "@/assets/fonts";
import { EnkaIcon } from "@/assets/icons";
import type { library } from "@/assets/icons-library";
import ListDiffExample from "@/assets/listoriginal.webp" with { type: "file" };
import DiffPageExample from "@/assets/ororo.png" with { type: "file" };
import shadeSvg from "@/assets/shade.svg" with { type: "file" };
import { star } from "@/assets/stars";
@@ -75,6 +76,7 @@ export function card(app: App) {
}))
.get("/genshin/diff", DiffPage)
.get("/genshin/diff/ororo.png", () => file(DiffPageExample))
.get("/genshin/diff/listoriginal.webp", () => file(ListDiffExample))
.head(
"/genshin/:uid/preload",
async ({ params: { uid }, log }) => {
@@ -135,7 +137,7 @@ export function card(app: App) {
),
feature("API") || uid !== "775956514"
? deduper.acquire(`enka-${uid}`, () =>
fetchEnka(uid, true).finally(
fetchEnka(uid, false).finally(
() => log`Enka data loaded`,
),
)
@@ -149,9 +151,7 @@ export function card(app: App) {
return ListPage(
data,
pfps[
`${data.playerInfo.profilePicture.id ?? 1}`
].IconPath.replace("_Circle", ""),
pfps[`${data.playerInfo.profilePicture.id ?? 1}`].IconPath,
namecards[`${data.playerInfo.nameCardId ?? 210001}`].Icon,
namecards,
avatarStore,
+10 -6
View File
@@ -1,12 +1,16 @@
import { EmbedTemplate } from "@/lib/comp/embed";
import { CardResponse } from "./response";
export async function ErrorPage(message: string) {
return CardResponse(
<div tw="flex p-[1em] gap-[.5em]">
<div></div>
<pre tw="m-0 text-[1.1em] leading-none whitespace-pre-wrap">
{message}
</pre>
</div>,
<EmbedTemplate>
<div tw="flex p-[1em] gap-[.5em]">
<div></div>
<pre tw="m-0 text-[1.1em] leading-none whitespace-pre-wrap">
{message}
</pre>
</div>
</EmbedTemplate>,
);
}
+288 -7
View File
@@ -1,14 +1,29 @@
import type { ResolvedRoot } from "enka.ts";
import type { ReactNode } from "react";
import {
elements,
listBadges,
listBg,
listNcFrame,
listRtop,
smug,
} from "@/assets";
import { assets, getAvatar } from "@/lib/assets";
import type { Logger } from "@/lib/logger";
import { CharacterNamecardMap } from "@/lib/namecards";
import type { AvatarStore, EnkaLocsLanguage, EnkaNamecards } from "@/type/enka";
import { arrRandom } from "@/lib/util";
import {
ElementList,
type AvatarStore,
type EnkaLocsLanguage,
type EnkaNamecards,
} from "@/type/enka";
import { CardResponse } from "./response";
export async function ListPage(
{ playerInfo: p, uid }: ResolvedRoot,
{ playerInfo: p, uid, avatarInfoList }: ResolvedRoot,
pfp: string,
namecard: string,
namecards: EnkaNamecards,
@@ -16,7 +31,7 @@ export async function ListPage(
locs: EnkaLocsLanguage,
log: Logger,
) {
return CardResponse(
const _s = (
<div tw="flex font-[Open_Sans]">
{/* left bar */}
<div
@@ -162,19 +177,281 @@ export async function ListPage(
))}
</div>
</div>
</div>
);
return CardResponse(
<div tw="flex bg-black relative h-full w-full font-[ShinShin] text-[#7E6E5C]">
<img src="/bg" tw="absolute inset-0" />
{/* left section */}
<div tw="absolute left-[2.2em] top-[.7em] h-[44.4em] w-[33em] flex flex-col">
{/* top */}
<div tw="relative flex min-w-0 w-full min-h-0 aspect-[840/400] overflow-hidden shrink-0">
<img
src="/namecard"
tw="absolute top-0 left-0 right-0 min-h-full object-cover scale-101"
/>
<img
src="/frame"
tw="absolute top-0 left-0 right-0 min-h-full object-cover opacity-20"
/>
<div tw="absolute inset-0 z-10 flex flex-col items-center">
<div
tw="text-white text-[1.13em] mt-[1.05em] tracking-tight"
style={{
textShadow:
"0 1px 1px rgb(0 0 0 / 0.55), 0 0 2px rgb(0 0 0 / 0.35)",
}}
>
UID {uid}
</div>
<div
tw="relative rounded-full overflow-hidden mt-[.6em]"
style={{
width: "92px",
height: "92px",
background: "#DCDAB9",
padding: "1px",
boxSizing: "border-box",
}}
>
<div
tw="rounded-full overflow-hidden w-full h-full"
style={{
border: "4px solid #F3EBD9",
boxSizing: "border-box",
}}
>
<img src="/pfp" tw="w-full h-full object-cover bg-[#DAAB86]" />
</div>
</div>
<div
tw="text-white text-[1.53em] mt-[.74em] tracking-tight"
style={{
textShadow:
"0 1px 2px rgb(0 0 0 / 0.65), 0 0 4px rgb(0 0 0 / 0.4)",
}}
>
{p.nickname}
</div>
</div>
</div>
<div tw="p-[1.3em] px-[1.8em] h-full flex flex-col gap-[1.5em]">
{/* middle */}
<div tw="flex flex-col gap-[.4em]">
<div tw="flex items-center justify-between bg-[#A7B982] text-white tracking-tight px-[.5em] py-[.08em] text-[1.2em]">
<span tw="text-[.97em]">Adventure Rank</span>
<span tw="text-[1.04em]">{p.level}</span>
</div>
<div tw="flex items-center justify-between bg-[#CDBBA5] text-white tracking-tight px-[.5em] py-[.08em] text-[1.2em]">
<span tw="text-[.97em]">World Level</span>
<span tw="text-[1.04em]">{p.worldLevel}</span>
</div>
<div
tw="tracking-tight px-[.4em] py-[.15em] text-[1.165em] h-[4.25em]"
style={{ border: "2px #E4DCD0 solid" }}
>
{p.signature}
</div>
</div>
{/* bottom */}
<div tw="flex flex-col gap-[.25em]">
{(
[
[
"/b/achievements",
"Total Achievements",
p.finishAchievementNum,
"",
],
[
"/b/fren",
"Characters at maximum Friendship Level",
p.fetterCount ?? 0,
"text-[.89em] leading-none max-w-2/3",
],
[
"/b/abyss",
"Spiral Abyss",
p.towerFloorIndex ? (
<div tw="flex items-center leading-none">
{p.towerFloorIndex}-{p.towerLevelIndex}
{p.towerStarIndex && (
<div tw="flex items-center">
<div tw="mx-[.3em] h-2/3 mb-[.2em] w-0.5 bg-[#E3DED6] shrink-0" />
<span>
{p.towerStarIndex.toString().padStart(2, " ")}
</span>
<img src="/b/abyssStar" tw="-mt-1.5 mx-[.1em]" />
</div>
)}
</div>
) : (
<div tw="text-[#C7B7A5]">Not yet attempted</div>
),
"flex items-center",
],
[
"/b/theater",
"Imaginarium Theater",
p.theaterActIndex ? (
<div tw="flex items-center">
Act {p.theaterActIndex}
<div tw="mx-[.3em] h-2/3 mb-[.2em] w-0.5 bg-[#E3DED6] shrink-0" />
<span>
{p.theaterStarIndex!.toString().padStart(2, " ")}
</span>
<img src="/b/theaterStar" tw="-mt-1.5" />
</div>
) : (
<div tw="text-[#C7B7A5]">Not yet attempted</div>
),
"flex items-center",
],
[
"/b/stygian",
"Stygian Onslaught",
p.stygianSeconds ? (
<div tw="flex items-center gap-[.2em]">
<img src="/i/stygian" tw="w-8 h-8" />
<div tw="mt-[.2em]">{p.stygianSeconds}s</div>
</div>
) : (
<div tw="text-[#C7B7A5]">Not yet attempted</div>
),
"flex items-center",
],
] satisfies [string, string, ReactNode, string][]
).map(([icon, label, val, tw], i) => (
<>
{i > 0 && <div tw="w-full bg-[#E9E2D9] h-0.5" />}
<div tw="flex text-[1.12em] tracking-tight gap-[.45em]">
<img src={icon} />
<div tw={tw}>{label}</div>
<div tw="grow" />
<span tw="shrink-0 flex items-center">{val}</span>
</div>
</>
))}
</div>
</div>
</div>
{/* right section */}
<div tw="absolute right-[1.8em] top-[2.3em] h-[41.5em] w-[34em] flex flex-col">
{/* top */}
<div tw="relative flex min-w-0 w-full min-h-0 aspect-[546/48] shrink-0">
<img
src="/rtop"
tw="absolute top-0 left-0 right-0 min-h-full object-cover"
/>
<div tw="absolute inset-0 z-10 flex items-center gap-1">
<img src="/smug" tw="h-12 w-12 ml-2" />
<div tw="flex flex-col">
<div tw="-mb-1">See each character:</div>
<div tw="flex text-[1.1em] leading-none">
https://card.astrxl.dev/u/{uid}/
<div tw="border-b-2 border-b-current border-dashed bg-black/10 -mt-0.5 rounded-t-sm">
{(arrRandom(
p.showAvatarInfoList
.map((e) => e.avatar.name)
.filter((e) => e.length <= 7),
) ??
arrRandom(
Object.values(avatars)
.map((e) => locs[e.NameTextMapHash])
.filter((e) => e.length <= 7),
))!.toLowerCase()}
</div>
</div>
</div>
</div>
</div>
{/* grid */}
<div tw="mt-[2em] mx-[1.35em] h-[28.5em] grid grid-cols-4 gap-x-[1.2em] gap-y-[1.2em]">
{p.showAvatarInfoList
.map((a) => ({
a,
c: avatars[a.avatar.avatarId],
cons: avatarInfoList!.find(
(e) => e.avatar.avatarId === a.avatar.avatarId,
)!.talentIdList?.length,
}))
.map(({ a, c, cons }) => (
<div tw="relative flex flex-col rounded-[.3em] overflow-hidden shadow-[0_.08em_.35em_rgba(0,0,0,.18)]">
<div tw="bg-[#E9E6DB] absolute top-0 left-0 w-full aspect-square" />
<img
src={`/pfp/${a.avatar.avatarId}`}
tw="aspect-square rounded-br-[1.5em]"
style={{
background: `rgba(${
c?.QualityType === "QUALITY_ORANGE"
? "200,124,36"
: "148,112,187"
}) linear-gradient(136deg,rgba(92,82,120,.42),rgba(255,255,255,.04),transparent)`,
}}
/>
<img
tw="absolute top-[.2em] left-[.2em] w-[1.7em] h-[1.7em]"
src={`/e/${ElementList[(a as unknown as { energyType: number }).energyType]?.toLowerCase()}`}
/>
<div tw="flex text-[#404B57] bg-[#E9E6DB] items-center justify-center grow text-[1.1em]">
Lv. <div>{a.level}</div>
</div>
{cons && (
<div
tw={`absolute bottom-[2em] left-[.2em] flex items-center justify-center pt-[.2em] w-[1.2em] rounded-[.2em] h-[1.2em] ${cons >= 6 ? "bg-[#EAC847] text-[#8C5E10]" : "text-white bg-black/50"}`}
>
{cons}
</div>
)}
</div>
))}
</div>
{/* powered by */}
<div tw="absolute bottom-0 left-0 ml-[.5em] flex items-center">
<div tw="text-[2em] font-[shicon] pb-[.1em] -mr-[.3em] text-black/25">
</div>
<div tw="flex flex-col">
<div tw="leading-none text-black/75">Powered By</div>
<div tw="text-[1.3em] leading-[1] font-[ShinShin] text-black">
Enka.Network
</div>
</div>
</div>
</div>
</div>,
[
{ src: "/bg", data: listBg.arrayBuffer(), persistent: true },
{ src: "/pfp", data: assets.load(pfp) },
{ src: "/namecard", data: assets.load(namecard) },
{ src: "/i/achi", data: assets.load("/img/achievements.png") },
{ src: "/i/abyss", data: assets.load("/img/abyss.png") },
{ src: "/i/theater", data: assets.load("/ui/UI_Icon_Tower_Star1.png") },
{ src: "/frame", data: listNcFrame.arrayBuffer(), persistent: true },
{ src: "/rtop", data: listRtop.arrayBuffer(), persistent: true },
{ src: "/smug", data: smug.arrayBuffer(), persistent: true },
// { src: "/i/achi", data: assets.load("/img/achievements.png") },
// { src: "/i/abyss", data: assets.load("/img/abyss.png") },
// { src: "/i/theater", data: assets.load("/ui/UI_Icon_Tower_Star1.png") },
{
src: "/i/stygian",
data: assets.load(
`/ui/UI_LeyLineChallenge_Medal_${p.stygianIndex ?? 1}.png`,
),
},
...Object.entries(listBadges).map(([k, v]) => ({
src: `/b/${k}`,
data: () => v.arrayBuffer(),
persistent: true as const,
})),
...Object.entries(elements).map(([k, v]) => ({
src: `/e/${k}`,
data: () => v.arrayBuffer(),
persistent: true as const,
})),
...(p.showAvatarInfoList
.flatMap((a) => {
const avatar = getAvatar(avatars, p, a.avatar.avatarId);
@@ -189,12 +466,16 @@ export async function ListPage(
: null,
{
src: `/pfp/${a.avatar.avatarId}`,
data: assets.load(avatar.SideIconName.replace("_Side", "")),
data: assets.load(
(a.costumeId && avatar.Costumes?.[a.costumeId].Icon) ||
avatar.SideIconName.replace("_Side", ""),
),
},
];
})
.filter(Boolean) as { src: string; data: Promise<ArrayBuffer> }[]),
],
{ width: 1200, height: 732 },
log,
);
}
+52 -53
View File
@@ -9,59 +9,58 @@ import { createLogger } from "@/lib/logger";
export async function CardResponse(
children: ReactNode,
assets: MaybePromise<{
src: string;
data: MaybePromise<ArrayBuffer | Buffer>;
}>[] = [],
assets: (
| MaybePromise<{
src: string;
data: MaybePromise<ArrayBuffer | Buffer>;
persistent?: false;
}>
| MaybePromise<{
src: string;
data:
| MaybePromise<ArrayBuffer | Buffer>
| (() => MaybePromise<ArrayBuffer | Buffer>);
persistent: true;
}>
)[] = [],
// same ratio as card
size = { width: 1228, height: 512 },
log = createLogger("RES"),
) {
return new ImageResponse(
<div
tw="text-white bg-[#131416aa] w-full h-full flex flex-col rounded-[.5em] border-solid"
style={{
borderColor: "#2D2E32",
borderWidth: "1px",
borderLeftColor: "#323337",
borderLeftWidth: "4px",
}}
>
{children}
<div tw="absolute bottom-0 left-0 ml-[.5em] flex items-center">
<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/75">Powered By</div>
<div tw="text-[1.3em] leading-[1] font-[ShinShin]">Enka.Network</div>
</div>
</div>
</div>,
{
fonts: await Promise.all([
font.arrayBuffer(),
sansFont.arrayBuffer(),
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 })),
),
)
.then((l) => {
const res = l.filter((r) => {
if (r.data.byteLength) return true;
if (!feature("API")) console.warn(`[DEV] ${r.src} is blank`);
return false;
});
log`Image resources loaded\n${res.map((r) => `- ${r.src} ${(r.data.byteLength / 1000).toFixed(2)}Kb`).join("\n")}`;
return res;
}),
height: 512,
// drawDebugBorder: true,
},
);
const resources = await Promise.all(assets)
.then((list) =>
Promise.all(
list.map(async (a) => ({
src: a.src,
data: await a.data,
persistent: a.persistent,
})),
),
)
.then((l) => {
const res = l.filter((r) => {
if (typeof r.data === "function") return true;
if (r.data.byteLength) return true;
if (!feature("API")) console.warn(`[DEV] ${r.src} is blank`);
return false;
});
log`Image resources loaded\n${res.map((r) => `- ${r.src} ${typeof r.data === "function" ? "[FN]" : `${(r.data.byteLength / 1000).toFixed(2)}Kb`}`).join("\n")}`;
return res;
});
return new ImageResponse(children, {
fonts: await Promise.all([
font.arrayBuffer(),
sansFont.arrayBuffer(),
iconFont.arrayBuffer(),
]),
format: "webp",
fetchedResources: resources.filter((a) => !a.persistent) as {
src: string;
data: ArrayBuffer | Buffer;
}[],
persistentImages: resources.filter((a) => a.persistent),
...size,
// drawDebugBorder: true,
});
}