feat : update mutilple apply
This commit is contained in:
@@ -128,10 +128,10 @@ import {
|
|||||||
applyTeamFields,
|
applyTeamFields,
|
||||||
limitPresets,
|
limitPresets,
|
||||||
parseTeamPresets,
|
parseTeamPresets,
|
||||||
teamApplyFields,
|
|
||||||
teamPresetCookieKey,
|
teamPresetCookieKey,
|
||||||
teamPresetStorageKey,
|
teamPresetStorageKey,
|
||||||
type TeamApplyField,
|
type TeamApplySelection,
|
||||||
|
type TeamMemberApplyField,
|
||||||
type TeamPreset,
|
type TeamPreset,
|
||||||
type TeamToolValue,
|
type TeamToolValue,
|
||||||
} from "@/lib/guides/team-tools";
|
} from "@/lib/guides/team-tools";
|
||||||
@@ -159,6 +159,15 @@ type CatalogItem = {
|
|||||||
rarity?: number | null;
|
rarity?: number | null;
|
||||||
weaponType?: string | null;
|
weaponType?: string | null;
|
||||||
};
|
};
|
||||||
|
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])[];
|
||||||
type Guide = {
|
type Guide = {
|
||||||
id: string;
|
id: string;
|
||||||
characterKey: string;
|
characterKey: string;
|
||||||
@@ -2073,7 +2082,7 @@ function TeamEditor({
|
|||||||
const [presets, setPresets] = useState<TeamPreset[]>([]);
|
const [presets, setPresets] = useState<TeamPreset[]>([]);
|
||||||
const [presetsHydrated, setPresetsHydrated] = useState(false);
|
const [presetsHydrated, setPresetsHydrated] = useState(false);
|
||||||
const [presetsGuideId, setPresetsGuideId] = useState<string | null>(null);
|
const [presetsGuideId, setPresetsGuideId] = useState<string | null>(null);
|
||||||
const [applyFields, setApplyFields] = useState<TeamApplyField[]>(["loadout"]);
|
const [applyFields, setApplyFields] = useState<TeamApplySelection[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchTarget, setSearchTarget] = useState<TeamSearchTarget>("all");
|
const [searchTarget, setSearchTarget] = useState<TeamSearchTarget>("all");
|
||||||
const filteredTeams = useMemo(() => {
|
const filteredTeams = useMemo(() => {
|
||||||
@@ -2177,12 +2186,12 @@ function TeamEditor({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const toggleApplyField = (field: TeamApplyField, checked: boolean) => {
|
const toggleApplyField = (field: TeamApplySelection, checked: boolean) => {
|
||||||
setApplyFields((current) => {
|
setApplyFields((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
if (checked) next.add(field);
|
if (checked) next.add(field);
|
||||||
else next.delete(field);
|
else next.delete(field);
|
||||||
return teamApplyFields.filter((candidate) => next.has(candidate));
|
return [...next];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const applyChanges = () => {
|
const applyChanges = () => {
|
||||||
@@ -2878,18 +2887,47 @@ function TeamEditor({
|
|||||||
/>
|
/>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
<Dialog open={applyOpen} onOpenChange={setApplyOpen}>
|
<Dialog open={applyOpen} onOpenChange={setApplyOpen}>
|
||||||
<DialogContent>
|
<DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Multiple Apply</DialogTitle>
|
<DialogTitle>Multiple Apply</DialogTitle>
|
||||||
<DialogDescription>คัดลอกค่าจาก {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>
|
<DialogDescription>คัดลอกค่าจาก {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>
|
</DialogHeader>
|
||||||
<FieldSet>
|
<div className="flex flex-col gap-5">
|
||||||
<FieldLegend>ข้อมูลที่ต้องการคัดลอก</FieldLegend>
|
{teams.find((team) => team.clientId === sourceTeamId)?.members.map((member, memberIndex) => {
|
||||||
<Field orientation="horizontal"><Checkbox checked={applyFields.includes("loadout")} onCheckedChange={(checked) => toggleApplyField("loadout", checked === true)} /><FieldLabel>Loadout: Character, Weapon, Artifact, C, R</FieldLabel></Field>
|
const characterName = characters.find((character) => character.key === member.characterKey)?.name || "No character";
|
||||||
<Field orientation="horizontal"><Checkbox checked={applyFields.includes("damage")} onCheckedChange={(checked) => toggleApplyField("damage", checked === true)} /><FieldLabel>Damage</FieldLabel></Field>
|
return (
|
||||||
<Field orientation="horizontal"><Checkbox checked={applyFields.includes("rotation")} onCheckedChange={(checked) => toggleApplyField("rotation", checked === true)} /><FieldLabel>Rotation</FieldLabel></Field>
|
<FieldSet key={memberIndex}>
|
||||||
<Field orientation="horizontal"><Checkbox checked={applyFields.includes("combo")} onCheckedChange={(checked) => toggleApplyField("combo", checked === true)} /><FieldLabel>Combo</FieldLabel></Field>
|
<FieldLegend>{characterName} (char{memberIndex + 1})</FieldLegend>
|
||||||
</FieldSet>
|
<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" ? ` (R${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>
|
<DialogFooter><Button type="button" variant="outline" onClick={() => setApplyOpen(false)}>ยกเลิก</Button><Button type="button" disabled={!applyFields.length} onClick={applyChanges}>ยืนยันการคัดลอก</Button></DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function ScrollableNav({
|
|||||||
<nav
|
<nav
|
||||||
ref={navRef}
|
ref={navRef}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
className="flex min-w-0 flex-1 gap-1 overflow-x-auto [scrollbar-width:none]"
|
className="flex min-w-0 flex-1 gap-1 overflow-x-auto scrollbar-none"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -38,28 +38,16 @@ function ArtifactHalf({
|
|||||||
sizes: string;
|
sizes: string;
|
||||||
}) {
|
}) {
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<span className="relative block size-full">
|
||||||
<span className="relative block size-full">
|
<Image
|
||||||
<Image
|
src={artifact.imageUrl}
|
||||||
src={artifact.imageUrl}
|
alt={linked ? artifact.name : ""}
|
||||||
alt={linked ? artifact.name : ""}
|
fill
|
||||||
fill
|
loading={eager ? "eager" : undefined}
|
||||||
loading={eager ? "eager" : undefined}
|
sizes={sizes}
|
||||||
sizes={sizes}
|
className="object-contain p-1"
|
||||||
className="object-contain p-1"
|
/>
|
||||||
/>
|
</span>
|
||||||
</span>
|
|
||||||
{linked ? (
|
|
||||||
<Kbd
|
|
||||||
className={cn(
|
|
||||||
"absolute h-3 min-w-3 bg-transparent p-0 text-white",
|
|
||||||
side === "left" ? "bottom-0 left-0" : "top-0 right-0",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ArrowUpRightIcon aria-hidden="true" />
|
|
||||||
</Kbd>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
const className = cn(
|
const className = cn(
|
||||||
"absolute inset-0 overflow-hidden",
|
"absolute inset-0 overflow-hidden",
|
||||||
@@ -153,6 +141,11 @@ function TeamArtifactIcon({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute top-1/2 left-1/2 h-px w-[142%] -translate-x-1/2 -translate-y-1/2 rotate-45 bg-white/30"
|
className="pointer-events-none absolute top-1/2 left-1/2 h-px w-[142%] -translate-x-1/2 -translate-y-1/2 rotate-45 bg-white/30"
|
||||||
/>
|
/>
|
||||||
|
{linked ? (
|
||||||
|
<Kbd className="pointer-events-none absolute top-0 right-0 h-3 min-w-3 bg-transparent p-0 text-white">
|
||||||
|
<ArrowUpRightIcon aria-hidden="true" />
|
||||||
|
</Kbd>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -161,7 +154,7 @@ function TeamExportImage({ team }: { team: Team }) {
|
|||||||
const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0);
|
const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0);
|
||||||
const seconds = Number(team.rotationSeconds);
|
const seconds = Number(team.rotationSeconds);
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[1350px] w-[1080px] flex-col bg-[#05060a] p-[48px_52px] font-heading text-[white]">
|
<div className="flex min-h-337.5 w-270 flex-col bg-[#05060a] p-[48px_52px] font-heading text-[white]">
|
||||||
<header className="flex flex-col items-center text-center">
|
<header className="flex flex-col items-center text-center">
|
||||||
<h2 className="text-[64px] leading-tight font-bold">{team.title}</h2>
|
<h2 className="text-[64px] leading-tight font-bold">{team.title}</h2>
|
||||||
<p className="text-[32px] text-[#b9b9c2]">ระยะเวลาโรเทชั่น {seconds.toLocaleString(undefined, { maximumFractionDigits: 2 })} วิ</p>
|
<p className="text-[32px] text-[#b9b9c2]">ระยะเวลาโรเทชั่น {seconds.toLocaleString(undefined, { maximumFractionDigits: 2 })} วิ</p>
|
||||||
@@ -171,8 +164,8 @@ function TeamExportImage({ team }: { team: Team }) {
|
|||||||
const damage = Number(member.damage);
|
const damage = Number(member.damage);
|
||||||
const percent = total > 0 ? damage / total * 100 : 0;
|
const percent = total > 0 ? damage / total * 100 : 0;
|
||||||
return (
|
return (
|
||||||
<div key={member.position} className="flex h-[174px] items-center gap-6 rounded-3xl p-3.5" style={{ backgroundColor: elementBackgroundColor(member.character.element) }}>
|
<div key={member.position} className="flex h-43.5 items-center gap-6 rounded-3xl p-3.5" style={{ backgroundColor: elementBackgroundColor(member.character.element) }}>
|
||||||
<div className={cn("relative size-[140px] shrink-0 overflow-hidden rounded-3xl border-[3px] border-white/20", rarityGradientClass(isCustomTravelerKey(member.character.key) ? 5 : member.character.rarity))}>
|
<div className={cn("relative size-35] shrink-0 overflow-hidden rounded-3xl border-[3px] border-white/20", rarityGradientClass(isCustomTravelerKey(member.character.key) ? 5 : member.character.rarity))}>
|
||||||
<Image src={member.character.imageUrl} alt="" fill loading="eager" sizes="140px" className="object-contain" />
|
<Image src={member.character.imageUrl} alt="" fill loading="eager" sizes="140px" className="object-contain" />
|
||||||
<span className="absolute top-1.5 left-1.5 text-xl font-bold">C{member.constellation}</span>
|
<span className="absolute top-1.5 left-1.5 text-xl font-bold">C{member.constellation}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,7 +181,7 @@ function TeamExportImage({ team }: { team: Team }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-3.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-3.5">
|
||||||
<div className="flex items-center justify-between gap-4"><span className="truncate text-[36px] font-semibold">{member.character.name}</span><span className="text-[36px]">{damage.toLocaleString()}</span></div>
|
<div className="flex items-center justify-between gap-4"><span className="truncate text-[36px] font-semibold">{member.character.name}</span><span className="text-[36px]">{damage.toLocaleString()}</span></div>
|
||||||
<div className="flex items-center gap-5"><div className="h-11 flex-1 overflow-hidden rounded-xl bg-[#272730]"><div className="h-full rounded-xl bg-gradient-to-r from-[#b7791f] to-[#f0b72f]" style={{ width: `${Math.max(2, Math.min(100, percent))}%` }} /></div><span className="w-28 text-right text-[34px] font-bold">{percent.toFixed(1)}%</span></div>
|
<div className="flex items-center gap-5"><div className="h-11 flex-1 overflow-hidden rounded-xl bg-[#272730]"><div className="h-full rounded-xl bg-linear-to-r from-[#b7791f] to-[#f0b72f]" style={{ width: `${Math.max(2, Math.min(100, percent))}%` }} /></div><span className="w-28 text-right text-[34px] font-bold">{percent.toFixed(1)}%</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe("custom Traveler catalog", () => {
|
|||||||
{ key: "traveler_geo", name: "Geo Traveler" },
|
{ key: "traveler_geo", name: "Geo Traveler" },
|
||||||
{ key: "traveler_electro", name: "Electro Traveler" },
|
{ key: "traveler_electro", name: "Electro Traveler" },
|
||||||
{ key: "traveler_dendro", name: "Dendro Traveler" },
|
{ key: "traveler_dendro", name: "Dendro Traveler" },
|
||||||
{ key: "traveler_cryo", name: "Cryo Traveler" },
|
{ key: "traveler_cryo", name: "Cyro Traveler" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,24 @@ describe("team bulk tools", () => {
|
|||||||
expect(applyTeamFields([source], "source", new Set(["source"]), ["combo"])).toEqual([source]);
|
expect(applyTeamFields([source], "source", new Set(["source"]), ["combo"])).toEqual([source]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("copies one field for one character slot only", () => {
|
||||||
|
const source = team("source");
|
||||||
|
const target = team("target");
|
||||||
|
source.members[0] = { ...source.members[0], characterKey: "mavuika", refinement: 1 };
|
||||||
|
target.members[0] = { ...target.members[0], characterKey: "keep-character", refinement: 5 };
|
||||||
|
|
||||||
|
const result = applyTeamFields(
|
||||||
|
[source, target],
|
||||||
|
"source",
|
||||||
|
new Set(["target"]),
|
||||||
|
["member:0:refinement"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result[1].members[0].refinement).toBe(1);
|
||||||
|
expect(result[1].members[0].characterKey).toBe("keep-character");
|
||||||
|
expect(result[1].members.slice(1)).toEqual(target.members.slice(1));
|
||||||
|
});
|
||||||
|
|
||||||
it("deduplicates and limits presets", () => {
|
it("deduplicates and limits presets", () => {
|
||||||
const presets = Array.from({ length: 7 }, (_, index) => ({ id: String(index), name: String(index), createdAt: String(index), team: { ...team(String(index)), clientId: undefined as never } }));
|
const presets = Array.from({ length: 7 }, (_, index) => ({ id: String(index), name: String(index), createdAt: String(index), team: { ...team(String(index)), clientId: undefined as never } }));
|
||||||
expect(limitPresets([presets[0], presets[0], ...presets.slice(1)])).toHaveLength(5);
|
expect(limitPresets([presets[0], presets[0], ...presets.slice(1)])).toHaveLength(5);
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ export interface TeamToolValue {
|
|||||||
export const teamApplyFields = ["loadout", "damage", "rotation", "combo"] as const;
|
export const teamApplyFields = ["loadout", "damage", "rotation", "combo"] as const;
|
||||||
export type TeamApplyField = (typeof teamApplyFields)[number];
|
export type TeamApplyField = (typeof teamApplyFields)[number];
|
||||||
|
|
||||||
|
export const teamMemberApplyFields = [
|
||||||
|
"characterKey",
|
||||||
|
"weaponKey",
|
||||||
|
"artifactKey",
|
||||||
|
"secondArtifactKey",
|
||||||
|
"constellation",
|
||||||
|
"refinement",
|
||||||
|
"damage",
|
||||||
|
] as const;
|
||||||
|
export type TeamMemberApplyField = (typeof teamMemberApplyFields)[number];
|
||||||
|
export type TeamApplySelection =
|
||||||
|
| TeamApplyField
|
||||||
|
| `member:${number}:${TeamMemberApplyField}`;
|
||||||
|
|
||||||
export interface TeamPreset {
|
export interface TeamPreset {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -38,7 +52,7 @@ export function applyTeamFields(
|
|||||||
teams: readonly TeamToolValue[],
|
teams: readonly TeamToolValue[],
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
targetIds: ReadonlySet<string>,
|
targetIds: ReadonlySet<string>,
|
||||||
fields: readonly TeamApplyField[],
|
fields: readonly TeamApplySelection[],
|
||||||
): TeamToolValue[] {
|
): TeamToolValue[] {
|
||||||
const source = teams.find((team) => team.clientId === sourceId);
|
const source = teams.find((team) => team.clientId === sourceId);
|
||||||
if (!source) return teams.map(cloneTeam);
|
if (!source) return teams.map(cloneTeam);
|
||||||
@@ -53,6 +67,19 @@ export function applyTeamFields(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (selected.has("damage")) next.members = source.members.map(cloneMember);
|
if (selected.has("damage")) next.members = source.members.map(cloneMember);
|
||||||
|
for (const field of fields) {
|
||||||
|
const match = /^member:(\d+):(.+)$/.exec(field);
|
||||||
|
if (!match) continue;
|
||||||
|
const memberIndex = Number(match[1]);
|
||||||
|
const memberField = match[2] as TeamMemberApplyField;
|
||||||
|
const sourceMember = source.members[memberIndex];
|
||||||
|
const targetMember = next.members[memberIndex];
|
||||||
|
if (!sourceMember || !targetMember || !teamMemberApplyFields.includes(memberField)) continue;
|
||||||
|
next.members[memberIndex] = {
|
||||||
|
...targetMember,
|
||||||
|
[memberField]: sourceMember[memberField],
|
||||||
|
};
|
||||||
|
}
|
||||||
if (selected.has("rotation")) next.rotationSeconds = source.rotationSeconds;
|
if (selected.has("rotation")) next.rotationSeconds = source.rotationSeconds;
|
||||||
if (selected.has("combo")) next.combo = source.combo;
|
if (selected.has("combo")) next.combo = source.combo;
|
||||||
return next;
|
return next;
|
||||||
@@ -63,7 +90,7 @@ export function applyPresetFields(
|
|||||||
teams: readonly TeamToolValue[],
|
teams: readonly TeamToolValue[],
|
||||||
preset: TeamPreset,
|
preset: TeamPreset,
|
||||||
targetIds: ReadonlySet<string>,
|
targetIds: ReadonlySet<string>,
|
||||||
fields: readonly TeamApplyField[],
|
fields: readonly TeamApplySelection[],
|
||||||
): TeamToolValue[] {
|
): TeamToolValue[] {
|
||||||
return applyTeamFields(
|
return applyTeamFields(
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user