feat : improve import UI
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_8rem_auto]">
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_4rem_auto]">
|
||||
<CatalogPicker
|
||||
items={artifacts}
|
||||
value={row.firstArtifactKey}
|
||||
@@ -2118,16 +2126,20 @@ function ArtifactEditor({
|
||||
onOpenChange={(open) => { if (!open) setRenamingTargetGroupIndex(null); }}
|
||||
onChange={(name) => setTargetGroups(targetGroups.map((group, index) => index === renamingTargetGroupIndex ? { ...group, name } : group))}
|
||||
/>
|
||||
<Button type="button" variant="outline" disabled={targetGroups.length >= 20} onClick={() => {
|
||||
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), name: "", note: "", rows: targetStatDefaults.map((target) => ({ ...target })) }]);
|
||||
setActiveTargetGroupIndex(targetGroups.length);
|
||||
}}><PlusIcon data-icon="inline-start" />เพิ่ม Condition</Button>
|
||||
<Button type="button" variant="outline" disabled={targetGroups.length >= 20} onClick={() => {
|
||||
const source = targetGroups[activeTargetGroupIndex];
|
||||
if (!source) return;
|
||||
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), name: source.name, note: source.note, rows: source.rows.map((row) => ({ ...row })) }]);
|
||||
setActiveTargetGroupIndex(targetGroups.length);
|
||||
}}><CopyIcon data-icon="inline-start" />Duplicate Condition</Button>
|
||||
<SimpleTooltip text="เพิ่ม Condition">
|
||||
<Button type="button" size="icon" variant="outline" aria-label="เพิ่ม Condition" disabled={targetGroups.length >= 20} onClick={() => {
|
||||
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), name: "", note: "", rows: targetStatDefaults.map((target) => ({ ...target })) }]);
|
||||
setActiveTargetGroupIndex(targetGroups.length);
|
||||
}}><PlusIcon /></Button>
|
||||
</SimpleTooltip>
|
||||
<SimpleTooltip text="Duplicate Condition">
|
||||
<Button type="button" size="icon" variant="outline" aria-label="Duplicate Condition" disabled={targetGroups.length >= 20} onClick={() => {
|
||||
const source = targetGroups[activeTargetGroupIndex];
|
||||
if (!source) return;
|
||||
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), name: source.name, note: source.note, rows: source.rows.map((row) => ({ ...row })) }]);
|
||||
setActiveTargetGroupIndex(targetGroups.length);
|
||||
}}><CopyIcon /></Button>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`target-condition-note-${targetGroups[activeTargetGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
|
||||
@@ -2404,27 +2416,49 @@ function ConstellationEditor({
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<ScrollableNav ariaLabel="เลือกเงื่อนไขกลุ่มดาว" className="w-full min-w-0">
|
||||
<ToggleGroup
|
||||
aria-label="เลือกเงื่อนไขกลุ่มดาว"
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ScrollableNav ariaLabel="เลือกเงื่อนไขกลุ่มดาว" className="min-w-0 flex-1">
|
||||
<ToggleGroup
|
||||
aria-label="เลือกเงื่อนไขกลุ่มดาว"
|
||||
variant="outline"
|
||||
value={[String(activeConstellationGroupIndex)]}
|
||||
onValueChange={(values) => {
|
||||
if (values[0]) setActiveConstellationGroupIndex(Number(values[0]));
|
||||
}}
|
||||
>
|
||||
{groups.map((group, index) => (
|
||||
<ToggleGroupItem
|
||||
key={group.clientId}
|
||||
value={String(index)}
|
||||
className="aria-pressed:bg-primary aria-pressed:text-primary-foreground"
|
||||
onDoubleClick={() => setRenamingConstellationGroupIndex(index)}
|
||||
>
|
||||
{group.name.trim() || `Condition ${index + 1}`}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</ScrollableNav>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
value={[String(activeConstellationGroupIndex)]}
|
||||
onValueChange={(values) => {
|
||||
if (values[0]) setActiveConstellationGroupIndex(Number(values[0]));
|
||||
disabled={groups.length >= 20}
|
||||
onClick={() => {
|
||||
setGroups([
|
||||
...groups,
|
||||
{
|
||||
clientId: createClientId("constellation-condition"),
|
||||
name: "",
|
||||
note: "",
|
||||
rows: makeRows(),
|
||||
},
|
||||
]);
|
||||
setActiveConstellationGroupIndex(groups.length);
|
||||
}}
|
||||
>
|
||||
{groups.map((group, index) => (
|
||||
<ToggleGroupItem
|
||||
key={group.clientId}
|
||||
value={String(index)}
|
||||
className="aria-pressed:bg-primary aria-pressed:text-primary-foreground"
|
||||
onDoubleClick={() => setRenamingConstellationGroupIndex(index)}
|
||||
>
|
||||
{group.name.trim() || `Condition ${index + 1}`}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</ScrollableNav>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
เพิ่ม Condition
|
||||
</Button>
|
||||
</div>
|
||||
<ConditionRenameDialog
|
||||
open={renamingConstellationGroupIndex !== null}
|
||||
fallback={`Condition ${(renamingConstellationGroupIndex ?? 0) + 1}`}
|
||||
@@ -2598,27 +2632,7 @@ function ConstellationEditor({
|
||||
</Card>
|
||||
);}) : null}
|
||||
</CardContent>
|
||||
<CardFooter className="flex-wrap justify-between gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={groups.length >= 20}
|
||||
onClick={() => {
|
||||
setGroups([
|
||||
...groups,
|
||||
{
|
||||
clientId: createClientId("constellation-condition"),
|
||||
name: "",
|
||||
note: "",
|
||||
rows: makeRows(),
|
||||
},
|
||||
])
|
||||
setActiveConstellationGroupIndex(groups.length);
|
||||
}}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
เพิ่ม Condition
|
||||
</Button>
|
||||
<CardFooter className="justify-end">
|
||||
<AutosaveStatus
|
||||
state={autosave.state}
|
||||
issues={
|
||||
@@ -2636,6 +2650,22 @@ function ConstellationEditor({
|
||||
);
|
||||
}
|
||||
|
||||
type TeamImportGuide = {
|
||||
id: string;
|
||||
name: string;
|
||||
groups: Array<{
|
||||
id: string;
|
||||
name: string | null;
|
||||
teams: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
rotationSeconds: string;
|
||||
combo: string;
|
||||
members: Array<TeamToolValue["members"][number] & { characterName: string }>;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
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<File[]>([]);
|
||||
const [guideImportOpen, setGuideImportOpen] = useState(false);
|
||||
const [importGuides, setImportGuides] = useState<Array<{ id: string; name: string; characterKey: string; isPublic: boolean; coverUrl: string }> | null>(null);
|
||||
const [activeImportGuide, setActiveImportGuide] = useState<TeamImportGuide | null>(null);
|
||||
const [selectedImports, setSelectedImports] = useState<Record<string, Omit<TeamToolValue, "clientId">>>({});
|
||||
const [guideImportLoading, setGuideImportLoading] = useState(false);
|
||||
const [guideImportError, setGuideImportError] = useState<string | null>(null);
|
||||
const [guideImportCondition, setGuideImportCondition] = useState("0");
|
||||
const [guideTeamQuery, setGuideTeamQuery] = useState("");
|
||||
const [guideTeamSearchTarget, setGuideTeamSearchTarget] = useState<TeamSearchTarget>("all");
|
||||
const [guideListQuery, setGuideListQuery] = useState("");
|
||||
const [presetName, setPresetName] = useState("");
|
||||
const [presets, setPresets] = useState<TeamPreset[]>([]);
|
||||
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<Omit<TeamToolValue, "clientId">>) => {
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -3736,44 +3824,293 @@ function TeamEditor({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={importOpen} onOpenChange={setImportOpen}>
|
||||
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden">
|
||||
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Team</DialogTitle>
|
||||
<DialogDescription>
|
||||
วาง JSON ที่ Export จาก Team ใน Guide อื่น ระบบจะเพิ่ม Team ใหม่ต่อท้ายรายการ
|
||||
เลือก Team จาก Guide อื่น หรืออัปโหลดไฟล์ JSON ที่เคย Export ไว้
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup className="min-h-0 overflow-y-auto pr-1">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="team-import-json">Team JSON</FieldLabel>
|
||||
<Textarea
|
||||
id="team-import-json"
|
||||
value={importValue}
|
||||
rows={12}
|
||||
placeholder={'{\n "type": "buzz-sheet.team",\n ...\n}'}
|
||||
onChange={(event) => setImportValue(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="team-import-files">เลือกไฟล์ JSON</FieldLabel>
|
||||
<Input
|
||||
id="team-import-files"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
multiple
|
||||
onChange={(event) => setImportFiles(Array.from(event.target.files ?? []))}
|
||||
/>
|
||||
<FieldDescription>
|
||||
เลือกหลายไฟล์เพื่อ Import หลาย Team พร้อมกันได้
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>เลือกจาก Guide</CardTitle>
|
||||
<CardDescription>เลือก Team ที่บันทึกอยู่ใน Guide อื่นได้โดยไม่ต้องดาวน์โหลดไฟล์</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Button type="button" variant="outline" onClick={() => void openGuideImport()}>
|
||||
<BookOpenIcon data-icon="inline-start" />
|
||||
เปิดรายการ Guide
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>อัปโหลดไฟล์ JSON</CardTitle>
|
||||
<CardDescription>เลือกหลายไฟล์เพื่อ Import หลาย Team พร้อมกันได้</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="team-import-files">ไฟล์ Team JSON</FieldLabel>
|
||||
<Input
|
||||
id="team-import-files"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
multiple
|
||||
onChange={(event) => setImportFiles(Array.from(event.target.files ?? []))}
|
||||
/>
|
||||
</Field>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="button" disabled={!importFiles.length} onClick={() => void importTeamFiles()}>
|
||||
Import {importFiles.length || ""} Team
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setImportOpen(false)}>
|
||||
ยกเลิก
|
||||
ปิด
|
||||
</Button>
|
||||
<Button type="button" disabled={!importValue.trim() && !importFiles.length} onClick={() => void importTeam()}>
|
||||
Import Team
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={guideImportOpen} onOpenChange={setGuideImportOpen}>
|
||||
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{activeImportGuide ? activeImportGuide.name : "เลือก Guide"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{activeImportGuide
|
||||
? "เลือก Team ที่ต้องการ สามารถย้อนกลับไปเลือกจาก Guide อื่นเพิ่มได้"
|
||||
: "ค้นหาและเปิด Guide เพื่อเลือก Team ที่ต้องการ Import"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
{guideImportError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>โหลดข้อมูลไม่สำเร็จ</AlertTitle>
|
||||
<AlertDescription>{guideImportError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{guideImportLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : activeImportGuide ? (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||
<Button type="button" variant="ghost" className="self-start" onClick={() => setActiveImportGuide(null)}>
|
||||
<ArrowLeftIcon data-icon="inline-start" />
|
||||
กลับไปเลือก Guide
|
||||
</Button>
|
||||
{activeImportGuide.groups.length > 1 ? (
|
||||
<ScrollableNav ariaLabel="เลือกเงื่อนไขทีม" className="w-full min-w-0">
|
||||
<ToggleGroup
|
||||
aria-label="เลือกเงื่อนไขทีม"
|
||||
variant="outline"
|
||||
value={[guideImportCondition]}
|
||||
onValueChange={(values) => { if (values[0]) setGuideImportCondition(values[0]); }}
|
||||
>
|
||||
{activeImportGuide.groups.map((group, index) => (
|
||||
<ToggleGroupItem key={group.id} value={String(index)} className="aria-pressed:bg-primary aria-pressed:text-primary-foreground">
|
||||
{group.name || `Condition ${index + 1}`}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</ScrollableNav>
|
||||
) : null}
|
||||
<div className="relative">
|
||||
<SearchIcon aria-hidden="true" className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="ค้นหาทีม ตัวละคร อาวุธ หรืออาร์ติแฟกต์"
|
||||
className="pl-9"
|
||||
placeholder="ค้นหาทีม ตัวละคร อาวุธ หรืออาร์ติแฟกต์"
|
||||
value={guideTeamQuery}
|
||||
onValueChange={setGuideTeamQuery}
|
||||
/>
|
||||
</div>
|
||||
<ToggleGroup
|
||||
aria-label="เลือกประเภทข้อมูลที่ต้องการค้นหา"
|
||||
className="max-w-full flex-wrap justify-start"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
value={[guideTeamSearchTarget]}
|
||||
onValueChange={(values) => { if (values[0]) setGuideTeamSearchTarget(values[0] as TeamSearchTarget); }}
|
||||
>
|
||||
<ToggleGroupItem value="all">ทั้งหมด</ToggleGroupItem>
|
||||
<ToggleGroupItem value="team">ชื่อทีม</ToggleGroupItem>
|
||||
<ToggleGroupItem value="character">ตัวละคร</ToggleGroupItem>
|
||||
<ToggleGroupItem value="weapon">อาวุธ</ToggleGroupItem>
|
||||
<ToggleGroupItem value="artifact">อาร์ติแฟกต์</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div className="min-h-0 overflow-y-auto pr-1">
|
||||
{filteredImportTeams.length ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{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 (
|
||||
<Card key={team.id} className={cn(checked && "ring-2 ring-primary")}>
|
||||
<CardHeader>
|
||||
<CardTitle>{team.title}</CardTitle>
|
||||
<CardDescription>โรเทชั่น {seconds} วิ</CardDescription>
|
||||
<CardAction>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={`guide-team-${team.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(nextChecked) => 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;
|
||||
})}
|
||||
/>
|
||||
<FieldLabel htmlFor={`guide-team-${team.id}`}>เลือก</FieldLabel>
|
||||
</Field>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{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 (
|
||||
<div key={member.characterKey} className="min-w-0 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className={cn("relative size-16 shrink-0 overflow-hidden rounded-lg bg-muted", rarityGradientClass(isCustomTravelerKey(member.characterKey) ? 5 : character?.rarity))}>
|
||||
{character ? <Image src={character.imageUrl} alt={character.name} fill sizes="64px" className="object-contain" /> : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{[weapon, artifact, secondArtifact].filter((item): item is CatalogItem => Boolean(item)).map((item) => (
|
||||
<div key={item.key} className={cn("relative size-7 overflow-hidden rounded-md", rarityGradientClass(item.rarity))}>
|
||||
<Image src={item.imageUrl} alt={item.name} fill sizes="28px" className="object-contain p-0.5" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-center gap-1">
|
||||
<Badge variant="secondary">C{member.constellation}</Badge>
|
||||
<Badge variant="secondary">R{member.refinement}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-sm font-medium">{character?.name ?? member.characterName}</p>
|
||||
<p className="mt-1 text-sm font-semibold">{Number(member.damage).toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{total ? ((Number(member.damage) / total) * 100).toFixed(1) : "0.0"}%</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div><p className="text-sm text-muted-foreground">ดาเมจรวม</p><p className="font-semibold">{total.toLocaleString()}</p></div>
|
||||
<div className="text-right"><p className="text-sm text-muted-foreground">DPS</p><p className="text-3xl font-semibold text-primary">{seconds > 0 ? (total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 }) : "0"}</p></div>
|
||||
</div>
|
||||
<p className="rounded-lg bg-muted p-3 text-sm leading-6">{team.combo}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{activeImportGroup?.teams.length ? "ไม่พบ Team ที่ตรงกับคำค้นหา" : "Condition นี้ยังไม่มี Team"}</EmptyTitle>
|
||||
<EmptyDescription>{activeImportGroup?.teams.length ? "ลองเปลี่ยนคำค้นหาหรือประเภทข้อมูล" : "เลือก Condition หรือ Guide อื่นเพื่อ Import"}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : importGuides?.length ? (
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
<div className="relative">
|
||||
<SearchIcon aria-hidden="true" className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="ค้นหา Guide"
|
||||
className="pl-9"
|
||||
placeholder="ค้นหาชื่อ Guide หรือตัวละคร…"
|
||||
value={guideListQuery}
|
||||
onValueChange={setGuideListQuery}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 overflow-y-auto pr-1">
|
||||
{filteredImportGuides.length ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredImportGuides.map((sourceGuide) => (
|
||||
<Card key={sourceGuide.id} className="relative overflow-hidden py-0 transition-colors hover:bg-muted/50">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`เปิด Guide ${sourceGuide.name}`}
|
||||
className="absolute inset-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => void enterImportGuide(sourceGuide.id)}
|
||||
/>
|
||||
<div className="relative aspect-4/3 bg-muted">
|
||||
<Image
|
||||
src={sourceGuide.coverUrl}
|
||||
alt={`Guide ${sourceGuide.name}`}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 45vw, 90vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<CardHeader className="pt-4">
|
||||
<CardTitle>{sourceGuide.name}</CardTitle>
|
||||
<CardDescription>{sourceGuide.characterKey}</CardDescription>
|
||||
<CardAction>
|
||||
<Badge variant={sourceGuide.isPublic ? "default" : "secondary"}>
|
||||
{sourceGuide.isPublic ? "Published" : "Draft"}
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="relative pb-4">
|
||||
<Button type="button" size="xs" variant="outline" onClick={() => void enterImportGuide(sourceGuide.id)}>
|
||||
เปิด Guide
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>ไม่พบ Guide ที่ตรงกับคำค้นหา</EmptyTitle>
|
||||
<EmptyDescription>ลองค้นหาด้วยชื่อ Guide หรือตัวละครอื่น</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon"><BookOpenIcon /></EmptyMedia>
|
||||
<EmptyTitle>ไม่มี Guide อื่นให้เลือก</EmptyTitle>
|
||||
<EmptyDescription>สร้างหรือบันทึก Team ใน Guide อื่นก่อน</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setGuideImportOpen(false)}>ยกเลิก</Button>
|
||||
<Button type="button" disabled={!Object.keys(selectedImports).length} onClick={confirmGuideImport}>
|
||||
Import {Object.keys(selectedImports).length || ""} Team
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
+74
-1
@@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, asc, eq, inArray, isNotNull, isNull } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray, isNotNull, isNull, ne } from "drizzle-orm";
|
||||
import * as z from "zod";
|
||||
|
||||
import { getDb } from "@/db";
|
||||
@@ -134,6 +134,79 @@ export async function listGuideCards(includeTrash = false) {
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listAdminTeamImportGuides(excludeGuideId: string) {
|
||||
const rows = await getDb()
|
||||
.select({
|
||||
id: guides.id,
|
||||
name: guides.name,
|
||||
characterKey: guides.characterKey,
|
||||
isPublic: guides.isPublic,
|
||||
coverObjectKey: media.objectKey,
|
||||
})
|
||||
.from(guides)
|
||||
.innerJoin(media, eq(media.id, guides.coverMediaId))
|
||||
.where(and(isNull(guides.trashedAt), ne(guides.id, excludeGuideId)))
|
||||
.orderBy(asc(guides.sortOrder), asc(guides.name));
|
||||
|
||||
return rows.map(({ coverObjectKey, ...guide }) => ({
|
||||
...guide,
|
||||
coverUrl: publicMediaUrl(coverObjectKey),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAdminTeamImportGuide(guideId: string) {
|
||||
const [guide] = await getDb()
|
||||
.select({ id: guides.id, name: guides.name })
|
||||
.from(guides)
|
||||
.where(and(eq(guides.id, guideId), isNull(guides.trashedAt)))
|
||||
.limit(1);
|
||||
if (!guide) return null;
|
||||
|
||||
const [conditions, teams] = await Promise.all([
|
||||
getDb().select().from(teamConditions)
|
||||
.where(eq(teamConditions.guideId, guideId))
|
||||
.orderBy(asc(teamConditions.sortOrder)),
|
||||
getDb().select().from(teamCompositions)
|
||||
.where(eq(teamCompositions.guideId, guideId))
|
||||
.orderBy(asc(teamCompositions.sortOrder)),
|
||||
]);
|
||||
const members = teams.length
|
||||
? await getDb()
|
||||
.select({ member: teamMembers, characterName: catalogCharacters.name })
|
||||
.from(teamMembers)
|
||||
.leftJoin(catalogCharacters, eq(catalogCharacters.key, teamMembers.characterKey))
|
||||
.where(inArray(teamMembers.teamId, teams.map((team) => team.id)))
|
||||
.orderBy(asc(teamMembers.position))
|
||||
: [];
|
||||
const hydratedTeams = teams.map((team) => ({
|
||||
id: team.id,
|
||||
title: team.title,
|
||||
rotationSeconds: team.rotationSeconds,
|
||||
combo: team.combo,
|
||||
members: members.filter(({ member }) => member.teamId === team.id).map(({ member, characterName }) => ({
|
||||
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,
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
...guide,
|
||||
groups: conditions.length
|
||||
? conditions.map((condition) => ({
|
||||
id: condition.id,
|
||||
name: condition.name,
|
||||
teams: hydratedTeams.filter((team) => teams.find((candidate) => candidate.id === team.id)?.conditionId === condition.id),
|
||||
}))
|
||||
: [{ id: "team-condition-default", name: null, teams: hydratedTeams }],
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPublicGuideCards() {
|
||||
const [rows, aliases] = await Promise.all([getDb()
|
||||
.select({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
applyTeamFields,
|
||||
cloneTeamImports,
|
||||
findInvalidTeamCatalogReference,
|
||||
findUnsupportedTeamWeapon,
|
||||
limitPresets,
|
||||
@@ -144,6 +145,21 @@ describe("team bulk tools", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clones imported teams with new ids and independent members", () => {
|
||||
const source = team("source");
|
||||
const transfer = {
|
||||
title: source.title,
|
||||
rotationSeconds: source.rotationSeconds,
|
||||
combo: source.combo,
|
||||
members: source.members,
|
||||
};
|
||||
const imported = cloneTeamImports([transfer], () => "imported-1");
|
||||
|
||||
expect(imported[0]).toEqual({ ...source, clientId: "imported-1" });
|
||||
expect(imported[0].members).not.toBe(source.members);
|
||||
expect(imported[0].members[0]).not.toBe(source.members[0]);
|
||||
});
|
||||
|
||||
it("rejects malformed and unsupported team transfers", () => {
|
||||
expect(() => parseTeamTransfer("not-json")).toThrow();
|
||||
expect(() => parseTeamTransfer(JSON.stringify({
|
||||
|
||||
@@ -106,6 +106,17 @@ export function parseTeamTransfer(serialized: string): Omit<TeamToolValue, "clie
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneTeamImports(
|
||||
teams: readonly Omit<TeamToolValue, "clientId">[],
|
||||
createId: () => string,
|
||||
): TeamToolValue[] {
|
||||
return teams.map((team) => ({
|
||||
clientId: createId(),
|
||||
...team,
|
||||
members: team.members.map((member) => ({ ...member })),
|
||||
}));
|
||||
}
|
||||
|
||||
export function findInvalidTeamCatalogReference(
|
||||
team: Omit<TeamToolValue, "clientId">,
|
||||
characters: readonly TeamToolCatalogItem[],
|
||||
|
||||
Reference in New Issue
Block a user