"use client"; import { useEffect, useMemo, useState } from "react"; import { CopyIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { toast } from "sonner"; import { CatalogCombobox } from "@/components/catalog-combobox"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Field, FieldDescription, 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 { Textarea } from "@/components/ui/textarea"; import { calculateSubstats, generateTeam, SUBSTAT_SCALARS } from "@/lib/translator/generate"; import { formatBaseStat } from "@/lib/translator/stat-format"; import { rollKeys, translationSchema, type Build, type Catalog, type CatalogItem, type MainStatKey, type RollKey, type Team, type Translation } from "@/lib/translator/types"; const emptyRolls = Object.fromEntries(rollKeys.map((key) => [key, 0])) as Record; 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 { const weapon = catalog.weapons.find((item) => !character.weaponType || item.weaponType === character.weaponType) ?? catalog.weapons[0]; const artifact = catalog.artifacts[0]; return { id, characterId: character.id, characterKey: character.key, characterName: character.name, characterImageUrl: character.imageUrl, level: 90, maxLevel: 90, constellation: 0, talents: [9, 9, 9], weaponId: weapon.id, weaponKey: weapon.key, weaponName: weapon.name, weaponLevel: 90, weaponMaxLevel: 90, refinement: 1, artifactMode: "4pc", artifactOneId: artifact.id, artifactOneKey: artifact.key, artifactOneName: artifact.name, artifactTwoId: "", artifactTwoKey: "", artifactTwoName: "", sands: "atk%", goblet: "atk%", circlet: "cr", rolls: { ...emptyRolls } }; } 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 }); }; const calculated = calculateSubstats(build); const totalRolls = Object.values(build.rolls).reduce((sum, value) => sum + value, 0); return
{build.characterName}{build.characterKey} · {totalRolls} total rolls
Character
Level v <= build.maxLevel)} 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 })} placeholder="Choose weapon" />Level v <= build.weaponMaxLevel)} onChange={(v) => update("weaponLevel", v)} />Ascension cap onChange({ ...build, weaponMaxLevel: v, weaponLevel: Math.min(build.weaponLevel, v) })} />Refinement update("refinement", v)} />
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}Sands update("sands", v as Build["sands"])} />Goblet update("goblet", v as Build["goblet"])} />Circlet update("circlet", v as Build["circlet"])} />
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)})}
; } 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); useEffect(() => { localStorage.setItem("gcsim-translator-draft", JSON.stringify(translation)); }, [translation]); useEffect(() => { const stored = localStorage.getItem("gcsim-translator-draft"); if (stored) try { const parsed = translationSchema.safeParse(JSON.parse(stored)); if (parsed.success) setTimeout(() => { setTranslation(parsed.data); setActiveTeam(parsed.data.teams[0].id); }, 0); } catch { localStorage.removeItem("gcsim-translator-draft"); } }, []); const team = translation.teams.find((item) => item.id === activeTeam) ?? translation.teams[0]; const output = useMemo(() => generateTeam(team), [team]); const updateTeam = (next: Team) => setTranslation((current) => ({ ...current, teams: current.teams.map((item) => item.id === next.id ? next : item) })); const copy = async (value = output) => { try { await copyText(value); toast.success("GCSim build copied"); } catch { toast.error("Could not copy automatically. Select the generated config and copy it manually."); } }; return
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()} />
)}
; } 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 }: { output: string; onCopy: () => void }) { return Generated configCharacter build block for the active team.