171 lines
24 KiB
TypeScript
171 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { CopyIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { createBuild } from "@/lib/translator/build";
|
||
import { ImportGcsimDialog } from "@/components/import-gcsim-dialog";
|
||
import { CatalogCombobox } from "@/components/catalog-combobox";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { Toggle } from "@/components/ui/toggle";
|
||
import { translateRotation } from "@/lib/translator/rotation";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { calculateSubstats, generateTeam, SUBSTAT_SCALARS } from "@/lib/translator/generate";
|
||
import { formatBaseStat } from "@/lib/translator/stat-format";
|
||
import { calculateFinalStats, formatFinalStat } from "@/lib/translator/final-stats";
|
||
import { importedStatKeys, rollKeys, translationSchema, type Build, type Catalog, type CatalogItem, type MainStatKey, type Team, type Translation } from "@/lib/translator/types";
|
||
|
||
const levels = [20, 40, 50, 60, 70, 80, 90];
|
||
const sands: MainStatKey[] = ["atk%", "hp%", "def%", "em", "er"];
|
||
const goblets: MainStatKey[] = ["atk%", "hp%", "def%", "em", "pyro%", "hydro%", "cryo%", "electro%", "anemo%", "geo%", "dendro%", "phys%"];
|
||
const circlets: MainStatKey[] = ["cr", "cd", "atk%", "hp%", "def%", "em", "heal"];
|
||
|
||
function newId(prefix: string): string {
|
||
const uuid = globalThis.crypto?.randomUUID?.();
|
||
return uuid ? `${prefix}-${uuid}` : `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||
}
|
||
|
||
function makeBuild(catalog: Catalog, character = catalog.characters[0], id = newId("build")): Build {
|
||
return createBuild(catalog, character, id);
|
||
}
|
||
function makeTeam(catalog: Catalog, index: number, deterministic = false): Team {
|
||
const id = deterministic ? `team-${index}` : newId("team");
|
||
return { id, name: `Team ${index}`, builds: [makeBuild(catalog, catalog.characters[0], deterministic ? `build-${index}-1` : newId("build"))] };
|
||
}
|
||
|
||
async function copyText(value: string): Promise<void> {
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(value);
|
||
return;
|
||
}
|
||
const textarea = document.createElement("textarea");
|
||
textarea.value = value;
|
||
textarea.setAttribute("readonly", "");
|
||
textarea.style.position = "fixed";
|
||
textarea.style.opacity = "0";
|
||
document.body.append(textarea);
|
||
textarea.select();
|
||
const copied = document.execCommand("copy");
|
||
textarea.remove();
|
||
if (!copied) throw new Error("Clipboard access is unavailable.");
|
||
}
|
||
|
||
function NumberSelect({ value, values, onChange }: { value: number; values: number[]; onChange: (value: number) => void }) {
|
||
return <Select value={String(value)} onValueChange={(next) => onChange(Number(next))}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{values.map((item) => <SelectItem key={item} value={String(item)}>{item}</SelectItem>)}</SelectGroup></SelectContent></Select>;
|
||
}
|
||
function StatSelect({ value, values, onChange }: { value: MainStatKey; values: MainStatKey[]; onChange: (value: MainStatKey) => void }) {
|
||
return <Select value={value} onValueChange={(next) => onChange(next as MainStatKey)}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{values.map((item) => <SelectItem key={item} value={item}>{item.toUpperCase()}</SelectItem>)}</SelectGroup></SelectContent></Select>;
|
||
}
|
||
|
||
function BaseStats({ stats }: { stats: CatalogItem["baseStats"] }) {
|
||
if (!stats.length) return <FieldDescription>Level 90 base stats unavailable.</FieldDescription>;
|
||
return <div className="flex flex-wrap gap-2" aria-label="Level 90 base stats">{stats.map((stat) => <Badge key={stat.key} variant="outline">{formatBaseStat(stat)}</Badge>)}</div>;
|
||
}
|
||
|
||
function BuildEditor({ build, catalog, onChange, onRemove, canRemove }: { build: Build; catalog: Catalog; onChange: (build: Build) => void; onRemove: () => void; canRemove: boolean }) {
|
||
const selectedCharacter = catalog.characters.find((item) => item.id === build.characterId);
|
||
const selectedWeapon = catalog.weapons.find((item) => item.id === build.weaponId);
|
||
const weapons = catalog.weapons.filter((item) => !selectedCharacter?.weaponType || item.weaponType === selectedCharacter.weaponType);
|
||
const update = <K extends keyof Build>(key: K, value: Build[K]) => onChange({ ...build, [key]: value });
|
||
const chooseCharacter = (item: CatalogItem) => {
|
||
const weapon = catalog.weapons.find((candidate) => !item.weaponType || candidate.weaponType === item.weaponType) ?? catalog.weapons[0];
|
||
onChange({ ...build, characterId: item.id, characterKey: item.key, characterName: item.name, characterImageUrl: item.imageUrl, weaponId: weapon.id, weaponKey: weapon.key, weaponName: weapon.name, importedParams: undefined });
|
||
};
|
||
const calculated = calculateSubstats(build);
|
||
const finalStats = selectedCharacter?.baseStats.length && selectedWeapon?.baseStats.length
|
||
? calculateFinalStats(build, selectedCharacter.baseStats, selectedWeapon.baseStats)
|
||
: [];
|
||
const totalRolls = Object.values(build.rolls).reduce((sum, value) => sum + value, 0);
|
||
return <Card><CardHeader><div><CardTitle>{build.characterName}</CardTitle><CardDescription>{build.characterKey} · {build.importedStats ? "Imported artifact stats" : `${totalRolls} total rolls`}</CardDescription></div><CardAction className="flex items-start gap-2"><div className="flex flex-col items-end gap-1" aria-label="Final level 90 stats">{finalStats.length ? finalStats.map((stat) => <Badge key={stat.key} variant="secondary">{formatFinalStat(stat)}</Badge>) : <span className="text-sm text-muted-foreground">Final stats unavailable</span>}</div><Button variant="ghost" size="icon" disabled={!canRemove} onClick={onRemove} aria-label="Remove character"><Trash2Icon /></Button></CardAction></CardHeader><CardContent><FieldGroup>
|
||
<Field><FieldLabel>Character</FieldLabel><CatalogCombobox items={catalog.characters} value={build.characterId} onChange={chooseCharacter} placeholder="Choose character" /><BaseStats stats={selectedCharacter?.baseStats ?? []} /></Field>
|
||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4"><Field><FieldLabel>Level</FieldLabel><NumberSelect value={build.level} values={Array.from(new Set([...levels.filter((v) => v <= build.maxLevel), build.level])).sort((a, b) => a - b)} onChange={(v) => update("level", v)} /></Field><Field><FieldLabel>Ascension cap</FieldLabel><NumberSelect value={build.maxLevel} values={levels} onChange={(v) => onChange({ ...build, maxLevel: v, level: Math.min(build.level, v) })} /></Field><Field><FieldLabel>Constellation</FieldLabel><NumberSelect value={build.constellation} values={[0,1,2,3,4,5,6]} onChange={(v) => update("constellation", v)} /></Field><Field><FieldLabel>Talents NA / Skill / Burst</FieldLabel><div className="grid grid-cols-3 gap-2">{build.talents.map((value, index) => <Input key={index} type="number" min={1} max={15} value={value} onChange={(event) => { const next = [...build.talents] as Build["talents"]; next[index] = Number(event.target.value); update("talents", next); }} aria-label={["Normal attack", "Skill", "Burst"][index]} />)}</div></Field></div>
|
||
<Separator />
|
||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr_1fr_1fr]"><Field><FieldLabel>Weapon</FieldLabel><CatalogCombobox items={weapons} value={build.weaponId} onChange={(item) => onChange({ ...build, weaponId: item.id, weaponKey: item.key, weaponName: item.name, importedParams: build.importedParams ? { ...build.importedParams, weapon: undefined } : undefined })} placeholder="Choose weapon" /><BaseStats stats={selectedWeapon?.baseStats ?? []} /></Field><Field><FieldLabel>Level</FieldLabel><NumberSelect value={build.weaponLevel} values={Array.from(new Set([...levels.filter((v) => v <= build.weaponMaxLevel), build.weaponLevel])).sort((a, b) => a - b)} onChange={(v) => update("weaponLevel", v)} /></Field><Field><FieldLabel>Ascension cap</FieldLabel><NumberSelect value={build.weaponMaxLevel} values={levels} onChange={(v) => onChange({ ...build, weaponMaxLevel: v, weaponLevel: Math.min(build.weaponLevel, v) })} /></Field><Field><FieldLabel>Refinement</FieldLabel><NumberSelect value={build.refinement} values={[1,2,3,4,5]} onChange={(v) => update("refinement", v)} /></Field></div>
|
||
<Separator />
|
||
{build.importedSets ? <ImportedSetsEditor build={build} catalog={catalog} onChange={onChange} /> : <FieldSet><FieldLegend>Artifact sets and main stats</FieldLegend><div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"><Field><FieldLabel>Set layout</FieldLabel><Select value={build.artifactMode} onValueChange={(v) => update("artifactMode", v as Build["artifactMode"])}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="4pc">4-piece set</SelectItem><SelectItem value="2+2">2-piece + 2-piece</SelectItem></SelectGroup></SelectContent></Select></Field><Field><FieldLabel>First set</FieldLabel><CatalogCombobox items={catalog.artifacts} value={build.artifactOneId} onChange={(item) => onChange({ ...build, artifactOneId: item.id, artifactOneKey: item.key, artifactOneName: item.name })} placeholder="Choose artifact" /></Field>{build.artifactMode === "2+2" ? <Field><FieldLabel>Second set</FieldLabel><CatalogCombobox items={catalog.artifacts.filter((item) => item.id !== build.artifactOneId)} value={build.artifactTwoId} onChange={(item) => onChange({ ...build, artifactTwoId: item.id, artifactTwoKey: item.key, artifactTwoName: item.name })} placeholder="Choose artifact" /></Field> : null}{!build.importedStats && <><Field><FieldLabel>Sands</FieldLabel><StatSelect value={build.sands} values={sands} onChange={(v) => update("sands", v as Build["sands"])} /></Field><Field><FieldLabel>Goblet</FieldLabel><StatSelect value={build.goblet} values={goblets} onChange={(v) => update("goblet", v as Build["goblet"])} /></Field><Field><FieldLabel>Circlet</FieldLabel><StatSelect value={build.circlet} values={circlets} onChange={(v) => update("circlet", v as Build["circlet"])} /></Field></>}</div></FieldSet>}
|
||
{build.importedSets && !build.importedStats && <FieldSet><FieldLegend>Artifact main stats</FieldLegend><FieldGroup className="grid gap-6 sm:grid-cols-3"><Field><FieldLabel>Sands</FieldLabel><StatSelect value={build.sands} values={sands} onChange={(v) => update("sands", v as Build["sands"])} /></Field><Field><FieldLabel>Goblet</FieldLabel><StatSelect value={build.goblet} values={goblets} onChange={(v) => update("goblet", v as Build["goblet"])} /></Field><Field><FieldLabel>Circlet</FieldLabel><StatSelect value={build.circlet} values={circlets} onChange={(v) => update("circlet", v as Build["circlet"])} /></Field></FieldGroup></FieldSet>}
|
||
<Separator />
|
||
{build.importedStats ? <ImportedStatsEditor build={build} onChange={onChange} /> : <FieldSet><FieldLegend>Average substat rolls</FieldLegend><FieldDescription>Enter roll counts. Values use gcsim's KQM average-roll scalars.</FieldDescription><div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-5">{rollKeys.map((key) => <Field key={key}><FieldLabel htmlFor={`${build.id}-${key}`}>{key.toUpperCase()} <span className="text-muted-foreground">× {key.includes("%") || ["er","cr","cd"].includes(key) ? `${(SUBSTAT_SCALARS[key] * 100).toFixed(2)}%` : SUBSTAT_SCALARS[key]}</span></FieldLabel><Input id={`${build.id}-${key}`} type="number" min={0} max={99} value={build.rolls[key]} onChange={(event) => update("rolls", { ...build.rolls, [key]: Math.max(0, Math.min(99, Number(event.target.value))) })} /><FieldDescription>{key.includes("%") || ["er","cr","cd"].includes(key) ? `${(calculated[key] * 100).toFixed(2)}%` : calculated[key].toFixed(2)}</FieldDescription></Field>)}</div></FieldSet>}
|
||
{build.importedParams && <FieldDescription>Imported parameters: {Object.entries(build.importedParams).filter(([, value]) => value).map(([key, value]) => `${key} ${value}`).join(" · ")}</FieldDescription>}
|
||
</FieldGroup></CardContent></Card>;
|
||
}
|
||
|
||
export function Translator({ catalog }: { catalog: Catalog }) {
|
||
const [translation, setTranslation] = useState<Translation>(() => ({ name: "My builds", teams: [makeTeam(catalog, 1, true)] }));
|
||
const [activeTeam, setActiveTeam] = useState(translation.teams[0].id);
|
||
const [draftLoaded, setDraftLoaded] = useState(false);
|
||
useEffect(() => { if (draftLoaded) localStorage.setItem("gcsim-translator-draft", JSON.stringify(translation)); }, [translation, draftLoaded]);
|
||
useEffect(() => {
|
||
const stored = localStorage.getItem("gcsim-translator-draft");
|
||
let restored: Translation | undefined;
|
||
if (stored) try {
|
||
const parsed = translationSchema.safeParse(JSON.parse(stored));
|
||
if (parsed.success) restored = parsed.data;
|
||
} catch { localStorage.removeItem("gcsim-translator-draft"); }
|
||
const timer = setTimeout(() => {
|
||
if (restored) { setTranslation(restored); setActiveTeam(restored.teams[0].id); }
|
||
setDraftLoaded(true);
|
||
}, 0);
|
||
return () => clearTimeout(timer);
|
||
}, []);
|
||
const team = translation.teams.find((item) => item.id === activeTeam) ?? translation.teams[0];
|
||
const output = useMemo(() => generateTeam(team), [team]);
|
||
const rotationError = useMemo(() => translateRotation(team).error, [team]);
|
||
const updateTeam = (next: Team) => setTranslation((current) => ({ ...current, teams: current.teams.map((item) => item.id === next.id ? next : item) }));
|
||
const copy = async (value = output) => {
|
||
if (rotationError) return;
|
||
try {
|
||
await copyText(value);
|
||
toast.success("GCSim config copied");
|
||
} catch {
|
||
toast.error("Could not copy automatically. Select the generated config and copy it manually.");
|
||
}
|
||
};
|
||
return <main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-8 px-4 py-8 sm:px-6 lg:py-12"><Header /><ImportGcsimDialog catalog={catalog} disabled={!draftLoaded || translation.teams.length >= 12} onImport={(imported) => {
|
||
const next: Team = { ...imported, id: newId("team"), builds: imported.builds.map((build) => ({ ...build, id: newId("build") })) };
|
||
setTranslation((current) => ({ ...current, teams: [...current.teams, next] }));
|
||
setActiveTeam(next.id);
|
||
toast.success("Gcsim team imported");
|
||
}} /><div className="flex flex-col gap-2"><Field className="max-w-md"><FieldLabel htmlFor="translation-name">Collection name</FieldLabel><Input id="translation-name" value={translation.name} onChange={(event) => setTranslation({ ...translation, name: event.target.value })} /><FieldDescription>Saved automatically in this browser. Nothing is written to the database.</FieldDescription></Field></div><Tabs value={activeTeam} onValueChange={setActiveTeam}><div className="flex items-center gap-2 overflow-x-auto"><TabsList>{translation.teams.map((item) => <TabsTrigger key={item.id} value={item.id}>{item.name}</TabsTrigger>)}</TabsList><Button variant="outline" size="icon" disabled={translation.teams.length >= 12} onClick={() => { const next = makeTeam(catalog, translation.teams.length + 1); setTranslation({ ...translation, teams: [...translation.teams, next] }); setActiveTeam(next.id); }} aria-label="Add team"><PlusIcon /></Button></div>{translation.teams.map((item) => <TabsContent key={item.id} value={item.id}><div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_420px]"><div className="flex flex-col gap-6"><div className="flex items-end gap-3"><Field><FieldLabel>Team name</FieldLabel><Input value={item.name} onChange={(event) => updateTeam({ ...item, name: event.target.value })} /></Field><Button variant="ghost" size="icon" disabled={translation.teams.length === 1} onClick={() => { const teams = translation.teams.filter((candidate) => candidate.id !== item.id); setTranslation({ ...translation, teams }); setActiveTeam(teams[0].id); }} aria-label="Remove team"><Trash2Icon /></Button></div>{item.builds.map((build) => <BuildEditor key={build.id} build={build} catalog={catalog} canRemove={item.builds.length > 1} onRemove={() => updateTeam({ ...item, builds: item.builds.filter((candidate) => candidate.id !== build.id) })} onChange={(next) => updateTeam({ ...item, builds: item.builds.map((candidate) => candidate.id === next.id ? next : candidate) })} />)}<Button variant="outline" disabled={item.builds.length >= 4} onClick={() => { const used = new Set(item.builds.map((build) => build.characterId)); const character = catalog.characters.find((candidate) => !used.has(candidate.id)); if (!character) return toast.error("No more characters available"); updateTeam({ ...item, builds: [...item.builds, makeBuild(catalog, character)] }); }}><PlusIcon data-icon="inline-start" />Add character ({item.builds.length}/4)</Button></div><div className="flex flex-col gap-6 xl:sticky xl:top-8 xl:self-start"><RotationEditor team={item} onChange={updateTeam} /><OutputCard output={output} onCopy={() => copy()} copyDisabled={Boolean(rotationError)} /></div></div></TabsContent>)}</Tabs></main>;
|
||
}
|
||
|
||
function Header() { return <header className="flex flex-col gap-3"><div className="flex items-center gap-3"><Badge variant="secondary">GCSIM</Badge><span className="text-sm text-muted-foreground">Build translator</span></div><h1 className="max-w-3xl text-4xl font-semibold tracking-tight sm:text-5xl">Build teams, not config syntax.</h1><p className="max-w-2xl text-muted-foreground">Choose loadouts and average artifact rolls. Get clean character blocks ready to paste.</p></header>; }
|
||
function OutputCard({ output, onCopy, copyDisabled }: { output: string; onCopy: () => void; copyDisabled: boolean }) { return <Card><CardHeader><CardTitle>Generated config</CardTitle><CardDescription>Character builds and optional rotation for the active team.</CardDescription></CardHeader><CardContent><Textarea className="min-h-96 font-mono text-xs" value={output} readOnly aria-label="Generated gcsim config" /></CardContent><CardFooter><Button className="w-full" onClick={onCopy} disabled={copyDisabled}><CopyIcon data-icon="inline-start" />Copy config</Button></CardFooter></Card>; }
|
||
|
||
function RotationEditor({ team, onChange }: { team: Team; onChange: (team: Team) => void }) {
|
||
const rotation = team.rotation ?? { enabled: false, text: "", repeat: false };
|
||
const error = translateRotation(team).error;
|
||
const update = (patch: Partial<typeof rotation>) => onChange({ ...team, rotation: { ...rotation, ...patch } });
|
||
const inputId = `${team.id}-rotation`;
|
||
return <Card>
|
||
<CardHeader><CardTitle>Rotation translator</CardTitle><CardDescription>{rotation.format === "gcsim" ? "Imported script with its original loops and action parameters." : "Write your combo using character names and shorthand."}</CardDescription></CardHeader>
|
||
<CardContent><FieldGroup>
|
||
<Field><Toggle variant="outline" pressed={rotation.enabled} onPressedChange={(enabled) => update({ enabled })} aria-controls={`${inputId}-controls`}>Include rotation</Toggle><FieldDescription>{rotation.enabled ? "Includes rotation, the fixed delay helper, target, and energy settings." : "Off: only character builds are included."}</FieldDescription></Field>
|
||
{rotation.enabled && <FieldGroup id={`${inputId}-controls`}>
|
||
<Field data-invalid={Boolean(error)}><FieldLabel htmlFor={inputId}>{rotation.format === "gcsim" ? "Imported gcsim rotation" : "Rotation"}</FieldLabel><Textarea id={inputId} value={rotation.text} maxLength={10000} onChange={(event) => update({ text: event.target.value })} placeholder={rotation.format === "gcsim" ? "mavuika skill;" : "Mavuika E > Xilonen E N2"} aria-invalid={Boolean(error)} aria-describedby={`${inputId}-help${error ? ` ${inputId}-error` : ""}`} /><FieldDescription id={`${inputId}-help`}>{rotation.format === "gcsim" ? "Edit the imported gcsim script directly. Brackets are checked here; full script validation happens in gcsim." : <>Use names or keys from this team: {team.builds.map((build) => build.characterName).join(", ")}. Separate character steps with > or a new line.</>}</FieldDescription>{error && <FieldError id={`${inputId}-error`}>{error}</FieldError>}</Field>
|
||
{rotation.format === "gcsim" ? <Field><Button variant="outline" onClick={() => update({ format: "shorthand", text: "", repeat: false })}>Clear script and use shorthand</Button></Field> : <><Field><FieldDescription>E = skill · Q = burst · N2 = 2 normals · C = charged attack · D = dash · J = jump. tE / hE = tap / hold skill. W(60) = wait 60 frames. Combine actions: N2CD. Repeat a combo: 3(N2CD).</FieldDescription></Field>
|
||
<Field><Toggle variant="outline" pressed={rotation.repeat} onPressedChange={(repeat) => update({ repeat })}>Repeat rotation</Toggle><FieldDescription>{rotation.repeat ? "Loop until the simulation ends." : "Run the rotation once."}</FieldDescription></Field></>}
|
||
</FieldGroup>}
|
||
</FieldGroup></CardContent>
|
||
</Card>;
|
||
}
|
||
|
||
function ImportedStatsEditor({ build, onChange }: { build: Build; onChange: (build: Build) => void }) {
|
||
return <FieldSet><FieldLegend>Imported artifact stats</FieldLegend><FieldDescription>Exact totals from add stats lines. Percent bonuses use decimals: 0.466 = 46.6%. These replace main stats and roll estimates.</FieldDescription>
|
||
<FieldGroup className="grid gap-5 sm:grid-cols-2 lg:grid-cols-5">{importedStatKeys.map((key) => <Field key={key}><FieldLabel htmlFor={`${build.id}-imported-${key}`}>{key.toUpperCase()}</FieldLabel><Input id={`${build.id}-imported-${key}`} type="number" step="any" value={build.importedStats?.[key] ?? 0} onChange={(event) => { const value = Number(event.target.value); if (Number.isFinite(value)) onChange({ ...build, importedStats: { ...build.importedStats, [key]: value } }); }} /></Field>)}</FieldGroup>
|
||
<Button variant="outline" onClick={() => onChange({ ...build, importedStats: undefined })}>Replace imported stats with main stats and rolls</Button>
|
||
</FieldSet>;
|
||
}
|
||
|
||
function ImportedSetsEditor({ build, catalog, onChange }: { build: Build; catalog: Catalog; onChange: (build: Build) => void }) {
|
||
const sets = build.importedSets ?? [];
|
||
return <FieldSet><FieldLegend>Imported artifact sets</FieldLegend><FieldGroup>{sets.length ? sets.map((set, index) => <FieldGroup key={`${set.id}-${index}`} className="gap-3"><Field><FieldLabel>Artifact set</FieldLabel><CatalogCombobox items={catalog.artifacts} value={set.id} onChange={(item) => onChange({ ...build, importedSets: sets.map((candidate, i) => i === index ? { id: item.id, key: item.key, name: item.name, count: set.count } : candidate) })} placeholder="Choose artifact" /></Field><Field><FieldLabel htmlFor={`${build.id}-set-${index}`}>Piece count</FieldLabel><Input id={`${build.id}-set-${index}`} type="number" min={1} max={5 - sets.reduce((sum, candidate, i) => sum + (i === index ? 0 : candidate.count), 0)} value={set.count} onChange={(event) => { const max = 5 - sets.reduce((sum, candidate, i) => sum + (i === index ? 0 : candidate.count), 0); const count = Math.max(1, Math.min(max, Math.trunc(Number(event.target.value)))); if (Number.isFinite(count)) onChange({ ...build, importedSets: sets.map((candidate, i) => i === index ? { ...candidate, count } : candidate) }); }} /></Field>{set.params && <FieldDescription>Parameters: {set.params}</FieldDescription>}</FieldGroup>) : <FieldDescription>No artifact set bonuses were specified.</FieldDescription>}</FieldGroup><Button variant="outline" onClick={() => onChange({ ...build, importedSets: undefined })}>Replace imported sets with the set editor</Button></FieldSet>;
|
||
}
|