diff --git a/app/admin/actions.ts b/app/admin/actions.ts index 0b830cf..f0b6ee1 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -37,6 +37,7 @@ import { } from "@/lib/catalog/sync"; import { getAdminTeamImportGuide, + listAdminTeamImportsForCharacter, listAdminTeamImportGuides, } from "@/lib/guides/queries"; @@ -130,6 +131,23 @@ export async function loadTeamImportGuide(guideId: string) { } } +export async function listTeamImportsForCharacter(currentGuideId: string, characterKey: string) { + await requireAdmin(); + const guideId = z.string().uuid().safeParse(currentGuideId); + const character = z.string().trim().min(1).max(64).safeParse(characterKey); + if (!guideId.success || !character.success) { + return { status: "error" as const, message: "Guide หรือตัวละครไม่ถูกต้อง" }; + } + try { + return { + status: "success" as const, + teams: await listAdminTeamImportsForCharacter(guideId.data, character.data), + }; + } catch { + return { status: "error" as const, message: "โหลด Team จาก Guide อื่นไม่สำเร็จ" }; + } +} + export async function createGuideExtra(guideId: string, input: unknown) { const admin = await requireAdmin(); try { diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index 1bd8f32..b6d4699 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -49,6 +49,7 @@ import { saveConstellationSection, saveGuideExtra, listTeamImportGuides, + listTeamImportsForCharacter, loadTeamImportGuide, saveTeamSection, saveWeaponSection, @@ -131,6 +132,7 @@ import { import { Textarea } from "@/components/ui/textarea"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; import { Toggle } from "@/components/ui/toggle"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -172,6 +174,7 @@ import { applyPresetFields, applyTeamFields, cloneTeamImports, + filterTeamImportsByName, findInvalidTeamCatalogReference, findUnsupportedTeamWeapon, limitPresets, @@ -2628,6 +2631,103 @@ type TeamImportGuide = { }>; }; +type TeamEverywhereImport = { + id: string; + title: string; + rotationSeconds: string; + combo: string; + guideName: string; + conditionName: string | null; + members: Array; +}; + +function TeamImportPreviewCard({ + team, + characters, + weapons, + artifacts, + checked, + checkboxId, + onCheckedChange, + sourceLabel, + disabled = false, +}: { + team: TeamImportGuide["groups"][number]["teams"][number]; + characters: CatalogItem[]; + weapons: CatalogItem[]; + artifacts: CatalogItem[]; + checked: boolean; + checkboxId: string; + onCheckedChange: (checked: boolean) => void; + sourceLabel?: string; + disabled?: boolean; +}) { + const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0); + const seconds = Number(team.rotationSeconds); + + return ( + + + {team.title} + โรเทชั่น {seconds} วิ{sourceLabel ? ` · ${sourceLabel}` : ""} + + + onCheckedChange(value === true)} /> + {disabled ? "ชื่อซ้ำ" : "เลือก"} + + + + +
+ {team.members.map((member, index) => { + const character = characters.find((item) => item.key === member.characterKey); + const weapon = weapons.find((item) => item.key === member.weaponKey); + const artifact = artifacts.find((item) => item.key === member.artifactKey); + const secondArtifact = artifacts.find((item) => item.key === member.secondArtifactKey); + return ( +
+
+
+ {character ? {character.name} : null} +
+
+ {weapon ? ( +
+ {weapon.name} +
+ ) : null} + {artifact || secondArtifact ? ( + + ) : null} +
+
+
+ {teamConstellationLabel(member.constellation)} + {teamRefinementLabel(member.refinement)} +
+

{character?.name ?? member.characterName}

+

{Number(member.damage).toLocaleString()}

+

{total ? ((Number(member.damage) / total) * 100).toFixed(1) : "0.0"}%

+
+ ); + })} +
+ +
+

ดาเมจรวม

{total.toLocaleString()}

+

DPS

{seconds > 0 ? (total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 }) : "0"}

+
+

{team.combo}

+
+
+ ); +} + function TeamEditor({ guide, characters, @@ -2674,6 +2774,15 @@ function TeamEditor({ const [importOpen, setImportOpen] = useState(false); const [importFiles, setImportFiles] = useState([]); const [guideImportOpen, setGuideImportOpen] = useState(false); + const [everywhereImportOpen, setEverywhereImportOpen] = useState(false); + const everywhereSearchId = useRef(0); + const [everywhereCharacterKey, setEverywhereCharacterKey] = useState(""); + const [everywhereSearchedKey, setEverywhereSearchedKey] = useState(""); + const [everywhereTeams, setEverywhereTeams] = useState([]); + const [everywhereLoading, setEverywhereLoading] = useState(false); + const [everywhereError, setEverywhereError] = useState(null); + const [everywhereSelectedIds, setEverywhereSelectedIds] = useState>(() => new Set()); + const [ignoreSameName, setIgnoreSameName] = useState(true); const [importGuides, setImportGuides] = useState | null>(null); const [activeImportGuide, setActiveImportGuide] = useState(null); const [selectedImports, setSelectedImports] = useState>>({}); @@ -2737,6 +2846,12 @@ function TeamEditor({ const filteredTeams = teamSearch.items; const filteredImportTeams = importTeamSearch.items; const filteredImportGuides = importGuideSearch.items; + const eligibleEverywhereTeams = filterTeamImportsByName(teams, everywhereTeams, ignoreSameName); + const eligibleEverywhereIds = new Set(eligibleEverywhereTeams.map((team) => team.id)); + const selectedEverywhereTeams = eligibleEverywhereTeams.filter((team) => everywhereSelectedIds.has(team.id)); + const allEverywhereTeamsSelected = eligibleEverywhereTeams.length > 0 + && eligibleEverywhereTeams.every((team) => everywhereSelectedIds.has(team.id)); + const availableTeamSlots = 30 - teams.length; const value = { groups: teamGroups.map((group) => ({ name: group.name.trim() || null, note: group.note.trim() || null, iconMediaId: group.iconMediaId, teams: group.teams.map((team) => ({ title: team.title, @@ -3055,6 +3170,54 @@ function TeamEditor({ if (result.status === "success") setImportGuides(result.guides); else setGuideImportError(result.message); }; + const openEverywhereImport = () => { + everywhereSearchId.current += 1; + setImportOpen(false); + setEverywhereImportOpen(true); + setEverywhereCharacterKey(""); + setEverywhereSearchedKey(""); + setEverywhereTeams([]); + setEverywhereSelectedIds(new Set()); + setIgnoreSameName(true); + setEverywhereError(null); + setEverywhereLoading(false); + }; + const searchEverywhereTeams = async (characterKey: string) => { + const searchId = ++everywhereSearchId.current; + setEverywhereSearchedKey(""); + setEverywhereTeams([]); + setEverywhereSelectedIds(new Set()); + setEverywhereError(null); + setEverywhereLoading(true); + const result = await listTeamImportsForCharacter(guide.id, characterKey); + if (searchId !== everywhereSearchId.current) return; + setEverywhereLoading(false); + if (result.status === "success") { + setEverywhereTeams(result.teams); + setEverywhereSearchedKey(characterKey); + } else setEverywhereError(result.message); + }; + const importEverywhereTeams = (sourceTeams: TeamEverywhereImport[]) => { + if (!sourceTeams.length) return; + const importedTeams = sourceTeams.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, + secondArtifactKey: member.secondArtifactKey, + constellation: member.constellation, + refinement: member.refinement, + damage: member.damage, + })), + })); + if (!appendImportedTeams(importedTeams)) return; + setEverywhereImportOpen(false); + setEverywhereSelectedIds(new Set()); + toast.success(`Import ${importedTeams.length} Team แล้ว`); + }; const enterImportGuide = async (guideId: string) => { setGuideImportError(null); setGuideImportLoading("teams"); @@ -3772,6 +3935,18 @@ function TeamEditor({
+ + + Import Everywhere + เลือกตัวละคร แล้วค้นหา Team จาก Guide อื่นที่มีตัวละครนั้นอยู่ในทีม + + + + + เลือกจาก Guide @@ -3815,6 +3990,117 @@ function TeamEditor({ + { + setEverywhereImportOpen(open); + if (!open) { + everywhereSearchId.current += 1; + setEverywhereLoading(false); + } + }}> + + + Import Everywhere + + เลือกตัวละครเพื่อค้นหา Team จาก Guide อื่น แล้วดูตัวอย่างก่อนนำเข้า Condition ปัจจุบัน + + +
+ + ตัวละครใน Team + { + setEverywhereCharacterKey(key); + if (key) void searchEverywhereTeams(key); + else { + everywhereSearchId.current += 1; + setEverywhereSearchedKey(""); + setEverywhereTeams([]); + setEverywhereSelectedIds(new Set()); + setEverywhereError(null); + setEverywhereLoading(false); + } + }} + placeholder="เลือกตัวละคร" + label="เลือกตัวละครที่อยู่ใน Team" + showSelectedImage + rarityBackground + characterBackground + /> + + + + Ignore same name + + {everywhereError ? ( + โหลดข้อมูลไม่สำเร็จ{everywhereError} + ) : null} + {everywhereLoading ? ( +
+ {Array.from({ length: 4 }, (_, index) => )} +
+ ) : everywhereSearchedKey && everywhereTeams.length ? ( + <> +
+

+ พบ {everywhereTeams.length} Team · เลือกได้ {eligibleEverywhereTeams.length} Team · Condition นี้เหลือที่ว่าง {availableTeamSlots} Team +

+ +
+ {eligibleEverywhereTeams.length > availableTeamSlots ? ( +

Import All ใช้ไม่ได้เมื่อจำนวน Team เกินที่ว่าง กรุณาเลือก Team ไม่เกิน {availableTeamSlots} Team

+ ) : null} +
+ {everywhereTeams.map((team) => { + const eligible = eligibleEverywhereIds.has(team.id); + return ( + setEverywhereSelectedIds((current) => { + const next = new Set(current); + if (checked) next.add(team.id); + else next.delete(team.id); + return next; + })} + /> + ); + })} +
+ + ) : everywhereSearchedKey && !everywhereError ? ( + ไม่พบ Teamยังไม่มี Team จาก Guide อื่นที่มีตัวละครนี้ + ) : null} +
+ + + + + +
+
@@ -3956,96 +4242,38 @@ function TeamEditor({ {filteredImportTeams.length ? (
- {filteredImportTeams.map((team) => { - const checked = Boolean(selectedImports[team.id]); - const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0); - const seconds = Number(team.rotationSeconds); - return ( - - - {team.title} - โรเทชั่น {seconds} วิ - - - setSelectedImports((current) => { - if (nextChecked === true) { - return { ...current, [team.id]: { - title: team.title, - rotationSeconds: team.rotationSeconds, - combo: team.combo, - members: team.members.map((member) => ({ - characterKey: member.characterKey, - weaponKey: member.weaponKey, - artifactKey: member.artifactKey, - secondArtifactKey: member.secondArtifactKey, - constellation: member.constellation, - refinement: member.refinement, - damage: member.damage, - })), - } }; - } - const next = { ...current }; - delete next[team.id]; - return next; - })} - /> - เลือก - - - - -
- {team.members.map((member) => { - const character = characters.find((item) => item.key === member.characterKey); - const weapon = weapons.find((item) => item.key === member.weaponKey); - const artifact = artifacts.find((item) => item.key === member.artifactKey); - const secondArtifact = artifacts.find((item) => item.key === member.secondArtifactKey); - return ( -
-
-
- {character ? {character.name} : null} -
-
- {weapon ? ( -
- {weapon.name} -
- ) : null} - {artifact || secondArtifact ? ( - - ) : null} -
-
-
- {teamConstellationLabel(member.constellation)} - {teamRefinementLabel(member.refinement)} -
-

{character?.name ?? member.characterName}

-

{Number(member.damage).toLocaleString()}

-

{total ? ((Number(member.damage) / total) * 100).toFixed(1) : "0.0"}%

-
- ); - })} -
- -
-

ดาเมจรวม

{total.toLocaleString()}

-

DPS

{seconds > 0 ? (total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 }) : "0"}

-
-

{team.combo}

-
-
- ); - })} + {filteredImportTeams.map((team) => ( + setSelectedImports((current) => { + if (checked) { + return { ...current, [team.id]: { + title: team.title, + rotationSeconds: team.rotationSeconds, + combo: team.combo, + members: team.members.map((member) => ({ + characterKey: member.characterKey, + weaponKey: member.weaponKey, + artifactKey: member.artifactKey, + secondArtifactKey: member.secondArtifactKey, + constellation: member.constellation, + refinement: member.refinement, + damage: member.damage, + })), + } }; + } + const next = { ...current }; + delete next[team.id]; + return next; + })} + /> + ))}
) : ( diff --git a/lib/guides/queries.test.ts b/lib/guides/queries.test.ts index b0dd92d..e2fcef7 100644 --- a/lib/guides/queries.test.ts +++ b/lib/guides/queries.test.ts @@ -26,10 +26,10 @@ vi.mock("@/db", () => ({ import { artifactConditions, artifactProfiles, constellationConditions, extraSectionRows, - guides, guideSections, teamConditions, weaponConditions, + guides, guideSections, teamCompositions, teamConditions, teamMembers, weaponConditions, catalogMaterials, guidePosterBleeding, media, } from "@/db/schema"; -import { getAdminGuideContent } from "./queries"; +import { getAdminGuideContent, listAdminTeamImportsForCharacter } from "./queries"; beforeEach(() => { mocks.reads.length = 0; @@ -102,3 +102,32 @@ describe("admin section reads", () => { expect(mocks.reads.filter((table) => table === media)).toHaveLength(1); }); }); + +describe("team import lookup", () => { + it("hydrates matching teams and source labels with one member read", async () => { + mocks.rows.set(teamCompositions, [{ + id: "team-1", title: "Example", rotationSeconds: "20", combo: "E > Q", + guideName: "Other Guide", conditionName: "Boss", + }]); + mocks.rows.set(teamMembers, [{ + member: { + teamId: "team-1", characterKey: "character-1", weaponKey: "weapon-1", + artifactKey: "artifact-1", secondArtifactKey: null, + constellation: "2", refinement: "1", damage: "100", + }, + characterName: "Character One", + }]); + + const result = await listAdminTeamImportsForCharacter("current-guide", "character-1"); + expect(result).toEqual([{ + id: "team-1", title: "Example", rotationSeconds: "20", combo: "E > Q", + guideName: "Other Guide", conditionName: "Boss", + members: [{ + characterKey: "character-1", characterName: "Character One", weaponKey: "weapon-1", + artifactKey: "artifact-1", secondArtifactKey: null, + constellation: "2", refinement: "1", damage: "100", + }], + }]); + expect(mocks.reads).toEqual([teamCompositions, teamMembers]); + }); +}); diff --git a/lib/guides/queries.ts b/lib/guides/queries.ts index d0e666d..427e32c 100644 --- a/lib/guides/queries.ts +++ b/lib/guides/queries.ts @@ -200,6 +200,64 @@ export async function listAdminTeamImportGuides(excludeGuideId: string) { })); } +export async function listAdminTeamImportsForCharacter( + excludeGuideId: string, + characterKey: string, +) { + const matches = await getDb() + .select({ + id: teamCompositions.id, + title: teamCompositions.title, + rotationSeconds: teamCompositions.rotationSeconds, + combo: teamCompositions.combo, + guideName: guides.name, + conditionName: teamConditions.name, + }) + .from(teamCompositions) + .innerJoin(guides, eq(guides.id, teamCompositions.guideId)) + .innerJoin(teamMembers, eq(teamMembers.teamId, teamCompositions.id)) + .leftJoin(teamConditions, eq(teamConditions.id, teamCompositions.conditionId)) + .where(and( + eq(teamMembers.characterKey, characterKey), + ne(guides.id, excludeGuideId), + isNull(guides.trashedAt), + )) + .orderBy(asc(guides.name), asc(guides.id), asc(teamConditions.sortOrder), asc(teamCompositions.sortOrder), asc(teamCompositions.id)); + + if (!matches.length) return []; + const members = await getDb() + .select({ member: teamMembers, characterName: catalogCharacters.name }) + .from(teamMembers) + .leftJoin(catalogCharacters, eq(catalogCharacters.key, teamMembers.characterKey)) + .where(inArray(teamMembers.teamId, matches.map((team) => team.id))) + .orderBy(asc(teamMembers.position)); + const membersByTeam = new Map>(); + for (const { member, characterName } of members) { + const group = membersByTeam.get(member.teamId) ?? []; + group.push({ + characterKey: member.characterKey, + characterName: characterName ?? member.characterKey, + weaponKey: member.weaponKey, + artifactKey: member.artifactKey, + secondArtifactKey: member.secondArtifactKey, + constellation: member.constellation, + refinement: member.refinement, + damage: member.damage, + }); + membersByTeam.set(member.teamId, group); + } + return matches.map((team) => ({ ...team, members: membersByTeam.get(team.id) ?? [] })); +} + export async function getAdminTeamImportGuide(guideId: string) { const [guide] = await getDb() .select({ id: guides.id, name: guides.name }) diff --git a/lib/guides/team-tools.test.ts b/lib/guides/team-tools.test.ts index 6d4ba3a..7fbe67c 100644 --- a/lib/guides/team-tools.test.ts +++ b/lib/guides/team-tools.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { applyTeamFields, cloneTeamImports, + filterTeamImportsByName, findInvalidTeamCatalogReference, findUnsupportedTeamWeapon, limitPresets, @@ -160,6 +161,12 @@ describe("team bulk tools", () => { expect(imported[0].members[0]).not.toBe(source.members[0]); }); + it("ignores existing and repeated names unless duplicate filtering is disabled", () => { + const candidates = [team("one", " Alpha "), team("two", "Beta"), team("three", "BETA")]; + expect(filterTeamImportsByName([team("current", "alpha")], candidates, true).map((item) => item.clientId)).toEqual(["two"]); + expect(filterTeamImportsByName([team("current", "alpha")], candidates, false)).toEqual(candidates); + }); + it("rejects malformed and unsupported team transfers", () => { expect(() => parseTeamTransfer("not-json")).toThrow(); expect(() => parseTeamTransfer(JSON.stringify({ diff --git a/lib/guides/team-tools.ts b/lib/guides/team-tools.ts index 844a565..8eff7af 100644 --- a/lib/guides/team-tools.ts +++ b/lib/guides/team-tools.ts @@ -125,6 +125,21 @@ export function cloneTeamImports( })); } +export function filterTeamImportsByName( + currentTeams: readonly { title: string }[], + candidates: readonly T[], + ignoreSameName: boolean, +): T[] { + if (!ignoreSameName) return [...candidates]; + const names = new Set(currentTeams.map((team) => team.title.trim().toLocaleLowerCase())); + return candidates.filter((team) => { + const name = team.title.trim().toLocaleLowerCase(); + if (names.has(name)) return false; + names.add(name); + return true; + }); +} + export function findInvalidTeamCatalogReference( team: Omit, characters: readonly TeamToolCatalogItem[],