feat : update mutilple apply
CI / Verify (push) Failing after 45s
CI / Build immutable images and deploy (push) Has been skipped

This commit is contained in:
2026-09-04 17:50:40 +07:00 Unverified
parent 45c1567dff
commit dc96ff0036
6 changed files with 119 additions and 43 deletions
+51 -13
View File
@@ -128,10 +128,10 @@ import {
applyTeamFields,
limitPresets,
parseTeamPresets,
teamApplyFields,
teamPresetCookieKey,
teamPresetStorageKey,
type TeamApplyField,
type TeamApplySelection,
type TeamMemberApplyField,
type TeamPreset,
type TeamToolValue,
} from "@/lib/guides/team-tools";
@@ -159,6 +159,15 @@ type CatalogItem = {
rarity?: number | 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 = {
id: string;
characterKey: string;
@@ -2073,7 +2082,7 @@ function TeamEditor({
const [presets, setPresets] = useState<TeamPreset[]>([]);
const [presetsHydrated, setPresetsHydrated] = useState(false);
const [presetsGuideId, setPresetsGuideId] = useState<string | null>(null);
const [applyFields, setApplyFields] = useState<TeamApplyField[]>(["loadout"]);
const [applyFields, setApplyFields] = useState<TeamApplySelection[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [searchTarget, setSearchTarget] = useState<TeamSearchTarget>("all");
const filteredTeams = useMemo(() => {
@@ -2177,12 +2186,12 @@ function TeamEditor({
return next;
});
};
const toggleApplyField = (field: TeamApplyField, checked: boolean) => {
const toggleApplyField = (field: TeamApplySelection, checked: boolean) => {
setApplyFields((current) => {
const next = new Set(current);
if (checked) next.add(field);
else next.delete(field);
return teamApplyFields.filter((candidate) => next.has(candidate));
return [...next];
});
};
const applyChanges = () => {
@@ -2878,18 +2887,47 @@ function TeamEditor({
/>
</CardFooter>
<Dialog open={applyOpen} onOpenChange={setApplyOpen}>
<DialogContent>
<DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<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>
</DialogHeader>
<FieldSet>
<FieldLegend>ข้อมูลที่ต้องการคัดลอก</FieldLegend>
<Field orientation="horizontal"><Checkbox checked={applyFields.includes("loadout")} onCheckedChange={(checked) => toggleApplyField("loadout", checked === true)} /><FieldLabel>Loadout: Character, Weapon, Artifact, C, R</FieldLabel></Field>
<Field orientation="horizontal"><Checkbox checked={applyFields.includes("damage")} onCheckedChange={(checked) => toggleApplyField("damage", checked === true)} /><FieldLabel>Damage</FieldLabel></Field>
<Field orientation="horizontal"><Checkbox checked={applyFields.includes("rotation")} onCheckedChange={(checked) => toggleApplyField("rotation", checked === true)} /><FieldLabel>Rotation</FieldLabel></Field>
<Field orientation="horizontal"><Checkbox checked={applyFields.includes("combo")} onCheckedChange={(checked) => toggleApplyField("combo", checked === true)} /><FieldLabel>Combo</FieldLabel></Field>
</FieldSet>
<div className="flex flex-col gap-5">
{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" ? ` (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>
</DialogContent>
</Dialog>
+1 -1
View File
@@ -58,7 +58,7 @@ export function ScrollableNav({
<nav
ref={navRef}
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}
</nav>
+19 -26
View File
@@ -38,28 +38,16 @@ function ArtifactHalf({
sizes: string;
}) {
const content = (
<>
<span className="relative block size-full">
<Image
src={artifact.imageUrl}
alt={linked ? artifact.name : ""}
fill
loading={eager ? "eager" : undefined}
sizes={sizes}
className="object-contain p-1"
/>
</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}
</>
<span className="relative block size-full">
<Image
src={artifact.imageUrl}
alt={linked ? artifact.name : ""}
fill
loading={eager ? "eager" : undefined}
sizes={sizes}
className="object-contain p-1"
/>
</span>
);
const className = cn(
"absolute inset-0 overflow-hidden",
@@ -153,6 +141,11 @@ function TeamArtifactIcon({
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"
/>
{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>
);
}
@@ -161,7 +154,7 @@ function TeamExportImage({ team }: { team: Team }) {
const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0);
const seconds = Number(team.rotationSeconds);
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">
<h2 className="text-[64px] leading-tight font-bold">{team.title}</h2>
<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 percent = total > 0 ? damage / total * 100 : 0;
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 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 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-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" />
<span className="absolute top-1.5 left-1.5 text-xl font-bold">C{member.constellation}</span>
</div>
@@ -188,7 +181,7 @@ function TeamExportImage({ team }: { team: Team }) {
</div>
<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 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>
);
+1 -1
View File
@@ -39,7 +39,7 @@ describe("custom Traveler catalog", () => {
{ key: "traveler_geo", name: "Geo Traveler" },
{ key: "traveler_electro", name: "Electro Traveler" },
{ key: "traveler_dendro", name: "Dendro Traveler" },
{ key: "traveler_cryo", name: "Cryo Traveler" },
{ key: "traveler_cryo", name: "Cyro Traveler" },
]);
});
});
+18
View File
@@ -23,6 +23,24 @@ describe("team bulk tools", () => {
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", () => {
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);
+29 -2
View File
@@ -19,6 +19,20 @@ export interface TeamToolValue {
export const teamApplyFields = ["loadout", "damage", "rotation", "combo"] as const;
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 {
id: string;
name: string;
@@ -38,7 +52,7 @@ export function applyTeamFields(
teams: readonly TeamToolValue[],
sourceId: string,
targetIds: ReadonlySet<string>,
fields: readonly TeamApplyField[],
fields: readonly TeamApplySelection[],
): TeamToolValue[] {
const source = teams.find((team) => team.clientId === sourceId);
if (!source) return teams.map(cloneTeam);
@@ -53,6 +67,19 @@ export function applyTeamFields(
}));
}
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("combo")) next.combo = source.combo;
return next;
@@ -63,7 +90,7 @@ export function applyPresetFields(
teams: readonly TeamToolValue[],
preset: TeamPreset,
targetIds: ReadonlySet<string>,
fields: readonly TeamApplyField[],
fields: readonly TeamApplySelection[],
): TeamToolValue[] {
return applyTeamFields(
[