feat(guides) : add selectable target and team conditions
CI / Build immutable images and deploy (push) Has been cancelled
CI / Verify (push) Has been cancelled

This commit is contained in:
2026-09-06 17:40:06 +07:00 Unverified
parent 6dc46ca2a7
commit fcf6a2c187
13 changed files with 4207 additions and 63 deletions
+116 -30
View File
@@ -22,7 +22,7 @@ import {
UserRoundIcon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useState, type ReactNode, type SetStateAction } from "react";
import { toast } from "sonner";
import {
closestCenter,
@@ -232,6 +232,10 @@ type ArtifactProfile = {
circlet: string[];
substats: string[];
targets: Array<{ stat: string; value: string }>;
targetConditions: Array<{
note: string | null;
rows: Array<{ stat: string; value: string }>;
}>;
talents: TalentPriority[];
note: string | null;
} | null;
@@ -272,6 +276,11 @@ type TeamValue = {
damage: string;
}>;
};
type TeamGroupValue = {
id: string;
note: string | null;
teams: TeamValue[];
};
let clientIdCounter = 0;
const DEFAULT_EXTRA_BACKGROUND_COLOR = "#24232C";
@@ -333,6 +342,7 @@ export interface GuideSectionEditorProps {
artifactProfile: ArtifactProfile;
constellationGroups: ConstellationGroupValue[];
teams: TeamValue[];
teamGroups: TeamGroupValue[];
extras: Array<{
id: string;
title: string;
@@ -1345,18 +1355,25 @@ function ArtifactEditor({
const [goblet, setGoblet] = useState(profile?.goblet ?? []);
const [circlet, setCirclet] = useState(profile?.circlet ?? []);
const [substats, setSubstats] = useState(profile?.substats ?? []);
const [targets, setTargets] = useState(() => {
const existing = new Map(
(profile?.targets ?? []).flatMap((target) => {
const [targetGroups, setTargetGroups] = useState(() => {
const source = profile?.targetConditions.length
? profile.targetConditions
: [{ note: null, rows: profile?.targets ?? [] }];
return source.map((group, index) => {
const existing = new Map(group.rows.flatMap((target) => {
const stat = normalizeTargetStat(target.stat);
return stat ? [[stat, target.value] as const] : [];
}),
);
return targetStatDefaults.map((target) => ({
stat: target.stat,
value: existing.get(target.stat) ?? target.value,
}));
}));
return {
clientId: `target-condition-${index}`,
note: group.note ?? "",
rows: targetStatDefaults.map((target) => ({ stat: target.stat, value: existing.get(target.stat) ?? target.value })),
};
});
});
const [activeTargetGroupIndex, setActiveTargetGroupIndex] = useState(0);
const targets = targetGroups[activeTargetGroupIndex]?.rows ?? [];
const setTargets = (rows: typeof targets) => setTargetGroups((current) => current.map((group, index) => index === activeTargetGroupIndex ? { ...group, rows } : group));
const [talents, setTalents] = useState<TalentPriority[]>(() => {
const source: TalentPriority[] = profile?.talents.length
? profile.talents
@@ -1392,9 +1409,10 @@ function ArtifactEditor({
goblet,
circlet,
substats,
targets: targets
.filter((target) => target.value.trim())
.map((target) => ({ stat: target.stat, value: target.value.trim() })),
targetGroups: targetGroups.map((group) => ({
note: group.note.trim() || null,
rows: group.rows.filter((target) => target.value.trim()).map((target) => ({ stat: target.stat, value: target.value.trim() })),
})),
talents,
note: note || null,
};
@@ -1410,7 +1428,8 @@ function ArtifactEditor({
goblet,
circlet,
substats,
targets,
targetGroups,
activeTargetGroupIndex,
talents,
note,
};
@@ -1431,7 +1450,7 @@ function ArtifactEditor({
"goblet",
"circlet",
"substats",
"targets",
"targetGroups",
"talents",
],
["note"],
@@ -1442,7 +1461,8 @@ function ArtifactEditor({
setGoblet(draft.goblet);
setCirclet(draft.circlet);
setSubstats(draft.substats);
setTargets(draft.targets);
setTargetGroups(draft.targetGroups);
setActiveTargetGroupIndex(draft.activeTargetGroupIndex ?? 0);
setTalents(draft.talents);
setNote(draft.note);
},
@@ -1794,6 +1814,27 @@ function ArtifactEditor({
/>
<FieldSet>
<FieldLegend>Target Stats</FieldLegend>
<div className="flex flex-wrap gap-2">
{targetGroups.map((group, index) => (
<Button key={group.clientId} type="button" variant={index === activeTargetGroupIndex ? "default" : "outline"} onClick={() => setActiveTargetGroupIndex(index)}>
Condition {index + 1}
</Button>
))}
<Button type="button" variant="outline" disabled={targetGroups.length >= 20} onClick={() => {
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), note: "", rows: targetStatDefaults.map((target) => ({ ...target })) }]);
setActiveTargetGroupIndex(targetGroups.length);
}}><PlusIcon data-icon="inline-start" />เพิ่ม Condition</Button>
<Button type="button" variant="outline" disabled={targetGroups.length >= 20} onClick={() => {
const source = targetGroups[activeTargetGroupIndex];
if (!source) return;
setTargetGroups([...targetGroups, { clientId: createClientId("target-condition"), note: source.note, rows: source.rows.map((row) => ({ ...row })) }]);
setActiveTargetGroupIndex(targetGroups.length);
}}><CopyIcon data-icon="inline-start" />Duplicate Condition</Button>
</div>
<Field>
<FieldLabel htmlFor={`target-condition-note-${targetGroups[activeTargetGroupIndex]?.clientId}`}>Condition note (optional)</FieldLabel>
<Textarea id={`target-condition-note-${targetGroups[activeTargetGroupIndex]?.clientId}`} value={targetGroups[activeTargetGroupIndex]?.note ?? ""} onChange={(event) => setTargetGroups(targetGroups.map((group, index) => index === activeTargetGroupIndex ? { ...group, note: event.target.value } : group))} />
</Field>
<div className="grid gap-5 sm:grid-cols-2">
{[
targetStatOptions.slice(0, 4),
@@ -1836,6 +1877,12 @@ function ArtifactEditor({
กรอกเฉพาะค่าเป็นตัวเลข เปอร์เซ็นต์ หรือข้อความ เช่น -%
หากไม่กรอกค่า ระบบจะไม่แสดงส่วนค่าสถานะเป้าหมาย
</FieldDescription>
{targetGroups.length > 1 ? (
<Button type="button" variant="ghost" onClick={() => {
setTargetGroups(targetGroups.filter((_, index) => index !== activeTargetGroupIndex));
setActiveTargetGroupIndex(Math.max(0, activeTargetGroupIndex - 1));
}}><Trash2Icon data-icon="inline-start" />ลบ Condition นี้</Button>
) : null}
</FieldSet>
<FieldSet>
<FieldLegend>Talent Priority</FieldLegend>
@@ -2261,9 +2308,12 @@ function TeamEditor({
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const [teams, setTeams] = useState<TeamToolValue[]>(
initial.teams.map(({ id, ...team }) => ({ clientId: id, ...team })),
);
const [teamGroups, setTeamGroups] = useState(() => initial.teamGroups.length
? initial.teamGroups.map((group) => ({ clientId: group.id, note: group.note ?? "", teams: group.teams.map(({ id, ...team }) => ({ clientId: id, ...team })) }))
: [{ clientId: "team-condition-default", note: "", teams: [] as TeamToolValue[] }]);
const [activeTeamGroupIndex, setActiveTeamGroupIndex] = useState(0);
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(),
);
@@ -2309,7 +2359,7 @@ function TeamEditor({
}, searchQuery, searchTarget));
}, [artifacts, characters, searchQuery, searchTarget, teams, weapons]);
const value = {
teams: teams.map((team) => ({
groups: teamGroups.map((group) => ({ note: group.note.trim() || null, teams: group.teams.map((team) => ({
title: team.title,
rotationSeconds: Number(team.rotationSeconds),
combo: team.combo,
@@ -2317,7 +2367,7 @@ function TeamEditor({
...member,
damage: Number(member.damage),
})),
})),
})) })),
};
const validation = teamSectionSchema.safeParse({
...value,
@@ -2325,14 +2375,14 @@ function TeamEditor({
});
const teamFieldInvalid = (teamIndex: number, field: string) =>
!validation.success && validation.error.issues.some((issue) =>
issue.path[0] === "teams" && issue.path[1] === teamIndex && issue.path[2] === field,
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] === "teams" && issue.path[1] === teamIndex && issue.path[2] === "members" &&
issue.path[3] === memberIndex && issue.path[4] === field,
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 = { teams };
const localDraft = { teamGroups, activeTeamGroupIndex };
useEffect(() => {
const timer = window.setTimeout(() => {
try {
@@ -2351,7 +2401,7 @@ function TeamEditor({
const stored = JSON.parse(
window.localStorage.getItem(multipleApplyStorageKey(guide.id)) ?? "null",
) as { selectedTeamIds?: unknown; applyFields?: unknown } | null;
const teamIds = new Set(initial.teams.map((team) => team.id));
const teamIds = new Set(initial.teamGroups.flatMap((group) => group.teams.map((team) => team.id)));
setSelectedTeamIds(new Set(
Array.isArray(stored?.selectedTeamIds)
? stored.selectedTeamIds.filter(
@@ -2373,7 +2423,7 @@ function TeamEditor({
setMultipleApplyGuideId(guide.id);
}, 0);
return () => window.clearTimeout(timer);
}, [guide.id, initial.teams]);
}, [guide.id, initial.teamGroups]);
useEffect(() => {
if (multipleApplyGuideId !== guide.id) return;
try {
@@ -2406,8 +2456,11 @@ function TeamEditor({
storageKey: adminDraftKey(guide.id, "team"),
localDraft,
parseLocalDraft: (candidate) =>
parseEditorDraft<typeof localDraft>(candidate, ["teams"]),
restoreLocalDraft: (draft) => setTeams(draft.teams),
parseEditorDraft<typeof localDraft>(candidate, ["teamGroups"]),
restoreLocalDraft: (draft) => {
setTeamGroups(draft.teamGroups);
setActiveTeamGroupIndex(draft.activeTeamGroupIndex ?? 0);
},
});
const handleTeamDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
@@ -2588,7 +2641,40 @@ function TeamEditor({
ระบบจะแสดงสัดส่วนและ Total DPS และบันทึกอัตโนมัติ
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
{teamGroups.map((group, index) => (
<Button key={group.clientId} type="button" variant={index === activeTeamGroupIndex ? "default" : "outline"} onClick={() => {
setActiveTeamGroupIndex(index);
setSelectedTeamIds(new Set());
setSourceTeamId(group.teams[0]?.clientId ?? "");
}}>Condition {index + 1}</Button>
))}
<Button type="button" variant="outline" disabled={teamGroups.length >= 20} onClick={() => {
setTeamGroups([...teamGroups, { clientId: createClientId("team-condition"), note: "", teams: [] }]);
setActiveTeamGroupIndex(teamGroups.length);
setSelectedTeamIds(new Set());
setSourceTeamId("");
}}><PlusIcon data-icon="inline-start" />เพิ่ม Condition</Button>
<Button type="button" variant="outline" disabled={teamGroups.length >= 20} onClick={() => {
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"), note: source.note, teams: copiedTeams }]);
setActiveTeamGroupIndex(teamGroups.length);
setSelectedTeamIds(new Set());
setSourceTeamId(copiedTeams[0]?.clientId ?? "");
}}><CopyIcon data-icon="inline-start" />Duplicate Condition</Button>
</div>
<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>
{teamGroups.length > 1 ? <Button className="self-start" type="button" variant="ghost" onClick={() => {
setTeamGroups(teamGroups.filter((_, index) => index !== activeTeamGroupIndex));
setActiveTeamGroupIndex(Math.max(0, activeTeamGroupIndex - 1));
setSelectedTeamIds(new Set());
}}><Trash2Icon data-icon="inline-start" />ลบ Condition นี้</Button> : null}
<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}