- 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:
+177
-22
@@ -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
|
||||
|
||||
@@ -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")}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user