feat : update
CI / Verify (push) Successful in 3m8s
CI / Build immutable images and deploy (push) Successful in 3m11s

This commit is contained in:
2026-10-05 22:40:59 +07:00 Unverified
parent 7729068219
commit d6153be50d
19 changed files with 304 additions and 84 deletions
+11 -2
View File
@@ -1,8 +1,17 @@
import { SiteHeader } from "@/components/public/site-header";
import { EmailActionForm } from "@/components/auth/email-action-form";
import { connection } from "next/server";
import { safeAuthReturnPath } from "@/lib/auth/return-path";
import { isSudlohOidcEnabled } from "@/lib/auth/server";
export default function ForgotPasswordPage() {
export const instant = false;
export default async function ForgotPasswordPage({ searchParams }: PageProps<"/auth/forgot-password">) {
await connection();
const { next } = await searchParams;
const oidcEnabled = isSudlohOidcEnabled();
const oidcOnly = oidcEnabled && process.env.SUDLOH_OIDC_ONLY === "true";
return <div className="min-h-svh"><SiteHeader /><main className="grid min-h-[calc(100svh-4rem)] place-items-center p-4">
<EmailActionForm mode="forgot" />
<EmailActionForm mode="forgot" nextPath={safeAuthReturnPath(next)} oidcEnabled={oidcEnabled} oidcOnly={oidcOnly} />
</main></div>;
}
+7 -2
View File
@@ -1,13 +1,18 @@
import { connection } from "next/server";
import { SiteHeader } from "@/components/public/site-header";
import { EmailActionForm } from "@/components/auth/email-action-form";
import { safeAuthReturnPath } from "@/lib/auth/return-path";
import { isSudlohOidcEnabled } from "@/lib/auth/server";
export const instant = false;
export default async function ResetPasswordPage({ searchParams }: PageProps<"/auth/password-reset">) {
await connection();
const { token, error } = await searchParams;
const { token, error, next } = await searchParams;
const oidcEnabled = isSudlohOidcEnabled();
const oidcOnly = oidcEnabled && process.env.SUDLOH_OIDC_ONLY === "true";
return <div className="min-h-svh"><SiteHeader /><main className="grid min-h-[calc(100svh-4rem)] place-items-center p-4">
<EmailActionForm mode="reset" token={typeof token === "string" ? token : ""} invalidToken={typeof error === "string"} />
<EmailActionForm mode="reset" token={typeof token === "string" ? token : ""} invalidToken={typeof error === "string"}
nextPath={safeAuthReturnPath(next)} oidcEnabled={oidcEnabled} oidcOnly={oidcOnly} />
</main></div>;
}
+8 -5
View File
@@ -3,8 +3,11 @@ import { Suspense } from "react";
import { connection } from "next/server";
import { SiteHeader } from "@/components/public/site-header";
import { CommissionRequestForm } from "@/components/commission/request-form";
import { CommissionIntro } from "@/components/commission/commission-intro";
import { CommissionPushEntryPrompt } from "@/components/commission/push-entry-prompt";
import { Button } from "@/components/ui/button";
import { Card, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { getCustomerSession } from "@/lib/auth/server";
import { getCatalogOptions } from "@/lib/guides/queries";
import { getCommissionAvailability } from "@/lib/commission/availability";
@@ -14,21 +17,21 @@ export const instant = false;
async function CommissionContent() {
await connection();
const availability = await getCommissionAvailability();
if (!availability.isOpen) return <div className="flex flex-col gap-4 rounded-xl border p-6">
if (!availability.isOpen) return <div className="flex flex-col gap-8"><div className="flex flex-col gap-4 rounded-xl border p-6">
<h2 className="font-heading text-xl font-semibold">ปิดรับ Commission ชั่วคราว</h2>
<p className="whitespace-pre-wrap">{availability.closureMessage}</p>
<Button variant="outline" nativeButton={false} render={<Link href="/commission/tickets" />}>
ดู Ticket และคำขอที่มีอยู่
</Button>
</div>;
</div><CommissionIntro /></div>;
const session = await getCustomerSession();
if (!session) return <div className="flex flex-col gap-4 rounded-xl border p-6">
if (!session) return <div className="flex flex-col gap-8"><CommissionIntro /><div className="flex flex-col gap-4 rounded-xl border p-6">
<p>เข้าสู่ระบบหรือสมัครสมาชิกก่อนเริ่มคำขอ</p>
<div className="flex flex-wrap gap-2">
<Button nativeButton={false} render={<Link href="/auth/login?next=%2Fcommission" />}>เข้าสู่ระบบ</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/auth/register?next=%2Fcommission" />}>สมัครสมาชิก</Button>
</div>
</div>;
</div></div>;
const catalog = await getCatalogOptions();
if (!catalog) return <p>ยังไม่มีข้อมูลตัวละครสำหรับสร้างคำขอ</p>;
return <div className="flex flex-col gap-6">
@@ -43,7 +46,7 @@ export default function CommissionPage() {
<main className="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4 py-10 sm:p-8">
<header><h1 className="font-heading text-3xl font-semibold">Commission</h1>
<p className="text-muted-foreground">ขอทีม เปรียบเทียบอาวุธหรือกลุ่มดาวตามที่ต้องการ</p></header>
<Suspense fallback={null}><CommissionContent /></Suspense>
<Suspense fallback={<Card><CardHeader><Skeleton className="h-6 w-48" /><Skeleton className="h-4 w-full max-w-md" /></CardHeader></Card>}><CommissionContent /></Suspense>
</main>
</div>;
}
+3 -2
View File
@@ -3,6 +3,7 @@ import { Suspense } from "react";
import { LiveRefresh } from "@/components/live-refresh";
import { GoogleAdsense } from "@/components/public/google-adsense";
import { DirectorySkeleton } from "@/components/public/directory-skeleton";
import { SiteFooter } from "@/components/public/site-footer";
import { StructuredDirectory } from "@/components/public/structured-directory";
import { SiteHeader } from "@/components/public/site-header";
@@ -23,13 +24,13 @@ export default function HomePage() {
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 pt-4 pb-10 sm:px-6 sm:pt-6 sm:pb-14">
<header className="max-w-3xl">
<h1 className="font-heading text-4xl font-semibold">
เลือกตัวละครแล้วส่องดูได้เลย
เลือกตัวละครแล้วส่องดู
</h1>
<p className="mt-3 text-lg leading-8 text-muted-foreground">
ไกด์ตัวละคร อาวุธ อาร์ติแฟกต์ กลุ่มดาว ดาเมทแต่ละทีม และ ส่วนเสริมเล็กๆน้อยๆ
</p>
</header>
<Suspense fallback={<div className="min-h-svh" aria-hidden="true" />}>
<Suspense fallback={<DirectorySkeleton />}>
<DirectoryContent />
</Suspense>
</main>
+9 -1
View File
@@ -2,6 +2,7 @@
import Image from "next/image";
import { CircleHelpIcon } from "lucide-react";
import type { ReactNode } from "react";
import { TeamArtifactIcon } from "@/components/public/team-search";
import {
@@ -129,6 +130,7 @@ type CatalogSlotSelectProps<Item extends AdminCatalogItem> = CommonSelectProps<I
onValueChange: (value: string) => void;
label: string;
className?: string;
placeholderIcon?: ReactNode;
};
function CatalogSlotSelect<Item extends AdminCatalogItem>({
@@ -143,6 +145,7 @@ function CatalogSlotSelect<Item extends AdminCatalogItem>({
characterBackground = false,
searchNames = (item) => [item.name, ...(item.aliases ?? [])],
className,
placeholderIcon,
}: CatalogSlotSelectProps<Item>) {
const selected = items.find((item) => item.key === value) ?? null;
@@ -168,7 +171,9 @@ function CatalogSlotSelect<Item extends AdminCatalogItem>({
{selected ? (
<Image src={selected.imageUrl} alt="" fill sizes="96px" className="object-contain p-0.5" />
) : (
<CircleHelpIcon className="size-8 text-muted-foreground" aria-hidden="true" />
<span className="flex size-full items-center justify-center text-muted-foreground">
{placeholderIcon ?? <CircleHelpIcon className="size-8" aria-hidden="true" />}
</span>
)}
</ComboboxTrigger>
<ComboboxContent className="min-w-72">
@@ -195,6 +200,7 @@ type DomainSelectProps<Item extends AdminCatalogItem> = CommonSelectProps<Item>
className?: string;
showSelectedImage?: boolean;
showClear?: boolean;
placeholderIcon?: ReactNode;
};
function DomainCatalogSelect<Item extends AdminCatalogItem>({
@@ -202,6 +208,7 @@ function DomainCatalogSelect<Item extends AdminCatalogItem>({
label,
className,
showSelectedImage,
placeholderIcon,
rarityBackground = true,
...props
}: DomainSelectProps<Item>) {
@@ -212,6 +219,7 @@ function DomainCatalogSelect<Item extends AdminCatalogItem>({
rarityBackground={rarityBackground}
label={label ?? props.placeholder}
className={className}
placeholderIcon={placeholderIcon}
/>
);
}
+4 -1
View File
@@ -198,6 +198,9 @@ export function AccountForm({
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{register ? "สมัครสมาชิก" : "เข้าสู่ระบบ"}</CardTitle>
{oidcEnabled && <p className="text-sm text-muted-foreground">
{oidcOnly ? "ใช้ Sudloh Account แล้วกลับมายัง Buzz Guide หลังเข้าสู่ระบบ" : "ใช้ Sudloh Account หรือบัญชี Buzz Guide ของคุณ"}
</p>}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{oidcEnabled && <Button type="button" variant="outline" disabled={busy} onClick={() => void signInWithSudloh()}>
@@ -302,7 +305,7 @@ export function AccountForm({
สมัครสมาชิก
</Button>
<Button variant="link" size="sm" className="px-0" nativeButton={false}
render={<Link href="/auth/forgot-password" />}>
render={<Link href={`/auth/forgot-password${nextPath === "/" ? "" : `?next=${encodeURIComponent(nextPath)}`}`} />}>
ลืมรหัสผ่าน?
</Button>
</div>}
+20 -6
View File
@@ -11,8 +11,8 @@ import { authClient } from "@/lib/auth/client";
type Mode = "forgot" | "reset";
export function EmailActionForm({ mode, token = "", invalidToken = false }: {
mode: Mode; token?: string; invalidToken?: boolean;
export function EmailActionForm({ mode, token = "", invalidToken = false, nextPath = "/", oidcEnabled = false, oidcOnly = false }: {
mode: Mode; token?: string; invalidToken?: boolean; nextPath?: string; oidcEnabled?: boolean; oidcOnly?: boolean;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -27,7 +27,8 @@ export function EmailActionForm({ mode, token = "", invalidToken = false }: {
setError("");
try {
if (mode === "forgot") {
const result = await authClient.requestPasswordReset({ email: address, redirectTo: "/auth/password-reset" });
const returnQuery = nextPath === "/" ? "" : `?next=${encodeURIComponent(nextPath)}`;
const result = await authClient.requestPasswordReset({ email: address, redirectTo: `/auth/password-reset${returnQuery}` });
if (result.error) throw new Error("ส่งลิงก์รีเซ็ตไม่สำเร็จ กรุณาลองอีกครั้ง");
} else {
const password = String(form.get("password") ?? "");
@@ -47,9 +48,21 @@ export function EmailActionForm({ mode, token = "", invalidToken = false }: {
}
const title = mode === "forgot" ? "ลืมรหัสผ่าน" : "ตั้งรหัสผ่านใหม่";
const loginHref = `/auth/login${nextPath === "/" ? "" : `?next=${encodeURIComponent(nextPath)}`}`;
const canReset = Boolean(token) && !invalidToken;
return <Card className="w-full max-w-sm">
<CardHeader><CardTitle>{title}</CardTitle></CardHeader>
<CardContent className="flex flex-col gap-4">
{oidcOnly ? <>
<p className="text-sm text-muted-foreground">Buzz Guide ใช้ Sudloh Account สำหรับเข้าสู่ระบบ กรุณาตั้งรหัสผ่านใหม่ที่ Sudloh Account</p>
<Button nativeButton={false} render={<a href="https://account.sudloh.com/reset-password" target="_blank" rel="noreferrer" />}>
ไปหน้าตั้งรหัสผ่าน Sudloh
</Button>
</> : null}
{oidcEnabled && !oidcOnly && mode === "forgot" ? <p className="text-sm text-muted-foreground">
หากเข้าสู่ระบบด้วย Sudloh Account ให้ <a href="https://account.sudloh.com/reset-password" target="_blank" rel="noreferrer" className="underline underline-offset-4">ตั้งรหัสผ่านที่ Sudloh</a> ส่วนแบบฟอร์มด้านล่างสำหรับบัญชี Buzz Guide ที่ใช้อีเมลและรหัสผ่าน
</p> : null}
{!oidcOnly && <>
{sent && <Alert><AlertDescription>{mode === "reset"
? "ตั้งรหัสผ่านใหม่แล้ว กรุณาเข้าสู่ระบบ"
: "หากอีเมลนี้มีบัญชีอยู่ โปรดตรวจสอบกล่องจดหมายและโฟลเดอร์สแปม"}</AlertDescription></Alert>}
@@ -57,7 +70,7 @@ export function EmailActionForm({ mode, token = "", invalidToken = false }: {
ลิงก์รีเซ็ตไม่ถูกต้องหรือหมดอายุ กรุณาขอลิงก์ใหม่
</AlertDescription></Alert>}
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
{(mode !== "reset" || (!sent && token)) && <form onSubmit={submit}>
{(mode !== "reset" || (!sent && canReset)) && <form onSubmit={submit}>
<FieldGroup>
{mode !== "reset" ? <Field>
<FieldLabel htmlFor="auth-email">อีเมล</FieldLabel>
@@ -74,10 +87,11 @@ export function EmailActionForm({ mode, token = "", invalidToken = false }: {
mode === "forgot" ? "ส่งลิงก์รีเซ็ต" : "ตั้งรหัสผ่าน"}</Button>
</FieldGroup>
</form>}
{mode === "reset" && !token && <Button variant="link" nativeButton={false} render={<Link href="/auth/forgot-password" />}>
{mode === "reset" && !canReset && !sent && <Button variant="link" nativeButton={false} render={<Link href={`/auth/forgot-password${nextPath === "/" ? "" : `?next=${encodeURIComponent(nextPath)}`}`} />}>
ขอลิงก์รีเซ็ตใหม่
</Button>}
<Button variant="link" nativeButton={false} render={<Link href="/auth/login" />}>กลับไปเข้าสู่ระบบ</Button>
</>}
<Button variant="link" nativeButton={false} render={<Link href={loginHref} />}>กลับไปเข้าสู่ระบบ</Button>
</CardContent>
</Card>;
}
@@ -27,6 +27,7 @@ export function SudlohSignInRedirect({ nextPath }: { nextPath: string }) {
return <div className="grid min-h-[calc(100svh-4rem)] place-items-center p-4">
<div className="flex flex-col items-center gap-4 text-center">
<p>{failed ? "ไม่สามารถเริ่มเข้าสู่ระบบได้" : "กำลังไปที่ Sudloh Account…"}</p>
<p className="max-w-sm text-sm text-muted-foreground">เข้าสู่ระบบหรือสมัครสมาชิกที่ Sudloh Account แล้วระบบจะพาคุณกลับมายัง Buzz Guide</p>
{failed && <Button onClick={() => void start().catch(() => setFailed(true))}>ลองอีกครั้ง</Button>}
</div>
</div>;
@@ -0,0 +1,76 @@
import Image from "next/image";
import { UserRoundIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
const steps = [
{ title: "1. เลือกรายการ", description: "เลือกทีม อาวุธ หรือกลุ่มดาวที่ต้องการคำนวณ" },
{ title: "2. ชำระเงิน", description: "ตรวจสอบยอด สแกน PromptPay และส่งสลิป" },
{ title: "3. รับผลใน Ticket", description: "ติดตามคำขอและพูดคุยเรื่องผลลัพธ์ใน Ticket ของคุณ" },
];
const sampleMembers = [
{ name: "ตัวละคร A", damage: "40,000", share: "40.00%" },
{ name: "ตัวละคร B", damage: "30,000", share: "30.00%" },
{ name: "ตัวละคร C", damage: "20,000", share: "20.00%" },
{ name: "ตัวละคร D", damage: "10,000", share: "10.00%" },
];
function CommissionTeamPreview() {
return <Card>
<CardHeader>
<CardTitle>ตัวอย่างทีม 4 ตัวละคร</CardTitle>
<CardDescription>โรเทชั่น 20 วิ · ข้อมูลสมมติสำหรับดูรูปแบบผลลัพธ์</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-4 gap-1 sm:gap-3">
{sampleMembers.map((member) => <div key={member.name} className="min-w-0 rounded-lg bg-muted/50 p-1 text-center sm:p-2">
<div className="flex flex-col gap-2 sm:grid sm:grid-cols-3">
<div className="flex aspect-square items-center justify-center rounded-lg bg-accent sm:col-span-2 sm:row-span-2"><UserRoundIcon className="size-8 text-muted-foreground" aria-hidden="true" /></div>
<div className="grid grid-cols-2 gap-1 sm:col-span-1 sm:row-span-2 sm:grid-cols-1">
<div className="flex aspect-square items-center justify-center rounded-md border bg-card"><Image src="/icon/nav/Weapon.png" alt="" width={24} height={24} className="size-5 object-contain opacity-60 sm:size-6" /></div>
<div className="flex aspect-square items-center justify-center rounded-md border bg-card"><Image src="/icon/nav/Artifact.webp" alt="" width={24} height={24} className="size-5 object-contain opacity-60 sm:size-6" /></div>
</div>
</div>
<div className="mt-2 flex items-center justify-center gap-1"><Badge variant="secondary">C0</Badge><Badge variant="secondary">R1</Badge></div>
<p className="mt-1 truncate text-xs font-medium sm:text-sm">{member.name}</p>
<p className="text-xs font-semibold sm:text-sm">{member.damage}</p>
<p className="text-xs text-muted-foreground">{member.share}</p>
</div>)}
</div>
<Separator />
<div className="flex items-end justify-between">
<div><p className="text-sm text-muted-foreground">ดาเมจรวม</p><p className="font-semibold">100,000</p></div>
<div className="text-right"><p className="text-sm text-muted-foreground">DPS</p><p className="text-3xl font-semibold text-primary">5,000</p></div>
</div>
<p className="rounded-lg bg-muted p-3 text-sm leading-6">ตัวอย่างลำดับการใช้สกิลและคำอธิบายทีม</p>
</CardContent>
</Card>;
}
export function CommissionIntro() {
return <section className="flex flex-col gap-6" aria-labelledby="commission-how-it-works">
<div>
<h2 id="commission-how-it-works" className="font-heading text-2xl font-semibold">ขอคำนวณแบบที่ต้องการ</h2>
<p className="mt-2 text-muted-foreground">เลือกได้ทั้งจัดทีม เปรียบเทียบอาวุธ และเปรียบเทียบกลุ่มดาว โดยเห็นยอดก่อนชำระเงิน</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{steps.map((step) => <Card key={step.title} size="sm">
<CardHeader><CardTitle>{step.title}</CardTitle><CardDescription>{step.description}</CardDescription></CardHeader>
</Card>)}
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader><CardTitle>ราคาเริ่มต้น</CardTitle><CardDescription>ระบบคำนวณยอดรวมจากรายการที่เลือก</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<p className="flex justify-between gap-3"><span>จัดทีม 4 ตัวละคร</span><strong>฿100 ต่อทีม</strong></p>
<p className="flex justify-between gap-3"><span>เปรียบเทียบอาวุธ</span><strong>฿20 ต่ออาวุธ</strong></p>
<p className="flex justify-between gap-3"><span>เปรียบเทียบกลุ่มดาว</span><strong>฿20 ต่อระดับ</strong></p>
<p className="border-t pt-3 text-muted-foreground">หากไม่เลือกทีม จะมีค่าบริการพื้นฐาน ฿100</p>
</CardContent>
</Card>
<CommissionTeamPreview />
</div>
</section>;
}
+7 -1
View File
@@ -2,7 +2,8 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { XIcon } from "lucide-react";
import Image from "next/image";
import { UserRoundIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { ArtifactSelect, CharacterSelect, WeaponSelect } from "@/components/admin/ui/catalog-select";
import { ConstellationSelect, RefinementSelect } from "@/components/admin/ui/option-select";
@@ -95,18 +96,21 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
<div className="grid grid-cols-[2fr_1fr] gap-1">
<CharacterSelect presentation="slot" items={catalog.characters} value={member.characterKey}
label={`เลือกตัวละครตำแหน่ง ${memberIndex + 1}`} placeholder="ค้นหาชื่อตัวละคร…"
placeholderIcon={<UserRoundIcon className="size-10" aria-hidden="true" />}
className="border-0 [&>img]:object-cover [&>img]:p-0" onValueChange={(characterKey) => {
const teams = structuredClone(request.teams); teams[teamIndex].members[memberIndex] = { ...blankMember(), characterKey }; patch({ teams });
}} />
<div className="grid min-w-0 grid-rows-2 gap-1">
<WeaponSelect presentation="slot" items={compatibleWeapons(member.characterKey)} value={member.weaponKey}
label={`เลือกอาวุธของตัวละครตำแหน่ง ${memberIndex + 1}`} placeholder="ค้นหาอาวุธที่รองรับ…"
placeholderIcon={<Image src="/icon/nav/Weapon.png" alt="" width={32} height={32} className="size-8 object-contain opacity-60" />}
emptyLabel={character ? "ไม่พบอาวุธ" : "เลือกตัวละครก่อน"} onValueChange={(weaponKey) => {
const teams = structuredClone(request.teams); teams[teamIndex].members[memberIndex].weaponKey = weaponKey;
teams[teamIndex].members[memberIndex].refinement = "1"; patch({ teams });
}} />
<ArtifactSelect presentation="slot" items={catalog.artifacts} value={member.artifactKey}
label={`เลือกอาร์ติแฟกต์ของตัวละครตำแหน่ง ${memberIndex + 1}`} placeholder="ค้นหาชุดอาร์ติแฟกต์…"
placeholderIcon={<Image src="/icon/nav/Artifact.webp" alt="" width={32} height={32} className="size-8 object-contain opacity-60" />}
onValueChange={(artifactKey) => {
const teams = structuredClone(request.teams); teams[teamIndex].members[memberIndex].artifactKey = artifactKey; patch({ teams });
}} />
@@ -177,6 +181,7 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
<div className="relative w-20 sm:w-24">
<CharacterSelect presentation="slot" items={catalog.characters} value={set.characterKey}
label={`เลือกตัวละครเปรียบเทียบอาวุธชุดที่ ${index + 1}`} placeholder="ค้นหาชื่อตัวละคร…"
placeholderIcon={<UserRoundIcon className="size-9" aria-hidden="true" />}
className="border-0 [&>img]:object-cover [&>img]:p-0" onValueChange={(characterKey) => {
const weapons = structuredClone(request.weapons); weapons[index] = { characterKey, weaponKeys: [""], refinements: {} }; patch({ weapons });
}} />
@@ -209,6 +214,7 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
<div className="w-20 sm:w-24">
<CharacterSelect presentation="slot" items={catalog.characters} value={set.characterKey}
label={`เลือกตัวละครเปรียบเทียบกลุ่มดาวชุดที่ ${index + 1}`} placeholder="ค้นหาชื่อตัวละคร…"
placeholderIcon={<UserRoundIcon className="size-9" aria-hidden="true" />}
className="border-0 [&>img]:object-cover [&>img]:p-0" onValueChange={(characterKey) => {
const constellations = structuredClone(request.constellations); constellations[index] = { characterKey, levels: [] }; patch({ constellations });
}} />
+1 -1
View File
@@ -30,7 +30,7 @@ export function ConditionSwitcher({
if (items.length <= 1) return items[0] ?? null;
return (
<Tabs className="min-w-0 gap-4" value={active} onValueChange={setActive}>
<Tabs className="w-full min-w-0 gap-4" value={active} onValueChange={setActive}>
<ScrollableNav ariaLabel={label} className="w-full min-w-0">
<TabsList aria-label={label} className="mx-auto shrink-0">
{items.map((_, index) => (
+13 -11
View File
@@ -1,22 +1,24 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Card, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export function DirectorySkeleton() {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }, (_, index) => (
<Card key={index} className="gap-0 py-0">
<Skeleton className="aspect-4/3 rounded-b-none" />
<CardHeader className="pt-4">
<div className="flex flex-col gap-6" aria-hidden="true">
<div className="flex flex-col gap-2">
<Skeleton className="h-9 w-full max-w-80" />
<Skeleton className="h-9 w-full sm:max-w-lg" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }, (_, index) => (
<Card key={index} className="h-24 flex-row gap-0 py-0 sm:h-auto sm:flex-col">
<Skeleton className="size-24 shrink-0 rounded-none sm:aspect-square sm:size-auto" />
<CardHeader className="min-w-0 flex-1 justify-center p-3 sm:justify-start sm:pt-5">
<Skeleton className="h-5 w-2/3" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-4/5" />
</CardHeader>
<CardContent className="flex gap-2 pb-4">
<Skeleton className="h-5 w-14" />
<Skeleton className="h-5 w-20" />
</CardContent>
</Card>
))}
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { BookOpenIcon, MenuIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
const destinations = [
{ href: "/", label: "หน้ารวม", icon: null },
{ href: "/commission", label: "Commission", icon: "/icon/commission.webp" },
{ href: "/stygian", label: "Stygian Guide", icon: "/icon/stygian.webp" },
] as const;
export function PublicNavigation() {
const pathname = usePathname();
const isCurrent = (href: string) => href === "/" ? pathname === "/" : pathname.startsWith(href);
return <>
<nav aria-label="เมนูหลัก" className="hidden items-center gap-1 lg:flex">
{destinations.slice(1).map(({ href, label, icon }) =>
<Button key={href} variant="ghost" size="sm" nativeButton={false}
render={<Link href={href} aria-current={isCurrent(href) ? "page" : undefined} />}>
{icon ? <Image src={icon} alt="" width={24} height={24} className="size-6 object-contain" /> : null}{label}
</Button>)}
</nav>
<div className="lg:hidden">
<Drawer>
<DrawerTrigger render={<Button type="button" variant="outline" size="sm" aria-label="เปิดเมนูหลัก" />}>
<MenuIcon data-icon="inline-start" aria-hidden="true" />เมนู
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>เมนูหลัก</DrawerTitle>
<DrawerDescription>เลือกหน้าที่ต้องการดู</DrawerDescription>
</DrawerHeader>
<nav aria-label="เมนูหลักบนมือถือ" className="grid gap-1 p-4">
{destinations.map(({ href, label, icon }) =>
<DrawerClose key={href} render={<Link href={href} aria-current={isCurrent(href) ? "page" : undefined}
className="flex min-h-11 items-center gap-3 rounded-lg px-3 text-sm hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />}>
{icon ? <Image src={icon} alt="" width={20} height={20} className="size-5 object-contain" /> : <BookOpenIcon aria-hidden="true" />}{label}
{isCurrent(href) ? <span className="ml-auto text-xs text-primary">ปัจจุบัน</span> : null}
</DrawerClose>)}
</nav>
</DrawerContent>
</Drawer>
</div>
</>;
}
+5 -19
View File
@@ -5,6 +5,7 @@ import { PencilIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { VisitorPresence } from "@/components/public/visitor-presence";
import { PublicNavigation } from "@/components/public/public-navigation";
import { AccountMenu } from "@/components/auth/account-menu";
import { getAdminSession } from "@/lib/auth/server";
@@ -31,25 +32,10 @@ export function SiteHeader({ editHref = "/admin", guideId }: { editHref?: string
<Image src="/icon.png" alt="" width={36} height={36} className="size-9 object-contain" />
<span className="hidden lg:inline">ไกด์เกนชินไม่ใช่เกมมือถือ</span>
</Link>
<nav aria-label="Public navigation" className="flex min-w-0 items-center gap-1 overflow-x-auto lg:overflow-visible">
<Button variant="ghost" size="sm" nativeButton={false} render={<Link href="/commission" />} aria-label="Commission">
<Image src="/icon/commission.webp" alt="" width={24} height={24} className="size-6 object-contain" />
<span className="hidden lg:inline">Commission</span>
</Button>
<Button
variant="ghost"
size="sm"
nativeButton={false}
render={<Link href="/stygian" />}
aria-label="Stygian Guide"
>
<Image src="/icon/stygian.webp" alt="" width={24} height={24} className="size-6 object-contain" />
<span className="hidden lg:inline">Stygian Guide</span>
</Button>
<Suspense fallback={null}>
<AdminEditLink editHref={editHref} />
</Suspense>
</nav>
<PublicNavigation />
<Suspense fallback={null}>
<AdminEditLink editHref={editHref} />
</Suspense>
<div className="hidden lg:block">
<VisitorPresence guideId={guideId} />
</div>
+70 -23
View File
@@ -5,7 +5,7 @@ import { searchItems } from "@/lib/search";
import Image from "next/image";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { SearchIcon, StarIcon, UsersIcon } from "lucide-react";
import { SearchIcon, SlidersHorizontalIcon, StarIcon, UsersIcon, XIcon } from "lucide-react";
import { useMemo, type ReactNode } from "react";
import { ElementIcon } from "@/components/public/element-icon";
@@ -18,6 +18,8 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer";
import {
Empty,
EmptyDescription,
@@ -112,6 +114,11 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
return sort === "name-asc" ? order : -order;
})
: search.items;
const activeFilters = [
...selectedElements.map((value) => ({ name: "element", value, label: elementLabels[value] ?? value })),
...selectedWeapons.map((value) => ({ name: "weapon", value, label: weaponTypeLabels[value] ?? value })),
...selectedRarities.map((value) => ({ name: "rarity", value, label: `${value} ดาว` })),
];
function setParam(name: string, value: string) {
const next = new URLSearchParams(searchParams.toString());
@@ -120,11 +127,29 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
const suffix = next.toString();
window.history.replaceState(null, "", suffix ? `?${suffix}` : window.location.pathname);
}
function clearParams(includeSearch = false) {
const next = new URLSearchParams(searchParams.toString());
for (const name of ["element", "weapon", "rarity", ...(includeSearch ? ["q"] : [])]) next.delete(name);
const suffix = next.toString();
window.history.replaceState(null, "", suffix ? `?${suffix}` : window.location.pathname);
}
function removeFilter(name: string, value: string) {
const values = name === "element" ? selectedElements : name === "weapon" ? selectedWeapons : selectedRarities;
setParam(name, values.filter((item) => item !== value).join(","));
}
const filterControls = (mobile: boolean) => <>
<GuideToggleFilter label="ธาตุ" value={selectedElements} options={elements} optionLabels={elementLabels} showLabels={mobile} optionIcon={(option) => elementIconUrls[option] ? <ElementIcon element={option} url={elementIconUrls[option]} /> : null} onChange={(values) => setParam("element", values.join(","))} />
<GuideToggleFilter label="อาวุธ" value={selectedWeapons} options={weapons} optionLabels={weaponTypeLabels} showLabels={mobile} optionIcon={(option) => {
const src = weaponTypeIconPath(option);
return src ? <Image src={src} alt="" width={16} height={16} className="size-4 object-contain" /> : null;
}} onChange={(values) => setParam("weapon", values.join(","))} />
<GuideToggleFilter label="ดาว" value={selectedRarities} options={rarities.map(String)} showLabels={mobile} optionIcon={() => <StarIcon className="size-4 fill-current text-yellow-500" />} optionText={(option) => option} onChange={(values) => setParam("rarity", values.join(","))} />
</>;
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<InputGroup className="h-9 w-48 shrink-0">
<InputGroup className="h-9 min-w-0 flex-1 sm:w-48 sm:flex-none">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
@@ -136,7 +161,7 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
/>
</InputGroup>
<Select value={sort} onValueChange={(next) => next && setParam("sort", next)}>
<SelectTrigger aria-label="เรียงตาม"><SelectValue>{sortOptions.find((option) => option.value === sort)?.label}</SelectValue></SelectTrigger>
<SelectTrigger aria-label="เรียงตาม" className="w-32 sm:w-auto"><SelectValue>{sortOptions.find((option) => option.value === sort)?.label}</SelectValue></SelectTrigger>
<SelectContent>
<SelectGroup>
{sortOptions.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
@@ -144,13 +169,29 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
</SelectContent>
</Select>
</div>
<div className="flex min-w-0 items-center gap-2 overflow-x-auto pb-1" aria-label="ตัวกรองไกด์">
<GuideToggleFilter label="ธาตุ" value={selectedElements} options={elements} optionLabels={elementLabels} optionIcon={(option) => elementIconUrls[option] ? <ElementIcon element={option} url={elementIconUrls[option]} /> : null} onChange={(values) => setParam("element", values.join(","))} />
<GuideToggleFilter label="อาวุธ" value={selectedWeapons} options={weapons} optionLabels={weaponTypeLabels} optionIcon={(option) => {
const src = weaponTypeIconPath(option);
return src ? <Image src={src} alt="" width={16} height={16} className="size-4 object-contain" /> : null;
}} onChange={(values) => setParam("weapon", values.join(","))} />
<GuideToggleFilter label="ดาว" value={selectedRarities} options={rarities.map(String)} optionIcon={() => <StarIcon className="size-4 fill-current text-yellow-500" />} optionText={(option) => option} onChange={(values) => setParam("rarity", values.join(","))} />
<div className="sm:hidden">
<Drawer>
<DrawerTrigger render={<Button variant="outline" className="w-full" />}>
<SlidersHorizontalIcon data-icon="inline-start" />ตัวกรอง{activeFilters.length ? ` (${activeFilters.length})` : ""}
</DrawerTrigger>
<DrawerContent>
<DrawerHeader><DrawerTitle>ตัวกรองไกด์</DrawerTitle><DrawerDescription>เลือกธาตุ อาวุธ หรือจำนวนดาว</DrawerDescription></DrawerHeader>
<div className="flex max-h-[60svh] flex-col gap-5 overflow-y-auto px-4 pb-4">{filterControls(true)}</div>
<div className="flex gap-2 border-t p-4">
<Button variant="outline" disabled={!activeFilters.length} onClick={() => clearParams()}>ล้างตัวกรอง</Button>
<DrawerClose render={<Button className="flex-1" />}>ดู {filtered.length} ไกด์</DrawerClose>
</div>
</DrawerContent>
</Drawer>
</div>
<div className="hidden min-w-0 items-center gap-2 overflow-x-auto pb-1 sm:flex" aria-label="ตัวกรองไกด์">{filterControls(false)}</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground" aria-live="polite">
<span>พบ {filtered.length} ไกด์</span>
{activeFilters.map((filter) => <Button key={`${filter.name}-${filter.value}`} variant="secondary" size="xs"
onClick={() => removeFilter(filter.name, filter.value)} aria-label={`ลบตัวกรอง ${filter.label}`}>
{filter.label}<XIcon data-icon="inline-end" />
</Button>)}
{activeFilters.length ? <Button variant="link" size="sm" onClick={() => clearParams()}>ล้างตัวกรอง</Button> : null}
</div>
</div>
@@ -163,30 +204,32 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
prefetch={false}
className="group rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Card className="h-full overflow-hidden py-0 transition-transform group-hover:-translate-y-0.5">
<div className="relative aspect-square bg-muted">
<Card className="h-full flex-row gap-0 overflow-hidden py-0 transition-transform group-hover:-translate-y-0.5 sm:flex-col sm:gap-4">
<div className="relative size-24 shrink-0 bg-muted sm:aspect-square sm:size-auto">
<Image
src={guide.coverUrl}
alt={`ไกด์ ${guide.name}`}
fill
fetchPriority={index === 0 ? "high" : undefined}
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 96px"
className="object-cover"
/>
</div>
<CardHeader className="pt-5">
<CardTitle>{guide.name}</CardTitle>
<CardHeader className="min-w-0 flex-1 p-3 sm:p-4 sm:pt-5">
<div className="min-w-0 space-y-1">
<CardTitle className="line-clamp-1">{guide.name}</CardTitle>
<CardDescription className="line-clamp-2 sm:line-clamp-3">
{guide.overview}
</CardDescription>
</div>
<CardAction
className={(presence?.byGuide[guide.id] ?? 0) > 0 ? undefined : "invisible"}
className={(presence?.byGuide[guide.id] ?? 0) > 0 ? "hidden sm:block" : "hidden sm:block sm:invisible"}
>
<Badge variant="secondary" className="min-w-14 tabular-nums">
<UsersIcon data-icon="inline-start" />
{presence?.byGuide[guide.id] ?? 0} คน
</Badge>
</CardAction>
<CardDescription className="line-clamp-3">
{guide.overview}
</CardDescription>
</CardHeader>
</Card>
</Link>
@@ -196,8 +239,9 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
<Empty className="border">
<EmptyHeader>
<EmptyTitle>ไม่พบไกด์</EmptyTitle>
<EmptyDescription>ลองค้นหาด้วยชื่อตัวละครอื่น</EmptyDescription>
<EmptyDescription>ลองค้นหาด้วยชื่อตัวละครอื่น หรือล้างตัวกรอง</EmptyDescription>
</EmptyHeader>
<Button variant="outline" onClick={() => clearParams(true)}>ล้างการค้นหาและตัวกรอง</Button>
</Empty>
)}
</div>
@@ -211,6 +255,7 @@ function GuideToggleFilter({
optionLabels = {},
optionIcon,
optionText,
showLabels = false,
onChange,
}: {
label: string;
@@ -219,14 +264,16 @@ function GuideToggleFilter({
optionLabels?: Record<string, string>;
optionIcon?: (value: string) => ReactNode;
optionText?: (value: string) => string;
showLabels?: boolean;
onChange: (value: string[]) => void;
}) {
return (
<div className="shrink-0 border-l pl-2">
<ToggleGroup aria-label={label} variant="outline" size="sm" value={value} onValueChange={onChange}>
<div className={showLabels ? "flex flex-col gap-2" : "shrink-0 border-l pl-2"}>
{showLabels ? <p className="text-sm font-medium">{label}</p> : null}
<ToggleGroup aria-label={label} variant="outline" size="sm" multiple value={value} onValueChange={onChange} className={showLabels ? "flex flex-wrap justify-start" : undefined}>
{options.map((option) => (
<ToggleGroupItem key={option} value={option} aria-label={`${label}: ${optionLabels[option] ?? option}`} title={`${label}: ${optionLabels[option] ?? option}`} className="aria-pressed:border-primary aria-pressed:bg-transparent aria-pressed:text-primary">
{optionIcon?.(option)}{optionText?.(option)}
{optionIcon?.(option)}{showLabels ? optionLabels[option] ?? option : optionText?.(option)}
</ToggleGroupItem>
))}
</ToggleGroup>
+5 -5
View File
@@ -713,7 +713,7 @@ function ArtifactSectionContent({
return (
<section
className={cn(
"artifact-export-root flex flex-col gap-6",
"artifact-export-root flex min-w-0 flex-col gap-6",
exporting &&
"relative isolate overflow-hidden bg-[#070b20] p-10 text-white",
)}
@@ -736,12 +736,12 @@ function ArtifactSectionContent({
height={56}
loading={exporting ? "eager" : "lazy"}
className={cn(
"size-14 shrink-0 object-contain",
exporting ? "size-14 shrink-0 object-contain" : "size-10 shrink-0 object-contain sm:size-14",
exporting &&
"drop-shadow-[0_4px_3px_rgba(0,0,0,0.8)]",
)}
/>
<h1 className={cn("whitespace-nowrap text-4xl font-semibold", !exporting && "font-heading", exporting && "drop-shadow-[0_3px_2px_rgba(0,0,0,0.9)]")}>
<h1 className={cn("whitespace-nowrap font-semibold", exporting ? "text-4xl" : "font-heading text-xl sm:text-4xl", exporting && "drop-shadow-[0_3px_2px_rgba(0,0,0,0.9)]")}>
Artifacts & Stats
</h1>
</div>
@@ -750,12 +750,12 @@ function ArtifactSectionContent({
</header>
<div
className={cn(
"artifact-export-grid relative z-10 grid gap-5",
"artifact-export-grid relative z-10 grid min-w-0 gap-5",
exporting ? "grid-cols-[1.2fr_1fr]" : "lg:grid-cols-[1.2fr_1fr]",
)}
>
{" "}
<div className="artifact-export-left flex flex-col gap-5">
<div className="artifact-export-left flex min-w-0 flex-col gap-5">
{exporting ? recommendedCards : (
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะแนะนำ" labels={recommendedConditions.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`)} iconUrls={recommendedConditions.map((condition) => condition.iconUrl)}>
{recommendedCards}
+2 -2
View File
@@ -109,12 +109,12 @@ export function StygianGuide({ schedule, catalog }: { schedule: Schedule; catalo
{([105, 110] as const).map((level) => (
<TabsContent key={level} value={String(level)}>
<Tabs defaultValue="0">
<TabsList variant="line" className="grid w-full grid-cols-3 overflow-hidden group-data-horizontal/tabs:h-auto group-data-horizontal/tabs:min-h-96" aria-label={`Level ${level} bosses`}>
<TabsList variant="line" className="grid w-full grid-cols-3 overflow-hidden group-data-horizontal/tabs:h-auto" aria-label={`Level ${level} bosses`}>
{[0, 1, 2].map((position) => {
const boss = schedule.bosses.find((candidate) => candidate.level === level && candidate.position === position);
const label = boss?.name ?? `Boss ${position + 1}`;
const positionLabel = ["บน", "กลาง", "ล่าง"][position];
return <TabsTrigger key={position} value={String(position)} aria-label={`${positionLabel}: ${label}`} title={label} className="h-full min-w-0 flex-col justify-start overflow-hidden rounded-none px-2 py-3 data-active:bg-accent/50 data-active:ring-2 data-active:ring-primary data-active:ring-inset">
return <TabsTrigger key={position} value={String(position)} aria-label={`${positionLabel}: ${label}`} title={label} className="h-full min-w-0 flex-col justify-start overflow-hidden rounded-none px-1 py-2 data-active:bg-accent/50 data-active:ring-2 data-active:ring-primary data-active:ring-inset sm:px-2 sm:py-3">
<StygianBossTab boss={boss} position={position} priority={level === 110} />
</TabsTrigger>;
})}
+2 -2
View File
@@ -81,7 +81,7 @@ export function StygianBossTab({
width={256}
height={256}
sizes="256px"
className="size-64 shrink-0 object-contain"
className="size-20 shrink-0 object-contain sm:size-32 lg:size-48"
priority={priority}
/>
) : (
@@ -131,6 +131,7 @@ export function StygianBossCard({
</p>
</header>
<div className="flex min-w-0 flex-col gap-5">
{children}
<Accordion>
<AccordionItem value="overview">
<AccordionTrigger>Boss overview</AccordionTrigger>
@@ -156,7 +157,6 @@ export function StygianBossCard({
</AccordionItem>
))}
</Accordion>
{children}
</div>
</CardContent>
</Card>
+1 -1
View File
@@ -26,7 +26,7 @@ describe("public rendering performance", () => {
expect(page).not.toContain("export const instant = false");
expect(page).toContain("async function DirectoryContent()");
expect(page).toContain("await connection()");
expect(page).toContain('fallback={<div className="min-h-svh"');
expect(page).toContain("fallback={<DirectorySkeleton />}");
});
it("isolates request-time header authentication", async () => {