sidebar: add edit form components for character/weapon modifications
This commit is contained in:
parent
ad10d3fe73
commit
4f132f9947
6 changed files with 1055 additions and 0 deletions
179
src/lib/components/sidebar/edit/AwakeningSelect.svelte
Normal file
179
src/lib/components/sidebar/edit/AwakeningSelect.svelte
Normal 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>
|
||||
308
src/lib/components/sidebar/edit/AxSkillSelect.svelte
Normal file
308
src/lib/components/sidebar/edit/AxSkillSelect.svelte
Normal 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>
|
||||
102
src/lib/components/sidebar/edit/EarringSelect.svelte
Normal file
102
src/lib/components/sidebar/edit/EarringSelect.svelte
Normal 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"
|
||||
/>
|
||||
76
src/lib/components/sidebar/edit/PerpetuityToggle.svelte
Normal file
76
src/lib/components/sidebar/edit/PerpetuityToggle.svelte
Normal 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>
|
||||
244
src/lib/components/sidebar/edit/RingsSelect.svelte
Normal file
244
src/lib/components/sidebar/edit/RingsSelect.svelte
Normal 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>
|
||||
146
src/lib/components/sidebar/edit/WeaponKeySelect.svelte
Normal file
146
src/lib/components/sidebar/edit/WeaponKeySelect.svelte
Normal 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>
|
||||
Loading…
Reference in a new issue