diff --git a/app/admin/actions.ts b/app/admin/actions.ts index 307fab0..88eed86 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -43,6 +43,10 @@ import { getCatalogSyncJob, startCatalogSyncJob, } from "@/lib/catalog/sync"; +import { + getAdminTeamImportGuide, + listAdminTeamImportGuides, +} from "@/lib/guides/queries"; const serializedBlockSchema = z.strictObject({ id: z.string().uuid(), @@ -275,6 +279,31 @@ export async function saveTeamSection(guideId: string, input: unknown) { return runGuideAction(() => saveTeams(guideId, input)); } +export async function listTeamImportGuides(currentGuideId: string) { + await requireAdmin(); + const parsed = z.string().uuid().safeParse(currentGuideId); + if (!parsed.success) return { status: "error" as const, message: "Guide ID ไม่ถูกต้อง" }; + try { + return { status: "success" as const, guides: await listAdminTeamImportGuides(parsed.data) }; + } catch { + return { status: "error" as const, message: "โหลดรายการ Guide ไม่สำเร็จ" }; + } +} + +export async function loadTeamImportGuide(guideId: string) { + await requireAdmin(); + const parsed = z.string().uuid().safeParse(guideId); + if (!parsed.success) return { status: "error" as const, message: "Guide ID ไม่ถูกต้อง" }; + try { + const guide = await getAdminTeamImportGuide(parsed.data); + return guide + ? { status: "success" as const, guide } + : { status: "error" as const, message: "ไม่พบ Guide นี้" }; + } catch { + return { status: "error" as const, message: "โหลด Team จาก Guide ไม่สำเร็จ" }; + } +} + export async function saveExtraSectionList(guideId: string, input: unknown) { await requireAdmin(); const result = await runGuideAction(() => saveExtraSections(guideId, input)); diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index 867e663..46d7e6e 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -4,6 +4,8 @@ import dynamic from "next/dynamic"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { + ArrowLeftIcon, + BookOpenIcon, ClipboardCopyIcon, ClipboardPasteIcon, CopyIcon, @@ -43,6 +45,8 @@ import { saveArtifactSection, saveConstellationSection, saveGuideExtra, + listTeamImportGuides, + loadTeamImportGuide, saveTeamSection, saveWeaponSection, } from "@/app/admin/actions"; @@ -59,7 +63,9 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Card, CardAction, @@ -106,6 +112,7 @@ import { import { Textarea } from "@/components/ui/textarea"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; +import { Spinner } from "@/components/ui/spinner"; import { Toggle } from "@/components/ui/toggle"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { @@ -144,6 +151,7 @@ import { isCustomTravelerKey } from "@/lib/catalog/travelers"; import { applyPresetFields, applyTeamFields, + cloneTeamImports, findInvalidTeamCatalogReference, findUnsupportedTeamWeapon, limitPresets, @@ -1828,7 +1836,7 @@ function ArtifactEditor({ > {dragHandle}
-
+
{ if (!open) setRenamingTargetGroupIndex(null); }} onChange={(name) => setTargetGroups(targetGroups.map((group, index) => index === renamingTargetGroupIndex ? { ...group, name } : group))} /> - - + + + + + +
Condition note (optional) @@ -2404,27 +2416,49 @@ function ConstellationEditor({ onChange={(event) => setNote(event.target.value)} /> - - + + { + if (values[0]) setActiveConstellationGroupIndex(Number(values[0])); + }} + > + {groups.map((group, index) => ( + setRenamingConstellationGroupIndex(index)} + > + {group.name.trim() || `Condition ${index + 1}`} + + ))} + + + +
);}) : null} - - + ; + }>; + }>; +}; + function TeamEditor({ guide, characters, @@ -2672,8 +2702,17 @@ function TeamEditor({ const [applyOpen, setApplyOpen] = useState(false); const [presetOpen, setPresetOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); - const [importValue, setImportValue] = useState(""); const [importFiles, setImportFiles] = useState([]); + const [guideImportOpen, setGuideImportOpen] = useState(false); + const [importGuides, setImportGuides] = useState | null>(null); + const [activeImportGuide, setActiveImportGuide] = useState(null); + const [selectedImports, setSelectedImports] = useState>>({}); + const [guideImportLoading, setGuideImportLoading] = useState(false); + const [guideImportError, setGuideImportError] = useState(null); + const [guideImportCondition, setGuideImportCondition] = useState("0"); + const [guideTeamQuery, setGuideTeamQuery] = useState(""); + const [guideTeamSearchTarget, setGuideTeamSearchTarget] = useState("all"); + const [guideListQuery, setGuideListQuery] = useState(""); const [presetName, setPresetName] = useState(""); const [presets, setPresets] = useState([]); const [presetsHydrated, setPresetsHydrated] = useState(false); @@ -2709,6 +2748,25 @@ function TeamEditor({ ), }, searchQuery, searchTarget)); }, [artifacts, characters, searchQuery, searchTarget, teams, weapons]); + const activeImportGroup = activeImportGuide?.groups[Number(guideImportCondition)] + ?? activeImportGuide?.groups[0]; + const filteredImportTeams = useMemo(() => { + const characterNames = new Map(characters.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]])); + const weaponNames = new Map(weapons.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]])); + const artifactNames = new Map(artifacts.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]])); + return (activeImportGroup?.teams ?? []).filter((team) => matchesTeamSearch({ + team: [team.title], + character: team.members.flatMap((member) => characterNames.get(member.characterKey) ?? [member.characterName]), + weapon: team.members.flatMap((member) => member.weaponKey ? weaponNames.get(member.weaponKey) ?? [] : []), + artifact: team.members.flatMap((member) => [member.artifactKey, member.secondArtifactKey].flatMap((key) => key ? artifactNames.get(key) ?? [] : [])), + }, guideTeamQuery, guideTeamSearchTarget)); + }, [activeImportGroup, artifacts, characters, guideTeamQuery, guideTeamSearchTarget, weapons]); + const filteredImportGuides = useMemo(() => { + const query = guideListQuery.trim().toLocaleLowerCase(); + return (importGuides ?? []).filter((sourceGuide) => + `${sourceGuide.name} ${sourceGuide.characterKey}`.toLocaleLowerCase().includes(query), + ); + }, [guideListQuery, importGuides]); const value = { groups: teamGroups.map((group) => ({ name: group.name.trim() || null, note: group.note.trim() || null, teams: group.teams.map((team) => ({ title: team.title, @@ -2971,46 +3029,76 @@ function TeamEditor({ toast.success(`ดาวน์โหลด ${teamTransferFileName(team.title)} แล้ว`); } }; - const importTeam = async () => { + const appendImportedTeams = (importedTeams: Array>) => { + if (!importedTeams.length) return false; + if (teams.length + importedTeams.length > 30) { + toast.error("Import ไม่ได้ เพราะ Guide จะมี Team เกิน 30 Team"); + return false; + } + for (const imported of importedTeams) { + const catalogIssue = findInvalidTeamCatalogReference( + imported, + characters, + weapons, + artifacts, + ); + if (catalogIssue) { + toast.error(catalogIssue); + return false; + } + } + setTeams((current) => [ + ...current, + ...cloneTeamImports(importedTeams, () => createClientId("team")), + ]); + return true; + }; + const importTeamFiles = async () => { try { - const serializedTeams = [ - ...(importValue.trim() ? [importValue] : []), - ...(await Promise.all(importFiles.map((file) => file.text()))), - ]; + const serializedTeams = await Promise.all(importFiles.map((file) => file.text())); if (!serializedTeams.length) return; - if (teams.length + serializedTeams.length > 30) { - toast.error("Import ไม่ได้ เพราะ Guide จะมี Team เกิน 30 Team"); - return; - } const importedTeams = serializedTeams.map(parseTeamTransfer); - for (const imported of importedTeams) { - const catalogIssue = findInvalidTeamCatalogReference( - imported, - characters, - weapons, - artifacts, - ); - if (catalogIssue) { - toast.error(catalogIssue); - return; - } - } - setTeams((current) => [ - ...current, - ...importedTeams.map((imported) => ({ - clientId: createClientId("team"), - ...imported, - members: imported.members.map((member) => ({ ...member })), - })), - ]); - setImportValue(""); + if (!appendImportedTeams(importedTeams)) return; setImportFiles([]); setImportOpen(false); toast.success(`Import ${importedTeams.length} Team แล้ว`); } catch { - toast.error("มีไฟล์หรือ JSON ของ Team ที่ไม่ถูกต้องหรือเป็นเวอร์ชันที่ไม่รองรับ"); + toast.error("มีไฟล์ Team ที่ไม่ถูกต้องหรือเป็นเวอร์ชันที่ไม่รองรับ"); } }; + const openGuideImport = async () => { + setImportOpen(false); + setGuideImportOpen(true); + setActiveImportGuide(null); + setSelectedImports({}); + setGuideListQuery(""); + setGuideImportError(null); + setGuideImportLoading(true); + const result = await listTeamImportGuides(guide.id); + setGuideImportLoading(false); + if (result.status === "success") setImportGuides(result.guides); + else setGuideImportError(result.message); + }; + const enterImportGuide = async (guideId: string) => { + setGuideImportError(null); + setGuideImportLoading(true); + const result = await loadTeamImportGuide(guideId); + setGuideImportLoading(false); + if (result.status === "success") { + setGuideImportCondition("0"); + setGuideTeamQuery(""); + setGuideTeamSearchTarget("all"); + setActiveImportGuide(result.guide); + } + else setGuideImportError(result.message); + }; + const confirmGuideImport = () => { + const importedTeams = Object.values(selectedImports); + if (!appendImportedTeams(importedTeams)) return; + setGuideImportOpen(false); + setSelectedImports({}); + toast.success(`Import ${importedTeams.length} Team แล้ว`); + }; return ( @@ -3736,44 +3824,293 @@ function TeamEditor({ - + Import Team - วาง JSON ที่ Export จาก Team ใน Guide อื่น ระบบจะเพิ่ม Team ใหม่ต่อท้ายรายการ + เลือก Team จาก Guide อื่น หรืออัปโหลดไฟล์ JSON ที่เคย Export ไว้ - - - Team JSON -