From 22b09a61369d2ce72ef7bd56c38891b12533bb63 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sun, 30 Aug 2026 05:39:54 +0000 Subject: [PATCH] feat(guides): add team loadouts and element icons --- app/admin/[character]/[page]/page.tsx | 2 +- app/api/teams/[teamId]/image/route.tsx | 2 + components/admin/guide-section-editor.tsx | 12 +- components/public/structured-guide.tsx | 9 +- components/ui/kbd.tsx | 26 + db/schema.ts | 5 + drizzle/0005_pink_carlie_cooper.sql | 5 + drizzle/meta/0005_snapshot.json | 3471 +++++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/catalog/lunaris.test.ts | 3 +- lib/catalog/lunaris.ts | 5 +- lib/catalog/sync.ts | 23 +- lib/guides/mutations.ts | 25 +- lib/guides/queries.ts | 30 +- lib/guides/schemas.test.ts | 5 +- lib/guides/schemas.ts | 4 + 16 files changed, 3605 insertions(+), 29 deletions(-) create mode 100644 components/ui/kbd.tsx create mode 100644 drizzle/0005_pink_carlie_cooper.sql create mode 100644 drizzle/meta/0005_snapshot.json diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index e13233c..0059ef0 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -21,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, weaponKey: member.weaponKey, artifactKey: member.artifactKey, constellation: member.constellation, refinement: member.refinement, 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/api/teams/[teamId]/image/route.tsx b/app/api/teams/[teamId]/image/route.tsx index f8502b4..2834d98 100644 --- a/app/api/teams/[teamId]/image/route.tsx +++ b/app/api/teams/[teamId]/image/route.tsx @@ -147,6 +147,8 @@ export async function GET(
DPS: {amount(dps)} + | + DPR: {amount(total)}
{team.combo} diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index d9130b3..d4c70b7 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -49,7 +49,7 @@ type ArtifactValue = { id: string; name: string; rows: Array<{ firstArtifactKey: type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; targets: Array<{ stat: string; value: string }>; talents: Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }>; note: string | null } | null; type ConstellationValue = { level: number; detail: string; cumulativePercent: string }; type CatalogConstellation = { level: number; name: string; description: string; iconUrl: string | null }; -type TeamValue = { title: string; rotationSeconds: string; combo: string; members: Array<{ characterKey: string; damage: string }> }; +type TeamValue = { title: string; rotationSeconds: string; combo: string; members: Array<{ characterKey: string; weaponKey: string | null; artifactKey: string | null; constellation: number; refinement: number; damage: string }> }; export interface GuideSectionEditorProps { section: "weapon" | "artifact" | "constellations" | "team" | "extra"; @@ -78,7 +78,7 @@ function CatalogPicker({ items, value, onChange, placeholder, weaponBackground = ไม่พบรายการ - {items.map((item) =>
{item.name}
)} + {items.map((item) =>
{item.name}
)}
@@ -133,7 +133,7 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) { Weapon tier listเรียงอาวุธจากดีที่สุดลงมา และกรอกเปอร์เซ็นต์อย่างน้อยหนึ่งช่อง ระบบจะบันทึกอัตโนมัติ - {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))} />
; })} + {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))} />
; })}
@@ -193,8 +193,8 @@ function ConstellationEditor({ guide, initial, catalogConstellations }: EditorBa ); } -function TeamEditor({ guide, characters, initial }: EditorBase) { - const fallbackMembers = characters.slice(0, 4).map((character) => ({ characterKey: character.key, damage: "0" })); +function TeamEditor({ guide, characters, weapons, artifacts, initial }: EditorBase) { + const fallbackMembers = characters.slice(0, 4).map((character) => ({ characterKey: character.key, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1, damage: "0" })); const [teams, setTeams] = useState(initial.teams); const value = { teams: teams.map((team) => ({ ...team, rotationSeconds: Number(team.rotationSeconds), members: team.members.map((member) => ({ ...member, damage: Number(member.damage) })) })) }; const autosave = useAutosave({ value, valid: teamSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveTeamSection(guide.id, { ...next, expectedVersion }) }); @@ -202,7 +202,7 @@ function TeamEditor({ guide, characters, initial }: EditorBase) { Team Calcใส่ความเสียหายของตัวละคร 4 ตัว ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ - {teams.map((team, teamIndex) => { const total = team.members.reduce((sum, member) => sum + (Number(member.damage) || 0), 0); const dps = total / (Number(team.rotationSeconds) || 1); return Team {teamIndex + 1}Damage {total.toLocaleString()} • DPS {dps.toFixed(0)} setTeams(moveItem(teams, from, to))} remove={() => setTeams(teams.filter((_, index) => index !== teamIndex))} />ชื่อ Team setTeams(teams.map((item, index) => index === teamIndex ? { ...item, title: event.target.value } : item))} />
{team.members.map((member, memberIndex) => ตัวละคร {memberIndex + 1} setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, characterKey } : candidate) } : item))} /> setTeams(teams.map((item, index) => index === teamIndex ? { ...item, members: item.members.map((candidate, inner) => inner === memberIndex ? { ...candidate, damage: event.target.value } : candidate) } : item))} />{total ? `${((Number(member.damage) || 0) / total * 100).toFixed(2)}%` : "0%"})}
Rotation (seconds) setTeams(teams.map((item, index) => index === teamIndex ? { ...item, rotationSeconds: event.target.value } : item))} />Combo