feat : update
CI / Verify (push) Successful in 59s
CI / Build immutable images and deploy (push) Successful in 2m11s

This commit is contained in:
2026-08-29 19:42:28 +00:00 Unverified
parent 1ec145a22e
commit 3f480ac143
9 changed files with 94 additions and 23 deletions
+2 -1
View File
@@ -4,6 +4,7 @@ import { notFound, redirect } from "next/navigation";
import { connection } from "next/server";
import { GuideSectionEditor } from "@/components/admin/guide-section-editor";
import { GuideSectionNav } from "@/components/admin/guide-section-nav";
import { AdminHeader } from "@/components/admin/admin-header";
import { Button } from "@/components/ui/button";
import { getAdminSession } from "@/lib/auth/server";
@@ -20,5 +21,5 @@ export default async function StructuredEditorPage({ params }: { params: Promise
if (!guide) notFound();
const [catalog, content] = await Promise.all([getActiveCatalog(), getPublicGuide(guide.slug)]);
if (!catalog || !content) notFound();
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={catalog.characters.find((item) => item.key === guide.characterKey)?.constellations ?? []} initial={{ weapons: content.weapons, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={catalog.characters.find((item) => item.key === guide.characterKey)?.constellations ?? []} initial={{ weapons: content.weapons, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
}
+2 -1
View File
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminHeader } from "@/components/admin/admin-header";
import { GuideOverviewForm } from "@/components/admin/guide-overview-form";
import { GuideSectionNav } from "@/components/admin/guide-section-nav";
import { getAdminSession } from "@/lib/auth/server";
import { getGuideByCharacterKey } from "@/lib/guides/queries";
@@ -24,5 +25,5 @@ export default async function GuideOverviewPage({ params }: { params: Promise<{
const { character } = await params;
const guide = await getGuideByCharacterKey(character);
if (!guide) notFound();
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-8 p-4 sm:p-6 lg:p-8"><header className="flex flex-wrap items-center gap-4"><Button variant="outline" size="icon" render={<Link href="/admin" />} nativeButton={false} aria-label="กลับ Dashboard"><ArrowLeftIcon /></Button><div className="relative size-16 overflow-hidden rounded-lg border"><Image src={guide.coverUrl} alt={guide.name} fill className="object-cover" /></div><div><p className="text-sm text-muted-foreground">เลือกส่วนที่ต้องการแก้ไข</p><h1 className="font-heading text-3xl font-semibold">{guide.name}</h1></div></header><GuideOverviewForm guide={guide} /><div className="grid gap-4 md:grid-cols-2">{sections.map(({ slug, title, description, icon: Icon }) => <Link key={slug} href={`/admin/${guide.characterKey}/${slug}`} className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full transition-colors hover:bg-muted/50"><CardHeader><Icon className="size-6 text-primary" /><CardTitle>{title}</CardTitle><CardDescription>{description}</CardDescription></CardHeader></Card></Link>)}<Link href={`/admin/${guide.characterKey}/extra`} className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full transition-colors hover:bg-muted/50"><CardHeader><BoxesIcon className="size-6 text-primary" /><CardTitle>Extra sections</CardTitle><CardDescription>ส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์</CardDescription></CardHeader></Card></Link></div></main></div>;
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-8 p-4 sm:p-6 lg:p-8"><header className="flex flex-wrap items-center gap-4"><Button variant="outline" size="icon" render={<Link href="/admin" />} nativeButton={false} aria-label="กลับ Dashboard"><ArrowLeftIcon /></Button><div className="relative size-16 overflow-hidden rounded-lg border"><Image src={guide.coverUrl} alt={guide.name} fill className="object-cover" /></div><div><p className="text-sm text-muted-foreground">เลือกส่วนที่ต้องการแก้ไข</p><h1 className="font-heading text-3xl font-semibold">{guide.name}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current="overview" /><GuideOverviewForm guide={guide} /><div className="grid gap-4 md:grid-cols-2">{sections.map(({ slug, title, description, icon: Icon }) => <Link key={slug} href={`/admin/${guide.characterKey}/${slug}`} className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full transition-colors hover:bg-muted/50"><CardHeader><Icon className="size-6 text-primary" /><CardTitle>{title}</CardTitle><CardDescription>{description}</CardDescription></CardHeader></Card></Link>)}<Link href={`/admin/${guide.characterKey}/extra`} className="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full transition-colors hover:bg-muted/50"><CardHeader><BoxesIcon className="size-6 text-primary" /><CardTitle>Extra sections</CardTitle><CardDescription>ส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์</CardDescription></CardHeader></Card></Link></div></main></div>;
}
+2 -7
View File
@@ -94,13 +94,10 @@ export async function GET(
{team.guideName}
</div>
<div style={{ display: "flex", justifyContent: "center", color: "#82d6ce", fontSize: 20 }}>
{team.title} · {seconds.toLocaleString(undefined, { maximumFractionDigits: 2 })}s rotation
{team.title} · ระยะเวลาโรเทชั่น {seconds.toLocaleString(undefined, { maximumFractionDigits: 2 })} วิ
</div>
</div>
<div style={{ display: "flex", marginTop: 30, color: "#a6d8d4", fontSize: 22, fontWeight: 600 }}>
Damage Share
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 16, marginTop: 10 }}>
{team.members.map((member, index) => {
const damage = Number(member.damage);
@@ -150,14 +147,12 @@ export async function GET(
<div style={{ display: "flex", flex: 1 }} />
<div style={{ display: "flex", justifyContent: "center", gap: 16, borderTop: "1px solid #28555a", paddingTop: 20, fontSize: 26, fontWeight: 700 }}>
<span style={{ color: "#f0c95f" }}>DPS: {amount(dps)}</span>
<span style={{ color: "#688c90" }}>|</span>
<span>DPR: {amount(total)}</span>
</div>
<div style={{ display: "flex", justifyContent: "center", marginTop: 14, textAlign: "center", color: "#d1e9e6", fontSize: 18, lineHeight: 1.45 }}>
{team.combo}
</div>
<div style={{ display: "flex", justifyContent: "center", marginTop: 18, color: "#5c9995", fontSize: 14 }}>
BUZZ SHEET
By YT เกนชินไม่ใช่เกมมือถือ
</div>
</div>,
{
+9 -12
View File
@@ -24,9 +24,6 @@ import {
Field, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious,
} from "@/components/ui/pagination";
import {
Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
@@ -42,8 +39,10 @@ import {
teamSectionSchema,
weaponSectionSchema,
} from "@/lib/guides/schemas";
import { supportsWeaponType } from "@/lib/catalog/lunaris";
import { cn } from "@/lib/utils";
type CatalogItem = { key: string; name: string; imageUrl: string };
type CatalogItem = { key: string; name: string; imageUrl: string; rarity?: number | null; weaponType?: string | null };
type Guide = { id: string; characterKey: string; version: number; name: string };
type WeaponValue = { weaponKey: string; refinement: number; overallPercent: string | null; personalPercent: string | null; weapon: CatalogItem };
type ArtifactValue = { id: string; name: string; rows: Array<{ firstArtifactKey: string; secondArtifactKey: string | null; percent: string }> };
@@ -69,11 +68,9 @@ export interface GuideSectionEditorProps {
};
}
const SECTION_ORDER = ["weapon", "artifact", "constellations", "team", "extra"] as const;
const SECTION_LABELS = { weapon: "Weapons", artifact: "Artifacts & Stats", constellations: "Constellations", team: "Team Calc", extra: "Extra" };
const DEFAULT_TARGET_STATS = ["ATK", "EM", "ER", "CRIT Rate", "CRIT DMG"] as const;
function CatalogPicker({ items, value, onChange, placeholder }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; placeholder: string }) {
function CatalogPicker({ items, value, onChange, placeholder, weaponBackground = false }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; placeholder: string; weaponBackground?: boolean }) {
const selected = items.find((item) => item.key === value) ?? null;
return (
<Combobox items={items} value={selected} onValueChange={(next) => onChange(next?.key ?? "")} itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key}>
@@ -81,7 +78,7 @@ function CatalogPicker({ items, value, onChange, placeholder }: { items: Catalog
<ComboboxContent>
<ComboboxEmpty>ไม่พบรายการ</ComboboxEmpty>
<ComboboxList>
{items.map((item) => <ComboboxItem key={item.key} value={item}><Image src={item.imageUrl} alt="" width={32} height={32} className="rounded-md" />{item.name}</ComboboxItem>)}
{items.map((item) => <ComboboxItem key={item.key} value={item}><div className={cn("relative size-8 shrink-0 overflow-hidden rounded-md", weaponBackground && item.rarity === 5 && "bg-[linear-gradient(90deg,rgba(138,90,0,1)_0%,rgba(15,8,0,1)_100%)]", weaponBackground && item.rarity === 4 && "bg-gradient-to-r from-[#a300b8] to-black")}><Image src={item.imageUrl} alt="" fill className="object-contain" /></div>{item.name}</ComboboxItem>)}
</ComboboxList>
</ComboboxContent>
</Combobox>
@@ -113,7 +110,6 @@ function moveItem<T>(items: T[], from: number, to: number): T[] {
}
export function GuideSectionEditor(props: GuideSectionEditorProps) {
const index = SECTION_ORDER.indexOf(props.section);
return (
<div className="flex flex-col gap-6">
{props.section === "weapon" ? <WeaponEditor {...props} /> : null}
@@ -121,14 +117,15 @@ export function GuideSectionEditor(props: GuideSectionEditorProps) {
{props.section === "constellations" ? <ConstellationEditor {...props} /> : null}
{props.section === "team" ? <TeamEditor {...props} /> : null}
{props.section === "extra" ? <ExtraEditor {...props} /> : null}
<Pagination><PaginationContent className="flex-wrap"><PaginationItem>{index > 0 ? <PaginationPrevious href={`/admin/${props.guide.characterKey}/${SECTION_ORDER[index - 1]}`} text={SECTION_LABELS[SECTION_ORDER[index - 1]]} /> : <PaginationLink href={`/admin/${props.guide.characterKey}`} size="default">Overview</PaginationLink>}</PaginationItem><PaginationItem><PaginationLink href={`/admin/${props.guide.characterKey}/${props.section}`} size="default" isActive>{SECTION_LABELS[props.section]}</PaginationLink></PaginationItem>{index < SECTION_ORDER.length - 1 ? <PaginationItem><PaginationNext href={`/admin/${props.guide.characterKey}/${SECTION_ORDER[index + 1]}`} text={SECTION_LABELS[SECTION_ORDER[index + 1]]} /></PaginationItem> : null}</PaginationContent></Pagination>
</div>
);
}
type EditorBase = GuideSectionEditorProps;
function WeaponEditor({ guide, weapons, initial }: EditorBase) {
function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
const characterWeaponType = characters.find((character) => character.key === guide.characterKey)?.weaponType;
const compatibleWeapons = weapons.filter((weapon) => supportsWeaponType(characterWeaponType, weapon.weaponType));
const [rows, setRows] = useState(initial.weapons.map((row) => ({ weaponKey: row.weaponKey, refinement: row.refinement, overallPercent: row.overallPercent ?? "", personalPercent: row.personalPercent ?? "" })));
const value = { rows: rows.map((row) => ({ ...row, overallPercent: row.overallPercent === "" ? null : Number(row.overallPercent), personalPercent: row.personalPercent === "" ? null : Number(row.personalPercent) })) };
const autosave = useAutosave({ value, valid: weaponSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveWeaponSection(guide.id, { ...next, expectedVersion }) });
@@ -136,7 +133,7 @@ function WeaponEditor({ guide, weapons, initial }: EditorBase) {
<Card>
<CardHeader><CardTitle>Weapon tier list</CardTitle><CardDescription>เรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง ระบบจะบันทึกอัตโนมัติ</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-4">
{rows.map((row, rowIndex) => <Card key={rowIndex} size="sm"><CardHeader><CardTitle>อันดับ {rowIndex + 1}</CardTitle><CardAction><RowActions index={rowIndex} length={rows.length} move={(from, to) => setRows(moveItem(rows, from, to))} remove={() => setRows(rows.filter((_, index) => index !== rowIndex))} /></CardAction></CardHeader><CardContent><FieldGroup><Field><FieldLabel>อาวุธ</FieldLabel><CatalogPicker items={weapons} value={row.weaponKey} placeholder="ค้นหาชื่ออาวุธ…" onChange={(weaponKey) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, weaponKey } : item))} /></Field><Field><FieldLabel>Refinement</FieldLabel><ToggleGroup value={[`R${row.refinement}`]} onValueChange={(selected) => { const next = selected[0]; if (next) setRows(rows.map((item, index) => index === rowIndex ? { ...item, refinement: Number(next.slice(1)) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field><div className="grid gap-4 sm:grid-cols-2"><Field><FieldLabel>Overall %</FieldLabel><Input type="number" min="0" step="0.01" value={row.overallPercent} onChange={(event) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, overallPercent: event.target.value } : item))} /></Field><Field><FieldLabel>Personal %</FieldLabel><Input type="number" min="0" step="0.01" value={row.personalPercent} onChange={(event) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, personalPercent: event.target.value } : item))} /></Field></div></FieldGroup></CardContent></Card>)}
{rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return <Card key={rowIndex} size="sm"><CardHeader><CardTitle className="flex items-center gap-3">{selectedWeapon ? <div className={cn("relative size-10 shrink-0 overflow-hidden rounded-lg", selectedWeapon.rarity === 5 && "bg-[linear-gradient(90deg,rgba(138,90,0,1)_0%,rgba(15,8,0,1)_100%)]", selectedWeapon.rarity === 4 && "bg-gradient-to-r from-[#a300b8] to-black")}><Image src={selectedWeapon.imageUrl} alt="" fill className="object-contain" /></div> : null}<span>อันดับ {rowIndex + 1}</span></CardTitle><CardAction><RowActions index={rowIndex} length={rows.length} move={(from, to) => setRows(moveItem(rows, from, to))} remove={() => setRows(rows.filter((_, index) => index !== rowIndex))} /></CardAction></CardHeader><CardContent><FieldGroup><Field><FieldLabel>อาวุธ</FieldLabel><CatalogPicker items={compatibleWeapons} value={row.weaponKey} placeholder="ค้นหาชื่ออาวุธ…" weaponBackground onChange={(weaponKey) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, weaponKey } : item))} /></Field><Field><FieldLabel>Refinement</FieldLabel><ToggleGroup value={[`R${row.refinement}`]} onValueChange={(selected) => { const next = selected[0]; if (next) setRows(rows.map((item, index) => index === rowIndex ? { ...item, refinement: Number(next.slice(1)) } : item)); }}>{[1, 2, 3, 4, 5].map((refinement) => <ToggleGroupItem key={refinement} value={`R${refinement}`}>R{refinement}</ToggleGroupItem>)}</ToggleGroup></Field><div className="grid gap-4 sm:grid-cols-2"><Field><FieldLabel>Overall %</FieldLabel><Input type="number" min="0" step="0.01" value={row.overallPercent} onChange={(event) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, overallPercent: event.target.value } : item))} /></Field><Field><FieldLabel>Personal %</FieldLabel><Input type="number" min="0" step="0.01" value={row.personalPercent} onChange={(event) => setRows(rows.map((item, index) => index === rowIndex ? { ...item, personalPercent: event.target.value } : item))} /></Field></div></FieldGroup></CardContent></Card>; })}
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3"><Button type="button" variant="outline" onClick={() => setRows([...rows, { weaponKey: "", refinement: 1, overallPercent: "", personalPercent: "" }])}><PlusIcon data-icon="inline-start" />เพิ่มอาวุธ</Button><AutosaveStatus state={autosave} /></CardFooter>
</Card>
+49
View File
@@ -0,0 +1,49 @@
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
} from "@/components/ui/pagination";
const sections = [
{ slug: "weapon", label: "Weapons" },
{ slug: "artifact", label: "Artifacts & Stats" },
{ slug: "constellations", label: "Constellations" },
{ slug: "team", label: "Team Calc" },
{ slug: "extra", label: "Extra" },
] as const;
export function GuideSectionNav({
characterKey,
current,
}: {
characterKey: string;
current: "overview" | (typeof sections)[number]["slug"];
}) {
return (
<Pagination className="justify-start overflow-x-auto rounded-xl border bg-card p-2">
<PaginationContent>
<PaginationItem>
<PaginationLink
href={`/admin/${characterKey}`}
size="default"
isActive={current === "overview"}
>
Overview
</PaginationLink>
</PaginationItem>
{sections.map((section) => (
<PaginationItem key={section.slug}>
<PaginationLink
href={`/admin/${characterKey}/${section.slug}`}
size="default"
isActive={current === section.slug}
>
{section.label}
</PaginationLink>
</PaginationItem>
))}
</PaginationContent>
</Pagination>
);
}
+1 -1
View File
@@ -43,7 +43,7 @@ function GuideSection({ guide, section }: { guide: PublicGuide; section: string
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><div className="grid gap-3 lg:grid-cols-2">{guide.weapons.map((row, index) => <Card key={row.id} size="sm" className={cn(row.weapon.rarity === 5 && "bg-[linear-gradient(90deg,rgba(138,90,0,1)_0%,rgba(15,8,0,1)_100%)]", row.weapon.rarity === 4 && "bg-gradient-to-r from-[#a300b8] to-black")}><CardContent className="flex items-center gap-4"><div className="relative size-16 shrink-0 overflow-hidden rounded-lg bg-muted"><Image src={row.weapon.imageUrl} alt={row.weapon.name} fill className="object-cover" /></div><div className="min-w-0 flex-1"><p className="font-semibold">{index + 1}. {row.weapon.name} R{row.refinement}</p><p className="text-sm text-muted-foreground">{row.weapon.weaponType}</p></div>{row.overallPercent ? <div className="text-right"><p className="text-xs text-muted-foreground">ดาเมทโดยรวม</p><p className="text-2xl font-semibold">{metric(row.overallPercent)}</p></div> : null}{row.personalPercent ? <div className="text-right"><p className="text-xs text-muted-foreground">ดาเมทส่วนตัว</p><p className="text-2xl font-semibold">{metric(row.personalPercent)}</p></div> : null}</CardContent></Card>)}</div></section>; }
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><div className="grid gap-3 lg:grid-cols-2">{guide.weapons.map((row, index) => <Card key={row.id} size="sm"><CardContent className="flex items-center gap-4"><div className={cn("relative size-16 shrink-0 overflow-hidden rounded-lg bg-muted", row.weapon.rarity === 5 && "bg-[linear-gradient(90deg,rgba(138,90,0,1)_0%,rgba(15,8,0,1)_100%)]", row.weapon.rarity === 4 && "bg-gradient-to-r from-[#a300b8] to-black")}><Image src={row.weapon.imageUrl} alt={row.weapon.name} fill className="object-contain" /></div><div className="min-w-0 flex-1"><p className="font-semibold">{index + 1}. {row.weapon.name} R{row.refinement}</p><p className="text-sm text-muted-foreground">{row.weapon.weaponType}</p></div>{row.overallPercent ? <div className="text-right"><p className="text-xs text-muted-foreground">ดาเมทโดยรวม</p><p className="text-2xl font-semibold">{metric(row.overallPercent)}</p></div> : null}{row.personalPercent ? <div className="text-right"><p className="text-xs text-muted-foreground">ดาเมทส่วนตัว</p><p className="text-2xl font-semibold">{metric(row.personalPercent)}</p></div> : null}</CardContent></Card>)}</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"><Image src={row.first.imageUrl} alt={row.first.name} width={56} height={56} className="rounded-lg border bg-muted" />{row.second ? <Image src={row.second.imageUrl} alt={row.second.name} width={56} height={56} className="rounded-lg border bg-muted" /> : 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>; }
+20
View File
@@ -5,6 +5,7 @@ import {
characterDetailSchema,
cleanLunarisText,
constellationSnapshot,
supportsWeaponType,
} from "@/lib/catalog/lunaris";
describe("Lunaris character data", () => {
@@ -37,4 +38,23 @@ describe("Lunaris character data", () => {
expect(constellationSnapshot(detail, (icon) => icon)).toEqual([]);
});
it("reads the supported weapon type from character details", () => {
const detail = characterDetailSchema.parse({
info: { weapon: "WEAPON_SWORD_ONE_HAND" },
constellations: null,
});
expect(detail.info?.weapon).toBe("WEAPON_SWORD_ONE_HAND");
});
it("matches only weapons supported by the character", () => {
expect(
supportsWeaponType("WEAPON_SWORD_ONE_HAND", "WEAPON_SWORD_ONE_HAND"),
).toBe(true);
expect(
supportsWeaponType("WEAPON_SWORD_ONE_HAND", "WEAPON_CATALYST"),
).toBe(false);
expect(supportsWeaponType(undefined, "WEAPON_SWORD_ONE_HAND")).toBe(false);
});
});
+8
View File
@@ -14,6 +14,7 @@ const constellationSchema = z.object({
});
export const characterDetailSchema = z.object({
info: z.object({ weapon: z.string().optional() }).nullish(),
constellations: z.record(z.string(), constellationSchema).nullish(),
});
@@ -29,6 +30,13 @@ export function avatarIconName(value: string): string {
return value.replace(/^UI_Gacha_AvatarIcon_/u, "UI_AvatarIcon_");
}
export function supportsWeaponType(
characterWeaponType: string | null | undefined,
weaponType: string | null | undefined,
): boolean {
return Boolean(characterWeaponType && characterWeaponType === weaponType);
}
export function constellationSnapshot(
value: z.infer<typeof characterDetailSchema>,
iconKey: (icon: string) => string,
+1 -1
View File
@@ -130,7 +130,7 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
const detail = detailRaw ? characterDetailSchema.parse(detailRaw) : null;
const avatarIcon = webpName(avatarIconName(value.CardImg));
return {
version, key, name: value.enName, element: value.element ?? null, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
version, key, name: value.enName, element: value.element ?? null, rarity: rarity(value.qualityType), weaponType: detail?.info?.weapon ?? value.weaponType ?? null,
imageKey: `catalog/${version}/characters/${avatarIcon}`,
sourceImage: `${API_ROOT}/assets/avataricon/${encodeURIComponent(avatarIcon)}`,
constellations: detail ? constellationSnapshot(detail, (icon) => `catalog/${version}/constellations/${webpName(icon)}`) : [],