sidebar: add EditCharacterSidebar and EditWeaponSidebar
This commit is contained in:
parent
4f132f9947
commit
8ac9dea2d3
2 changed files with 537 additions and 0 deletions
239
src/lib/components/sidebar/EditCharacterSidebar.svelte
Normal file
239
src/lib/components/sidebar/EditCharacterSidebar.svelte
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
<script lang="ts">
|
||||
import type { GridCharacter } from '$lib/types/api/party'
|
||||
import type { Awakening } from '$lib/types/api/entities'
|
||||
import DetailsSection from './details/DetailsSection.svelte'
|
||||
import ItemHeader from './details/ItemHeader.svelte'
|
||||
import AwakeningSelect from './edit/AwakeningSelect.svelte'
|
||||
import RingsSelect from './edit/RingsSelect.svelte'
|
||||
import EarringSelect from './edit/EarringSelect.svelte'
|
||||
import PerpetuityToggle from './edit/PerpetuityToggle.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
|
||||
interface ExtendedMastery {
|
||||
modifier: number
|
||||
strength: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
character: GridCharacter
|
||||
onSave?: (updates: Partial<GridCharacter>) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
let { character, onSave, onCancel }: Props = $props()
|
||||
|
||||
// Character data shortcut
|
||||
const characterData = $derived(character.character)
|
||||
const characterElement = $derived(characterData?.element)
|
||||
|
||||
// Awakening state - initialize from existing awakening
|
||||
let selectedAwakening = $state<Awakening | undefined>(character.awakening?.type)
|
||||
let awakeningLevel = $state(character.awakening?.level ?? 1)
|
||||
|
||||
// Rings state - initialize from existing overMastery
|
||||
let rings = $state<ExtendedMastery[]>(
|
||||
character.overMastery ?? [
|
||||
{ modifier: 1, strength: 0 },
|
||||
{ modifier: 2, strength: 0 },
|
||||
{ modifier: 0, strength: 0 },
|
||||
{ modifier: 0, strength: 0 }
|
||||
]
|
||||
)
|
||||
|
||||
// Earring state - initialize from existing aetherialMastery
|
||||
let earring = $state<ExtendedMastery | undefined>(character.aetherialMastery)
|
||||
|
||||
// Perpetuity state - initialize from existing value
|
||||
let perpetuity = $state(character.perpetuity ?? false)
|
||||
|
||||
// Derived conditions for what can be edited
|
||||
// Characters always have maxAwakeningLevel of 10 (not returned by API, but hardcoded)
|
||||
const maxAwakeningLevel = 10
|
||||
const hasAwakening = $derived((characterData?.awakenings?.length ?? 0) > 0)
|
||||
const availableAwakenings = $derived(characterData?.awakenings ?? [])
|
||||
|
||||
// Perpetuity is only available for non-MC characters (position > 0)
|
||||
const canHavePerpetuity = $derived(character.position > 0)
|
||||
|
||||
// Awakening slug to UUID map (awakenings come from API with id: null, only slugs)
|
||||
const AWAKENING_MAP: Record<string, string> = {
|
||||
'character-balanced': 'b1847c82-ece0-4d7a-8af1-c7868d90f34a',
|
||||
'character-atk': '6e233877-8cda-4c8f-a091-3db6f68749e2',
|
||||
'character-def': 'c95441de-f949-4a62-b02b-101aa2e0a638',
|
||||
'character-multi': 'e36b0573-79c3-4dd2-9524-c95def4bbb1a'
|
||||
}
|
||||
|
||||
// Element name for the sidebar theming
|
||||
const ELEMENT_MAP: Record<number, 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'> = {
|
||||
1: 'wind',
|
||||
2: 'fire',
|
||||
3: 'water',
|
||||
4: 'earth',
|
||||
5: 'dark',
|
||||
6: 'light'
|
||||
}
|
||||
const elementName = $derived(characterElement ? ELEMENT_MAP[characterElement] : undefined)
|
||||
|
||||
function handleSave() {
|
||||
// Build API-formatted updates (field names match Rails API expectations)
|
||||
const updates: Record<string, unknown> = {}
|
||||
|
||||
// Collect awakening updates - convert slug to UUID using AWAKENING_MAP
|
||||
// The awakenings list from API has id: null, only slugs are populated
|
||||
if (hasAwakening) {
|
||||
if (selectedAwakening && selectedAwakening.slug) {
|
||||
const awakeningId = AWAKENING_MAP[selectedAwakening.slug]
|
||||
if (awakeningId) {
|
||||
updates.awakening = {
|
||||
id: awakeningId,
|
||||
level: awakeningLevel
|
||||
}
|
||||
} else {
|
||||
console.warn(`Unknown awakening slug: ${selectedAwakening.slug}`)
|
||||
}
|
||||
} else {
|
||||
updates.awakening = null
|
||||
}
|
||||
}
|
||||
|
||||
// Collect rings updates - API expects 'rings' not 'overMastery'
|
||||
updates.rings = rings
|
||||
|
||||
// Collect earring updates - API expects 'earring' not 'aetherialMastery'
|
||||
if (earring) {
|
||||
updates.earring = earring
|
||||
}
|
||||
|
||||
// Collect perpetuity updates
|
||||
if (canHavePerpetuity) {
|
||||
updates.perpetuity = perpetuity
|
||||
}
|
||||
|
||||
onSave?.(updates as Partial<GridCharacter>)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
// Reset to original values
|
||||
selectedAwakening = character.awakening?.type
|
||||
awakeningLevel = character.awakening?.level ?? 1
|
||||
rings = character.overMastery ?? [
|
||||
{ modifier: 1, strength: 0 },
|
||||
{ modifier: 2, strength: 0 },
|
||||
{ modifier: 0, strength: 0 },
|
||||
{ modifier: 0, strength: 0 }
|
||||
]
|
||||
earring = character.aetherialMastery
|
||||
perpetuity = character.perpetuity ?? false
|
||||
onCancel?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="character-edit-sidebar">
|
||||
<ItemHeader
|
||||
type="character"
|
||||
item={character}
|
||||
itemData={characterData}
|
||||
gridUncapLevel={character.uncapLevel}
|
||||
gridTranscendence={character.transcendenceStep}
|
||||
/>
|
||||
|
||||
<div class="edit-sections">
|
||||
{#if hasAwakening && availableAwakenings.length > 0}
|
||||
<DetailsSection title="Awakening">
|
||||
<div class="section-content">
|
||||
<AwakeningSelect
|
||||
awakenings={availableAwakenings}
|
||||
value={selectedAwakening}
|
||||
level={awakeningLevel}
|
||||
maxLevel={maxAwakeningLevel}
|
||||
onAwakeningChange={(awakening) => {
|
||||
selectedAwakening = awakening
|
||||
}}
|
||||
onLevelChange={(level) => {
|
||||
awakeningLevel = level
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DetailsSection>
|
||||
{/if}
|
||||
|
||||
<DetailsSection title="Over Mastery Rings">
|
||||
<div class="section-content">
|
||||
<RingsSelect
|
||||
{rings}
|
||||
onChange={(newRings) => {
|
||||
rings = newRings
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DetailsSection>
|
||||
|
||||
<DetailsSection title="Aetherial Mastery">
|
||||
<div class="section-content">
|
||||
<EarringSelect
|
||||
value={earring}
|
||||
element={characterElement}
|
||||
onChange={(newEarring) => {
|
||||
earring = newEarring
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DetailsSection>
|
||||
|
||||
{#if canHavePerpetuity}
|
||||
<DetailsSection title="Perpetuity">
|
||||
<div class="section-content">
|
||||
<PerpetuityToggle
|
||||
value={perpetuity}
|
||||
element={elementName}
|
||||
onChange={(value) => {
|
||||
perpetuity = value
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DetailsSection>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="edit-footer">
|
||||
<Button variant="secondary" onclick={handleCancel}>Cancel</Button>
|
||||
<Button variant="primary" element={elementName} elementStyle={!!elementName} onclick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
|
||||
.character-edit-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: spacing.$unit-4x;
|
||||
}
|
||||
|
||||
.edit-sections {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-3x;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: spacing.$unit;
|
||||
}
|
||||
|
||||
.edit-footer {
|
||||
display: flex;
|
||||
gap: spacing.$unit-2x;
|
||||
padding: spacing.$unit-2x;
|
||||
border-top: 1px solid var(--border-secondary);
|
||||
flex-shrink: 0;
|
||||
|
||||
:global(button) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
298
src/lib/components/sidebar/EditWeaponSidebar.svelte
Normal file
298
src/lib/components/sidebar/EditWeaponSidebar.svelte
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
<script lang="ts">
|
||||
import type { GridWeapon } from '$lib/types/api/party'
|
||||
import type { WeaponKey } from '$lib/api/adapters/entity.adapter'
|
||||
import type { Awakening, SimpleAxSkill } from '$lib/types/api/entities'
|
||||
import DetailsSection from './details/DetailsSection.svelte'
|
||||
import ItemHeader from './details/ItemHeader.svelte'
|
||||
import Select from '$lib/components/ui/Select.svelte'
|
||||
import WeaponKeySelect from './edit/WeaponKeySelect.svelte'
|
||||
import AwakeningSelect from './edit/AwakeningSelect.svelte'
|
||||
import AxSkillSelect from './edit/AxSkillSelect.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
|
||||
interface Props {
|
||||
weapon: GridWeapon
|
||||
onSave?: (updates: Partial<GridWeapon>) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
let { weapon, onSave, onCancel }: Props = $props()
|
||||
|
||||
// Local state for edits
|
||||
let element = $state(weapon.element ?? weapon.weapon?.element ?? 0)
|
||||
|
||||
// Weapon key state - initialize from existing weapon keys
|
||||
let weaponKey1 = $state<string | undefined>(weapon.weaponKeys?.[0]?.id)
|
||||
let weaponKey2 = $state<string | undefined>(weapon.weaponKeys?.[1]?.id)
|
||||
let weaponKey3 = $state<string | undefined>(weapon.weaponKeys?.[2]?.id)
|
||||
|
||||
// Awakening state - initialize from existing awakening
|
||||
let selectedAwakening = $state<Awakening | undefined>(weapon.awakening?.type)
|
||||
let awakeningLevel = $state(weapon.awakening?.level ?? 1)
|
||||
|
||||
// AX skill state - initialize from existing AX skills
|
||||
let axSkills = $state<SimpleAxSkill[]>(
|
||||
weapon.ax ?? [
|
||||
{ modifier: -1, strength: 0 },
|
||||
{ modifier: -1, strength: 0 }
|
||||
]
|
||||
)
|
||||
|
||||
// Weapon data shortcuts
|
||||
const weaponData = $derived(weapon.weapon)
|
||||
const canChangeElement = $derived(weaponData?.element === 0)
|
||||
const series = $derived(weaponData?.series ?? 0)
|
||||
const transcendenceStep = $derived(weapon.transcendenceStep ?? 0)
|
||||
|
||||
// Weapon key config keyed by WEAPON series
|
||||
// Maps weapon.series → { slots, keySeries } where keySeries is what weapon_keys API expects
|
||||
const WEAPON_KEY_SERIES: Record<number, { name: string; slots: number; keySeries: number }> = {
|
||||
2: { name: 'Dark Opus', slots: 2, keySeries: 3 }, // Pendulum (slot 0) + Chain/Pendulum (slot 1)
|
||||
3: { name: 'Ultima', slots: 3, keySeries: 13 }, // Gauph Key (slot 0) + Ultima Key (slot 1) + Gate (slot 2)
|
||||
17: { name: 'Draconic', slots: 2, keySeries: 27 }, // Teluma (slot 0) + Teluma (slot 1)
|
||||
22: { name: 'Astral', slots: 1, keySeries: 19 }, // Emblem (slot 0)
|
||||
34: { name: 'Superlative', slots: 2, keySeries: 40 } // Teluma (slot 0) + Teluma (slot 1)
|
||||
}
|
||||
|
||||
const weaponKeyConfig = $derived(WEAPON_KEY_SERIES[series])
|
||||
const hasWeaponKeys = $derived(!!weaponKeyConfig)
|
||||
const keySlotCount = $derived(weaponKeyConfig?.slots ?? 0)
|
||||
const keySeries = $derived(weaponKeyConfig?.keySeries ?? 0)
|
||||
|
||||
const hasAxSkills = $derived(weaponData?.ax === true)
|
||||
const axType = $derived(weaponData?.axType ?? 1)
|
||||
const hasAwakening = $derived((weaponData?.maxAwakeningLevel ?? 0) > 0)
|
||||
const availableAwakenings = $derived(weaponData?.awakenings ?? [])
|
||||
|
||||
// Element name for button styling
|
||||
const ELEMENT_MAP: Record<number, 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'> = {
|
||||
1: 'wind',
|
||||
2: 'fire',
|
||||
3: 'water',
|
||||
4: 'earth',
|
||||
5: 'dark',
|
||||
6: 'light'
|
||||
}
|
||||
const weaponElement = $derived(element || weaponData?.element)
|
||||
const elementName = $derived(weaponElement ? ELEMENT_MAP[weaponElement] : undefined)
|
||||
|
||||
// Element options
|
||||
const elementOptions = [
|
||||
{ value: 1, label: 'Wind', image: '/images/elements/element-wind.png' },
|
||||
{ value: 2, label: 'Fire', image: '/images/elements/element-fire.png' },
|
||||
{ value: 3, label: 'Water', image: '/images/elements/element-water.png' },
|
||||
{ value: 4, label: 'Earth', image: '/images/elements/element-earth.png' },
|
||||
{ value: 5, label: 'Dark', image: '/images/elements/element-dark.png' },
|
||||
{ value: 6, label: 'Light', image: '/images/elements/element-light.png' }
|
||||
]
|
||||
|
||||
function displayName(input: any): string {
|
||||
if (!input) return '—'
|
||||
const maybe = input.name ?? input
|
||||
if (typeof maybe === 'string') return maybe
|
||||
if (maybe && typeof maybe === 'object') return maybe.en || maybe.ja || '—'
|
||||
return '—'
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const updates: Partial<GridWeapon> = {}
|
||||
|
||||
if (canChangeElement && element !== weapon.element) {
|
||||
updates.element = element
|
||||
}
|
||||
|
||||
// TODO: Add weapon keys, AX skills, awakening updates
|
||||
|
||||
onSave?.(updates)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
// Reset to original values
|
||||
element = weapon.element ?? weapon.weapon?.element ?? 0
|
||||
weaponKey1 = weapon.weaponKeys?.[0]?.id
|
||||
weaponKey2 = weapon.weaponKeys?.[1]?.id
|
||||
weaponKey3 = weapon.weaponKeys?.[2]?.id
|
||||
selectedAwakening = weapon.awakening?.type
|
||||
awakeningLevel = weapon.awakening?.level ?? 1
|
||||
axSkills = weapon.ax ?? [
|
||||
{ modifier: -1, strength: 0 },
|
||||
{ modifier: -1, strength: 0 }
|
||||
]
|
||||
onCancel?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="weapon-edit-sidebar">
|
||||
<ItemHeader
|
||||
type="weapon"
|
||||
item={weapon}
|
||||
itemData={weaponData}
|
||||
gridUncapLevel={weapon.uncapLevel}
|
||||
gridTranscendence={weapon.transcendenceStep}
|
||||
/>
|
||||
|
||||
<div class="edit-sections">
|
||||
{#if canChangeElement}
|
||||
<DetailsSection title="Element">
|
||||
<div class="element-select">
|
||||
<Select
|
||||
options={elementOptions}
|
||||
bind:value={element}
|
||||
placeholder="Select element"
|
||||
size="medium"
|
||||
fullWidth
|
||||
contained
|
||||
/>
|
||||
</div>
|
||||
</DetailsSection>
|
||||
{/if}
|
||||
|
||||
{#if hasWeaponKeys}
|
||||
<DetailsSection title="Weapon Keys">
|
||||
<div class="key-selects">
|
||||
{#if keySlotCount >= 1}
|
||||
<WeaponKeySelect
|
||||
series={keySeries}
|
||||
slot={0}
|
||||
bind:value={weaponKey1}
|
||||
{transcendenceStep}
|
||||
/>
|
||||
{/if}
|
||||
{#if keySlotCount >= 2}
|
||||
<WeaponKeySelect
|
||||
series={keySeries}
|
||||
slot={1}
|
||||
bind:value={weaponKey2}
|
||||
{transcendenceStep}
|
||||
/>
|
||||
{/if}
|
||||
{#if keySlotCount >= 3}
|
||||
<WeaponKeySelect
|
||||
series={keySeries}
|
||||
slot={2}
|
||||
bind:value={weaponKey3}
|
||||
{transcendenceStep}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</DetailsSection>
|
||||
{/if}
|
||||
|
||||
{#if hasAxSkills}
|
||||
<DetailsSection title="AX Skills">
|
||||
<div class="ax-skills-wrapper">
|
||||
<AxSkillSelect
|
||||
{axType}
|
||||
currentSkills={axSkills}
|
||||
onChange={(skills) => {
|
||||
axSkills = skills
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DetailsSection>
|
||||
{/if}
|
||||
|
||||
{#if hasAwakening && availableAwakenings.length > 0}
|
||||
<DetailsSection title="Awakening">
|
||||
<div class="awakening-select-wrapper">
|
||||
<AwakeningSelect
|
||||
awakenings={availableAwakenings}
|
||||
value={selectedAwakening}
|
||||
level={awakeningLevel}
|
||||
maxLevel={weaponData?.maxAwakeningLevel ?? 9}
|
||||
onAwakeningChange={(awakening) => {
|
||||
selectedAwakening = awakening
|
||||
}}
|
||||
onLevelChange={(level) => {
|
||||
awakeningLevel = level
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DetailsSection>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="edit-footer">
|
||||
<Button variant="secondary" onclick={handleCancel}>Cancel</Button>
|
||||
<Button variant="primary" element={elementName} elementStyle={!!elementName} onclick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</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;
|
||||
|
||||
.weapon-edit-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: spacing.$unit-4x;
|
||||
}
|
||||
|
||||
.weapon-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: spacing.$unit-half;
|
||||
margin-top: spacing.$unit-2x;
|
||||
margin-bottom: spacing.$unit-2x;
|
||||
padding-bottom: spacing.$unit-2x;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
|
||||
.weapon-name {
|
||||
margin: 0;
|
||||
font-size: typography.$font-large;
|
||||
font-weight: typography.$bold;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.weapon-subtitle {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.edit-sections {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-3x;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.element-select {
|
||||
padding: spacing.$unit;
|
||||
}
|
||||
|
||||
.key-selects {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-2x;
|
||||
padding: spacing.$unit;
|
||||
}
|
||||
|
||||
.awakening-select-wrapper {
|
||||
padding: spacing.$unit;
|
||||
}
|
||||
|
||||
.ax-skills-wrapper {
|
||||
padding: spacing.$unit;
|
||||
}
|
||||
|
||||
.edit-footer {
|
||||
display: flex;
|
||||
gap: spacing.$unit-2x;
|
||||
padding: spacing.$unit-2x;
|
||||
border-top: 1px solid var(--border-secondary);
|
||||
flex-shrink: 0;
|
||||
|
||||
:global(button) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue