1873 lines
94 KiB
TypeScript
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>
|
|
);
|
|
}
|