"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 { 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 ; } 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)})}
; } 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 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(" · ")}}
; } export function Translator({ catalog }: { catalog: Catalog }) { 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(() => { 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
= 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"); }} />
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)} />
)}
; } 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.