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, 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>
+1 -1
View File
@@ -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>
+19 -26
View File
@@ -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>
); );
+1 -1
View File
@@ -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" },
]); ]);
}); });
}); });
+18
View File
@@ -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);
+29 -2
View File
@@ -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(
[ [