sidebar: add edit form components for character/weapon modifications

This commit is contained in:
Justin Edmund 2025-11-30 20:06:15 -08:00
parent ad10d3fe73
commit 4f132f9947
6 changed files with 1055 additions and 0 deletions

View file

@ -0,0 +1,179 @@
<svelte:options runes={true} />
<script lang="ts">
import type { Awakening } from '$lib/types/api/entities'
import { NO_AWAKENING } from '$lib/types/api/entities'
import Select from '$lib/components/ui/Select.svelte'
import Input from '$lib/components/ui/Input.svelte'
import DetailRow from '$lib/components/sidebar/details/DetailRow.svelte'
import { getAwakeningImage } from '$lib/utils/modifiers'
interface Props {
/** Available awakenings for the weapon */
awakenings: Awakening[]
/** Currently selected awakening */
value?: Awakening
/** Current awakening level */
level?: number
/** Maximum awakening level for the weapon */
maxLevel: number
/** Called when awakening type changes */
onAwakeningChange?: (awakening: Awakening | undefined) => void
/** Called when awakening level changes */
onLevelChange?: (level: number) => void
}
let {
awakenings,
value = undefined,
level = 1,
maxLevel,
onAwakeningChange,
onLevelChange
}: Props = $props()
// Local state for the selected awakening ID (use id or slug as key)
let selectedId = $state(value ? (value.id || value.slug || NO_AWAKENING.id) : NO_AWAKENING.id)
let localLevel = $state(level)
// Error state for level input
let levelError = $state('')
// Helper to get a unique identifier for an awakening (use id if available, fallback to slug)
function getAwakeningKey(awk: Awakening): string {
return awk.id || awk.slug || 'unknown'
}
// Build options list with NO_AWAKENING first
const options = $derived.by(() => {
const sorted = [...awakenings].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const items: Array<{ value: string; label: string; image?: string }> = sorted.map((awk) => {
const img = getAwakeningImage({ type: awk, level: 1 })
return {
value: getAwakeningKey(awk),
label: awk.name?.en || awk.name?.ja || 'Unknown',
image: img ?? undefined
}
})
// Add NO_AWAKENING at the beginning if not already present
if (!awakenings.find((a) => getAwakeningKey(a) === NO_AWAKENING.id)) {
items.unshift({
value: NO_AWAKENING.id,
label: NO_AWAKENING.name.en
})
}
return items
})
// Is the current selection the "No awakening" option?
const isNoAwakening = $derived(selectedId === NO_AWAKENING.id)
// Handle awakening type change
function handleAwakeningChange(newId: string | undefined) {
if (!newId) {
selectedId = NO_AWAKENING.id
onAwakeningChange?.(undefined)
return
}
selectedId = newId
if (selectedId === NO_AWAKENING.id) {
onAwakeningChange?.(undefined)
} else {
// Find by id first, then by slug (for awakenings with null id)
const selected = awakenings.find((a) => getAwakeningKey(a) === selectedId)
onAwakeningChange?.(selected)
}
}
// Handle level change with validation
function handleLevelChange(event: Event) {
const input = event.target as HTMLInputElement
const newLevel = parseInt(input.value, 10)
// Validate the level
if (isNaN(newLevel)) {
levelError = 'Please enter a valid number'
return
}
if (newLevel < 1) {
levelError = 'Level must be at least 1'
return
}
if (newLevel > maxLevel) {
levelError = `Level cannot exceed ${maxLevel}`
return
}
if (!Number.isInteger(newLevel)) {
levelError = 'Level must be a whole number'
return
}
levelError = ''
localLevel = newLevel
onLevelChange?.(newLevel)
}
</script>
<div class="awakening-select">
<div class="awakening-type">
<Select
options={options}
value={selectedId}
onValueChange={handleAwakeningChange}
placeholder="Select awakening"
size="medium"
fullWidth
contained
/>
</div>
{#if !isNoAwakening}
<DetailRow label="Level" noHover noPadding>
<Input
type="number"
min={1}
max={maxLevel}
step={1}
value={localLevel}
oninput={handleLevelChange}
error={levelError || undefined}
contained
variant="number"
placeholder="1~{maxLevel}"
/>
</DetailRow>
{/if}
{#if levelError}
<p class="level-error">{levelError}</p>
{/if}
</div>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.awakening-select {
display: flex;
flex-direction: column;
gap: spacing.$unit-2x;
}
.awakening-type {
flex: 1;
}
.level-error {
margin: 0;
font-size: typography.$font-small;
color: colors.$error;
}
</style>

View file

@ -0,0 +1,308 @@
<svelte:options runes={true} />
<script lang="ts">
import type { SimpleAxSkill } from '$lib/types/api/entities'
import {
type AxSkill,
NO_AX_SKILL,
getAxSkillsForType,
findPrimarySkill,
findSecondarySkill
} from '$lib/data/ax'
import Select from '$lib/components/ui/Select.svelte'
interface Props {
/** The weapon's axType (1-3) */
axType: number
/** Current AX skills on the weapon */
currentSkills?: SimpleAxSkill[]
/** Called when skills change */
onChange?: (skills: SimpleAxSkill[]) => void
}
let { axType, currentSkills, onChange }: Props = $props()
// Get available primary skills for this axType
const primarySkills = $derived(getAxSkillsForType(axType))
// State for primary skill
let primaryModifier = $state(currentSkills?.[0]?.modifier ?? -1)
let primaryValue = $state(currentSkills?.[0]?.strength ?? 0)
let primaryError = $state('')
// State for secondary skill
let secondaryModifier = $state(currentSkills?.[1]?.modifier ?? -1)
let secondaryValue = $state(currentSkills?.[1]?.strength ?? 0)
let secondaryError = $state('')
// Get the selected primary skill
const selectedPrimarySkill = $derived(findPrimarySkill(axType, primaryModifier))
// Whether secondary skill selection should be shown
// Hide if no primary skill selected, or if primary skill has no secondaries (like EXP/Rupie)
const showSecondary = $derived(
primaryModifier >= 0 &&
selectedPrimarySkill?.secondary &&
selectedPrimarySkill.secondary.length > 0
)
// Build primary skill options
const primaryOptions = $derived.by(() => {
const items: Array<{ value: number; label: string }> = [
{ value: -1, label: NO_AX_SKILL.name.en }
]
for (const skill of primarySkills) {
items.push({
value: skill.id,
label: skill.name.en
})
}
return items
})
// Build secondary skill options based on selected primary
const secondaryOptions = $derived.by(() => {
const items: Array<{ value: number; label: string }> = [
{ value: -1, label: NO_AX_SKILL.name.en }
]
if (selectedPrimarySkill?.secondary) {
for (const skill of selectedPrimarySkill.secondary) {
items.push({
value: skill.id,
label: skill.name.en
})
}
}
return items
})
// Get range string for input placeholder
function getRangeString(skill: AxSkill | undefined): string {
if (!skill) return ''
return `${skill.minValue}~${skill.maxValue}${skill.suffix || ''}`
}
// Validate a value against a skill's constraints
function validateValue(value: number, skill: AxSkill | undefined): string {
if (!skill) return ''
if (isNaN(value) || value <= 0) {
return `Please enter a value for ${skill.name.en}`
}
if (value < skill.minValue) {
return `${skill.name.en} must be at least ${skill.minValue}${skill.suffix || ''}`
}
if (value > skill.maxValue) {
return `${skill.name.en} cannot exceed ${skill.maxValue}${skill.suffix || ''}`
}
if (!skill.fractional && value % 1 !== 0) {
return `${skill.name.en} must be a whole number`
}
return ''
}
// Handle primary skill change
function handlePrimaryChange(value: number | undefined) {
const newValue = value ?? -1
primaryModifier = newValue
primaryValue = 0
primaryError = ''
// Reset secondary when primary changes
secondaryModifier = -1
secondaryValue = 0
secondaryError = ''
emitChange()
}
// Handle primary value change
function handlePrimaryValueChange(event: Event) {
const input = event.target as HTMLInputElement
const value = parseFloat(input.value)
primaryValue = value
primaryError = validateValue(value, selectedPrimarySkill)
emitChange()
}
// Handle secondary skill change
function handleSecondaryChange(value: number | undefined) {
const newValue = value ?? -1
secondaryModifier = newValue
secondaryValue = 0
secondaryError = ''
emitChange()
}
// Handle secondary value change
function handleSecondaryValueChange(event: Event) {
const input = event.target as HTMLInputElement
const value = parseFloat(input.value)
secondaryValue = value
const secondarySkill = findSecondarySkill(selectedPrimarySkill!, secondaryModifier)
secondaryError = validateValue(value, secondarySkill)
emitChange()
}
// Emit change to parent
function emitChange() {
const skills: SimpleAxSkill[] = [
{ modifier: primaryModifier, strength: primaryValue },
{ modifier: secondaryModifier, strength: secondaryValue }
]
onChange?.(skills)
}
</script>
<div class="ax-skill-select">
<!-- Primary Skill -->
<div class="skill-row">
<div class="skill-fields">
<div class="skill-select">
<Select
options={primaryOptions}
value={primaryModifier}
onValueChange={handlePrimaryChange}
placeholder="Select skill"
size="medium"
fullWidth
contained
/>
</div>
{#if primaryModifier >= 0 && selectedPrimarySkill}
<input
type="number"
class="skill-value"
class:error={primaryError !== ''}
min={selectedPrimarySkill.minValue}
max={selectedPrimarySkill.maxValue}
step={selectedPrimarySkill.fractional ? '0.5' : '1'}
placeholder={getRangeString(selectedPrimarySkill)}
value={primaryValue || ''}
oninput={handlePrimaryValueChange}
/>
{/if}
</div>
{#if primaryError}
<p class="error-text">{primaryError}</p>
{/if}
</div>
<!-- Secondary Skill (only shown when primary has secondaries) -->
{#if showSecondary}
<div class="skill-row">
<div class="skill-fields">
<div class="skill-select">
<Select
options={secondaryOptions}
value={secondaryModifier}
onValueChange={handleSecondaryChange}
placeholder="Select skill"
size="medium"
fullWidth
contained
/>
</div>
{#if secondaryModifier >= 0}
{@const secondarySkill = findSecondarySkill(selectedPrimarySkill!, secondaryModifier)}
{#if secondarySkill}
<input
type="number"
class="skill-value"
class:error={secondaryError !== ''}
min={secondarySkill.minValue}
max={secondarySkill.maxValue}
step={secondarySkill.fractional ? '0.5' : '1'}
placeholder={getRangeString(secondarySkill)}
value={secondaryValue || ''}
oninput={handleSecondaryValueChange}
/>
{/if}
{/if}
</div>
{#if secondaryError}
<p class="error-text">{secondaryError}</p>
{/if}
</div>
{/if}
</div>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
.ax-skill-select {
display: flex;
flex-direction: column;
gap: spacing.$unit-2x;
}
.skill-row {
display: flex;
flex-direction: column;
gap: spacing.$unit;
}
.skill-fields {
display: flex;
gap: spacing.$unit-2x;
align-items: center;
}
.skill-select {
flex: 1;
min-width: 0;
}
.skill-value {
width: 90px;
flex-shrink: 0;
padding: spacing.$unit spacing.$unit-2x;
background: var(--input-bg, colors.$grey-85);
border: 1px solid var(--border-secondary);
border-radius: layout.$item-corner-small;
color: var(--text-primary);
font-size: typography.$font-regular;
text-align: center;
&:focus {
outline: none;
border-color: var(--accent-primary);
}
&.error {
border-color: colors.$error;
}
// Remove spin buttons
-moz-appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
.error-text {
margin: 0;
font-size: typography.$font-small;
color: colors.$error;
}
</style>

View file

@ -0,0 +1,102 @@
<svelte:options runes={true} />
<script lang="ts">
import MasteryRow from './MasteryRow.svelte'
import { aetherialMastery, type ItemSkill } from '$lib/data/overMastery'
import { getElementalizedEarringStat } from '$lib/utils/masteryUtils'
interface ExtendedMastery {
modifier: number
strength: number
}
interface Props {
/** Current earring value */
value?: ExtendedMastery
/** Character's element for filtering/labeling options */
element?: number
/** Called when earring changes */
onChange?: (earring: ExtendedMastery | undefined) => void
}
let { value, element, onChange }: Props = $props()
// Local state
let modifier = $state(value?.modifier ?? 0)
let strength = $state(value?.strength ?? 0)
// Get the ItemSkill data for a modifier, with element substitution
function getSkillData(mod: number): ItemSkill | undefined {
if (mod <= 0) return undefined
return getElementalizedEarringStat(mod, element, 'en')
}
// Build modifier options
const modifierOptions = $derived.by(() => {
const options: Array<{ value: number; label: string }> = [{ value: 0, label: 'None' }]
for (const skill of aetherialMastery) {
// Use elementalized name for display
const elementalizedSkill = getElementalizedEarringStat(skill.id, element, 'en')
options.push({
value: skill.id,
label: elementalizedSkill?.name.en ?? skill.name.en
})
}
return options
})
// Build strength options based on selected modifier
const strengthOptions = $derived.by(() => {
if (modifier <= 0) return []
const skill = getSkillData(modifier)
if (!skill) return []
const options: Array<{ value: number; label: string }> = []
for (let v = skill.minValue; v <= skill.maxValue; v++) {
options.push({
value: v,
label: `${v}${skill.suffix || ''}`
})
}
return options
})
// Emit changes
function emitChange() {
if (modifier <= 0) {
onChange?.(undefined)
} else {
onChange?.({ modifier, strength })
}
}
function handleModifierChange(value: number | string | undefined) {
modifier = typeof value === 'number' ? value : parseInt(String(value), 10) || 0
// Reset strength to minimum when modifier changes
if (modifier > 0) {
const skill = getSkillData(modifier)
strength = skill?.minValue ?? 0
} else {
strength = 0
}
emitChange()
}
function handleStrengthChange(value: number | string | undefined) {
strength = typeof value === 'number' ? value : parseInt(String(value), 10) || 0
emitChange()
}
</script>
<MasteryRow
{modifierOptions}
{strengthOptions}
{modifier}
{strength}
onModifierChange={handleModifierChange}
onStrengthChange={handleStrengthChange}
modifierPlaceholder="Select earring"
/>

View file

@ -0,0 +1,76 @@
<svelte:options runes={true} />
<script lang="ts">
import Checkbox from '$lib/components/ui/checkbox/Checkbox.svelte'
interface Props {
/** Whether perpetuity is enabled */
value: boolean
/** Called when value changes */
onChange?: (value: boolean) => void
/** Element for theming */
element?: 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
}
let { value = false, onChange, element }: Props = $props()
let localValue = $state(value)
function handleChange(checked: boolean) {
localValue = checked
onChange?.(checked)
}
</script>
<div class="perpetuity-toggle">
<label class="toggle-row">
<Checkbox
checked={localValue}
onCheckedChange={handleChange}
contained
{element}
/>
<div class="toggle-content">
<img
src="/images/perpetuity.png"
alt="Perpetuity Ring"
class="perpetuity-icon"
/>
<span class="toggle-label">Perpetuity Ring</span>
</div>
</label>
</div>
<style lang="scss">
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.perpetuity-toggle {
display: flex;
flex-direction: column;
}
.toggle-row {
display: flex;
align-items: center;
gap: spacing.$unit-2x;
cursor: pointer;
}
.toggle-content {
display: flex;
align-items: center;
gap: spacing.$unit;
}
.perpetuity-icon {
width: 24px;
height: 24px;
object-fit: contain;
}
.toggle-label {
font-size: typography.$font-regular;
color: var(--text-primary);
}
</style>

View file

@ -0,0 +1,244 @@
<svelte:options runes={true} />
<script lang="ts">
import MasteryRow from './MasteryRow.svelte'
import { overMastery, type ItemSkill } from '$lib/data/overMastery'
interface ExtendedMastery {
modifier: number
strength: number
}
interface Props {
/** Current 4 rings array */
rings: ExtendedMastery[]
/** Called when rings change */
onChange?: (rings: ExtendedMastery[]) => void
}
let { rings = [], onChange }: Props = $props()
// Local state for each ring
let ring0Strength = $state(rings[0]?.strength ?? 0)
let ring1Strength = $state(rings[1]?.strength ?? 0)
let ring2Modifier = $state(rings[2]?.modifier ?? 0)
let ring2Strength = $state(rings[2]?.strength ?? 0)
let ring3Modifier = $state(rings[3]?.modifier ?? 0)
let ring3Strength = $state(rings[3]?.strength ?? 0)
// Ring 0 and 1 have fixed modifiers (ATK=1, HP=2)
const ATK_MODIFIER = 1
const HP_MODIFIER = 2
// Get the ItemSkill data for a modifier
function getSkillData(modifier: number): ItemSkill | undefined {
if (modifier <= 0) return undefined
if (modifier <= 2) return overMastery.a.find((s) => s.id === modifier)
if (modifier <= 9) return overMastery.b.find((s) => s.id === modifier)
return overMastery.c.find((s) => s.id === modifier)
}
// Build strength options from skill data
function buildStrengthOptions(
skill: ItemSkill | undefined
): Array<{ value: number; label: string }> {
if (!skill?.values) return []
return skill.values.map((v) => ({
value: v,
label: `${v}${skill.suffix || ''}`
}))
}
// Build options for secondary/tertiary modifiers (rings 3&4)
function buildModifierOptions(): Array<{ value: number; label: string }> {
const options: Array<{ value: number; label: string }> = [{ value: 0, label: 'None' }]
// Add secondary options (3-9)
for (const skill of overMastery.b) {
options.push({
value: skill.id,
label: skill.name.en
})
}
// Add tertiary options (10-15)
for (const skill of overMastery.c) {
options.push({
value: skill.id,
label: skill.name.en
})
}
return options
}
// Get strength options for a given modifier
function getStrengthOptionsForModifier(
modifier: number
): Array<{ value: number; label: string }> {
if (modifier <= 0) return []
const skill = getSkillData(modifier)
if (!skill) return []
if (skill.values) {
return skill.values.map((v) => ({
value: v,
label: `${v}${skill.suffix || ''}`
}))
}
// Fallback to range
const options: Array<{ value: number; label: string }> = []
for (let v = skill.minValue; v <= skill.maxValue; v++) {
options.push({
value: v,
label: `${v}${skill.suffix || ''}`
})
}
return options
}
// Primary ring data
const atkSkill = $derived(getSkillData(ATK_MODIFIER))
const hpSkill = $derived(getSkillData(HP_MODIFIER))
// Fixed modifier options for ATK and HP (single option, disabled)
const atkModifierOptions = $derived([{ value: ATK_MODIFIER, label: atkSkill?.name.en ?? 'ATK' }])
const hpModifierOptions = $derived([{ value: HP_MODIFIER, label: hpSkill?.name.en ?? 'HP' }])
// Strength options
const atkStrengthOptions = $derived(buildStrengthOptions(atkSkill))
const hpStrengthOptions = $derived(buildStrengthOptions(hpSkill))
// Secondary ring options
const modifierOptions = $derived(buildModifierOptions())
const ring2StrengthOptions = $derived(getStrengthOptionsForModifier(ring2Modifier))
const ring3StrengthOptions = $derived(getStrengthOptionsForModifier(ring3Modifier))
// Ring 4 is only shown if Ring 3 has a modifier
const ring3Enabled = $derived(ring2Modifier > 0)
// Build the rings array and emit changes
function emitChange() {
const newRings: ExtendedMastery[] = [
{ modifier: ATK_MODIFIER, strength: ring0Strength },
{ modifier: HP_MODIFIER, strength: ring1Strength },
{ modifier: ring2Modifier, strength: ring2Strength },
{ modifier: ring3Modifier, strength: ring3Strength }
]
onChange?.(newRings)
}
// Sync ring 0 (ATK) and ring 1 (HP) - when one changes, update both to same index
function syncPrimaryRings(changedRing: 0 | 1, newStrength: number) {
const atkValues = atkSkill?.values ?? []
const hpValues = hpSkill?.values ?? []
// Find the index of the new value in the appropriate array
const selectedIndex =
changedRing === 0 ? atkValues.indexOf(newStrength) : hpValues.indexOf(newStrength)
if (selectedIndex === -1) return
// Update both rings to the corresponding index values
ring0Strength = atkValues[selectedIndex] ?? 0
ring1Strength = hpValues[selectedIndex] ?? 0
emitChange()
}
// Handlers
function handleRing0StrengthChange(value: number | string | undefined) {
const newStrength = typeof value === 'number' ? value : parseInt(String(value), 10) || 0
syncPrimaryRings(0, newStrength)
}
function handleRing1StrengthChange(value: number | string | undefined) {
const newStrength = typeof value === 'number' ? value : parseInt(String(value), 10) || 0
syncPrimaryRings(1, newStrength)
}
function handleRing2ModifierChange(value: number | string | undefined) {
ring2Modifier = typeof value === 'number' ? value : parseInt(String(value), 10) || 0
// Reset strength when modifier changes
const options = getStrengthOptionsForModifier(ring2Modifier)
ring2Strength = options[0]?.value ?? 0
// Also reset ring 3 if ring 2 is cleared
if (ring2Modifier <= 0) {
ring3Modifier = 0
ring3Strength = 0
}
emitChange()
}
function handleRing2StrengthChange(value: number | string | undefined) {
ring2Strength = typeof value === 'number' ? value : parseInt(String(value), 10) || 0
emitChange()
}
function handleRing3ModifierChange(value: number | string | undefined) {
ring3Modifier = typeof value === 'number' ? value : parseInt(String(value), 10) || 0
// Reset strength when modifier changes
const options = getStrengthOptionsForModifier(ring3Modifier)
ring3Strength = options[0]?.value ?? 0
emitChange()
}
function handleRing3StrengthChange(value: number | string | undefined) {
ring3Strength = typeof value === 'number' ? value : parseInt(String(value), 10) || 0
emitChange()
}
</script>
<div class="rings-select">
<!-- Ring 1: ATK (fixed modifier) -->
<MasteryRow
modifierOptions={atkModifierOptions}
strengthOptions={atkStrengthOptions}
modifier={ATK_MODIFIER}
strength={ring0Strength}
modifierDisabled
onStrengthChange={handleRing0StrengthChange}
/>
<!-- Ring 2: HP (fixed modifier) -->
<MasteryRow
modifierOptions={hpModifierOptions}
strengthOptions={hpStrengthOptions}
modifier={HP_MODIFIER}
strength={ring1Strength}
modifierDisabled
onStrengthChange={handleRing1StrengthChange}
/>
<!-- Ring 3: Optional -->
<MasteryRow
{modifierOptions}
strengthOptions={ring2StrengthOptions}
modifier={ring2Modifier}
strength={ring2Strength}
onModifierChange={handleRing2ModifierChange}
onStrengthChange={handleRing2StrengthChange}
/>
<!-- Ring 4: Optional (only if Ring 3 is set) -->
{#if ring3Enabled}
<MasteryRow
{modifierOptions}
strengthOptions={ring3StrengthOptions}
modifier={ring3Modifier}
strength={ring3Strength}
onModifierChange={handleRing3ModifierChange}
onStrengthChange={handleRing3StrengthChange}
/>
{/if}
</div>
<style lang="scss">
@use '$src/themes/spacing' as spacing;
.rings-select {
display: flex;
flex-direction: column;
gap: spacing.$unit-2x;
}
</style>

View file

@ -0,0 +1,146 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query'
import { entityQueries } from '$lib/api/queries/entity.queries'
import type { WeaponKey } from '$lib/api/adapters/entity.adapter'
import Select from '$lib/components/ui/Select.svelte'
interface Props {
/** The weapon series (determines which keys are available) */
series: number
/** The slot number (1, 2, or 3) */
slot: number
/** Currently selected weapon key ID */
value?: string
/** Called when selection changes */
onchange?: (key: WeaponKey | undefined) => void
/** Current transcendence step (for key validation) */
transcendenceStep?: number
}
let { series, slot, value = $bindable(), onchange, transcendenceStep = 0 }: Props = $props()
// Key type names based on series and slot (0-based indexing)
const KEY_TYPE_NAMES: Record<number, Record<number, { en: string; ja: string }>> = {
// Dark Opus (series 3)
3: {
0: { en: 'Pendulum', ja: 'ペンデュラム' },
1: { en: 'Pendulum/Chain', ja: 'ペンデュラム/チェイン' }
},
// Draconic (series 27)
27: {
0: { en: 'Teluma', ja: 'テルマ' },
1: { en: 'Teluma', ja: 'テルマ' }
},
// Ultima (series 13)
13: {
0: { en: 'Gauph Key', ja: 'ガフスキー' },
1: { en: 'Ultima Key', ja: 'ガフスキーΩ' },
2: { en: 'Gate of Omnipotence', ja: 'ガフスキー' }
},
// Astral (series 19)
19: {
0: { en: 'Emblem', ja: 'エンブレム' }
},
// Superlative (series 40)
40: {
0: { en: 'Teluma', ja: 'テルマ' },
1: { en: 'Teluma', ja: 'テルマ' }
}
}
// Fetch weapon keys for this series and slot
const weaponKeysQuery = createQuery(() => entityQueries.weaponKeys({ series, slot }))
// Get the key type name for this series/slot
const keyTypeName = $derived(KEY_TYPE_NAMES[series]?.[slot]?.en ?? 'Key')
// Group and sort weapon keys
const groupedOptions = $derived.by(() => {
const keys = weaponKeysQuery.data ?? []
if (keys.length === 0) return []
// Group by group property
const groups = new Map<number, WeaponKey[]>()
for (const key of keys) {
const existing = groups.get(key.group) ?? []
existing.push(key)
groups.set(key.group, existing)
}
// Sort within groups and flatten
const result: Array<{ value: string; label: string; disabled?: boolean }> = []
// Add "No key" option first
result.push({
value: '',
label: `No ${keyTypeName}`
})
// Add grouped keys
for (const [, groupKeys] of [...groups.entries()].sort((a, b) => a[0] - b[0])) {
const sorted = groupKeys.sort((a, b) => a.order - b.order)
for (const key of sorted) {
// Check if key requires transcendence (specific granblue_ids)
const requiresTranscendence = [14005, 14006, 14007].includes(key.granblue_id)
const isDisabled = requiresTranscendence && transcendenceStep < 3
result.push({
value: key.id,
label: key.name.en,
disabled: isDisabled
})
}
}
return result
})
// Find the full weapon key object from ID
function findKeyById(id: string | undefined): WeaponKey | undefined {
if (!id) return undefined
return (weaponKeysQuery.data ?? []).find((k: WeaponKey) => k.id === id)
}
function handleChange(newValue: string | undefined) {
value = newValue
onchange?.(findKeyById(newValue))
}
</script>
<div class="weapon-key-select">
{#if weaponKeysQuery.isPending}
<div class="loading">Loading keys...</div>
{:else if weaponKeysQuery.error}
<div class="error">Failed to load keys</div>
{:else}
<Select
options={groupedOptions}
value={value ?? ''}
onValueChange={handleChange}
placeholder={`Select ${keyTypeName}`}
size="medium"
fullWidth
contained
/>
{/if}
</div>
<style lang="scss">
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.weapon-key-select {
width: 100%;
}
.loading,
.error {
padding: spacing.$unit-2x;
font-size: typography.$font-small;
color: var(--text-tertiary);
}
.error {
color: var(--text-error);
}
</style>