Added editable mode to character detail

This commit is contained in:
Justin Edmund 2025-09-17 22:15:24 -07:00
parent 0e92b9baf5
commit 5c8e23a38e
2 changed files with 371 additions and 26 deletions

View file

@ -2,8 +2,11 @@ import type { PageServerLoad } from './$types'
import { get } from '$lib/api/core'
import { error } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ params, fetch }) => {
export const load: PageServerLoad = async ({ params, fetch, parent }) => {
try {
// Get parent data to access role
const parentData = await parent()
const character = await get(fetch, `/characters/${params.id}`)
if (!character) {
@ -11,7 +14,8 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
}
return {
character
character,
role: parentData.role
}
} catch (err) {
console.error('Failed to load character:', err)

View file

@ -5,11 +5,11 @@
import { goto } from '$app/navigation'
// Utility functions
import { getRarityLabel } from '$lib/utils/rarity'
import { getElementLabel } from '$lib/utils/element'
import { getProficiencyLabel } from '$lib/utils/proficiency'
import { getRaceLabel } from '$lib/utils/race'
import { getGenderLabel } from '$lib/utils/gender'
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
@ -17,6 +17,7 @@
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'
// Types
import type { PageData } from './$types'
@ -25,6 +26,160 @@
// Get character from server data
const character = $derived(data.character)
const userRole = $derived(data.role || 0)
const canEdit = $derived(userRole >= 7)
// Edit mode state
let editMode = $state(false)
let isSaving = $state(false)
let saveError = $state<string | null>(null)
let saveSuccess = $state(false)
// Editable fields - create reactive state for each field
let editData = $state({
name: character?.name || '',
granblue_id: character?.granblue_id || '',
rarity: character?.rarity || 1,
element: character?.element || 0,
race1: character?.race?.[0] ?? null,
race2: character?.race?.[1] ?? null,
gender: character?.gender || 0,
proficiency1: character?.proficiency?.[0] || 0,
proficiency2: character?.proficiency?.[1] || 0,
min_hp: character?.hp?.min_hp || 0,
max_hp: character?.hp?.max_hp || 0,
max_hp_flb: character?.hp?.max_hp_flb || 0,
min_atk: character?.atk?.min_atk || 0,
max_atk: character?.atk?.max_atk || 0,
max_atk_flb: character?.atk?.max_atk_flb || 0,
flb: character?.uncap?.flb || false,
ulb: character?.uncap?.ulb || false,
transcendence: character?.uncap?.transcendence || false,
special: character?.special || false
})
// Reset edit data when character changes
$effect(() => {
if (character) {
editData = {
name: character.name || '',
granblue_id: character.granblue_id || '',
rarity: character.rarity || 1,
element: character.element || 0,
race1: character.race?.[0] ?? null,
race2: character.race?.[1] ?? null,
gender: character.gender || 0,
proficiency1: character.proficiency?.[0] || 0,
proficiency2: character.proficiency?.[1] || 0,
min_hp: character.hp?.min_hp || 0,
max_hp: character.hp?.max_hp || 0,
max_hp_flb: character.hp?.max_hp_flb || 0,
min_atk: character.atk?.min_atk || 0,
max_atk: character.atk?.max_atk || 0,
max_atk_flb: character.atk?.max_atk_flb || 0,
flb: character.uncap?.flb || false,
ulb: character.uncap?.ulb || false,
transcendence: character.uncap?.transcendence || false,
special: character.special || false
}
}
})
// 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
saveSuccess = false
// Reset data when canceling
if (!editMode && character) {
editData = {
name: character.name || '',
granblue_id: character.granblue_id || '',
rarity: character.rarity || 1,
element: character.element || 0,
race1: character.race?.[0] ?? null,
race2: character.race?.[1] ?? null,
gender: character.gender || 0,
proficiency1: character.proficiency?.[0] || 0,
proficiency2: character.proficiency?.[1] || 0,
min_hp: character.hp?.min_hp || 0,
max_hp: character.hp?.max_hp || 0,
max_hp_flb: character.hp?.max_hp_flb || 0,
min_atk: character.atk?.min_atk || 0,
max_atk: character.atk?.max_atk || 0,
max_atk_flb: character.atk?.max_atk_flb || 0,
flb: character.uncap?.flb || false,
ulb: character.uncap?.ulb || false,
transcendence: character.uncap?.transcendence || false,
special: character.special || false
}
}
}
async function saveChanges() {
isSaving = true
saveError = null
saveSuccess = false
try {
// Prepare the data for API
const payload = {
name: editData.name,
granblue_id: editData.granblue_id,
rarity: editData.rarity,
element: editData.element,
race: [editData.race1, editData.race2].filter(r => r !== null && r !== undefined),
gender: editData.gender,
proficiency: [editData.proficiency1, editData.proficiency2],
hp: {
min_hp: editData.min_hp,
max_hp: editData.max_hp,
max_hp_flb: editData.max_hp_flb
},
atk: {
min_atk: editData.min_atk,
max_atk: editData.max_atk,
max_atk_flb: editData.max_atk_flb
},
uncap: {
flb: editData.flb,
ulb: editData.ulb,
transcendence: editData.transcendence
},
special: editData.special
}
// TODO: When backend endpoint is ready, make the API call here
// const response = await fetch(`/api/v1/characters/${character.id}`, {
// method: 'PUT',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(payload)
// })
// For now, just simulate success
await new Promise((resolve) => setTimeout(resolve, 1000))
saveSuccess = true
editMode = false
// Show success message for 3 seconds
setTimeout(() => {
saveSuccess = false
}, 3000)
} catch (error) {
saveError = 'Failed to save changes. Please try again.'
console.error('Save error:', error)
} finally {
isSaving = false
}
}
// Helper function to get character image
function getCharacterImage(character: any): string {
@ -33,11 +188,15 @@
}
// Calculate uncap properties for the indicator
const uncap = $derived(character?.uncap ?? {})
const uncap = $derived(
editMode
? { flb: editData.flb, ulb: editData.ulb, transcendence: editData.transcendence }
: (character?.uncap ?? {})
)
const flb = $derived(uncap.flb ?? false)
const ulb = $derived(uncap.ulb ?? false)
const transcendence = $derived(uncap.transcendence ?? false)
const special = $derived(character?.special ?? false)
const special = $derived(editMode ? editData.special : (character?.special ?? false))
const uncapLevel = $derived(getCharacterMaxUncapLevel({ special, uncap }))
const transcendenceStage = $derived(transcendence ? 5 : 0)
@ -46,11 +205,49 @@
<div>
{#if character}
<div class="content">
<DetailsHeader type="character" item={character} image={getCharacterImage(character)} />
<DetailsHeader
type="character"
item={character}
image={getCharacterImage(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}
<DetailsContainer title="Metadata">
<DetailItem label="Rarity" value={getRarityLabel(character.rarity)} />
<DetailItem label="Granblue ID" value={character.granblue_id} />
{#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"
/>
{:else}
<DetailItem label="Rarity" value={getRarityLabel(character.rarity)} />
<DetailItem label="Granblue ID" value={character.granblue_id} />
{/if}
</DetailsContainer>
<DetailsContainer title="Details">
{#if character.uncap}
@ -67,27 +264,140 @@
/>
</DetailItem>
{/if}
<DetailItem label="Element" value={getElementLabel(character.element)} />
<DetailItem label="Race" value={getRaceLabel(character.race)} />
<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])} />
{#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"
/>
{/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])} />
<DetailItem label="Proficiency 2" value={getProficiencyLabel(character.proficiency[1])} />
{/if}
</DetailsContainer>
<DetailsContainer title="HP Stats">
<DetailItem label="Base HP" value={character.hp?.min_hp} />
<DetailItem label="Max HP" value={character.hp?.max_hp} />
{#if flb}
<DetailItem label="Max HP (FLB)" value={character.hp?.max_hp_flb} />
{#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"
/>
{:else}
<DetailItem label="Base HP" value={character.hp?.min_hp} />
<DetailItem label="Max HP" value={character.hp?.max_hp} />
{#if flb}
<DetailItem label="Max HP (FLB)" value={character.hp?.max_hp_flb} />
{/if}
{/if}
</DetailsContainer>
<DetailsContainer title="Attack Stats">
<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} />
{#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} />
{/if}
{/if}
</DetailsContainer>
</div>
@ -105,6 +415,7 @@
@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;
@ -129,7 +440,37 @@
background: white;
border-radius: layout.$card-corner;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
overflow: visible; // Changed from hidden to allow sticky header
margin-top: spacing.$unit-2x;
position: relative;
}
.edit-controls {
padding: spacing.$unit-2x;
border-bottom: 1px solid colors.$grey-80;
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;
}
}
</style>