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
} nativeButton={false} aria-label="กลับหน้า Guide">{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 } nativeButton={false} aria-label="กลับหน้า Guide">{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 } nativeButton={false} aria-label="กลับ Dashboard">
เลือกส่วนที่ต้องการแก้ไข
{guide.name}
{sections.map(({ slug, title, description, icon: Icon }) => {title}{description})}Extra sectionsส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์
;
+ return } nativeButton={false} aria-label="กลับ Dashboard">
เลือกส่วนที่ต้องการแก้ไข
{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 {guide.weapons.map((row, index) =>
{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 {guide.weapons.map((row, index) =>
{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 {guide.artifactGroups.map((group) =>
{group.name}{group.rows.map((row, index) => {row.second ? : 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)}`) : [],