Files
buzz-sheet/components/admin/editors/team-editor.tsx
T
gunshiz 9580407a90
CI / Verify (push) Successful in 1m56s
CI / Build immutable images and deploy (push) Successful in 3m12s
perf : cache
2026-10-08 23:21:34 +07:00

1873 lines
94 KiB
TypeScript

"use client";
import { searchItems } from "@/lib/search";
import Image from "@/components/ui/resilient-image";
import { ArrowLeftIcon, BookOpenIcon, ClipboardCopyIcon, ClipboardPasteIcon, CopyIcon, GripVerticalIcon, MousePointerClickIcon, PlusIcon, SearchIcon, Trash2Icon, XIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode, type SetStateAction } from "react";
import { toast } from "sonner";
import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { listTeamImportGuides, listTeamImportsForCharacter, loadTeamImportGuide, saveTeamSection } from "@/app/admin/actions";
import { AutosaveStatus } from "@/components/admin/autosave-status";
import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog";
import { ArtifactSelect, CatalogDropdown, CharacterSelect, WeaponSelect } from "@/components/admin/ui/catalog-select";
import { ConstellationSelect, RefinementSelect } from "@/components/admin/ui/option-select";
import { ScrollableNav } from "@/components/public/scrollable-nav";
import { TeamArtifactIcon } from "@/components/public/team-search";
import { SimpleTooltip } from "@/components/tooltip";
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, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { Field, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
import { ClearableInput as Input } from "@/components/admin/ui/clearable-input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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 { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useAutosave } from "@/hooks/use-autosave";
import { adminDraftKey } from "@/lib/editor/local-draft";
import { teamSectionSchema } from "@/lib/guides/schemas";
import { elementBackgroundColor } from "@/lib/catalog/element";
import { supportsWeaponType } from "@/lib/catalog/lunaris";
import { characterRarityGradientClass, rarityGradientClass } from "@/lib/catalog/rarity";
import { isCustomTravelerKey } from "@/lib/catalog/travelers";
import { toTeamDraft } from "@/lib/guides/team-draft";
import { applyPresetFields, applyTeamFields, cloneTeamImports, filterTeamImportsByName, findInvalidTeamCatalogReference, findUnsupportedTeamWeapon, limitPresets, parseTeamPresets, parseTeamTransfer, serializeTeamTransfer, teamConstellationLabel, teamRefinementLabel, teamTransferFileName, teamPresetCookieKey, teamPresetStorageKey, type TeamApplySelection, type TeamMemberApplyField, type TeamToolMember, type TeamPreset, type TeamToolValue } from "@/lib/guides/team-tools";
import { searchTeams, type TeamSearchTarget } from "@/lib/guides/team-search";
import { formatGuideValidationIssues } from "@/lib/guides/validation-messages";
import { cn } from "@/lib/utils";
import { type CatalogItem, type EditorBase, createClientId, parseEditorDraft, ConditionControls, reorderConditions, ConditionRenameDialog } from "./shared";
export const teamSearchTargetLabels: Record<TeamSearchTarget, string> = {
all: "ทั้งหมด",
team: "ชื่อทีม",
character: "ตัวละคร",
weapon: "อาวุธ",
artifact: "อาร์ติแฟกต์",
};
export const teamMemberApplyOptions = [
["characterKey", "Character"],
["weaponKey", "Weapon"],
["artifactKey", "Artifact 1"],
["secondArtifactKey", "Artifact 2"],
["constellation", "C"],
["refinement", "R"],
["damage", "Damage"],
] as const satisfies readonly (readonly [TeamMemberApplyField, string])[];
export const multipleApplyStorageKey = (guideId: string) =>
`buzz-sheet:team-multiple-apply:${guideId}`;
export function SortableTeamItem({
id,
children,
}: {
id: string;
children: (dragHandle: ReactNode) => ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn("min-w-0", isDragging && "opacity-60")}
>
{children(
<SimpleTooltip text="ลากเพื่อจัดลำดับ Team">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="touch-none"
{...attributes}
{...listeners}
>
<GripVerticalIcon />
<span className="sr-only">ลากเพื่อจัดลำดับ Team</span>
</Button>
</SimpleTooltip>,
)}
</div>
);
}
export function SortableTeamMember({
id,
children,
}: {
id: string;
children: (dragHandle: ReactNode) => ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(isDragging && "opacity-60")}
>
{children(
<SimpleTooltip text="ลากเพื่อย้ายตำแหน่งตัวละคร">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="touch-none"
{...attributes}
{...listeners}
>
<GripVerticalIcon />
<span className="sr-only">ลากเพื่อย้ายตำแหน่งตัวละคร</span>
</Button>
</SimpleTooltip>,
)}
</div>
);
}
export 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 }>;
}>;
}>;
};
export type TeamEverywhereImport = {
id: string;
title: string;
rotationSeconds: string;
combo: string;
guideName: string;
conditionName: string | null;
members: Array<TeamToolValue["members"][number] & { characterName: string }>;
};
export 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 (
<Card className={cn(checked && "ring-2 ring-primary")}>
<CardHeader>
<CardTitle>{team.title}</CardTitle>
<CardDescription>โรเทชั่น {seconds} วิ{sourceLabel ? ` - ${sourceLabel}` : ""}</CardDescription>
<CardAction>
<Field orientation="horizontal">
<Checkbox id={checkboxId} checked={checked} disabled={disabled} onCheckedChange={(value) => onCheckedChange(value === true)} />
<FieldLabel htmlFor={checkboxId}>{disabled ? "ชื่อซ้ำ" : "เลือก"}</FieldLabel>
</Field>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-4 gap-2">
{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 (
<div key={`${member.characterKey}-${index}`} 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", characterRarityGradientClass(member.characterKey, 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 ? (
<div className={cn("relative size-7 overflow-hidden rounded-md", rarityGradientClass(weapon.rarity))}>
<Image src={weapon.imageUrl} alt={weapon.name} fill sizes="28px" className="object-contain p-0.5" />
</div>
) : null}
{artifact || secondArtifact ? (
<TeamArtifactIcon
first={artifact ? { ...artifact, rarity: artifact.rarity ?? null } : null}
second={secondArtifact ? { ...secondArtifact, rarity: secondArtifact.rarity ?? null } : null}
sizes="28px"
className="size-7 shrink-0 scale-100"
/>
) : null}
</div>
</div>
<div className="mt-2 flex items-center justify-center gap-1">
<Badge variant="secondary">{teamConstellationLabel(member.constellation)}</Badge>
<Badge variant="secondary">{teamRefinementLabel(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>
);
}
export function TeamEditor({
guide,
characters,
weapons,
artifacts,
initial,
}: EditorBase) {
const fallbackMembers = Array.from({ length: 4 }, () => ({
characterKey: "",
weaponKey: null,
artifactKey: null,
secondArtifactKey: null,
constellation: "0",
refinement: "1",
damage: "0",
}));
const teamSensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const memberSortableIds = useRef(new WeakMap<TeamToolMember, string>());
const getMemberSortableId = (member: TeamToolMember) => {
const existingId = memberSortableIds.current.get(member);
if (existingId) return existingId;
const id = createClientId("team-member");
memberSortableIds.current.set(member, id);
return id;
};
const [teamGroups, setTeamGroups] = useState(() => initial.teamGroups.length
? initial.teamGroups.map((group) => ({ clientId: group.id, name: group.name ?? "", note: group.note ?? "", iconMediaId: group.iconMediaId ?? null, iconUrl: group.iconUrl ?? null, teams: group.teams.map(({ id, ...team }) => ({ clientId: id, ...team })) }))
: [{ clientId: "team-condition-default", name: "", note: "", iconMediaId: null, iconUrl: null, teams: [] as TeamToolValue[] }]);
const [activeTeamGroupIndex, setActiveTeamGroupIndex] = useState(0);
const [renamingTeamGroupIndex, setRenamingTeamGroupIndex] = useState<number | null>(null);
const teams = useMemo(() => teamGroups[activeTeamGroupIndex]?.teams ?? [], [activeTeamGroupIndex, teamGroups]);
const setTeams = (update: SetStateAction<TeamToolValue[]>) => setTeamGroups((current) => current.map((group, index) => index === activeTeamGroupIndex ? { ...group, teams: typeof update === "function" ? update(group.teams) : update } : group));
const [selectedTeamIds, setSelectedTeamIds] = useState<Set<string>>(
() => new Set(),
);
const [sourceTeamId, setSourceTeamId] = useState(teams[0]?.clientId ?? "");
const [applyOpen, setApplyOpen] = useState(false);
const [presetOpen, setPresetOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importFiles, setImportFiles] = useState<File[]>([]);
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<TeamEverywhereImport[]>([]);
const [everywhereLoading, setEverywhereLoading] = useState(false);
const [everywhereError, setEverywhereError] = useState<string | null>(null);
const [everywhereSelectedIds, setEverywhereSelectedIds] = useState<Set<string>>(() => new Set());
const [ignoreSameName, setIgnoreSameName] = useState(true);
const [importGuides, setImportGuides] = useState<Array<{ id: string; name: string; characterKey: string; coverUrl: string }> | null>(null);
const [activeImportGuide, setActiveImportGuide] = useState<TeamImportGuide | null>(null);
const [selectedImports, setSelectedImports] = useState<Record<string, Omit<TeamToolValue, "clientId">>>({});
const [guideImportLoading, setGuideImportLoading] = useState<"guides" | "teams" | null>(null);
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);
const [presetsGuideId, setPresetsGuideId] = useState<string | null>(null);
const [applyFields, setApplyFields] = useState<TeamApplySelection[]>([]);
const [multipleApplyGuideId, setMultipleApplyGuideId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [searchTarget, setSearchTarget] = useState<TeamSearchTarget>("all");
const teamSearch = 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 searchTeams(teams.map((team, teamIndex) => ({ team, teamIndex })), ({ team }) => ({
team: [team.title],
character: team.members.flatMap((member) => {
const name = characterNames.get(member.characterKey);
return name ?? [];
}),
weapon: team.members.flatMap((member) => {
const name = member.weaponKey
? weaponNames.get(member.weaponKey)
: undefined;
return name ?? [];
}),
artifact: team.members.flatMap((member) =>
[member.artifactKey, member.secondArtifactKey].flatMap((key) => {
const name = key ? artifactNames.get(key) : undefined;
return name ?? [];
}),
),
}), searchQuery, searchTarget);
}, [artifacts, characters, searchQuery, searchTarget, teams, weapons]);
const activeImportGroup = activeImportGuide?.groups[Number(guideImportCondition)]
?? activeImportGuide?.groups[0];
const importTeamSearch = 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 searchTeams(activeImportGroup?.teams ?? [], (team) => ({
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 importGuideSearch = useMemo(() => {
return searchItems(importGuides ?? [], guideListQuery,
(sourceGuide) => `${sourceGuide.name} ${sourceGuide.characterKey}`,
(sourceGuide) => [sourceGuide.name]);
}, [guideListQuery, importGuides]);
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 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,
rotationSeconds: Number(team.rotationSeconds),
combo: team.combo,
members: team.members.map((member) => ({
...member,
damage: Number(member.damage),
})),
})) })),
};
const validation = teamSectionSchema.safeParse({
...value,
expectedVersion: guide.version,
});
const teamFieldInvalid = (teamIndex: number, field: string) =>
!validation.success && validation.error.issues.some((issue) =>
issue.path[0] === "groups" && issue.path[1] === activeTeamGroupIndex && issue.path[2] === "teams" && issue.path[3] === teamIndex && issue.path[4] === field,
);
const memberFieldInvalid = (teamIndex: number, memberIndex: number, field: string) =>
!validation.success && validation.error.issues.some((issue) =>
issue.path[0] === "groups" && issue.path[1] === activeTeamGroupIndex && issue.path[2] === "teams" && issue.path[3] === teamIndex && issue.path[4] === "members" &&
issue.path[5] === memberIndex && issue.path[6] === field,
);
const localDraft = { teamGroups, activeTeamGroupIndex };
useEffect(() => {
const timer = window.setTimeout(() => {
try {
setPresets(parseTeamPresets(window.localStorage.getItem(teamPresetStorageKey(guide.id))));
} catch {
setPresets([]);
}
setPresetsHydrated(true);
setPresetsGuideId(guide.id);
}, 0);
return () => window.clearTimeout(timer);
}, [guide.id]);
useEffect(() => {
const timer = window.setTimeout(() => {
try {
const stored = JSON.parse(
window.localStorage.getItem(multipleApplyStorageKey(guide.id)) ?? "null",
) as { selectedTeamIds?: unknown; applyFields?: unknown } | null;
const teamIds = new Set(initial.teamGroups.flatMap((group) => group.teams.map((team) => team.id)));
setSelectedTeamIds(new Set(
Array.isArray(stored?.selectedTeamIds)
? stored.selectedTeamIds.filter(
(id): id is string => typeof id === "string" && teamIds.has(id),
)
: [],
));
setApplyFields(
Array.isArray(stored?.applyFields)
? stored.applyFields.filter(
(field): field is TeamApplySelection => typeof field === "string",
)
: [],
);
} catch {
setSelectedTeamIds(new Set());
setApplyFields([]);
}
setMultipleApplyGuideId(guide.id);
}, 0);
return () => window.clearTimeout(timer);
}, [guide.id, initial.teamGroups]);
useEffect(() => {
if (multipleApplyGuideId !== guide.id) return;
try {
window.localStorage.setItem(
multipleApplyStorageKey(guide.id),
JSON.stringify({
selectedTeamIds: [...selectedTeamIds],
applyFields,
}),
);
} catch {
// Browser storage is optional; multiple apply remains usable without it.
}
}, [applyFields, guide.id, multipleApplyGuideId, selectedTeamIds]);
useEffect(() => {
if (!presetsHydrated || presetsGuideId !== guide.id) return;
try {
window.localStorage.setItem(teamPresetStorageKey(guide.id), JSON.stringify(presets));
document.cookie = `${teamPresetCookieKey(guide.id)}=${encodeURIComponent(JSON.stringify(presets.map(({ id, name, createdAt }) => ({ id, name, createdAt })) ))}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
} catch {
// Browser storage is optional; the editor remains usable without it.
}
}, [guide.id, presets, presetsGuideId, presetsHydrated]);
const autosave = useAutosave({
guideId: guide.id,
scope: "team",
value,
valid: validation.success,
initialVersion: guide.version,
persist: (next, expectedVersion, intent) =>
saveTeamSection(guide.id, { ...next, expectedVersion }, intent),
storageKey: adminDraftKey(guide.id, "team"),
localDraft,
parseLocalDraft: (candidate) =>
parseEditorDraft<typeof localDraft>(candidate, ["teamGroups"]),
restoreLocalDraft: (draft) => {
setTeamGroups(draft.teamGroups.map((group) => ({ ...group, name: group.name ?? "" })));
setActiveTeamGroupIndex(draft.activeTeamGroupIndex ?? 0);
},
});
const handleTeamDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
setTeams((current) => {
const from = current.findIndex((team) => team.clientId === active.id);
const to = current.findIndex((team) => team.clientId === over.id);
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
});
};
const handleTeamMemberDragEnd = (
teamId: string,
{ active, over }: DragEndEvent,
) => {
if (!over || active.id === over.id) return;
setTeams((current) =>
current.map((team) => {
if (team.clientId !== teamId) return team;
const memberIds = team.members.map(getMemberSortableId);
const from = memberIds.indexOf(String(active.id));
const to = memberIds.indexOf(String(over.id));
return from === -1 || to === -1
? team
: { ...team, members: arrayMove(team.members, from, to) };
}),
);
};
const updateTeam = (
teamIndex: number,
update: Partial<Omit<TeamToolValue, "clientId" | "members">>,
) => {
setTeams((current) =>
current.map((team, index) =>
index === teamIndex ? { ...team, ...update } : team,
),
);
};
const updateTeamMember = (
teamIndex: number,
memberIndex: number,
update: Partial<TeamToolValue["members"][number]>,
) => {
setTeams((current) =>
current.map((team, index) =>
index === teamIndex
? {
...team,
members: team.members.map((member, innerIndex) => {
if (innerIndex !== memberIndex) return member;
const nextMember = { ...member, ...update };
memberSortableIds.current.set(
nextMember,
getMemberSortableId(member),
);
return nextMember;
}),
}
: team,
),
);
};
const toggleTeamSelection = (teamId: string, checked: boolean) => {
setSelectedTeamIds((current) => {
const next = new Set(current);
if (checked) next.add(teamId);
else next.delete(teamId);
return next;
});
};
const toggleApplyField = (field: TeamApplySelection, checked: boolean) => {
setApplyFields((current) => {
const next = new Set(current);
if (checked) next.add(field);
else next.delete(field);
return [...next];
});
};
const applyChanges = () => {
if (!sourceTeamId || !selectedTeamIds.size || !applyFields.length) return;
const nextTeams = applyTeamFields(
teams,
sourceTeamId,
selectedTeamIds,
applyFields,
);
const unsupported = findUnsupportedTeamWeapon(
nextTeams,
selectedTeamIds,
characters,
weapons,
);
if (unsupported) {
toast.error(
`${unsupported.characterName} ไม่รองรับอาวุธ ${unsupported.weaponName}`,
);
return;
}
setTeams(nextTeams);
setSelectedTeamIds(new Set());
setApplyOpen(false);
};
const savePreset = () => {
const source = teams.find((team) => team.clientId === sourceTeamId);
const name = presetName.trim();
if (!source || !name) return;
const preset: TeamPreset = {
id: createClientId("preset"),
name,
createdAt: new Date().toISOString(),
team: {
title: source.title,
rotationSeconds: source.rotationSeconds,
combo: source.combo,
members: source.members.map((member) => ({ ...member })),
},
};
setPresets((current) => limitPresets([preset, ...current]));
setPresetName("");
setPresetOpen(false);
};
const applyPreset = (preset: TeamPreset) => {
if (!selectedTeamIds.size || !applyFields.length) return;
const nextTeams = applyPresetFields(
teams,
preset,
selectedTeamIds,
applyFields,
);
const unsupported = findUnsupportedTeamWeapon(
nextTeams,
selectedTeamIds,
characters,
weapons,
);
if (unsupported) {
toast.error(
`${unsupported.characterName} ไม่รองรับอาวุธ ${unsupported.weaponName}`,
);
return;
}
setTeams(nextTeams);
setSelectedTeamIds(new Set());
};
const exportTeam = async (team: TeamToolValue) => {
let serialized: string;
try {
serialized = serializeTeamTransfer(team);
} catch {
toast.error("Export Team ไม่สำเร็จ กรุณาตรวจสอบข้อมูล Team");
return;
}
const url = URL.createObjectURL(new Blob([serialized], { type: "application/json" }));
const link = document.createElement("a");
link.href = url;
link.download = teamTransferFileName(team.title);
link.click();
URL.revokeObjectURL(url);
try {
await navigator.clipboard.writeText(serialized);
toast.success(`คัดลอกและดาวน์โหลด ${team.title} แล้ว`);
} catch {
toast.success(`ดาวน์โหลด ${teamTransferFileName(team.title)} แล้ว`);
}
};
const appendImportedTeams = (importedTeams: Array<Omit<TeamToolValue, "clientId">>) => {
if (!importedTeams.length) 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 = 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("มีไฟล์ Team ที่ไม่ถูกต้องหรือเป็นเวอร์ชันที่ไม่รองรับ");
}
};
const openGuideImport = async () => {
setImportOpen(false);
setGuideImportOpen(true);
setActiveImportGuide(null);
setSelectedImports({});
setGuideListQuery("");
setGuideImportError(null);
setGuideImportLoading("guides");
const result = await listTeamImportGuides(guide.id);
setGuideImportLoading(null);
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(toTeamDraft);
if (!appendImportedTeams(importedTeams)) return;
setEverywhereImportOpen(false);
setEverywhereSelectedIds(new Set());
toast.success(`Import ${importedTeams.length} Team แล้ว`);
};
const enterImportGuide = async (guideId: string) => {
setGuideImportError(null);
setGuideImportLoading("teams");
const result = await loadTeamImportGuide(guideId);
setGuideImportLoading(null);
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>
<CardTitle>Team Calc</CardTitle>
<CardDescription>
ลากเพื่อเรียง Team แล้วใส่ความเสียหายของตัวละคร 4 ตัว
ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<ConditionControls
ariaLabel="เลือกเงื่อนไข Team"
items={teamGroups}
activeIndex={activeTeamGroupIndex}
onActiveIndexChange={(nextIndex) => {
const group = teamGroups[nextIndex];
if (!group) return;
setActiveTeamGroupIndex(nextIndex);
setSelectedTeamIds(new Set());
setSourceTeamId(group.teams[0]?.clientId ?? "");
}}
onRename={setRenamingTeamGroupIndex}
onReorder={(from, to) => {
const reordered = reorderConditions(teamGroups, activeTeamGroupIndex, from, to);
setTeamGroups(reordered.items);
setActiveTeamGroupIndex(reordered.activeIndex);
}}
onAdd={(details) => {
setTeamGroups([...teamGroups, { clientId: createClientId("team-condition"), ...details, note: "", teams: [] }]);
setActiveTeamGroupIndex(teamGroups.length);
setSelectedTeamIds(new Set());
setSourceTeamId("");
}}
onDuplicate={() => {
const source = teamGroups[activeTeamGroupIndex];
if (!source) return;
const copiedTeams = source.teams.map((team) => ({ ...team, clientId: createClientId("team"), members: team.members.map((member) => ({ ...member })) }));
setTeamGroups([...teamGroups, { clientId: createClientId("team-condition"), name: source.name, note: source.note, iconMediaId: source.iconMediaId, iconUrl: source.iconUrl, teams: copiedTeams }]);
setActiveTeamGroupIndex(teamGroups.length);
setSelectedTeamIds(new Set());
setSourceTeamId(copiedTeams[0]?.clientId ?? "");
}}
onDelete={() => {
setTeamGroups(teamGroups.filter((_, index) => index !== activeTeamGroupIndex));
setActiveTeamGroupIndex(Math.max(0, activeTeamGroupIndex - 1));
setSelectedTeamIds(new Set());
}}
/>
<ConditionRenameDialog
open={renamingTeamGroupIndex !== null}
fallback={`Condition ${(renamingTeamGroupIndex ?? 0) + 1}`}
value={renamingTeamGroupIndex === null ? "" : teamGroups[renamingTeamGroupIndex]?.name ?? ""}
iconUrl={renamingTeamGroupIndex === null ? null : teamGroups[renamingTeamGroupIndex]?.iconUrl ?? null}
onOpenChange={(open) => { if (!open) setRenamingTeamGroupIndex(null); }}
onChange={(name) => setTeamGroups(teamGroups.map((group, index) => index === renamingTeamGroupIndex ? { ...group, name } : group))}
onIconChange={(icon) => setTeamGroups(teamGroups.map((group, index) => index === renamingTeamGroupIndex ? { ...group, ...icon } : group))}
/>
<Field>
<FieldLabel htmlFor={`team-condition-note-${teamGroups[activeTeamGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
<Textarea id={`team-condition-note-${teamGroups[activeTeamGroupIndex]?.clientId}`} value={teamGroups[activeTeamGroupIndex]?.note ?? ""} onChange={(event) => setTeamGroups(teamGroups.map((group, index) => index === activeTeamGroupIndex ? { ...group, note: event.target.value } : group))} />
</Field>
<div className="flex min-w-0 flex-wrap items-center gap-2 rounded-lg border bg-muted/30 p-3">
<Checkbox
checked={selectedTeamIds.size === teams.length && teams.length > 0}
onCheckedChange={(checked) => setSelectedTeamIds(checked ? new Set(teams.map((team) => team.clientId)) : new Set())}
aria-label="เลือกทุก Team"
/>
<span className="text-sm font-medium">เลือก Team สำหรับ Multiple Apply</span>
<Select value={sourceTeamId} onValueChange={(value) => { if (value) setSourceTeamId(value); }}>
<SelectTrigger className="w-full basis-full"><span>{teams.find((team) => team.clientId === sourceTeamId)?.title || "Source Team"}</span></SelectTrigger>
<SelectContent>
<SelectGroup>{teams.map((team) => <SelectItem key={team.clientId} value={team.clientId}>{team.title || "Untitled Team"}</SelectItem>)}</SelectGroup>
</SelectContent>
</Select>
<Button type="button" size="sm" disabled={!selectedTeamIds.size} onClick={() => setApplyOpen(true)}>Multiple Apply</Button>
<Button type="button" variant="outline" size="sm" disabled={!sourceTeamId} onClick={() => setPresetOpen(true)}>Save preset</Button>
{presets.map((preset) => (
<div key={preset.id} className="flex min-w-0 max-w-full items-center gap-1">
<Button className="min-w-0 max-w-full truncate" type="button" variant="ghost" size="sm" disabled={!selectedTeamIds.size} onClick={() => applyPreset(preset)}>{preset.name}</Button>
<SimpleTooltip text={`ลบ preset ${preset.name}`}>
<Button type="button" variant="ghost" size="icon-sm" onClick={() => setPresets((current) => current.filter((item) => item.id !== preset.id))} aria-label={`ลบ preset ${preset.name}`}><XIcon /></Button>
</SimpleTooltip>
</div>
))}
</div>
<FieldGroup className="mt-4">
<Field>
<FieldLabel htmlFor="team-search">ค้นหา Team</FieldLabel>
<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
id="team-search"
className="pl-9"
placeholder="ค้นหาชื่อทีม ตัวละคร อาวุธ หรืออาร์ติแฟกต์"
value={searchQuery}
onValueChange={setSearchQuery}
/>
</div>
</Field>
<Field>
<FieldLabel>ค้นหาใน</FieldLabel>
<ToggleGroup
aria-label="เลือกประเภทข้อมูลที่ต้องการค้นหา"
className="max-w-full flex-wrap justify-start"
variant="outline"
size="sm"
value={[searchTarget]}
onValueChange={(value) => {
const target = value[0] as TeamSearchTarget | undefined;
if (target) setSearchTarget(target);
}}
>
<ToggleGroupItem value="all">ทั้งหมด</ToggleGroupItem>
<ToggleGroupItem value="team">ชื่อทีม</ToggleGroupItem>
<ToggleGroupItem value="character">ตัวละคร</ToggleGroupItem>
<ToggleGroupItem value="weapon">อาวุธ</ToggleGroupItem>
<ToggleGroupItem value="artifact">อาร์ติแฟกต์</ToggleGroupItem>
</ToggleGroup>
</Field>
</FieldGroup>
<DndContext
sensors={teamSensors}
collisionDetection={closestCenter}
onDragEnd={handleTeamDragEnd}
>
<SortableContext
items={filteredTeams.map(({ team }) => team.clientId)}
strategy={rectSortingStrategy}
>
{filteredTeams.length ? (
<div className="grid gap-5 xl:grid-cols-2">
{filteredTeams.map(({ team, teamIndex }) => {
const total = team.members.reduce(
(sum, member) => sum + (Number(member.damage) || 0),
0,
);
const seconds = Number(team.rotationSeconds);
const dps = seconds > 0 ? total / seconds : 0;
return (
<SortableTeamItem key={team.clientId} id={team.clientId}>
{(dragHandle) => (
<Card
className="h-full"
size="sm"
data-invalid={teamFieldInvalid(teamIndex, "members")}
>
<CardHeader>
<CardTitle>
<Field data-invalid={teamFieldInvalid(teamIndex, "title")}>
<FieldLabel className="sr-only" htmlFor={`team-title-${team.clientId}`}>
ชื่อ Team
</FieldLabel>
<Input
id={`team-title-${team.clientId}`}
value={team.title}
placeholder={`Team ${teamIndex + 1}`}
aria-invalid={teamFieldInvalid(teamIndex, "title")}
onChange={(event) =>
updateTeam(teamIndex, { title: event.target.value })
}
/>
</Field>
</CardTitle>
<CardDescription>
<Field
className="w-fit justify-start"
orientation="horizontal"
data-invalid={teamFieldInvalid(teamIndex, "rotationSeconds")}
>
<FieldLabel
className="flex-none"
htmlFor={`team-rotation-${team.clientId}`}
>
Rotation
</FieldLabel>
<Input
id={`team-rotation-${team.clientId}`}
className="w-24 text-foreground"
type="number"
min="0.01"
step="0.5"
value={team.rotationSeconds}
aria-invalid={teamFieldInvalid(teamIndex, "rotationSeconds")}
onChange={(event) =>
updateTeam(teamIndex, {
rotationSeconds: event.target.value,
})
}
/>
<span>วินาที</span>
</Field>
</CardDescription>
<CardAction className="flex max-w-44 flex-wrap justify-end gap-1">
<Checkbox
checked={selectedTeamIds.has(team.clientId)}
onCheckedChange={(checked) =>
toggleTeamSelection(team.clientId, checked === true)
}
aria-label={`เลือก ${team.title || `Team ${teamIndex + 1}`}`}
/>
{dragHandle}
<SimpleTooltip text="Export Team">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => void exportTeam(team)}
>
<ClipboardCopyIcon />
<span className="sr-only">
Export {team.title || `Team ${teamIndex + 1}`}
</span>
</Button>
</SimpleTooltip>
<SimpleTooltip text="ทำสำเนา Team">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() =>
setTeams((current) => {
const sourceIndex = current.findIndex(
(item) => item.clientId === team.clientId,
);
if (sourceIndex === -1)
return current;
const source = current[sourceIndex];
const title = source.title.trim()
? `${source.title.slice(0, 153)} (Copy)`
: `Team ${sourceIndex + 1} (Copy)`;
return current.toSpliced(sourceIndex + 1, 0, {
...source,
clientId: createClientId("team"),
title,
members: source.members.map((member) => ({
...member,
})),
});
})
}
>
<CopyIcon />
<span className="sr-only">ทำสำเนา Team</span>
</Button>
</SimpleTooltip>
<DeleteConfirmationDialog
triggerTooltip="ลบ Team"
trigger={
<Button
type="button"
variant="ghost"
size="icon-sm"
/>
}
type="Team"
description="ข้อมูล Team และตัวละครทั้ง 4 คนจะถูกลบออกจาก Guide"
confirmLabel="ยืนยันลบ Team"
onConfirm={() =>
setTeams(
teams.filter(
(item) => item.clientId !== team.clientId,
),
)
}
>
<Trash2Icon />
<span className="sr-only">ลบ Team</span>
</DeleteConfirmationDialog>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<DndContext
sensors={teamSensors}
collisionDetection={closestCenter}
onDragEnd={(event) =>
handleTeamMemberDragEnd(team.clientId, event)
}
>
<SortableContext
items={team.members.map(getMemberSortableId)}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-4 gap-1 sm:gap-3">
{team.members.map((member, memberIndex) => {
const character = characters.find(
(item) => item.key === member.characterKey,
);
const compatibleWeapons = weapons.filter(
(weapon) =>
supportsWeaponType(
character?.weaponType,
weapon.weaponType,
),
);
const selectedWeapon = compatibleWeapons.find(
(weapon) => weapon.key === member.weaponKey,
);
const share = total
? ((Number(member.damage) || 0) / total) * 100
: 0;
return (
<SortableTeamMember
key={getMemberSortableId(member)}
id={getMemberSortableId(member)}
>
{(memberDragHandle) => (
<div
className={cn(
"flex min-w-0 flex-col rounded-lg p-0.5 text-center sm:p-1",
!character && "bg-muted/40",
)}
style={
character?.element
? {
backgroundColor:
elementBackgroundColor(
character.element,
),
}
: undefined
}
>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
#{memberIndex + 1}
</span>
{memberDragHandle}
</div>
<div className="flex flex-col gap-1 sm:grid sm:grid-cols-3">
<div className="sm:col-span-2 sm:row-span-2">
<CharacterSelect
presentation="slot"
items={characters}
value={member.characterKey}
label={`เลือกตัวละครตำแหน่ง ${memberIndex + 1}`}
placeholder="ค้นหาชื่อตัวละคร…"
rarityBackground
invalid={memberFieldInvalid(
teamIndex,
memberIndex,
"characterKey",
)}
className="rounded-lg border-0 [&>img]:object-cover [&>img]:p-0"
onValueChange={(characterKey) =>
updateTeamMember(
teamIndex,
memberIndex,
{
characterKey,
weaponKey: null,
artifactKey: null,
secondArtifactKey: null,
constellation: "0",
refinement: "1",
},
)
}
/>
</div>
<div className="grid min-w-0 grid-cols-2 gap-1 sm:row-span-2 sm:grid-cols-1">
<WeaponSelect
presentation="slot"
items={compatibleWeapons}
value={member.weaponKey ?? ""}
label={`เลือกอาวุธของตัวละครตำแหน่ง ${memberIndex + 1}`}
placeholder="ค้นหาอาวุธที่รองรับ…"
rarityBackground
invalid={memberFieldInvalid(
teamIndex,
memberIndex,
"weaponKey",
)}
onValueChange={(weaponKey) =>
updateTeamMember(
teamIndex,
memberIndex,
{
weaponKey:
weaponKey || null,
refinement: "1",
},
)
}
/>
<ArtifactSelect
multiple
items={artifacts}
values={[
member.artifactKey,
member.secondArtifactKey,
].filter(
(key): key is string => Boolean(key),
)}
label={`เลือก Artifact สูงสุด 2 ชุดของตัวละครตำแหน่ง ${memberIndex + 1}`}
invalid={memberFieldInvalid(
teamIndex,
memberIndex,
"artifactKey",
)}
placeholder="ค้นหาชุด Artifact…"
onValuesChange={(artifactKeys) =>
updateTeamMember(
teamIndex,
memberIndex,
{
artifactKey:
artifactKeys[0] ?? null,
secondArtifactKey:
artifactKeys[1] ?? null,
},
)
}
/>
</div>
</div>
<div className="mt-1 flex min-w-0 gap-1">
<ConstellationSelect
value={String(member.constellation)}
includeRange
disabled={!character}
onValueChange={(value) =>
updateTeamMember(
teamIndex,
memberIndex,
{ constellation: value },
)
}
aria-label={`Constellation ของตัวละครตำแหน่ง ${memberIndex + 1}`}
triggerProps={{
size: "sm",
className: "h-6 min-w-0 flex-1 justify-center px-1 text-xs [&>svg]:hidden",
}}
/>
<RefinementSelect
value={String(member.refinement)}
includeRange
disabled={!selectedWeapon}
onValueChange={(value) =>
updateTeamMember(
teamIndex,
memberIndex,
{ refinement: value },
)
}
aria-label={`Refinement ของอาวุธตำแหน่ง ${memberIndex + 1}`}
triggerProps={{
size: "sm",
className: "h-6 min-w-0 flex-1 justify-center px-1 text-xs [&>svg]:hidden",
}}
/>
</div>
<Field
data-invalid={memberFieldInvalid(
teamIndex,
memberIndex,
"damage",
)}
>
<FieldLabel
className="sr-only"
htmlFor={`team-${team.clientId}-damage-${memberIndex}`}
>
Damage ของตัวละครตำแหน่ง {memberIndex + 1}
</FieldLabel>
<Input
id={`team-${team.clientId}-damage-${memberIndex}`}
className="h-7 text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
type="number"
min="0"
step="1"
value={member.damage}
aria-invalid={memberFieldInvalid(
teamIndex,
memberIndex,
"damage",
)}
onChange={(event) =>
updateTeamMember(
teamIndex,
memberIndex,
{
damage: event.target.value,
},
)
}
/>
</Field>
<p className="mt-0.5 text-xs text-muted-foreground">
{share.toFixed(2)}%
</p>
</div>
)}
</SortableTeamMember>
);
})}
</div>
</SortableContext>
</DndContext>
<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">
{dps.toLocaleString(undefined, {
maximumFractionDigits: 0,
})}
</p>
</div>
</div>
<Field data-invalid={teamFieldInvalid(teamIndex, "combo")}>
<FieldLabel
className="sr-only"
htmlFor={`team-combo-${team.clientId}`}
>
Combo
</FieldLabel>
<Textarea
id={`team-combo-${team.clientId}`}
value={team.combo}
placeholder="ใส่ลำดับ Combo…"
aria-invalid={teamFieldInvalid(teamIndex, "combo")}
onChange={(event) =>
updateTeam(teamIndex, {
combo: event.target.value,
})
}
/>
</Field>
</CardContent>
</Card>
)}
</SortableTeamItem>
);
})}
</div>
) : (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchIcon />
</EmptyMedia>
<EmptyTitle>
{teams.length
? "ไม่พบ Team ที่ตรงกับคำค้นหา"
: "ยังไม่มี Team ใน Condition นี้"}
</EmptyTitle>
<EmptyDescription>
{teams.length
? "ลองเปลี่ยนคำค้นหาหรือประเภทข้อมูล"
: "เพิ่ม Team เพื่อเริ่มจัดตัวละครและคำนวณ DPS"}
</EmptyDescription>
</EmptyHeader>
{!teams.length ? (
<EmptyContent>
<Button
type="button"
variant="outline"
disabled={characters.length < 4}
onClick={() =>
setTeams([
...teams,
{
clientId: createClientId("team"),
title: "",
rotationSeconds: "20",
combo: "",
members: fallbackMembers.map((member) => ({
...member,
})),
},
])
}
>
<PlusIcon data-icon="inline-start" />
เพิ่ม Team
</Button>
</EmptyContent>
) : null}
</Empty>
)}
</SortableContext>
</DndContext>
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3">
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
disabled={characters.length < 4}
onClick={() =>
setTeams([
...teams,
{
clientId: createClientId("team"),
title: "",
rotationSeconds: "20",
combo: "",
members: fallbackMembers.map((member) => ({ ...member })),
},
])
}
>
<PlusIcon data-icon="inline-start" />
เพิ่ม Team
</Button>
<Button
type="button"
variant="outline"
onClick={() => setImportOpen(true)}
>
<ClipboardPasteIcon data-icon="inline-start" />
Import Team
</Button>
</div>
<AutosaveStatus
state={autosave.state}
conflictVersion={autosave.conflictVersion}
canOverwrite={autosave.canOverwrite}
onReloadLatest={autosave.reloadLatest}
onOverwrite={autosave.overwrite}
issues={
validation.success
? []
: formatGuideValidationIssues("team", validation.error.issues, {
teamNames: teams.map((team) => team.title),
})
}
onSave={autosave.saveNow}
/>
</CardFooter>
<Dialog open={applyOpen} onOpenChange={setApplyOpen}>
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Multiple Apply</DialogTitle>
<DialogDescription className="wrap-break-word">คัดลอกค่าจาก {teams.find((team) => team.clientId === sourceTeamId)?.title || "Source Team"} ไปยัง {selectedTeamIds.size} Team ที่เลือก: {[...selectedTeamIds].map((id) => teams.find((team) => team.clientId === id)?.title || "Untitled Team").join(", ")}</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-col gap-5 overflow-y-auto pr-1">
{teams.find((team) => team.clientId === sourceTeamId)?.members.map((member, memberIndex) => {
const characterName = characters.find((character) => character.key === member.characterKey)?.name || "No character";
return (
<FieldSet key={memberIndex}>
<FieldLegend>{characterName} (char{memberIndex + 1})</FieldLegend>
<div className="grid gap-3 sm:grid-cols-4">
{teamMemberApplyOptions.map(([field, label]) => {
const selection = `member:${memberIndex}:${field}` as TeamApplySelection;
const id = `apply-${memberIndex}-${field}`;
return (
<Field key={field} orientation="horizontal">
<Checkbox id={id} checked={applyFields.includes(selection)} onCheckedChange={(checked) => toggleApplyField(selection, checked === true)} />
<FieldLabel htmlFor={id}>{label}{field === "refinement" ? ` (${teamRefinementLabel(member.refinement)})` : ""}</FieldLabel>
</Field>
);
})}
</div>
</FieldSet>
);
})}
<FieldSet>
<FieldLegend>Team fields</FieldLegend>
<div className="grid gap-3 sm:grid-cols-2">
{(["rotation", "combo"] as const).map((field) => {
const id = `apply-${field}`;
return (
<Field key={field} orientation="horizontal">
<Checkbox id={id} checked={applyFields.includes(field)} onCheckedChange={(checked) => toggleApplyField(field, checked === true)} />
<FieldLabel htmlFor={id}>{field === "rotation" ? "Rotation" : "Combo"}</FieldLabel>
</Field>
);
})}
</div>
</FieldSet>
</div>
<DialogFooter><Button type="button" variant="outline" onClick={() => setApplyOpen(false)}>ยกเลิก</Button><Button type="button" disabled={!applyFields.length} onClick={applyChanges}>ยืนยันการคัดลอก</Button></DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={importOpen} onOpenChange={setImportOpen}>
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-lg">
<DialogHeader>
<DialogTitle>Import Team</DialogTitle>
<DialogDescription>
เลือก Team จาก Guide อื่น หรืออัปโหลดไฟล์ JSON ที่เคย Export ไว้
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
<Card size="sm">
<CardHeader>
<CardTitle>Import Everywhere</CardTitle>
<CardDescription>เลือกตัวละคร แล้วค้นหา Team จาก Guide อื่นที่มีตัวละครนั้นอยู่ในทีม</CardDescription>
</CardHeader>
<CardFooter>
<Button type="button" variant="outline" onClick={openEverywhereImport}>
<SearchIcon data-icon="inline-start" />
เลือกตัวละคร
</Button>
</CardFooter>
</Card>
<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>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={everywhereImportOpen} onOpenChange={(open) => {
setEverywhereImportOpen(open);
if (!open) {
everywhereSearchId.current += 1;
setEverywhereLoading(false);
}
}}>
<DialogContent className="max-h-[90svh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Import Everywhere</DialogTitle>
<DialogDescription>
เลือกตัวละครเพื่อค้นหา Team จาก Guide อื่น แล้วดูตัวอย่างก่อนนำเข้า Condition ปัจจุบัน
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
<Field>
<FieldLabel>ตัวละครใน Team</FieldLabel>
<CatalogDropdown
items={characters}
value={everywhereCharacterKey}
onValueChange={(key) => {
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
/>
</Field>
<Field orientation="horizontal">
<Switch id="ignore-same-team-name" checked={ignoreSameName} onCheckedChange={setIgnoreSameName} />
<FieldLabel htmlFor="ignore-same-team-name">Ignore same name</FieldLabel>
</Field>
{everywhereError ? (
<Alert variant="destructive"><AlertTitle>โหลดข้อมูลไม่สำเร็จ</AlertTitle><AlertDescription>{everywhereError}</AlertDescription></Alert>
) : null}
{everywhereLoading ? (
<div aria-busy="true" aria-label="กำลังโหลด Team" className="grid gap-4 lg:grid-cols-2">
{Array.from({ length: 4 }, (_, index) => <Skeleton key={index} className="h-72 rounded-xl" />)}
</div>
) : everywhereSearchedKey && everywhereTeams.length ? (
<>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
พบ {everywhereTeams.length} Team - เลือกได้ {eligibleEverywhereTeams.length} Team
</p>
<Button
type="button"
size="sm"
variant="outline"
disabled={!eligibleEverywhereTeams.length}
onClick={() => setEverywhereSelectedIds(
allEverywhereTeamsSelected ? new Set() : new Set(eligibleEverywhereIds),
)}
>
{allEverywhereTeamsSelected ? "ยกเลิกเลือกทั้งหมด" : "เลือกทั้งหมด"}
</Button>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{everywhereTeams.map((team) => {
const eligible = eligibleEverywhereIds.has(team.id);
return (
<TeamImportPreviewCard
key={team.id}
team={team}
characters={characters}
weapons={weapons}
artifacts={artifacts}
checked={eligible && everywhereSelectedIds.has(team.id)}
checkboxId={`everywhere-team-${team.id}`}
sourceLabel={`${team.guideName}${team.conditionName ? ` - ${team.conditionName}` : ""}`}
disabled={!eligible}
onCheckedChange={(checked) => setEverywhereSelectedIds((current) => {
const next = new Set(current);
if (checked) next.add(team.id);
else next.delete(team.id);
return next;
})}
/>
);
})}
</div>
</>
) : everywhereSearchedKey && !everywhereError ? (
<Empty><EmptyHeader><EmptyTitle>ไม่พบ Team</EmptyTitle><EmptyDescription>ยังไม่มี Team จาก Guide อื่นที่มีตัวละครนี้</EmptyDescription></EmptyHeader></Empty>
) : null}
</div>
<DialogFooter className="flex-wrap">
<Button type="button" variant="outline" onClick={() => setEverywhereImportOpen(false)}>ปิด</Button>
<Button type="button" variant="outline" disabled={!eligibleEverywhereTeams.length} onClick={() => importEverywhereTeams(eligibleEverywhereTeams)}>
Import All {eligibleEverywhereTeams.length} Team
</Button>
<Button type="button" disabled={!selectedEverywhereTeams.length} onClick={() => importEverywhereTeams(selectedEverywhereTeams)}>
Import Selected {selectedEverywhereTeams.length} Team
</Button>
</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 ? (
guideImportLoading === "guides" ? (
<div
className="flex h-full min-h-0 flex-col gap-4"
aria-label="กำลังโหลดรายการ Guide"
aria-busy="true"
>
<Skeleton className="h-8 w-full" />
<div className="min-h-0 overflow-y-auto pr-1">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }, (_, index) => (
<Card key={index} className="overflow-hidden py-0">
<Skeleton className="aspect-4/3 rounded-b-none" />
<CardHeader className="pt-4">
<CardTitle><Skeleton className="h-5 w-2/3" /></CardTitle>
<CardDescription><Skeleton className="h-4 w-1/2" /></CardDescription>
<CardAction><Skeleton className="h-5 w-16" /></CardAction>
</CardHeader>
<CardFooter className="pb-4">
<Skeleton className="h-7 w-20" />
</CardFooter>
</Card>
))}
</div>
</div>
</div>
) : (
<div
className="flex h-full min-h-0 flex-col gap-3"
aria-label="กำลังโหลดรายการ Team"
aria-busy="true"
>
<Skeleton className="h-8 w-32" />
<div className="flex min-w-0 items-center gap-2">
<Skeleton className="h-8 min-w-0 flex-1" />
<Skeleton className="h-8 w-32 shrink-0" />
</div>
<div className="min-h-0 overflow-y-auto pr-1">
<div className="grid gap-4 lg:grid-cols-2">
{Array.from({ length: 4 }, (_, index) => (
<Card key={index}>
<CardHeader>
<CardTitle><Skeleton className="h-5 w-1/3" /></CardTitle>
<CardDescription><Skeleton className="h-4 w-24" /></CardDescription>
<CardAction><Skeleton className="h-5 w-12" /></CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 4 }, (_, memberIndex) => (
<div key={memberIndex} className="flex min-w-0 flex-col items-center gap-2">
<Skeleton className="size-16 rounded-lg" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
))}
</div>
<Separator />
<div className="flex items-end justify-between gap-4">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-12 w-28" />
</div>
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
</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">
<Tabs value={guideImportCondition} onValueChange={setGuideImportCondition}>
<TabsList aria-label="เลือกเงื่อนไขทีม">
{activeImportGuide.groups.map((group, index) => (
<TabsTrigger key={group.id} value={String(index)}>
<MousePointerClickIcon aria-hidden="true" data-icon="inline-start" />
{group.name || `Condition ${index + 1}`}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</ScrollableNav>
) : null}
<div className="flex min-w-0 items-center gap-2">
<div className="relative min-w-0 flex-1">
<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>
<Select value={guideTeamSearchTarget} onValueChange={(value) => {
if (value) setGuideTeamSearchTarget(value as TeamSearchTarget);
}}>
<SelectTrigger
aria-label="เลือกประเภทข้อมูลที่ต้องการค้นหา"
className="w-32 shrink-0"
>
<SelectValue>{teamSearchTargetLabels[guideTeamSearchTarget]}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">ทั้งหมด</SelectItem>
<SelectItem value="team">ชื่อทีม</SelectItem>
<SelectItem value="character">ตัวละคร</SelectItem>
<SelectItem value="weapon">อาวุธ</SelectItem>
<SelectItem value="artifact">อาร์ติแฟกต์</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="min-h-0 overflow-y-auto pr-1">
{filteredImportTeams.length ? (
<div className="grid gap-4 lg:grid-cols-2">
{filteredImportTeams.map((team) => (
<TeamImportPreviewCard
key={team.id}
team={team}
characters={characters}
weapons={weapons}
artifacts={artifacts}
checked={Boolean(selectedImports[team.id])}
checkboxId={`guide-team-${team.id}`}
onCheckedChange={(checked) => setSelectedImports((current) => {
if (checked) {
return { ...current, [team.id]: toTeamDraft(team) };
}
const next = { ...current };
delete next[team.id];
return next;
})}
/>
))}
</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>
</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>
</Dialog>
<Dialog open={presetOpen} onOpenChange={setPresetOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Save team preset</DialogTitle><DialogDescription>บันทึก Source Team นี้ไว้ใช้กับ Guide นี้ในเบราว์เซอร์นี้</DialogDescription></DialogHeader>
<Field><FieldLabel htmlFor="team-preset-name">ชื่อ Preset</FieldLabel><Input id="team-preset-name" value={presetName} onChange={(event) => setPresetName(event.target.value)} /></Field>
<DialogFooter><Button type="button" variant="outline" onClick={() => setPresetOpen(false)}>ยกเลิก</Button><Button type="button" disabled={!presetName.trim()} onClick={savePreset}>บันทึก</Button></DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}