sidebar: add EditCharacterSidebar and EditWeaponSidebar

This commit is contained in:
Justin Edmund 2025-11-30 20:06:21 -08:00
parent 4f132f9947
commit 8ac9dea2d3
2 changed files with 537 additions and 0 deletions

View 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>

View 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>