From 3f480ac1438c17db6642e0af7f23c5e9932a3106 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sat, 29 Aug 2026 19:42:28 +0000 Subject: [PATCH] feat : update --- app/admin/[character]/[page]/page.tsx | 3 +- app/admin/[character]/page.tsx | 3 +- app/api/teams/[teamId]/image/route.tsx | 9 +---- components/admin/guide-section-editor.tsx | 21 +++++----- components/admin/guide-section-nav.tsx | 49 +++++++++++++++++++++++ components/public/structured-guide.tsx | 2 +- lib/catalog/lunaris.test.ts | 20 +++++++++ lib/catalog/lunaris.ts | 8 ++++ lib/catalog/sync.ts | 2 +- 9 files changed, 94 insertions(+), 23 deletions(-) create mode 100644 components/admin/guide-section-nav.tsx diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index 93f6f6c..e13233c 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -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

{guide.name}

{guide.sections.find((section) => section.slug === page)?.title ?? page}

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 })) })) }} />
; + return

{guide.name}

{guide.sections.find((section) => section.slug === page)?.title ?? page}

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 })) })) }} />
; } diff --git a/app/admin/[character]/page.tsx b/app/admin/[character]/page.tsx index 00db604..d0dd342 100644 --- a/app/admin/[character]/page.tsx +++ b/app/admin/[character]/page.tsx @@ -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
{guide.name}

เลือกส่วนที่ต้องการแก้ไข

{guide.name}

{sections.map(({ slug, title, description, icon: Icon }) => {title}{description})}Extra sectionsส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์
; + return
{guide.name}

เลือกส่วนที่ต้องการแก้ไข

{guide.name}

{sections.map(({ slug, title, description, icon: Icon }) => {title}{description})}Extra sectionsส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์
; } diff --git a/app/api/teams/[teamId]/image/route.tsx b/app/api/teams/[teamId]/image/route.tsx index 50eec37..f8502b4 100644 --- a/app/api/teams/[teamId]/image/route.tsx +++ b/app/api/teams/[teamId]/image/route.tsx @@ -94,13 +94,10 @@ export async function GET( {team.guideName}
- {team.title} · {seconds.toLocaleString(undefined, { maximumFractionDigits: 2 })}s rotation + {team.title} · ระยะเวลาโรเทชั่น {seconds.toLocaleString(undefined, { maximumFractionDigits: 2 })} วิ
-
- Damage Share -
{team.members.map((member, index) => { const damage = Number(member.damage); @@ -150,14 +147,12 @@ export async function GET(
DPS: {amount(dps)} - | - DPR: {amount(total)}
{team.combo}
- BUZZ SHEET + By YT เกนชินไม่ใช่เกมมือถือ
, { diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index 3bd75a1..d9130b3 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -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 ( onChange(next?.key ?? "")} itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key}> @@ -81,7 +78,7 @@ function CatalogPicker({ items, value, onChange, placeholder }: { items: Catalog ไม่พบรายการ - {items.map((item) => {item.name})} + {items.map((item) =>
{item.name}
)}
@@ -113,7 +110,6 @@ function moveItem(items: T[], from: number, to: number): T[] { } export function GuideSectionEditor(props: GuideSectionEditorProps) { - const index = SECTION_ORDER.indexOf(props.section); return (
{props.section === "weapon" ? : null} @@ -121,14 +117,15 @@ export function GuideSectionEditor(props: GuideSectionEditorProps) { {props.section === "constellations" ? : null} {props.section === "team" ? : null} {props.section === "extra" ? : null} - {index > 0 ? : Overview}{SECTION_LABELS[props.section]}{index < SECTION_ORDER.length - 1 ? : null}
); } 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) { Weapon tier listเรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง ระบบจะบันทึกอัตโนมัติ - {rows.map((row, rowIndex) => อันดับ {rowIndex + 1} setRows(moveItem(rows, from, to))} remove={() => setRows(rows.filter((_, index) => index !== rowIndex))} />อาวุธ setRows(rows.map((item, index) => index === rowIndex ? { ...item, weaponKey } : item))} />Refinement { 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) => R{refinement})}
Overall % setRows(rows.map((item, index) => index === rowIndex ? { ...item, overallPercent: event.target.value } : item))} />Personal % setRows(rows.map((item, index) => index === rowIndex ? { ...item, personalPercent: event.target.value } : item))} />
)} + {rows.map((row, rowIndex) => { const selectedWeapon = compatibleWeapons.find((weapon) => weapon.key === row.weaponKey); return {selectedWeapon ?
: null}อันดับ {rowIndex + 1}
setRows(moveItem(rows, from, to))} remove={() => setRows(rows.filter((_, index) => index !== rowIndex))} />
อาวุธ setRows(rows.map((item, index) => index === rowIndex ? { ...item, weaponKey } : item))} />Refinement { 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) => R{refinement})}
Overall % setRows(rows.map((item, index) => index === rowIndex ? { ...item, overallPercent: event.target.value } : item))} />Personal % setRows(rows.map((item, index) => index === rowIndex ? { ...item, personalPercent: event.target.value } : item))} />
; })}
diff --git a/components/admin/guide-section-nav.tsx b/components/admin/guide-section-nav.tsx new file mode 100644 index 0000000..6164944 --- /dev/null +++ b/components/admin/guide-section-nav.tsx @@ -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 ( + + + + + Overview + + + {sections.map((section) => ( + + + {section.label} + + + ))} + + + ); +} diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx index 78247b9..292f7ab 100644 --- a/components/public/structured-guide.tsx +++ b/components/public/structured-guide.tsx @@ -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

อาวุธที่แนะนำ

Weapons

{guide.weapons.map((row, index) =>
{row.weapon.name}

{index + 1}. {row.weapon.name} R{row.refinement}

{row.weapon.weaponType}

{row.overallPercent ?

ดาเมทโดยรวม

{metric(row.overallPercent)}

: null}{row.personalPercent ?

ดาเมทส่วนตัว

{metric(row.personalPercent)}

: null}
)}
; } +function WeaponSection({ guide }: { guide: PublicGuide }) { return

อาวุธที่แนะนำ

Weapons

{guide.weapons.map((row, index) =>
{row.weapon.name}

{index + 1}. {row.weapon.name} R{row.refinement}

{row.weapon.weaponType}

{row.overallPercent ?

ดาเมทโดยรวม

{metric(row.overallPercent)}

: null}{row.personalPercent ?

ดาเมทส่วนตัว

{metric(row.personalPercent)}

: null}
)}
; } function ArtifactSection({ guide }: { guide: PublicGuide }) { const profile = guide.artifactProfile; return

อาร์ติแฟกต์และสเตตัสที่แนะนำ

Artifacts & Stats

{guide.artifactGroups.map((group) => {group.name}{group.rows.map((row, index) =>
{row.first.name}{row.second ? {row.second.name} : null}

{index + 1}. {row.first.name}{row.second ? ` + ${row.second.name}` : ""}

{metric(row.percent)}

)}
)}
{profile ? Recommended Stats{profile.targets.length ?

Target Stats

{profile.targets.map((target) =>
{target.stat}{target.value}
)}
: null}{profile.talents.length ?

Talent Priority

{[...profile.talents].sort((a,b) => a.priority-b.priority).map((talent) => {talent.priority}. {talent.type}{talent.level ? ` ${talent.level}` : ""})}
: null}{profile.note ?

{profile.note}

: null}
: null}
; } diff --git a/lib/catalog/lunaris.test.ts b/lib/catalog/lunaris.test.ts index 3df45c6..1b97433 100644 --- a/lib/catalog/lunaris.test.ts +++ b/lib/catalog/lunaris.test.ts @@ -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); + }); }); diff --git a/lib/catalog/lunaris.ts b/lib/catalog/lunaris.ts index e7c8d2d..d6f255c 100644 --- a/lib/catalog/lunaris.ts +++ b/lib/catalog/lunaris.ts @@ -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, iconKey: (icon: string) => string, diff --git a/lib/catalog/sync.ts b/lib/catalog/sync.ts index b07c8db..7e1f587 100644 --- a/lib/catalog/sync.ts +++ b/lib/catalog/sync.ts @@ -130,7 +130,7 @@ export async function processCatalogSyncJob(jobId: string): Promise { 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)}`) : [],