diff --git a/app/page.tsx b/app/page.tsx index 55d1132..2d30261 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,8 @@ import { Translator } from "@/components/translator"; import { getCatalog } from "@/lib/catalog"; +import { getTeamGuides } from "@/lib/team-guide-data"; export default async function Home() { - return ; + const [catalog, teamGuides] = await Promise.all([getCatalog(), getTeamGuides()]); + return ; } diff --git a/components/import-team-guide-dialog.tsx b/components/import-team-guide-dialog.tsx new file mode 100644 index 0000000..95fd391 --- /dev/null +++ b/components/import-team-guide-dialog.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { BookOpenIcon, CheckIcon, ChevronsUpDownIcon } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { importGuideTeam, type TeamGuide, type TeamGuideTeam } from "@/lib/team-guides"; +import { rarityGradientClass } from "@/lib/catalog-rarity"; +import { cn } from "@/lib/utils"; +import type { Catalog, Team } from "@/lib/translator/types"; + +function GuideCharacter({ guide, catalog }: { guide: TeamGuide; catalog: Catalog }) { + const character = catalog.characters.find((item) => item.id === guide.characterId); + return {character?.name.slice(0, 1) ?? guide.name.slice(0, 1)}; +} + +function GuidePicker({ guides, catalog, value, onChange }: { guides: TeamGuide[]; catalog: Catalog; value: string; onChange: (id: string) => void }) { + const [open, setOpen] = useState(false); + const selected = guides.find((guide) => guide.id === value); + return + }> + {selected && }{selected?.name ?? "Choose guide"} + + No matching guide.{guides.map((guide) => { onChange(guide.id); setOpen(false); }}>{guide.name}{guide.teams.length})} + ; +} + +function TeamCharacters({ team, catalog }: { team: TeamGuideTeam; catalog: Catalog }) { + return
{team.members.map((member) => { + const character = catalog.characters.find((item) => item.id === member.characterId); + return {character?.name.slice(0, 1) ?? "?"}; + })}
; +} + +function TeamPicker({ groups, team, catalog, onChange }: { groups: [string, TeamGuideTeam[]][]; team: TeamGuideTeam | undefined; catalog: Catalog; onChange: (id: string) => void }) { + const [open, setOpen] = useState(false); + return + }> + {team ? <>{team.title} : Choose team} + + No matching team.{groups.map(([label, teams]) => {teams.map((item) => { + const characterNames = item.members.map((member) => catalog.characters.find((character) => character.id === member.characterId)?.name).filter(Boolean).join(" "); + return { onChange(item.id); setOpen(false); }}>{item.title}; + })})} + ; +} + +export function ImportTeamGuideDialog({ catalog, guides, disabled, onImport }: { catalog: Catalog; guides: TeamGuide[]; disabled: boolean; onImport: (team: Team) => void }) { + const [open, setOpen] = useState(false); + const [guideId, setGuideId] = useState(guides[0]?.id ?? ""); + const guide = guides.find((item) => item.id === guideId) ?? guides[0]; + const [teamId, setTeamId] = useState(guide?.teams[0]?.id ?? ""); + const team = guide?.teams.find((item) => item.id === teamId) ?? guide?.teams[0]; + const preview = useMemo(() => team ? importGuideTeam(team, catalog) : undefined, [team, catalog]); + const groups = useMemo(() => { + const result = new Map["teams"]>(); + for (const item of guide?.teams ?? []) { + const label = item.conditionName?.trim() || "Teams"; + result.set(label, [...(result.get(label) ?? []), item]); + } + return [...result.entries()]; + }, [guide]); + + const chooseGuide = (id: string) => { + const next = guides.find((item) => item.id === id); + setGuideId(id); + setTeamId(next?.teams[0]?.id ?? ""); + }; + + return + }>IMPORT FROM BUZZ GUIDE + + Import from Buzz GuideChoose a public guide team to import its characters, weapons, artifacts, constellations, refinements, and combo. + + Guide{guide?.teams.length ?? 0} teams available. + Team + {preview?.error && Cannot import this team{preview.error}} + {preview?.team && <> + Loadout preview
{preview.team.builds.map((build) =>
{build.characterName.slice(0, 1)}

{build.characterName} C{build.constellation}

{build.weaponName} R{build.refinement}

{build.artifactOneName}{build.artifactTwoName ? ` + ${build.artifactTwoName}` : " · 4pc"}

)}
+ {team?.combo.trim() && team.combo.trim() !== "-" && {preview.team.rotation?.enabled ? "Combo will be included" : "Combo saved as reference"}{preview.team.rotation?.enabled ? "The guide combo is valid shorthand and will be enabled." : "The combo uses unsupported guide notation, so it will be preserved with rotation disabled."}} + } +
+ }>Cancel +
+
; +} diff --git a/components/translator.tsx b/components/translator.tsx index b52dedd..5bdeeef 100644 --- a/components/translator.tsx +++ b/components/translator.tsx @@ -5,39 +5,122 @@ 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 { ImportTeamGuideDialog } from "@/components/import-team-guide-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 { + 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 { + 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 { + 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"; +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"; +import type { TeamGuide } from "@/lib/team-guides"; 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"]; +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)}`; + 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 { +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 { +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"))] }; + return { + id, + name: `Team ${index}`, + builds: [ + makeBuild( + catalog, + catalog.characters[0], + deterministic ? `build-${index}-1` : newId("build"), + ), + ], + }; } async function copyText(value: string): Promise { @@ -57,114 +140,1042 @@ async function copyText(value: string): Promise { if (!copied) throw new Error("Clipboard access is unavailable."); } -function NumberSelect({ value, values, onChange }: { value: number; values: number[]; onChange: (value: number) => void }) { - return ; +function NumberSelect({ + value, + values, + onChange, +}: { + value: number; + values: number[]; + onChange: (value: number) => void; +}) { + return ( + + ); } -function StatSelect({ value, values, onChange }: { value: MainStatKey; values: MainStatKey[]; onChange: (value: MainStatKey) => void }) { - return ; +function StatSelect({ + value, + values, + onChange, +}: { + value: MainStatKey; + values: MainStatKey[]; + onChange: (value: MainStatKey) => void; +}) { + return ( + + ); } function BaseStats({ stats }: { stats: CatalogItem["baseStats"] }) { - if (!stats.length) return Level 90 base stats unavailable.; - return
{stats.map((stat) => {formatBaseStat(stat)})}
; + if (!stats.length) + return ( + Level 90 base stats unavailable. + ); + return ( +
+ {stats.map((stat) => ( + + {formatBaseStat(stat)} + + ))} +
+ ); } -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 = (key: K, value: Build[K]) => onChange({ ...build, [key]: value }); +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 = (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 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
{build.characterName}{build.characterKey} · {build.importedStats ? "Imported artifact stats" : `${totalRolls} total rolls`}
{finalStats.length ? finalStats.map((stat) => {formatFinalStat(stat)}) : Final stats unavailable}
- Character -
Level v <= build.maxLevel), build.level])).sort((a, b) => a - b)} onChange={(v) => update("level", v)} />Ascension cap onChange({ ...build, maxLevel: v, level: Math.min(build.level, v) })} />Constellation update("constellation", v)} />Talents NA / Skill / Burst
{build.talents.map((value, index) => { const next = [...build.talents] as Build["talents"]; next[index] = Number(event.target.value); update("talents", next); }} aria-label={["Normal attack", "Skill", "Burst"][index]} />)}
- -
Weapon onChange({ ...build, weaponId: item.id, weaponKey: item.key, weaponName: item.name, importedParams: build.importedParams ? { ...build.importedParams, weapon: undefined } : undefined })} placeholder="Choose weapon" />Level v <= build.weaponMaxLevel), build.weaponLevel])).sort((a, b) => a - b)} onChange={(v) => update("weaponLevel", v)} />Ascension cap onChange({ ...build, weaponMaxLevel: v, weaponLevel: Math.min(build.weaponLevel, v) })} />Refinement update("refinement", v)} />
- - {build.importedSets ? :
Artifact sets and main stats
Set layoutFirst set onChange({ ...build, artifactOneId: item.id, artifactOneKey: item.key, artifactOneName: item.name })} placeholder="Choose artifact" />{build.artifactMode === "2+2" ? Second set item.id !== build.artifactOneId)} value={build.artifactTwoId} onChange={(item) => onChange({ ...build, artifactTwoId: item.id, artifactTwoKey: item.key, artifactTwoName: item.name })} placeholder="Choose artifact" /> : null}{!build.importedStats && <>Sands update("sands", v as Build["sands"])} />Goblet update("goblet", v as Build["goblet"])} />Circlet update("circlet", v as Build["circlet"])} />}
} - {build.importedSets && !build.importedStats &&
Artifact main statsSands update("sands", v as Build["sands"])} />Goblet update("goblet", v as Build["goblet"])} />Circlet update("circlet", v as Build["circlet"])} />
} - - {build.importedStats ? :
Average substat rollsEnter roll counts. Values use gcsim's KQM average-roll scalars.
{rollKeys.map((key) => {key.toUpperCase()} × {key.includes("%") || ["er","cr","cd"].includes(key) ? `${(SUBSTAT_SCALARS[key] * 100).toFixed(2)}%` : SUBSTAT_SCALARS[key]} update("rolls", { ...build.rolls, [key]: Math.max(0, Math.min(99, Number(event.target.value))) })} />{key.includes("%") || ["er","cr","cd"].includes(key) ? `${(calculated[key] * 100).toFixed(2)}%` : calculated[key].toFixed(2)})}
} - {build.importedParams && Imported parameters: {Object.entries(build.importedParams).filter(([, value]) => value).map(([key, value]) => `${key} ${value}`).join(" · ")}} -
; + 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 ( + + +
+ {build.characterName} + + {build.characterKey} ·{" "} + {build.importedStats + ? "Imported artifact stats" + : `${totalRolls} total rolls`} + +
+ +
+ {finalStats.length ? ( + finalStats.map((stat) => ( + + {formatFinalStat(stat)} + + )) + ) : ( + + Final stats unavailable + + )} +
+ +
+
+ + + + Character + + + +
+ + Level + v <= build.maxLevel), + build.level, + ]), + ).sort((a, b) => a - b)} + onChange={(v) => update("level", v)} + /> + + + Ascension cap + + onChange({ + ...build, + maxLevel: v, + level: Math.min(build.level, v), + }) + } + /> + + + Constellation + update("constellation", v)} + /> + + + Talents NA / Skill / Burst +
+ {build.talents.map((value, index) => ( + { + const next = [...build.talents] as Build["talents"]; + next[index] = Number(event.target.value); + update("talents", next); + }} + aria-label={["Normal attack", "Skill", "Burst"][index]} + /> + ))} +
+
+
+ +
+ + Weapon + + onChange({ + ...build, + weaponId: item.id, + weaponKey: item.key, + weaponName: item.name, + importedParams: build.importedParams + ? { ...build.importedParams, weapon: undefined } + : undefined, + }) + } + placeholder="Choose weapon" + /> + + + + Level + v <= build.weaponMaxLevel), + build.weaponLevel, + ]), + ).sort((a, b) => a - b)} + onChange={(v) => update("weaponLevel", v)} + /> + + + Ascension cap + + onChange({ + ...build, + weaponMaxLevel: v, + weaponLevel: Math.min(build.weaponLevel, v), + }) + } + /> + + + Refinement + update("refinement", v)} + /> + +
+ + {build.importedSets ? ( + + ) : ( +
+ Artifact sets and main stats +
+ + Set layout + + + + First set + + onChange({ + ...build, + artifactOneId: item.id, + artifactOneKey: item.key, + artifactOneName: item.name, + }) + } + placeholder="Choose artifact" + /> + + {build.artifactMode === "2+2" ? ( + + Second set + item.id !== build.artifactOneId, + )} + value={build.artifactTwoId} + onChange={(item) => + onChange({ + ...build, + artifactTwoId: item.id, + artifactTwoKey: item.key, + artifactTwoName: item.name, + }) + } + placeholder="Choose artifact" + /> + + ) : null} + {!build.importedStats && ( + <> + + Sands + update("sands", v as Build["sands"])} + /> + + + Goblet + update("goblet", v as Build["goblet"])} + /> + + + Circlet + + update("circlet", v as Build["circlet"]) + } + /> + + + )} +
+
+ )} + {build.importedSets && !build.importedStats && ( +
+ Artifact main stats + + + Sands + update("sands", v as Build["sands"])} + /> + + + Goblet + update("goblet", v as Build["goblet"])} + /> + + + Circlet + update("circlet", v as Build["circlet"])} + /> + + +
+ )} + + {build.importedStats ? ( + + ) : ( +
+ Average substat rolls + + Enter roll counts. Values use gcsim's KQM average-roll + scalars. + +
+ {rollKeys.map((key) => ( + + + {key.toUpperCase()}{" "} + + ×{" "} + {key.includes("%") || ["er", "cr", "cd"].includes(key) + ? `${(SUBSTAT_SCALARS[key] * 100).toFixed(2)}%` + : SUBSTAT_SCALARS[key]} + + + + update("rolls", { + ...build.rolls, + [key]: Math.max( + 0, + Math.min(99, Number(event.target.value)), + ), + }) + } + /> + + {key.includes("%") || ["er", "cr", "cd"].includes(key) + ? `${(calculated[key] * 100).toFixed(2)}%` + : calculated[key].toFixed(2)} + + + ))} +
+
+ )} + {build.importedParams && ( + + Imported parameters:{" "} + {Object.entries(build.importedParams) + .filter(([, value]) => value) + .map(([key, value]) => `${key} ${value}`) + .join(" · ")} + + )} +
+
+
+ ); } -export function Translator({ catalog }: { catalog: Catalog }) { - const [translation, setTranslation] = useState(() => ({ name: "My builds", teams: [makeTeam(catalog, 1, true)] })); +export function Translator({ + catalog, + teamGuides, +}: { + catalog: Catalog; + teamGuides: TeamGuide[]; +}) { + const [translation, setTranslation] = useState(() => ({ + 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(() => { + 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"); } + 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); } + 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 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 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."); + toast.error( + "Could not copy automatically. Select the generated config and copy it manually.", + ); } }; - return
= 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] })); + const addImportedTeam = (imported: Team, source: string) => { + 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"); - }} />
Collection name setTranslation({ ...translation, name: event.target.value })} />Saved automatically in this browser. Nothing is written to the database.
{translation.teams.map((item) => {item.name})}
{translation.teams.map((item) =>
Team name updateTeam({ ...item, name: event.target.value })} />
{item.builds.map((build) => 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) })} />)}
copy()} copyDisabled={Boolean(rotationError)} />
)}
; + toast.success(`${source} team imported`); + }; + const importDisabled = !draftLoaded || translation.teams.length >= 12; + return ( +
+
+
+ addImportedTeam(team, "Guide")} + /> + addImportedTeam(team, "Gcsim")} + /> +
+
+ + Collection name + + setTranslation({ ...translation, name: event.target.value }) + } + /> + + Saved automatically in this browser. + + +
+ +
+ + {translation.teams.map((item) => ( + + {item.name} + + ))} + + +
+ {translation.teams.map((item) => ( + +
+
+
+ + Team name + + updateTeam({ ...item, name: event.target.value }) + } + /> + + +
+ {item.builds.map((build) => ( + 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, + ), + }) + } + /> + ))} + +
+
+ + copy()} + copyDisabled={Boolean(rotationError)} + /> +
+
+
+ ))} +
+
+ ); } -function Header() { return
GCSIMBuild translator

Build teams, not config syntax.

Choose loadouts and average artifact rolls. Get clean character blocks ready to paste.

; } -function OutputCard({ output, onCopy, copyDisabled }: { output: string; onCopy: () => void; copyDisabled: boolean }) { return Generated configCharacter builds and optional rotation for the active team.