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