- Add guide for Artifact Enka, for blank showcase

- Refactor Countdown component to have custom render function
- Change Enka fetch route to use Redis as a cache
This commit is contained in:
2026-01-17 20:58:25 +07:00 Unverified
parent c0e389c047
commit 89190434cf
5 changed files with 207 additions and 37 deletions
+177 -22
View File
@@ -2,21 +2,44 @@
/** biome-ignore-all lint/a11y/useKeyWithClickEvents: Not needed */
"use client";
import { BookAlert, CircleX, Loader2, Search } from "lucide-react";
import {
ArrowBigUp,
BookAlert,
CircleX,
Loader2,
Search,
UserSearch,
Wrench,
} from "lucide-react";
import Image from "next/image";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import GuideImage1 from "#/guide/1.png";
import GuideImage2 from "#/guide/2.png";
import Avatar from "@/components/avatar";
import { Blocker } from "@/components/blocker";
import { ComboBox } from "@/components/combobox";
import { SimpleTooltip } from "@/components/tooltip";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { getCharacters } from "@/lib/api";
import { shared } from "@/lib/comms";
import { uidRegex } from "@/lib/const";
import type { characters } from "@/lib/db/schema";
import { cn } from "@/lib/utils";
import type { EnkaNetworkUser } from "@/types/enka";
import { Countdown } from "../rubgram/client";
import { RulesDialog } from "./rules";
export function CharacterChooser({
@@ -30,26 +53,44 @@ export function CharacterChooser({
const [uid, setUid] = useState("");
const [chars, setChars] = useState<(typeof characters.$inferSelect)[]>([]);
const [selected, setSelected] = useState<string | undefined>();
const [isError, setIsError] = useState(false);
const [isError, setIsError] = useState<boolean | string>(false);
const [isLoading, setIsLoading] = useState(false);
const [manual, setManual] = useState(false);
const [isRefreshing, setRefreshing] = useState(false);
const [guideDialogOpen, setGuideDialogOpen] = useState(false);
const [cacheTtl, setCacheTtl] = useState(new Date());
useEffect(() => {
setIsLoading(true);
setIsError(false);
async function fetchChars() {
if (!uidRegex.test(uid)) return setChars([]);
const res = await fetch(`/api/enka/${uid}`);
if (!res.ok) return setIsError(true);
const data: EnkaNetworkUser = await res.json();
if (!data.playerInfo.showAvatarInfoList) return setIsError(true);
const charIds = data.playerInfo.showAvatarInfoList.map((c) =>
c.avatarId.toString(),
);
const chars = await getCharacters(charIds);
setChars(
charIds.map((e) => chars.find((c) => c.amber === e)!).filter(Boolean),
);
try {
if (!uidRegex.test(uid)) return setChars([]);
const res = await fetch(`/api/enka/${uid}`);
const data:
| (EnkaNetworkUser & { message: undefined })
| { message: string; playerInfo: undefined } = await res
.json()
.catch(() => ({ message: "not json" }));
if (data.message === "This player does not exist.")
return setIsError("ไม่พบผู้เล่นที่มี UID นี้");
if (!res.ok) return setIsError(true);
if (!data.playerInfo) return setIsError(true);
setCacheTtl(new Date(Date.now() + (data.ttl + 5) * 1000));
if (!data.playerInfo.showAvatarInfoList)
return setIsError("UID นี้ไม่มีตัวละครที่จัดแสดงเป็นสาธารณะ");
const charIds = data.playerInfo.showAvatarInfoList.map((c) =>
c.avatarId.toString(),
);
const chars = await getCharacters(charIds);
setChars(
charIds.map((e) => chars.find((c) => c.amber === e)!).filter(Boolean),
);
setSelected(undefined);
} catch (e) {
console.error(e);
setIsError("ข้อผิดพลาดภายในระบบ");
}
}
fetchChars()
.catch(() => setIsError(true))
@@ -98,8 +139,120 @@ export function CharacterChooser({
) : (
<ScrollArea>
{isError ? (
<Placeholder>
<CircleX className="text-red-500" /> เกิดข้อผิดพลาดในการดึงข้อมูล
<Placeholder className="flex-col">
<div className="flex gap-2">
<CircleX className="text-red-500" />{" "}
{typeof isError === "string"
? isError
: "เกิดข้อผิดพลาดในการดึงข้อมูล"}
</div>
{typeof isError === "string" && isError.startsWith("UID") && (
<AlertDialog
onOpenChange={setGuideDialogOpen}
open={guideDialogOpen}
>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
<Wrench />
วิธีแก้ไข
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>
วิธีแก้ไข Character Showcase
</AlertDialogTitle>
<div className="gap-1 flex flex-col">
<center>
ในเกม เปิด<b>เมนูเกม</b>แล้วไปที่ <b>แก้ไขข้อมูลส่วนตัว</b>
</center>
<Image
src={GuideImage1}
width={500}
height={200}
alt="Guide Image 1"
className="border-2 border-foreground rounded-md"
/>
</div>
<div className="gap-1 flex flex-col">
<center>
ใส่ตัวละครที่ต้องการ แล้วเปิด "<b>แสดงรายละเอียดตัวละคร</b>"
</center>
<Image
src={GuideImage2}
width={500}
height={200}
alt="Guide Image 1"
className="border-2 border-foreground rounded-md"
/>
<span className="ml-[60%] md:ml-[65%]">
อย่าลืมเปิด <ArrowBigUp className="inline" />
</span>
</div>
<center>ออกจากเกมเพื่อให้ข้อมูลอัพเดท แล้วกดรีโหลด</center>
<AlertDialogFooter>
<AlertDialogCancel>ปิดหน้าต่าง</AlertDialogCancel>
<Countdown
time={cacheTtl}
render={(s) => (
<Button
disabled={!!s || isRefreshing}
onClick={() => {
setRefreshing(true);
async function refreshChars() {
const res = await fetch(`/api/enka/${uid}`);
const data:
| (EnkaNetworkUser & { message: undefined })
| { message: string; playerInfo: undefined } =
await res
.json()
.catch(() => ({ message: "not json" }));
if (
data.message === "This player does not exist."
)
return toast.error("ไม่พบผู้เล่นที่มี UID นี้");
if (!res.ok)
return toast.error("เกิดข้อผิดพลาดในการดึงข้อมูล");
if (!data.playerInfo)
return toast.error("เกิดข้อผิดพลาดในการดึงข้อมูล");
setCacheTtl(
new Date(Date.now() + data.ttl * 1000),
);
if (!data.playerInfo.showAvatarInfoList)
return toast.error(
"ยังไม่พบตัวละคร ลองใหม่อีกครั้ง",
);
const charIds =
data.playerInfo.showAvatarInfoList.map((c) =>
c.avatarId.toString(),
);
const chars = await getCharacters(charIds);
setChars(
charIds
.map(
(e) => chars.find((c) => c.amber === e)!,
)
.filter(Boolean),
);
setSelected(undefined);
setGuideDialogOpen(false);
}
refreshChars()
.catch((e) => {
console.error(e);
toast.error("ข้อผิดพลาดภายในระบบ");
})
.finally(() => setRefreshing(false));
}}
>
{isRefreshing ? <Spinner /> : <UserSearch />}
{s ? `รีโหลดใหม่ (${s})` : `รีโหลดใหม่`}
</Button>
)}
/>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</Placeholder>
) : isLoading ? (
<Placeholder>
@@ -115,12 +268,14 @@ export function CharacterChooser({
</div>
) : (
<div className="flex gap-2 mb-2">
<Placeholder
className="w-[76.8px] cursor-pointer rounded-sm"
onClick={() => setManual(true)}
>
<Search />
</Placeholder>
<SimpleTooltip text="ค้นหาด้วยตัวเอง">
<Placeholder
className="w-[76.8px] cursor-pointer rounded-sm"
onClick={() => setManual(true)}
>
<Search />
</Placeholder>
</SimpleTooltip>
{chars.map((c) => (
<Button key={c.id} asChild onClick={() => setSelected(c.name)}>
<Avatar
+12 -7
View File
@@ -1,7 +1,7 @@
"use client";
import { ArrowRight, Check, Download, LogIn, ScrollText } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Blocker } from "@/components/blocker";
import { Button } from "@/components/ui/button";
@@ -182,7 +182,13 @@ export function DownloadButton() {
);
}
export function Countdown({ time }: { time: Date }) {
export function Countdown({
time,
render = String,
}: {
time: Date;
render?: (time: string | null) => ReactNode;
}) {
const [now, setNow] = useState(new Date());
useEffect(() => {
@@ -192,11 +198,10 @@ export function Countdown({ time }: { time: Date }) {
const diff = Math.max(0, time.getTime() - now.getTime());
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
return (
<span>
{minutes.toString().padStart(2, "0")}:
{seconds.toString().padStart(2, "0")}
</span>
return render(
diff <= 0
? null
: `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`,
);
}
+18 -8
View File
@@ -1,3 +1,6 @@
import { redis } from "bun";
import type { EnkaNetworkUser } from "@/types/enka";
export const revalidate = 300; // 5 minutes
export async function GET(
@@ -5,13 +8,20 @@ export async function GET(
{ params }: { params: Promise<{ uid: string }> },
) {
const { uid } = await params;
return Response.json(
await fetch(`https://enka.network/api/uid/${uid}?info`, {
headers: { "User-Agent": "Buzz Event Platform" },
next: {
revalidate: 900, // refresh every 15 minutes
},
cache: "force-cache",
}).then((e) => e.json()),
const cached = await redis.get(`enka:${uid}`);
if (cached) return Response.json(JSON.parse(cached));
const res = await fetch(`https://enka.network/api/uid/${uid}?info`, {
headers: { "User-Agent": "Buzz Event Platform" },
}).then(
(e) =>
e.json() as Promise<
| (EnkaNetworkUser & { message: undefined })
| { message: string; playerInfo: undefined }
>,
);
if (!res.playerInfo) return Response.json(res);
if (res.ttl)
await redis.setex(`enka:${uid}`, res.ttl - 2, JSON.stringify(res));
return Response.json(res);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 KiB