feat : update
CI / Verify (push) Successful in 57s
CI / Build immutable images and deploy (push) Successful in 1m39s

This commit is contained in:
2026-08-30 10:26:42 +00:00 Unverified
parent 62af59ccdd
commit 0a643dd60e
15 changed files with 671 additions and 47 deletions
+13 -3
View File
@@ -7,13 +7,23 @@ import { getPublicGuide } from "@/lib/guides/queries";
type Params = Promise<{ character: string }>;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
export async function generateMetadata({
params,
}: {
params: Params;
}): Promise<Metadata> {
await connection();
const guide = await getPublicGuide((await params).character);
return guide ? { title: `${guide.name} Build Guide`, description: guide.overview } : { title: "ไม่พบ Guide" };
return guide
? { title: `${guide.name} Build Guide`, description: guide.overview }
: { title: "ไม่พบ Guide" };
}
export default async function GuideOverviewPage({ params }: { params: Params }) {
export default async function GuideOverviewPage({
params,
}: {
params: Params;
}) {
await connection();
const guide = await getPublicGuide((await params).character);
if (!guide) notFound();
+2 -2
View File
@@ -23,7 +23,7 @@ export const metadata: Metadata = {
default: "Buzz Guide",
template: "%s",
},
description: "คู่มือตัวละคร บิลด์ ทีม และโรเทชันที่อ่านง่ายในทุกหน้าจอ",
description: "ไกด์ตัวละครจากช่องเกนชินไม่ใช่เกมมือถือ",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
@@ -39,7 +39,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
<TooltipProvider>
{children}
<DeploymentUpdateNotifier deploymentId={deploymentId} />
<Toaster position="bottom-right" />
<Toaster richColors closeButton position="bottom-right" />
</TooltipProvider>
</body>
</html>
+20 -1
View File
@@ -8,5 +8,24 @@ import { listGuideCards } from "@/lib/guides/queries";
export default async function HomePage() {
await connection();
const guides = await listGuideCards();
return <div className="min-h-svh bg-background"><LiveRefresh endpoint="/api/events/directory" /><SiteHeader /><main className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 py-10 sm:px-6 sm:py-14"><header className="max-w-3xl"><p className="text-sm font-medium text-primary">Genshin Impact Build Guides</p><h1 className="mt-2 font-heading text-4xl font-semibold">เลือกตัวละคร แล้วจัด Build และ Team ได้เลย</h1><p className="mt-3 text-lg leading-8 text-muted-foreground">คู่มือแบบภาพ-first สำหรับ Weapons, Artifacts, Constellations และ Team Calc</p></header><StructuredDirectory guides={guides} /></main></div>;
return (
<div className="min-h-svh bg-background">
<LiveRefresh endpoint="/api/events/directory" />
<SiteHeader />
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 py-10 sm:px-6 sm:py-14">
<header className="max-w-3xl">
<p className="text-sm font-medium text-primary">
Genshin Impact Build Guides
</p>
<h1 className="mt-2 font-heading text-4xl font-semibold">
เลือกตัวละครแล้วส่องดูได้เลย
</h1>
<p className="mt-3 text-lg leading-8 text-muted-foreground">
ไกด์ตัวละคร Weapons, Artifacts, Constellations และ Team Calc
</p>
</header>
<StructuredDirectory guides={guides} />
</main>
</div>
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ import { getAdminSession } from "@/lib/auth/server";
export default async function RegisterPage() {
await connection();
if (await getAdminSession()) redirect("/admin");
if (await !getAdminSession()) redirect("/");
return ( null
// <div className="min-h-svh">
// <SiteHeader />
+6 -6
View File
@@ -115,12 +115,12 @@ export function LoginCard() {
</form>
</CardContent>
<CardFooter className="justify-center">
<Link
href="/register"
className={buttonVariants({ variant: "link", size: "sm" })}
>
สร้างบัญชีผู้ดูแลชั่วคราว
</Link>
<div>
ยังไม่มีบัญชี? {" "}
<Link href="https://file.gunshiz.top/api/raw/flRCGwLA" className={buttonVariants({ variant: "link", size: "sm" })}>
สร้างบัญชีเลย
</Link>
</div>
</CardFooter>
</Card>
);
-7
View File
@@ -1,8 +1,5 @@
import Image from "next/image";
import Link from "next/link";
import { SettingsIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
export function SiteHeader() {
return (
@@ -15,10 +12,6 @@ export function SiteHeader() {
<Image src="/icon.png" alt="" width={36} height={36} className="size-9 object-contain" />
<span>ไกด์ช่องเกนชินไม่ใช่เกมมือถือ</span>
</Link>
<Button variant="ghost" size="sm" nativeButton={false} render={<Link href="/admin" />}>
<SettingsIcon aria-hidden="true" />
จัดการเนื้อหา
</Button>
</div>
</header>
);
+79 -6
View File
@@ -6,15 +6,88 @@ import { SearchIcon } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "@/components/ui/empty";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
type GuideCard = { id: string; slug: string; name: string; overview: string; coverUrl: string; version: number };
type GuideCard = {
id: string;
slug: string;
name: string;
overview: string;
coverUrl: string;
version: number;
};
export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
const [query, setQuery] = useState("");
const normalized = query.trim().toLowerCase();
const filtered = guides.filter((guide) => `${guide.name} ${guide.overview}`.toLowerCase().includes(normalized));
return <div className="flex flex-col gap-8"><InputGroup className="h-11 max-w-2xl"><InputGroupAddon><SearchIcon /></InputGroupAddon><InputGroupInput value={query} onChange={(event) => setQuery(event.target.value)} placeholder="ค้นหา Guide…" aria-label="ค้นหา Guide" /></InputGroup>{filtered.length ? <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">{filtered.map((guide) => <Link key={guide.id} href={`/${guide.slug}`} 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"><Image src={guide.coverUrl} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><CardHeader className="pt-5"><CardTitle>{guide.name}</CardTitle><CardDescription className="line-clamp-3">{guide.overview}</CardDescription></CardHeader><CardContent className="pb-5"><Badge variant="secondary">Build Guide</Badge></CardContent></Card></Link>)}</div> : <Empty className="border"><EmptyHeader><EmptyTitle>ไม่พบ Guide</EmptyTitle><EmptyDescription>ลองค้นหาด้วยชื่อตัวละครอื่น</EmptyDescription></EmptyHeader></Empty>}</div>;
const filtered = guides.filter((guide) =>
`${guide.name} ${guide.overview}`.toLowerCase().includes(normalized),
);
return (
<div className="flex flex-col gap-8">
<InputGroup className="h-11 max-w-2xl">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="ค้นหา Guide…"
aria-label="ค้นหา Guide"
/>
</InputGroup>
{filtered.length ? (
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((guide) => (
<Link
key={guide.id}
href={`/${guide.slug}`}
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">
<Image
src={guide.coverUrl}
alt={`Guide ${guide.name}`}
fill
className="object-cover"
/>
</div>
<CardHeader className="pt-5">
<CardTitle>{guide.name}</CardTitle>
<CardDescription className="line-clamp-3">
{guide.overview}
</CardDescription>
</CardHeader>
</Card>
</Link>
))}
</div>
) : (
<Empty className="border">
<EmptyHeader>
<EmptyTitle>ไม่พบ Guide</EmptyTitle>
<EmptyDescription>ลองค้นหาด้วยชื่อตัวละครอื่น</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
);
}
+550 -21
View File
@@ -4,7 +4,14 @@ import { ChevronLeftIcon, DownloadIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Kbd } from "@/components/ui/kbd";
import { Separator } from "@/components/ui/separator";
import { SiteHeader } from "@/components/public/site-header";
@@ -15,51 +22,239 @@ import { cn } from "@/lib/utils";
function hasSection(guide: PublicGuide, slug: string): boolean {
if (slug === "weapon") return guide.weapons.length > 0;
if (slug === "artifact") return guide.artifactGroups.some((group) => group.rows.length > 0) || Boolean(guide.artifactProfile && (guide.artifactProfile.sands.length || guide.artifactProfile.goblet.length || guide.artifactProfile.circlet.length));
if (slug === "artifact")
return (
guide.artifactGroups.some((group) => group.rows.length > 0) ||
Boolean(
guide.artifactProfile &&
(guide.artifactProfile.sands.length ||
guide.artifactProfile.goblet.length ||
guide.artifactProfile.circlet.length),
)
);
if (slug === "constellations") return guide.constellations.length > 0;
if (slug === "team") return guide.teams.length > 0;
return guide.extras.some((extra) => extra.slug === slug && extra.rows.length > 0);
return guide.extras.some(
(extra) => extra.slug === slug && extra.rows.length > 0,
);
}
export function publicSections(guide: PublicGuide) {
return guide.sections.filter((section) => hasSection(guide, section.slug));
}
export function StructuredGuideShell({ guide, section }: { guide: PublicGuide; section?: string }) {
export function StructuredGuideShell({
guide,
section,
}: {
guide: PublicGuide;
section?: string;
}) {
const sections = publicSections(guide);
return <div className="min-h-svh bg-background"><LiveRefresh endpoint={`/api/events/page/${guide.id}`} /><SiteHeader /><div className="sticky top-0 border-b bg-background/95 supports-backdrop-filter:backdrop-blur"><div className="mx-auto flex min-h-16 w-full max-w-7xl items-center gap-3 px-4 sm:px-6"><Link href="/" aria-label="กลับหน้ารวม" className="grid size-10 place-items-center rounded-lg hover:bg-muted"><ChevronLeftIcon /></Link><div className="relative size-11 overflow-hidden rounded-lg border"><Image src={guide.character.imageUrl} alt={guide.name} fill className="object-cover" /></div><div className="min-w-0 flex-1"><p className="truncate font-heading font-semibold">{guide.name}</p><p className="truncate text-xs text-muted-foreground">Build Guide</p></div>{guide.character.element ? <Badge>{guide.character.elementIconUrl ? <Image src={guide.character.elementIconUrl} alt="" width={16} height={16} className="brightness-0 invert" /> : null}{guide.character.element}</Badge> : null}</div><nav className="mx-auto flex min-h-11 w-full max-w-7xl gap-1 overflow-x-auto px-4 sm:px-6"><Link href={`/${guide.slug}`} className={cn("flex items-center rounded-md px-3 text-sm text-muted-foreground", !section && "bg-muted text-foreground")}>Overview</Link>{sections.map((item) => <Link key={item.id} href={`/${guide.slug}/${item.slug}`} className={cn("flex items-center rounded-md px-3 text-sm text-muted-foreground", section === item.slug && "bg-muted text-foreground")}>{item.title}</Link>)}</nav></div><main className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10">{section ? <GuideSection guide={guide} section={section} /> : <GuideOverview guide={guide} sections={sections} />}</main></div>;
return (
<div className="min-h-svh bg-background">
<LiveRefresh endpoint={`/api/events/page/${guide.id}`} />
<SiteHeader />
<div className="sticky top-0 border-b bg-background/95 supports-backdrop-filter:backdrop-blur">
<div className="mx-auto flex min-h-16 w-full max-w-7xl items-center gap-3 px-4 sm:px-6">
<Link
href="/"
aria-label="กลับหน้ารวม"
className="grid size-10 place-items-center rounded-lg hover:bg-muted"
>
<ChevronLeftIcon />
</Link>
<div className="relative size-11 overflow-hidden rounded-lg border">
<Image
src={guide.character.imageUrl}
alt={guide.name}
fill
className="object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-heading font-semibold">{guide.name}</p>
</div>
{guide.character.element ? (
<Badge>
{guide.character.elementIconUrl ? (
<Image
src={guide.character.elementIconUrl}
alt=""
width={16}
height={16}
className="brightness-0 invert"
/>
) : null}
{guide.character.element}
</Badge>
) : null}
</div>
<nav className="mx-auto flex min-h-11 w-full max-w-7xl gap-1 overflow-x-auto px-4 sm:px-6">
<Link
href={`/${guide.slug}`}
className={cn(
"flex items-center rounded-md px-3 text-sm text-muted-foreground",
!section && "bg-muted text-foreground",
)}
>
Overview
</Link>
{sections.map((item) => (
<Link
key={item.id}
href={`/${guide.slug}/${item.slug}`}
className={cn(
"flex items-center rounded-md px-3 text-sm text-muted-foreground",
section === item.slug && "bg-muted text-foreground",
)}
>
{item.title}
</Link>
))}
</nav>
</div>
<main className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10">
{section ? (
<GuideSection guide={guide} section={section} />
) : (
<GuideOverview guide={guide} sections={sections} />
)}
</main>
</div>
);
}
function GuideOverview({ guide, sections }: { guide: PublicGuide; sections: ReturnType<typeof publicSections> }) {
return <div className="flex flex-col gap-8"><div className="relative mx-auto aspect-square w-full max-w-3xl overflow-hidden rounded-xl border bg-muted"><Image src={guide.coverUrl} alt={`Guide ${guide.name}`} fill priority className="object-cover" /></div><div className="mx-auto flex max-w-3xl flex-col gap-3 text-center"><p className="text-sm font-medium text-primary">Build Overview</p><h1 className="font-heading text-4xl font-semibold">{guide.name}</h1><p className="text-lg leading-8 text-muted-foreground">{guide.overview}</p></div>{sections.length ? <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">{sections.map((item) => <Link key={item.id} href={`/${guide.slug}/${item.slug}`}><Card className="h-full"><CardHeader><CardTitle>{item.title}</CardTitle><CardDescription>เปิดดูคำแนะนำฉบับเต็ม</CardDescription></CardHeader></Card></Link>)}</div> : <Card><CardHeader><CardTitle>กำลังจัดทำเนื้อหา</CardTitle><CardDescription>กลับมาตรวจสอบอีกครั้งหลังผู้ดูแลเพิ่มข้อมูล</CardDescription></CardHeader></Card>}</div>;
function GuideOverview({
guide,
sections,
}: {
guide: PublicGuide;
sections: ReturnType<typeof publicSections>;
}) {
return (
<div className="flex flex-col gap-8">
<div className="relative mx-auto aspect-square w-full max-w-3xl overflow-hidden rounded-xl border bg-muted">
<Image
src={guide.coverUrl}
alt={`Guide ${guide.name}`}
fill
priority
className="object-cover"
/>
</div>
<div className="mx-auto flex max-w-3xl flex-col gap-3 text-center">
<p className="text-sm font-medium text-primary">Build Overview</p>
<h1 className="font-heading text-4xl font-semibold">{guide.name}</h1>
<p className="text-lg leading-8 text-muted-foreground">
{guide.overview}
</p>
</div>
{sections.length ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{sections.map((item) => (
<Link key={item.id} href={`/${guide.slug}/${item.slug}`}>
<Card className="h-full">
<CardHeader>
<CardTitle>{item.title}</CardTitle>
<CardDescription>เปิดดูคำแนะนำฉบับเต็ม</CardDescription>
</CardHeader>
</Card>
</Link>
))}
</div>
) : (
<Card>
<CardHeader>
<CardTitle>กำลังจัดทำเนื้อหา</CardTitle>
<CardDescription>
กลับมาตรวจสอบอีกครั้งหลังผู้ดูแลเพิ่มข้อมูล
</CardDescription>
</CardHeader>
</Card>
)}
</div>
);
}
function GuideSection({ guide, section }: { guide: PublicGuide; section: string }) {
function GuideSection({
guide,
section,
}: {
guide: PublicGuide;
section: string;
}) {
if (section === "weapon") return <WeaponSection guide={guide} />;
if (section === "artifact") return <ArtifactSection guide={guide} />;
if (section === "constellations") return <ConstellationSection guide={guide} />;
if (section === "constellations")
return <ConstellationSection guide={guide} />;
if (section === "team") return <TeamSection guide={guide} />;
const extra = guide.extras.find((item) => item.slug === section);
return extra ? <ExtraSection extra={extra} /> : null;
}
function metric(value: string | null) { return value === null ? null : `${Number(value).toLocaleString(undefined, { maximumFractionDigits: 2 })}%`; }
function metric(value: string | null) {
return value === null
? null
: `${Number(value).toLocaleString(undefined, { maximumFractionDigits: 2 })}%`;
}
function WeaponSection({ guide }: { guide: PublicGuide }) {
return (
<section className="flex flex-col gap-6">
<header className="text-center"><p className="text-sm font-medium text-primary">อาวุธที่แนะนำ</p><h1 className="font-heading text-4xl font-semibold">Weapons</h1></header>
<header className="text-center">
<p className="text-sm font-medium text-primary">อาวุธที่แนะนำ</p>
<h1 className="font-heading text-4xl font-semibold">Weapons</h1>
</header>
<div className="grid gap-3">
{guide.weapons.map((row, index) => (
<Card key={row.id} size="sm" className="bg-white py-0">
<CardContent className="grid p-0 md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)]">
<div className={cn("flex min-h-28 min-w-0 items-center gap-4 rounded-br-[4rem] p-4 pr-10 text-white md:rounded-br-[8rem] md:pr-16", rarityGradientClass(row.weapon.rarity))}>
<div className="relative size-20 shrink-0"><Image src={row.weapon.imageUrl} alt={row.weapon.name} fill className="object-contain" /></div>
<p className="truncate text-base font-semibold sm:text-lg">{index + 1}. {row.weapon.name} R{row.refinement}</p>
<div
className={cn(
"flex min-h-28 min-w-0 items-center gap-4 rounded-br-[4rem] p-4 pr-10 text-white md:rounded-br-[8rem] md:pr-16",
rarityGradientClass(row.weapon.rarity),
)}
>
<div className="relative size-20 shrink-0">
<Image
src={row.weapon.imageUrl}
alt={row.weapon.name}
fill
className="object-contain"
/>
</div>
<p className="truncate text-base font-semibold sm:text-lg">
{index + 1}. {row.weapon.name} R{row.refinement}
</p>
</div>
<div className="flex items-center justify-around gap-4 bg-white px-4 py-5 text-black">
{row.overallPercent ? <div className="flex-1 text-center"><p className={cn("whitespace-nowrap text-4xl leading-none font-bold sm:text-5xl", rarityTextClass(row.weapon.rarity))}>{metric(row.overallPercent)}</p><p className="mt-1 text-sm sm:text-base">ดาเมททั้งทีม</p></div> : null}
{row.personalPercent ? <div className="flex-1 text-center"><p className={cn("whitespace-nowrap text-4xl leading-none font-bold sm:text-5xl", rarityTextClass(row.weapon.rarity))}>{metric(row.personalPercent)}</p><p className="mt-1 text-sm sm:text-base">ดาเมจส่วนตัว</p></div> : null}
{row.overallPercent ? (
<div className="flex-1 text-center">
<p
className={cn(
"whitespace-nowrap text-4xl leading-none font-bold sm:text-5xl",
rarityTextClass(row.weapon.rarity),
)}
>
{metric(row.overallPercent)}
</p>
<p className="mt-1 text-sm sm:text-base">ดาเมททั้งทีม</p>
</div>
) : null}
{row.personalPercent ? (
<div className="flex-1 text-center">
<p
className={cn(
"whitespace-nowrap text-4xl leading-none font-bold sm:text-5xl",
rarityTextClass(row.weapon.rarity),
)}
>
{metric(row.personalPercent)}
</p>
<p className="mt-1 text-sm sm:text-base">ดาเมจส่วนตัว</p>
</div>
) : null}
</div>
</CardContent>
</Card>
@@ -69,12 +264,346 @@ function WeaponSection({ guide }: { guide: PublicGuide }) {
);
}
function ArtifactSection({ guide }: { guide: PublicGuide }) { const profile = guide.artifactProfile; return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">อาร์ติแฟกต์และสเตตัสที่แนะนำ</p><h1 className="font-heading text-4xl font-semibold">Artifacts & Stats</h1></header><div className="grid gap-5 lg:grid-cols-[1.2fr_1fr]"> <div className="flex flex-col gap-5">{guide.artifactGroups.map((group) => <Card key={group.id}><CardHeader><CardTitle>{group.name}</CardTitle></CardHeader><CardContent className="flex flex-col gap-3">{group.rows.map((row, index) => <div key={row.id} className="flex items-center gap-3"><div className="flex -space-x-3"><div className={cn("relative size-14 overflow-hidden rounded-lg border", rarityGradientClass(row.first.rarity))}><Image src={row.first.imageUrl} alt={row.first.name} fill className="object-contain" /></div>{row.second ? <div className={cn("relative size-14 overflow-hidden rounded-lg border", rarityGradientClass(row.second.rarity))}><Image src={row.second.imageUrl} alt={row.second.name} fill className="object-contain" /></div> : null}</div><div className="min-w-0 flex-1"><p className="font-medium">{index + 1}. {row.first.name}{row.second ? ` + ${row.second.name}` : ""}</p></div><p className="text-2xl font-semibold">{metric(row.percent)}</p></div>)}</CardContent></Card>)}</div>{profile ? <Card><CardHeader><CardTitle>Recommended Stats</CardTitle></CardHeader><CardContent className="flex flex-col gap-5"><StatLine label="Sands" values={profile.sands} /><StatLine label="Goblet" values={profile.goblet} /><StatLine label="Circlet" values={profile.circlet} /><Separator /><StatLine label="Substats" values={profile.substats} />{profile.targets.length ? <div><p className="mb-2 text-sm font-medium">Target Stats</p>{profile.targets.map((target) => <div key={`${target.stat}-${target.value}`} className="flex justify-between py-1 text-sm"><span>{target.stat}</span><strong>{target.value}</strong></div>)}</div> : null}{profile.talents.length ? <div><p className="mb-2 text-sm font-medium">Talent Priority</p><div className="flex gap-2">{[...profile.talents].sort((a,b) => a.priority-b.priority).map((talent) => <Badge key={talent.type} variant="secondary">{talent.priority}. {talent.type}{talent.level ? ` ${talent.level}` : ""}</Badge>)}</div></div> : null}{profile.note ? <p className="text-sm leading-6 text-muted-foreground">{profile.note}</p> : null}</CardContent></Card> : null}</div></section>; }
function ArtifactSection({ guide }: { guide: PublicGuide }) {
const profile = guide.artifactProfile;
return (
<section className="flex flex-col gap-6">
<header className="text-center">
<p className="text-sm font-medium text-primary">
อาร์ติแฟกต์และสเตตัสที่แนะนำ
</p>
<h1 className="font-heading text-4xl font-semibold">
Artifacts & Stats
</h1>
</header>
<div className="grid gap-5 lg:grid-cols-[1.2fr_1fr]">
{" "}
<div className="flex flex-col gap-5">
{guide.artifactGroups.map((group) => (
<Card key={group.id}>
<CardHeader>
<CardTitle>{group.name}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{group.rows.map((row, index) => (
<div key={row.id} className="flex items-center gap-3">
<div className="flex -space-x-3">
<div
className={cn(
"relative size-14 overflow-hidden rounded-lg border",
rarityGradientClass(row.first.rarity),
)}
>
<Image
src={row.first.imageUrl}
alt={row.first.name}
fill
className="object-contain"
/>
</div>
{row.second ? (
<div
className={cn(
"relative size-14 overflow-hidden rounded-lg border",
rarityGradientClass(row.second.rarity),
)}
>
<Image
src={row.second.imageUrl}
alt={row.second.name}
fill
className="object-contain"
/>
</div>
) : null}
</div>
<div className="min-w-0 flex-1">
<p className="font-medium">
{index + 1}. {row.first.name}
{row.second ? ` + ${row.second.name}` : ""}
</p>
</div>
<p className="text-2xl font-semibold">
{metric(row.percent)}
</p>
</div>
))}
</CardContent>
</Card>
))}
</div>
{profile ? (
<Card>
<CardHeader>
<CardTitle>สแตทที่แนะนำ</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<StatLine label="นาฬิกา/Sands" values={profile.sands} />
<StatLine label="ถ้วย/Goblet" values={profile.goblet} />
<StatLine label="หมวก/Circlet" values={profile.circlet} />
<Separator />
<StatLine label="Substats" values={profile.substats} />
{profile.targets.length ? (
<div>
<p className="mb-2 text-sm font-medium">Target Stats</p>
{profile.targets.map((target) => (
<div
key={`${target.stat}-${target.value}`}
className="flex justify-between py-1 text-sm"
>
<span>{target.stat}</span>
<strong>{target.value}</strong>
</div>
))}
</div>
) : null}
{profile.talents.length ? (
<div>
<p className="mb-2 text-sm font-medium">ลำดับการอัพพรสรววค์</p>
<div className="flex gap-2">
{[...profile.talents]
.sort((a, b) => a.priority - b.priority)
.map((talent) => (
<Badge key={talent.type} variant="secondary">
{talent.priority}. {talent.type}
{talent.level ? ` ${talent.level}` : ""}
</Badge>
))}
</div>
</div>
) : null}
{profile.note ? (
<div className="pt-3">
<p className="mb-2 text-sm font-medium">โน็ต</p>
<p className="text-sm leading-6 text-muted-foreground">
{profile.note}
</p>
</div>
) : null}
</CardContent>
</Card>
) : null}
</div>
</section>
);
}
function StatLine({ label, values }: { label: string; values: string[] }) { return <div><p className="text-sm text-muted-foreground">{label}</p><div className="mt-1 flex flex-wrap gap-2">{values.map((value) => <Badge key={value} variant="outline">{value}</Badge>)}</div></div>; }
function StatLine({ label, values }: { label: string; values: string[] }) {
return (
<div>
<p className="text-sm text-muted-foreground">{label}</p>
<div className="mt-1 flex flex-wrap gap-2">
{values.map((value) => (
<Badge key={value} variant="outline">
{value}
</Badge>
))}
</div>
</div>
);
}
function ConstellationSection({ guide }: { guide: PublicGuide }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">กลุ่มดาว</p><h1 className="font-heading text-4xl font-semibold">Constellations</h1></header><div className="grid gap-4 md:grid-cols-2">{guide.constellations.map((row) => <Card key={row.level}><CardHeader><CardTitle className="text-3xl text-white">{metric(row.cumulativePercent)}</CardTitle><CardDescription className="text-base font-medium text-white">C{row.level} · {row.name}</CardDescription>{row.iconUrl ? <CardAction><Image src={row.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /></CardAction> : null}</CardHeader><CardContent><p className="leading-7">{row.detail}</p></CardContent></Card>)}</div></section>; }
function ConstellationSection({ guide }: { guide: PublicGuide }) {
return (
<section className="flex flex-col gap-6">
<header className="text-center">
<p className="text-sm font-medium text-primary">กลุ่มดาว</p>
<h1 className="font-heading text-4xl font-semibold">Constellations</h1>
</header>
<div className="grid gap-4 md:grid-cols-2">
{guide.constellations.map((row) => (
<Card key={row.level}>
<CardHeader>
<CardTitle className="text-3xl text-white">
{metric(row.cumulativePercent)}
</CardTitle>
<CardDescription className="text-base font-medium text-white">
C{row.level} · {row.name}
</CardDescription>
{row.iconUrl ? (
<CardAction>
<Image
src={row.iconUrl}
alt=""
width={48}
height={48}
className="rounded-lg bg-muted"
/>
</CardAction>
) : null}
</CardHeader>
<CardContent>
<p className="leading-7">{row.detail}</p>
</CardContent>
</Card>
))}
</div>
</section>
);
}
function TeamSection({ guide }: { guide: PublicGuide }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">คำนวณความเสียหาย</p><h1 className="font-heading text-4xl font-semibold">Team Calc</h1></header><div className="grid gap-5 xl:grid-cols-2">{guide.teams.map((team) => { const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0); const seconds = Number(team.rotationSeconds); return <Card key={team.id}><CardHeader><CardTitle>{team.title}</CardTitle><CardDescription>{seconds}s rotation</CardDescription><CardAction><a href={`/api/teams/${team.id}/image`} download className={buttonVariants({ variant: "outline", size: "sm" })}><DownloadIcon data-icon="inline-start" />Export image</a></CardAction></CardHeader><CardContent className="flex flex-col gap-4"><div className="grid grid-cols-2 gap-3 sm:grid-cols-4">{team.members.map((member) => <div key={member.position} className="min-w-0 text-center"><div className="grid grid-cols-[minmax(0,1fr)_2.75rem] gap-2"><div className="relative aspect-square overflow-hidden rounded-lg bg-muted"><Image src={member.character.imageUrl} alt={member.character.name} fill className="object-cover" /></div><div className="flex flex-col gap-2">{member.weapon ? <div className={cn("relative aspect-square overflow-hidden rounded-md bg-muted", rarityGradientClass(member.weapon.rarity))}><Image src={member.weapon.imageUrl} alt={member.weapon.name} fill className="object-contain" /><Kbd className="absolute top-0 right-0">R{member.refinement}</Kbd></div> : <div className="aspect-square rounded-md border bg-muted" />}{member.artifact ? <div className={cn("relative aspect-square overflow-hidden rounded-md bg-muted", rarityGradientClass(member.artifact.rarity))}><Image src={member.artifact.imageUrl} alt={member.artifact.name} fill className="object-contain" /></div> : <div className="aspect-square rounded-md border bg-muted" />}</div></div><p className="mt-2 truncate text-sm font-medium">C{member.constellation} {member.character.name}</p><p className="text-sm font-semibold">{Number(member.damage).toLocaleString(undefined, { maximumFractionDigits: 2 })}</p><p className="text-xs text-muted-foreground">{total ? (Number(member.damage) / total * 100).toFixed(2) : "0.00"}%</p></div>)}</div><Separator /><div className="flex items-end justify-between"><div><p className="text-sm text-muted-foreground">Total damage</p><p className="font-semibold">{total.toLocaleString()}</p></div><div className="text-right"><p className="text-sm text-muted-foreground">Total DPS</p><p className="text-3xl font-semibold text-primary">{seconds > 0 ? (total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 }) : "0"}</p></div></div><p className="rounded-lg bg-muted p-3 text-sm leading-6">{team.combo}</p></CardContent></Card>; })}</div></section>; }
function TeamSection({ guide }: { guide: PublicGuide }) {
return (
<section className="flex flex-col gap-6">
<header className="text-center">
<p className="text-sm font-medium text-primary">คำนวณความเสียหาย</p>
<h1 className="font-heading text-4xl font-semibold">Team Calc</h1>
</header>
<div className="grid gap-5 xl:grid-cols-2">
{guide.teams.map((team) => {
const total = team.members.reduce(
(sum, member) => sum + Number(member.damage),
0,
);
const seconds = Number(team.rotationSeconds);
return (
<Card key={team.id}>
<CardHeader>
<CardTitle>{team.title}</CardTitle>
<CardDescription>{seconds}s rotation</CardDescription>
<CardAction>
<a
href={`/api/teams/${team.id}/image`}
download
className={buttonVariants({
variant: "outline",
size: "sm",
})}
>
<DownloadIcon data-icon="inline-start" />
Export image
</a>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{team.members.map((member) => (
<div key={member.position} className="min-w-0 text-center">
<div className="grid grid-cols-[minmax(0,1fr)_2.75rem] gap-2">
<div className="relative aspect-square overflow-hidden rounded-lg bg-muted">
<Image
src={member.character.imageUrl}
alt={member.character.name}
fill
className="object-cover"
/>
</div>
<div className="flex flex-col gap-2">
{member.weapon ? (
<div
className={cn(
"relative aspect-square overflow-hidden rounded-md bg-muted",
rarityGradientClass(member.weapon.rarity),
)}
>
<Image
src={member.weapon.imageUrl}
alt={member.weapon.name}
fill
className="object-contain"
/>
<Kbd className="absolute top-0 right-0">
R{member.refinement}
</Kbd>
</div>
) : (
<div className="aspect-square rounded-md border bg-muted" />
)}
{member.artifact ? (
<div
className={cn(
"relative aspect-square overflow-hidden rounded-md bg-muted",
rarityGradientClass(member.artifact.rarity),
)}
>
<Image
src={member.artifact.imageUrl}
alt={member.artifact.name}
fill
className="object-contain"
/>
</div>
) : (
<div className="aspect-square rounded-md border bg-muted" />
)}
</div>
</div>
<p className="mt-2 truncate text-sm font-medium">
C{member.constellation} {member.character.name}
</p>
<p className="text-sm font-semibold">
{Number(member.damage).toLocaleString(undefined, {
maximumFractionDigits: 2,
})}
</p>
<p className="text-xs text-muted-foreground">
{total
? ((Number(member.damage) / total) * 100).toFixed(2)
: "0.00"}
%
</p>
</div>
))}
</div>
<Separator />
<div className="flex items-end justify-between">
<div>
<p className="text-sm text-muted-foreground">
Total damage
</p>
<p className="font-semibold">{total.toLocaleString()}</p>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">Total DPS</p>
<p className="text-3xl font-semibold text-primary">
{seconds > 0
? (total / seconds).toLocaleString(undefined, {
maximumFractionDigits: 0,
})
: "0"}
</p>
</div>
</div>
<p className="rounded-lg bg-muted p-3 text-sm leading-6">
{team.combo}
</p>
</CardContent>
</Card>
);
})}
</div>
</section>
);
}
function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">Extra Guide</p><h1 className="font-heading text-4xl font-semibold">{extra.title}</h1></header><div className="grid gap-5 md:grid-cols-2">{extra.rows.map((row) => <Card key={row.id} className="overflow-hidden py-0">{row.imageUrl ? <div className="relative aspect-video bg-muted"><Image src={row.imageUrl} alt={row.heading} fill className="object-cover" /></div> : null}<CardHeader className="pt-5"><CardTitle>{row.heading}</CardTitle><CardDescription>{row.body}</CardDescription>{row.percent ? <Badge>{metric(row.percent)}</Badge> : null}</CardHeader></Card>)}</div></section>; }
function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) {
return (
<section className="flex flex-col gap-6">
<header className="text-center">
<p className="text-sm font-medium text-primary">Extra Guide</p>
<h1 className="font-heading text-4xl font-semibold">{extra.title}</h1>
</header>
<div className="grid gap-5 md:grid-cols-2">
{extra.rows.map((row) => (
<Card key={row.id} className="overflow-hidden py-0">
{row.imageUrl ? (
<div className="relative aspect-video bg-muted">
<Image
src={row.imageUrl}
alt={row.heading}
fill
className="object-cover"
/>
</div>
) : null}
<CardHeader className="pt-5">
<CardTitle>{row.heading}</CardTitle>
<CardDescription>{row.body}</CardDescription>
{row.percent ? <Badge>{metric(row.percent)}</Badge> : null}
</CardHeader>
</Card>
))}
</div>
</section>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB