feat : improve import UI
CI / Verify (push) Successful in 1m15s
CI / Build immutable images and deploy (push) Successful in 1m50s

This commit is contained in:
2026-09-08 19:23:05 +07:00 Unverified
parent cd66d498ab
commit d85a48be25
5 changed files with 579 additions and 113 deletions
+29
View File
@@ -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));
+404 -67
View File
@@ -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={() => {
<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 data-icon="inline-start" />เพิ่ม Condition</Button>
<Button type="button" variant="outline" disabled={targetGroups.length >= 20} onClick={() => {
}}><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 data-icon="inline-start" />Duplicate Condition</Button>
}}><CopyIcon /></Button>
</SimpleTooltip>
</div>
<Field>
<FieldLabel htmlFor={`target-condition-note-${targetGroups[activeTargetGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
@@ -2404,7 +2416,8 @@ function ConstellationEditor({
onChange={(event) => setNote(event.target.value)}
/>
</Field>
<ScrollableNav ariaLabel="เลือกเงื่อนไขกลุ่มดาว" className="w-full min-w-0">
<div className="flex flex-wrap gap-2">
<ScrollableNav ariaLabel="เลือกเงื่อนไขกลุ่มดาว" className="min-w-0 flex-1">
<ToggleGroup
aria-label="เลือกเงื่อนไขกลุ่มดาว"
variant="outline"
@@ -2425,6 +2438,27 @@ function ConstellationEditor({
))}
</ToggleGroup>
</ScrollableNav>
<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>
</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,18 +3029,12 @@ function TeamEditor({
toast.success(`ดาวน์โหลด ${teamTransferFileName(team.title)} แล้ว`);
}
};
const importTeam = async () => {
try {
const serializedTeams = [
...(importValue.trim() ? [importValue] : []),
...(await Promise.all(importFiles.map((file) => file.text()))),
];
if (!serializedTeams.length) return;
if (teams.length + serializedTeams.length > 30) {
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;
return false;
}
const importedTeams = serializedTeams.map(parseTeamTransfer);
for (const imported of importedTeams) {
const catalogIssue = findInvalidTeamCatalogReference(
imported,
@@ -2992,25 +3044,61 @@ function TeamEditor({
);
if (catalogIssue) {
toast.error(catalogIssue);
return;
return false;
}
}
setTeams((current) => [
...current,
...importedTeams.map((imported) => ({
clientId: createClientId("team"),
...imported,
members: imported.members.map((member) => ({ ...member })),
})),
...cloneTeamImports(importedTeams, () => createClientId("team")),
]);
setImportValue("");
return true;
};
const importTeamFiles = async () => {
try {
const serializedTeams = await Promise.all(importFiles.map((file) => file.text()));
if (!serializedTeams.length) return;
const importedTeams = serializedTeams.map(parseTeamTransfer);
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,26 +3824,34 @@ 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">
<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-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>
<FieldLabel htmlFor="team-import-files">ไฟล์ Team JSON</FieldLabel>
<Input
id="team-import-files"
type="file"
@@ -3763,17 +3859,258 @@ function TeamEditor({
multiple
onChange={(event) => setImportFiles(Array.from(event.target.files ?? []))}
/>
<FieldDescription>
เลือกหลายไฟล์เพื่อ Import หลาย Team พร้อมกันได้
</FieldDescription>
</Field>
</FieldGroup>
</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
View File
@@ -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({
+16
View File
@@ -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({
+11
View File
@@ -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[],