refactor character edit page to use section components

- use DetailScaffold wrapper
- extract metadata, uncap, taxonomy, stats sections
- standardize field naming (camelCase)
- add element-themed checkboxes
This commit is contained in:
Justin Edmund 2025-12-01 00:52:12 -08:00
parent 817084cee5
commit b58cbbe72f
5 changed files with 98 additions and 360 deletions

View file

@ -5,11 +5,13 @@
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import { getRarityLabel, getRarityOptions } from '$lib/utils/rarity'
let {
character,
editMode = false,
editData = $bindable<any>()
}: { character: any; editMode?: boolean; editData?: any } = $props()
interface Props {
character: any
editMode?: boolean
editData?: any
}
let { character, editMode = false, editData = $bindable() }: Props = $props()
const rarityOptions = getRarityOptions()
</script>
@ -17,10 +19,11 @@
<DetailsContainer title="Metadata">
{#if editMode}
<DetailItem label="Rarity" bind:value={editData.rarity} editable={true} type="select" options={rarityOptions} />
<DetailItem label="Granblue ID" bind:value={editData.granblue_id} editable={true} type="text" />
<DetailItem label="Granblue ID" bind:value={editData.granblueId} editable={true} type="text" />
<DetailItem label="Character ID" bind:value={editData.characterId} editable={true} type="number" />
{:else}
<DetailItem label="Rarity" value={getRarityLabel(character.rarity)} />
<DetailItem label="Granblue ID" value={character.granblue_id} />
<DetailItem label="Granblue ID" value={character.granblueId} />
{/if}
</DetailsContainer>

View file

@ -4,39 +4,41 @@
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
let {
character,
editMode = false,
editData = $bindable<any>()
}: { character: any; editMode?: boolean; editData?: any } = $props()
interface Props {
character: any
editMode?: boolean
editData?: any
}
let { character, editMode = false, editData = $bindable() }: Props = $props()
const flb = $derived(editMode ? Boolean(editData.flb) : Boolean(character?.uncap?.flb))
</script>
<DetailsContainer title="HP Stats">
{#if editMode}
<DetailItem label="Base HP" bind:value={editData.min_hp} editable={true} type="number" placeholder="0" />
<DetailItem label="Max HP" bind:value={editData.max_hp} editable={true} type="number" placeholder="0" />
<DetailItem label="Max HP (FLB)" bind:value={editData.max_hp_flb} editable={true} type="number" placeholder="0" />
<DetailItem label="Base HP" bind:value={editData.minHp} editable={true} type="number" placeholder="0" />
<DetailItem label="Max HP" bind:value={editData.maxHp} editable={true} type="number" placeholder="0" />
<DetailItem label="Max HP (FLB)" bind:value={editData.maxHpFlb} editable={true} type="number" placeholder="0" />
{:else}
<DetailItem label="Base HP" value={character.hp?.min_hp} />
<DetailItem label="Max HP" value={character.hp?.max_hp} />
<DetailItem label="Base HP" value={character.hp?.minHp} />
<DetailItem label="Max HP" value={character.hp?.maxHp} />
{#if flb}
<DetailItem label="Max HP (FLB)" value={character.hp?.max_hp_flb} />
{/if}
{/if}
</DetailsContainer>
<DetailsContainer title="Attack Stats">
{#if editMode}
<DetailItem label="Base Attack" bind:value={editData.min_atk} editable={true} type="number" placeholder="0" />
<DetailItem label="Max Attack" bind:value={editData.max_atk} editable={true} type="number" placeholder="0" />
<DetailItem label="Max Attack (FLB)" bind:value={editData.max_atk_flb} editable={true} type="number" placeholder="0" />
{:else}
<DetailItem label="Base Attack" value={character.atk?.min_atk} />
<DetailItem label="Max Attack" value={character.atk?.max_atk} />
{#if flb}
<DetailItem label="Max Attack (FLB)" value={character.atk?.max_atk_flb} />
<DetailItem label="Max HP (FLB)" value={character.hp?.maxHpFlb} />
{/if}
{/if}
</DetailsContainer>
<DetailsContainer title="Attack Stats">
{#if editMode}
<DetailItem label="Base Attack" bind:value={editData.minAtk} editable={true} type="number" placeholder="0" />
<DetailItem label="Max Attack" bind:value={editData.maxAtk} editable={true} type="number" placeholder="0" />
<DetailItem label="Max Attack (FLB)" bind:value={editData.maxAtkFlb} editable={true} type="number" placeholder="0" />
{:else}
<DetailItem label="Base Attack" value={character.atk?.minAtk} />
<DetailItem label="Max Attack" value={character.atk?.maxAtk} />
{#if flb}
<DetailItem label="Max Attack (FLB)" value={character.atk?.maxAtkFlb} />
{/if}
{/if}
</DetailsContainer>

View file

@ -8,11 +8,13 @@
import { getGenderLabel, getGenderOptions } from '$lib/utils/gender'
import { getProficiencyLabel, getProficiencyOptions } from '$lib/utils/proficiency'
let {
character,
editMode = false,
editData = $bindable<any>()
}: { character: any; editMode?: boolean; editData?: any } = $props()
interface Props {
character: any
editMode?: boolean
editData?: any
}
let { character, editMode = false, editData = $bindable() }: Props = $props()
const elementOptions = getElementOptions()
const raceOptions = getRaceOptions()
@ -35,8 +37,8 @@
<DetailItem label="Race 2" value={getRaceLabel(character.race?.[1])} />
{/if}
<DetailItem label="Gender" value={getGenderLabel(character.gender)} />
<DetailItem label="Proficiency 1" value={getProficiencyLabel(character.proficiency[0])} />
<DetailItem label="Proficiency 2" value={getProficiencyLabel(character.proficiency[1])} />
<DetailItem label="Proficiency 1" value={getProficiencyLabel(character.proficiency?.[0] ?? 0)} />
<DetailItem label="Proficiency 2" value={getProficiencyLabel(character.proficiency?.[1] ?? 0)} />
{/if}
</DetailsContainer>

View file

@ -5,12 +5,17 @@
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import { getCharacterMaxUncapLevel } from '$lib/utils/uncap'
import { getElementLabel } from '$lib/utils/element'
let {
character,
editMode = false,
editData = $bindable<any>()
}: { character: any; editMode?: boolean; editData?: any } = $props()
type ElementName = 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
interface Props {
character: any
editMode?: boolean
editData?: any
}
let { character, editMode = false, editData = $bindable() }: Props = $props()
const uncap = $derived(
editMode
@ -23,10 +28,19 @@
const special = $derived(editMode ? editData.special : (character?.special ?? false))
const uncapLevel = $derived(getCharacterMaxUncapLevel({ special, uncap }))
const transcendenceStage = $derived(transcendence ? 5 : 0)
// Get element name for checkbox theming
const elementName = $derived.by((): ElementName | undefined => {
const el = editMode ? editData.element : character?.element
const label = getElementLabel(el)
return label !== '—' && label !== 'Null'
? (label.toLowerCase() as ElementName)
: undefined
})
</script>
<DetailsContainer title="Details">
{#if character.uncap}
<DetailsContainer title="Uncap">
{#if character?.uncap || editMode}
<DetailItem label="Uncap">
<UncapIndicator
type="character"
@ -42,10 +56,10 @@
{/if}
{#if editMode}
<DetailItem label="FLB" bind:value={editData.flb} editable={true} type="checkbox" />
<DetailItem label="ULB" bind:value={editData.ulb} editable={true} type="checkbox" />
<DetailItem label="Transcendence" bind:value={editData.transcendence} editable={true} type="checkbox" />
<DetailItem label="Special" bind:value={editData.special} editable={true} type="checkbox" />
<DetailItem label="FLB" bind:value={editData.flb} editable={true} type="checkbox" element={elementName} />
<DetailItem label="ULB" bind:value={editData.ulb} editable={true} type="checkbox" element={elementName} />
<DetailItem label="Transcendence" bind:value={editData.transcendence} editable={true} type="checkbox" element={elementName} />
<DetailItem label="Special" bind:value={editData.special} editable={true} type="checkbox" element={elementName} />
{/if}
</DetailsContainer>

View file

@ -10,20 +10,13 @@
import { entityAdapter } from '$lib/api/adapters/entity.adapter'
import { withInitialData } from '$lib/query/ssr'
// Utility functions
import { getRarityLabel, getRarityOptions } from '$lib/utils/rarity'
import { getElementLabel, getElementOptions } from '$lib/utils/element'
import { getProficiencyLabel, getProficiencyOptions } from '$lib/utils/proficiency'
import { getRaceLabel, getRaceOptions } from '$lib/utils/race'
import { getGenderLabel, getGenderOptions } from '$lib/utils/gender'
import { getCharacterMaxUncapLevel } from '$lib/utils/uncap'
// Components
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import DetailScaffold from '$lib/features/database/detail/DetailScaffold.svelte'
import CharacterMetadataSection from '$lib/features/database/characters/sections/CharacterMetadataSection.svelte'
import CharacterUncapSection from '$lib/features/database/characters/sections/CharacterUncapSection.svelte'
import CharacterTaxonomySection from '$lib/features/database/characters/sections/CharacterTaxonomySection.svelte'
import CharacterStatsSection from '$lib/features/database/characters/sections/CharacterStatsSection.svelte'
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import DetailsHeader from '$lib/components/ui/DetailsHeader.svelte'
import Button from '$lib/components/ui/Button.svelte'
import { getCharacterImage } from '$lib/utils/images'
// Types
@ -54,6 +47,7 @@
},
enabled: !!character?.characterId && !editMode
}))
let isSaving = $state(false)
let saveError = $state<string | null>(null)
let saveSuccess = $state(false)
@ -110,13 +104,6 @@
}
})
// Options for select dropdowns - using centralized utilities
const rarityOptions = getRarityOptions()
const elementOptions = getElementOptions()
const raceOptions = getRaceOptions()
const genderOptions = getGenderOptions()
const proficiencyOptions = getProficiencyOptions()
function toggleEditMode() {
editMode = !editMode
saveError = null
@ -155,7 +142,7 @@
saveSuccess = false
try {
// Prepare the data for API
// Prepare the data for API (convert to snake_case)
const payload = {
name: editData.name,
granblue_id: editData.granblueId,
@ -208,264 +195,32 @@
}
}
// Helper function to get character image
// Helper function for character grid image
function getCharacterGridImage(character: any): string {
return getCharacterImage(character?.granblueId, 'grid', '01')
}
// Calculate uncap properties for the indicator
const uncap = $derived(
editMode
? { flb: editData.flb, ulb: editData.ulb, transcendence: editData.transcendence }
: (character?.uncap ?? { flb: false, ulb: false, transcendence: false })
)
const flb = $derived(uncap.flb ?? false)
const ulb = $derived(uncap.ulb ?? false)
const transcendence = $derived(uncap.transcendence ?? false)
const special = $derived(editMode ? editData.special : (character?.special ?? false))
const uncapLevel = $derived(
getCharacterMaxUncapLevel({ special, uncap: { flb, ulb, transcendence } })
)
const transcendenceStage = $derived(transcendence ? 5 : 0)
// Get element name for checkbox theming
const elementName = $derived.by(() => {
const el = editMode ? editData.element : character?.element
const label = getElementLabel(el)
return label !== '—' && label !== 'Null'
? (label.toLowerCase() as 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light')
: undefined
})
</script>
<div>
{#if character}
<div class="content">
<DetailsHeader
type="character"
item={character}
image={getCharacterGridImage(character)}
onEdit={toggleEditMode}
showEdit={canEdit}
{editMode}
onSave={saveChanges}
onCancel={toggleEditMode}
{isSaving}
/>
{#if saveSuccess || saveError}
<div class="edit-controls">
{#if saveSuccess}
<span class="success-message">Changes saved successfully!</span>
{/if}
{#if saveError}
<span class="error-message">{saveError}</span>
{/if}
</div>
{/if}
<DetailScaffold
type="character"
item={character}
image={getCharacterGridImage(character)}
showEdit={canEdit}
{editMode}
{isSaving}
{saveSuccess}
{saveError}
onEdit={toggleEditMode}
onSave={saveChanges}
onCancel={toggleEditMode}
>
<section class="details">
<DetailsContainer title="Metadata">
{#if editMode}
<DetailItem
label="Rarity"
bind:value={editData.rarity}
editable={true}
type="select"
options={rarityOptions}
/>
<DetailItem
label="Granblue ID"
bind:value={editData.granblueId}
editable={true}
type="text"
/>
<DetailItem
label="Character ID"
bind:value={editData.characterId}
editable={true}
type="number"
/>
{:else}
<DetailItem label="Rarity" value={getRarityLabel(character.rarity)} />
<DetailItem label="Granblue ID" value={character.granblueId} />
{/if}
</DetailsContainer>
<DetailsContainer title="Details">
{#if character.uncap}
<DetailItem label="Uncap">
<UncapIndicator
type="character"
{uncapLevel}
{transcendenceStage}
{flb}
{ulb}
{transcendence}
{special}
editable={false}
/>
</DetailItem>
{/if}
{#if editMode}
<DetailItem
label="FLB"
bind:value={editData.flb}
editable={true}
type="checkbox"
element={elementName}
/>
<DetailItem
label="ULB"
bind:value={editData.ulb}
editable={true}
type="checkbox"
element={elementName}
/>
<DetailItem
label="Transcendence"
bind:value={editData.transcendence}
editable={true}
type="checkbox"
element={elementName}
/>
<DetailItem
label="Special"
bind:value={editData.special}
editable={true}
type="checkbox"
element={elementName}
/>
{/if}
{#if editMode}
<DetailItem
label="Element"
bind:value={editData.element}
editable={true}
type="select"
options={elementOptions}
/>
<DetailItem
label="Race 1"
bind:value={editData.race1}
editable={true}
type="select"
options={raceOptions}
/>
<DetailItem
label="Race 2"
bind:value={editData.race2}
editable={true}
type="select"
options={raceOptions}
/>
<DetailItem
label="Gender"
bind:value={editData.gender}
editable={true}
type="select"
options={genderOptions}
/>
<DetailItem
label="Proficiency 1"
bind:value={editData.proficiency1}
editable={true}
type="select"
options={proficiencyOptions}
/>
<DetailItem
label="Proficiency 2"
bind:value={editData.proficiency2}
editable={true}
type="select"
options={proficiencyOptions}
/>
{:else}
<DetailItem label="Element" value={getElementLabel(character.element)} />
<DetailItem label="Race 1" value={getRaceLabel(character.race?.[0])} />
{#if character.race?.[1]}
<DetailItem label="Race 2" value={getRaceLabel(character.race?.[1])} />
{/if}
<DetailItem label="Gender" value={getGenderLabel(character.gender)} />
<DetailItem
label="Proficiency 1"
value={getProficiencyLabel(character.proficiency?.[0] ?? 0)}
/>
<DetailItem
label="Proficiency 2"
value={getProficiencyLabel(character.proficiency?.[1] ?? 0)}
/>
{/if}
</DetailsContainer>
<DetailsContainer title="HP Stats">
{#if editMode}
<DetailItem
label="Base HP"
bind:value={editData.minHp}
editable={true}
type="number"
placeholder="0"
/>
<DetailItem
label="Max HP"
bind:value={editData.maxHp}
editable={true}
type="number"
placeholder="0"
/>
<DetailItem
label="Max HP (FLB)"
bind:value={editData.maxHpFlb}
editable={true}
type="number"
placeholder="0"
/>
{:else}
<DetailItem label="Base HP" value={character.hp?.minHp} />
<DetailItem label="Max HP" value={character.hp?.maxHp} />
{#if flb}
<DetailItem label="Max HP (FLB)" value={character.hp?.maxHpFlb} />
{/if}
{/if}
</DetailsContainer>
<DetailsContainer title="Attack Stats">
{#if editMode}
<DetailItem
label="Base Attack"
bind:value={editData.minAtk}
editable={true}
type="number"
placeholder="0"
/>
<DetailItem
label="Max Attack"
bind:value={editData.maxAtk}
editable={true}
type="number"
placeholder="0"
/>
<DetailItem
label="Max Attack (FLB)"
bind:value={editData.maxAtkFlb}
editable={true}
type="number"
placeholder="0"
/>
{:else}
<DetailItem label="Base Attack" value={character.atk?.minAtk} />
<DetailItem label="Max Attack" value={character.atk?.maxAtk} />
{#if flb}
<DetailItem label="Max Attack (FLB)" value={character.atk?.maxAtkFlb} />
{/if}
{/if}
</DetailsContainer>
<CharacterMetadataSection {character} {editMode} bind:editData />
<CharacterUncapSection {character} {editMode} bind:editData />
<CharacterTaxonomySection {character} {editMode} bind:editData />
<CharacterStatsSection {character} {editMode} bind:editData />
{#if !editMode && relatedQuery.data?.length}
<DetailsContainer title="Related Units">
@ -484,7 +239,7 @@
</DetailsContainer>
{/if}
</section>
</div>
</DetailScaffold>
{:else}
<div class="not-found">
<h2>Character Not Found</h2>
@ -499,7 +254,6 @@
@use '$src/themes/layout' as layout;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/effects' as effects;
.not-found {
text-align: center;
@ -520,46 +274,9 @@
}
}
.content {
background: white;
border-radius: layout.$card-corner;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: visible;
position: relative;
.details {
display: flex;
flex-direction: column;
}
}
.edit-controls {
padding: spacing.$unit-2x;
border-bottom: 1px solid colors.$grey-80;
.details {
display: flex;
gap: spacing.$unit;
align-items: center;
.success-message {
color: colors.$grey-30;
font-size: typography.$font-small;
animation: fadeIn effects.$duration-opacity-fade ease-in;
}
.error-message {
color: colors.$error;
font-size: typography.$font-small;
animation: fadeIn effects.$duration-opacity-fade ease-in;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
flex-direction: column;
}
.related-units {