new character page: add all fields, uncap cascade logic, validation

This commit is contained in:
Justin Edmund 2025-12-01 02:25:46 -08:00
parent 28ad2fb37e
commit 754d5a633c
3 changed files with 335 additions and 189 deletions

View file

@ -13,19 +13,28 @@
let { character, editMode = false, editData = $bindable() }: Props = $props() let { character, editMode = false, editData = $bindable() }: Props = $props()
const flb = $derived(editMode ? Boolean(editData.flb) : Boolean(character?.uncap?.flb)) const flb = $derived(editMode ? Boolean(editData.flb) : Boolean(character?.uncap?.flb))
const ulb = $derived(editMode ? Boolean(editData.ulb) : Boolean(character?.uncap?.ulb))
</script> </script>
<DetailsContainer title="HP Stats"> <DetailsContainer title="HP Stats">
{#if editMode} {#if editMode}
<DetailItem label="Base HP" bind:value={editData.minHp} 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" 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" /> {#if flb}
<DetailItem label="Max HP (FLB)" bind:value={editData.maxHpFlb} editable={true} type="number" placeholder="0" />
{/if}
{#if ulb}
<DetailItem label="Max HP (ULB)" bind:value={editData.maxHpUlb} editable={true} type="number" placeholder="0" />
{/if}
{:else} {:else}
<DetailItem label="Base HP" value={character.hp?.minHp} /> <DetailItem label="Base HP" value={character.hp?.minHp} />
<DetailItem label="Max HP" value={character.hp?.maxHp} /> <DetailItem label="Max HP" value={character.hp?.maxHp} />
{#if flb} {#if flb}
<DetailItem label="Max HP (FLB)" value={character.hp?.maxHpFlb} /> <DetailItem label="Max HP (FLB)" value={character.hp?.maxHpFlb} />
{/if} {/if}
{#if ulb}
<DetailItem label="Max HP (ULB)" value={character.hp?.maxHpUlb} />
{/if}
{/if} {/if}
</DetailsContainer> </DetailsContainer>
@ -33,13 +42,45 @@
{#if editMode} {#if editMode}
<DetailItem label="Base Attack" bind:value={editData.minAtk} editable={true} type="number" placeholder="0" /> <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" 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" /> {#if flb}
<DetailItem label="Max Attack (FLB)" bind:value={editData.maxAtkFlb} editable={true} type="number" placeholder="0" />
{/if}
{#if ulb}
<DetailItem label="Max Attack (ULB)" bind:value={editData.maxAtkUlb} editable={true} type="number" placeholder="0" />
{/if}
{:else} {:else}
<DetailItem label="Base Attack" value={character.atk?.minAtk} /> <DetailItem label="Base Attack" value={character.atk?.minAtk} />
<DetailItem label="Max Attack" value={character.atk?.maxAtk} /> <DetailItem label="Max Attack" value={character.atk?.maxAtk} />
{#if flb} {#if flb}
<DetailItem label="Max Attack (FLB)" value={character.atk?.maxAtkFlb} /> <DetailItem label="Max Attack (FLB)" value={character.atk?.maxAtkFlb} />
{/if} {/if}
{#if ulb}
<DetailItem label="Max Attack (ULB)" value={character.atk?.maxAtkUlb} />
{/if}
{/if}
</DetailsContainer>
<DetailsContainer title="Other Stats">
{#if editMode}
<DetailItem label="Base DA" bind:value={editData.baseDa} editable={true} type="number" placeholder="0" />
<DetailItem label="Base TA" bind:value={editData.baseTa} editable={true} type="number" placeholder="0" />
<DetailItem label="Ougi Ratio" bind:value={editData.ougiRatio} editable={true} type="number" placeholder="0" />
{#if flb}
<DetailItem label="Ougi Ratio (FLB)" bind:value={editData.ougiRatioFlb} editable={true} type="number" placeholder="0" />
{/if}
{:else}
{#if character.baseDa}
<DetailItem label="Base DA" value={character.baseDa} />
{/if}
{#if character.baseTa}
<DetailItem label="Base TA" value={character.baseTa} />
{/if}
{#if character.ougiRatio}
<DetailItem label="Ougi Ratio" value={character.ougiRatio} />
{/if}
{#if character.ougiRatioFlb}
<DetailItem label="Ougi Ratio (FLB)" value={character.ougiRatioFlb} />
{/if}
{/if} {/if}
</DetailsContainer> </DetailsContainer>

View file

@ -37,6 +37,42 @@
? (label.toLowerCase() as ElementName) ? (label.toLowerCase() as ElementName)
: undefined : undefined
}) })
// Auto-check/uncheck uncap levels in hierarchy: Transcendence > ULB > FLB
function handleFlbChange(checked: boolean) {
if (!checked) {
// Unchecking FLB should also uncheck ULB and Transcendence
editData.ulb = false
editData.transcendence = false
}
}
function handleUlbChange(checked: boolean) {
if (checked && !editData.flb) {
// Checking ULB should also check FLB
editData.flb = true
} else if (!checked) {
// Unchecking ULB should also uncheck Transcendence
editData.transcendence = false
}
}
function handleTranscendenceChange(checked: boolean) {
if (checked) {
// Checking Transcendence should also check ULB and FLB
if (!editData.ulb) editData.ulb = true
if (!editData.flb) editData.flb = true
}
}
function handleSpecialChange(checked: boolean) {
if (checked) {
// Special characters (Story SRs) don't have standard uncap levels
editData.flb = false
editData.ulb = false
editData.transcendence = false
}
}
</script> </script>
<DetailsContainer title="Uncap"> <DetailsContainer title="Uncap">
@ -56,10 +92,31 @@
{/if} {/if}
{#if editMode} {#if editMode}
<DetailItem label="FLB" bind:value={editData.flb} editable={true} type="checkbox" element={elementName} /> <DetailItem label="FLB" bind:value={editData.flb} editable={true} type="checkbox" element={elementName} onchange={handleFlbChange} />
<DetailItem label="ULB" bind:value={editData.ulb} editable={true} type="checkbox" element={elementName} /> <DetailItem label="ULB" bind:value={editData.ulb} editable={true} type="checkbox" element={elementName} onchange={handleUlbChange} />
<DetailItem label="Transcendence" bind:value={editData.transcendence} editable={true} type="checkbox" element={elementName} /> <DetailItem label="Transcendence" bind:value={editData.transcendence} editable={true} type="checkbox" element={elementName} onchange={handleTranscendenceChange} />
<DetailItem label="Special" bind:value={editData.special} editable={true} type="checkbox" element={elementName} /> <div class="special-field">
<DetailItem label="Special" bind:value={editData.special} editable={true} type="checkbox" element={elementName} onchange={handleSpecialChange} />
<p class="special-note">This is for Story SRs. Don't check this unless something really weird happens.</p>
</div>
{/if} {/if}
</DetailsContainer> </DetailsContainer>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.special-field {
display: flex;
flex-direction: column;
}
.special-note {
font-size: typography.$font-small;
color: colors.$grey-50;
margin: 0;
padding: 0 spacing.$unit spacing.$unit;
}
</style>

View file

@ -5,16 +5,16 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
// Components // Components
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 CharacterUncapSection from '$lib/features/database/characters/sections/CharacterUncapSection.svelte'
import CharacterTaxonomySection from '$lib/features/database/characters/sections/CharacterTaxonomySection.svelte' import CharacterTaxonomySection from '$lib/features/database/characters/sections/CharacterTaxonomySection.svelte'
import CharacterStatsSection from '$lib/features/database/characters/sections/CharacterStatsSection.svelte' import CharacterStatsSection from '$lib/features/database/characters/sections/CharacterStatsSection.svelte'
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte' import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte' import DetailItem from '$lib/components/ui/DetailItem.svelte'
import SidebarHeader from '$lib/components/ui/SidebarHeader.svelte'
import Button from '$lib/components/ui/Button.svelte' import Button from '$lib/components/ui/Button.svelte'
import { getCharacterImage } from '$lib/utils/images' import ValidatedInput from '$lib/components/ui/ValidatedInput.svelte'
import { entityAdapter } from '$lib/api/adapters/entity.adapter' import { entityAdapter } from '$lib/api/adapters/entity.adapter'
import { getRarityOptions } from '$lib/utils/rarity'
// Types // Types
import type { PageData } from './$types' import type { PageData } from './$types'
@ -23,38 +23,20 @@
// Always in edit mode for new character // Always in edit mode for new character
const editMode = true const editMode = true
const canEdit = true
let isSaving = $state(false) let isSaving = $state(false)
let saveError = $state<string | null>(null) let saveError = $state<string | null>(null)
let saveSuccess = $state(false)
// Validation state // Validation state for canCreate check
let isValidating = $state(false) let granblueIdValid = $state(false)
let validationError = $state<string | null>(null) let granblueIdExistsInDb = $state(false)
let validationResult = $state<{
valid: boolean
existsInDb: boolean
imageUrls?: { main?: string; grid?: string; square?: string }
} | null>(null)
// Download state
let isDownloading = $state(false)
let downloadStatus = $state<{
status: string
progress: number
imagesDownloaded?: number
imagesTotal?: number
error?: string
} | null>(null)
let downloadPollingInterval = $state<ReturnType<typeof setInterval> | null>(null)
// Empty character for new creation // Empty character for new creation
const emptyCharacter = { const emptyCharacter = {
id: '', id: '',
name: { en: '', jp: '' }, name: { en: '', jp: '' },
granblueId: '', granblueId: '',
characterId: null, characterId: '',
rarity: 3, rarity: 3,
element: 0, element: 0,
race: [], race: [],
@ -68,70 +50,97 @@
// Editable fields // Editable fields
let editData = $state({ let editData = $state({
// Basic Info
name: '', name: '',
nameJp: '',
granblueId: '', granblueId: '',
characterId: null as number | null, characterId: '', // Comma-separated for dual/trio units (e.g., "123, 456")
rarity: 3, rarity: 3,
// Taxonomy
element: 0, element: 0,
race1: null as number | null, race1: null as number | null,
race2: null as number | null, race2: null as number | null,
gender: 0, gender: 0,
proficiency1: 0, proficiency1: 0,
proficiency2: 0, proficiency2: 0,
// Stats
minHp: 0, minHp: 0,
maxHp: 0, maxHp: 0,
maxHpFlb: 0, maxHpFlb: 0,
maxHpUlb: 0,
minAtk: 0, minAtk: 0,
maxAtk: 0, maxAtk: 0,
maxAtkFlb: 0, maxAtkFlb: 0,
maxAtkUlb: 0,
baseDa: 0,
baseTa: 0,
ougiRatio: 0,
ougiRatioFlb: 0,
// Uncap
flb: false, flb: false,
ulb: false, ulb: false,
transcendence: false, special: false,
special: false
// Dates
releaseDate: '',
flbDate: '',
ulbDate: '',
// Links
wikiEn: '',
wikiJa: '',
gamewith: '',
kamigame: ''
}) })
const rarityOptions = getRarityOptions()
// Validation is required before create // Validation is required before create
const canCreate = $derived( const canCreate = $derived(
validationResult?.valid === true && granblueIdValid &&
!validationResult?.existsInDb && !granblueIdExistsInDb &&
editData.name.trim() !== '' && editData.name.trim() !== '' &&
editData.granblueId.trim() !== '' editData.granblueId.trim() !== ''
) )
// Get preview image from validation or placeholder async function validateGranblueId(value: string): Promise<{ valid: boolean; message: string }> {
const previewImage = $derived( console.log('[+page] validateGranblueId called with:', value)
validationResult?.imageUrls?.grid || getCharacterImage(editData.granblueId, 'grid', '01')
)
async function validateGranblueId() { if (!value || value.length !== 10) {
if (!editData.granblueId || editData.granblueId.length !== 10) { console.log('[+page] Invalid length, returning early')
validationError = 'Granblue ID must be exactly 10 digits' granblueIdValid = false
validationResult = null granblueIdExistsInDb = false
return return { valid: false, message: 'Granblue ID must be exactly 10 digits' }
} }
isValidating = true
validationError = null
validationResult = null
try { try {
const result = await entityAdapter.validateCharacterGranblueId(editData.granblueId) console.log('[+page] Calling entityAdapter.validateCharacterGranblueId...')
validationResult = { const result = await entityAdapter.validateCharacterGranblueId(value)
valid: result.valid, console.log('[+page] API result:', result)
existsInDb: result.existsInDb,
imageUrls: result.imageUrls
}
if (!result.valid) { if (!result.valid) {
validationError = result.error || 'Invalid Granblue ID' granblueIdValid = false
} else if (result.existsInDb) { granblueIdExistsInDb = false
validationError = 'A character with this Granblue ID already exists' return { valid: false, message: result.error || 'Invalid Granblue ID' }
} }
if (result.existsInDb) {
granblueIdValid = true
granblueIdExistsInDb = true
return { valid: false, message: 'A character with this Granblue ID already exists' }
}
granblueIdValid = true
granblueIdExistsInDb = false
return { valid: true, message: 'Valid Granblue ID - images found on server' }
} catch (error) { } catch (error) {
validationError = 'Failed to validate Granblue ID' granblueIdValid = false
console.error('Validation error:', error) granblueIdExistsInDb = false
} finally { console.error('[+page] Validation error:', error)
isValidating = false return { valid: false, message: 'Failed to validate Granblue ID' }
} }
} }
@ -140,37 +149,62 @@
isSaving = true isSaving = true
saveError = null saveError = null
saveSuccess = false
try { try {
// Prepare the data for API
const payload = { const payload = {
// Basic Info
granblue_id: editData.granblueId, granblue_id: editData.granblueId,
name_en: editData.name, name_en: editData.name,
name_jp: '', // Can be added later name_jp: editData.nameJp,
character_id:
editData.characterId.trim() === ''
? []
: editData.characterId
.split(',')
.map((id) => Number(id.trim()))
.filter((id) => !isNaN(id)),
rarity: editData.rarity, rarity: editData.rarity,
// Taxonomy
element: editData.element, element: editData.element,
race1: editData.race1, race1: editData.race1,
race2: editData.race2, race2: editData.race2,
gender: editData.gender, gender: editData.gender,
proficiency1: editData.proficiency1, proficiency1: editData.proficiency1,
proficiency2: editData.proficiency2, proficiency2: editData.proficiency2,
// Stats
min_hp: editData.minHp, min_hp: editData.minHp,
max_hp: editData.maxHp, max_hp: editData.maxHp,
max_hp_flb: editData.maxHpFlb, max_hp_flb: editData.maxHpFlb,
max_hp_ulb: editData.maxHpUlb,
min_atk: editData.minAtk, min_atk: editData.minAtk,
max_atk: editData.maxAtk, max_atk: editData.maxAtk,
max_atk_flb: editData.maxAtkFlb, max_atk_flb: editData.maxAtkFlb,
max_atk_ulb: editData.maxAtkUlb,
base_da: editData.baseDa,
base_ta: editData.baseTa,
ougi_ratio: editData.ougiRatio,
ougi_ratio_flb: editData.ougiRatioFlb,
// Uncap
flb: editData.flb, flb: editData.flb,
ulb: editData.ulb, ulb: editData.ulb,
special: editData.special special: editData.special,
// Dates
release_date: editData.releaseDate || null,
flb_date: editData.flbDate || null,
ulb_date: editData.ulbDate || null,
// Links
wiki_en: editData.wikiEn,
wiki_ja: editData.wikiJa,
gamewith: editData.gamewith,
kamigame: editData.kamigame
} }
const newCharacter = await entityAdapter.createCharacter(payload) const newCharacter = await entityAdapter.createCharacter(payload)
saveSuccess = true
// Redirect to the new character's page
await goto(`/database/characters/${newCharacter.id}`) await goto(`/database/characters/${newCharacter.id}`)
} catch (error) { } catch (error) {
saveError = 'Failed to create character. Please try again.' saveError = 'Failed to create character. Please try again.'
@ -180,155 +214,169 @@
} }
} }
async function startImageDownload() {
if (!validationResult?.valid || validationResult.existsInDb) return
// Note: This would need a character ID, so it would happen after creation
// For now, we'll skip this and implement it in the edit page
// The user can download images after creating the character
}
function handleCancel() { function handleCancel() {
goto('/database/characters') goto('/database/characters')
} }
</script> </script>
<div> <div class="page">
<DetailScaffold <SidebarHeader title="New Character">
type="character" {#snippet leftAccessory()}
item={{ ...emptyCharacter, name: { en: editData.name || 'New Character', jp: '' } }} <Button variant="secondary" size="small" onclick={handleCancel}>Cancel</Button>
image={previewImage} {/snippet}
showEdit={false} {#snippet rightAccessory()}
editMode={true} <Button
{isSaving} variant="primary"
{saveSuccess} size="small"
{saveError} onclick={createCharacter}
onSave={createCharacter} disabled={!canCreate || isSaving}
onCancel={handleCancel} >
> {isSaving ? 'Saving...' : 'Save'}
<section class="details"> </Button>
<!-- Granblue ID Validation Section --> {/snippet}
<DetailsContainer title="Granblue ID Validation"> </SidebarHeader>
<div class="validation-section">
<div class="validation-input">
<DetailItem
label="Granblue ID"
bind:value={editData.granblueId}
editable={true}
type="text"
placeholder="e.g., 3040001000"
/>
<Button
variant="primary"
size="small"
onclick={validateGranblueId}
disabled={isValidating || !editData.granblueId}
>
{isValidating ? 'Validating...' : 'Validate'}
</Button>
</div>
{#if validationError} {#if saveError}
<div class="validation-error">{validationError}</div> <div class="error-banner">{saveError}</div>
{/if} {/if}
{#if validationResult?.valid && !validationResult.existsInDb} <section class="details">
<div class="validation-success"> <DetailsContainer title="Basic Info">
Valid Granblue ID - images found on server <DetailItem label="Granblue ID">
</div> <ValidatedInput
{/if} bind:value={editData.granblueId}
placeholder="3040001000"
onValidate={validateGranblueId}
minLength={10}
contained
alignRight={false}
/>
</DetailItem>
<DetailItem
label="Name (EN)"
bind:value={editData.name}
editable={true}
type="text"
placeholder="Character name"
/>
<DetailItem
label="Name (JP)"
bind:value={editData.nameJp}
editable={true}
type="text"
placeholder="キャラクター名"
/>
<DetailItem
label="Rarity"
bind:value={editData.rarity}
editable={true}
type="select"
options={rarityOptions}
/>
<DetailItem
label="Character ID"
sublabel="Separate multiple IDs with commas (e.g. 123, 456)"
bind:value={editData.characterId}
editable={true}
type="text"
placeholder="Character IDs"
/>
</DetailsContainer>
{#if validationResult?.existsInDb} <CharacterUncapSection character={emptyCharacter} {editMode} bind:editData />
<div class="validation-warning"> <CharacterTaxonomySection character={emptyCharacter} {editMode} bind:editData />
A character with this ID already exists in the database <CharacterStatsSection character={emptyCharacter} {editMode} bind:editData />
</div>
{/if}
</div>
</DetailsContainer>
<!-- Basic Info --> <DetailsContainer title="Dates">
<DetailsContainer title="Basic Info"> <DetailItem
label="Release Date"
bind:value={editData.releaseDate}
editable={true}
type="text"
placeholder="YYYY-MM-DD"
/>
{#if editData.flb}
<DetailItem <DetailItem
label="Name (EN)" label="FLB Date"
bind:value={editData.name} bind:value={editData.flbDate}
editable={true} editable={true}
type="text" type="text"
placeholder="Character name" placeholder="YYYY-MM-DD"
/> />
</DetailsContainer> {/if}
{#if editData.ulb}
<DetailItem
label="ULB Date"
bind:value={editData.ulbDate}
editable={true}
type="text"
placeholder="YYYY-MM-DD"
/>
{/if}
</DetailsContainer>
<CharacterMetadataSection character={emptyCharacter} {editMode} bind:editData /> <DetailsContainer title="Links">
<CharacterUncapSection character={emptyCharacter} {editMode} bind:editData /> <DetailItem
<CharacterTaxonomySection character={emptyCharacter} {editMode} bind:editData /> label="Wiki (EN)"
<CharacterStatsSection character={emptyCharacter} {editMode} bind:editData /> bind:value={editData.wikiEn}
editable={true}
<!-- Action Buttons --> type="text"
<div class="action-buttons"> placeholder="https://gbf.wiki/..."
<Button variant="secondary" onclick={handleCancel}>Cancel</Button> width="480px"
<Button variant="primary" onclick={createCharacter} disabled={!canCreate || isSaving}> />
{isSaving ? 'Creating...' : 'Create Character'} <DetailItem
</Button> label="Wiki (JP)"
</div> bind:value={editData.wikiJa}
</section> editable={true}
</DetailScaffold> type="text"
placeholder="https://gbf-wiki.com/..."
width="480px"
/>
<DetailItem
label="Gamewith"
bind:value={editData.gamewith}
editable={true}
type="text"
placeholder="https://xn--bck3aza1a2if6kra4ee0hf.gamewith.jp/..."
width="480px"
/>
<DetailItem
label="Kamigame"
bind:value={editData.kamigame}
editable={true}
type="text"
placeholder="https://kamigame.jp/..."
width="480px"
/>
</DetailsContainer>
</section>
</div> </div>
<style lang="scss"> <style lang="scss">
@use '$src/themes/colors' as colors; @use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing; @use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography; @use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
.page {
background: white;
border-radius: layout.$card-corner;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.details { .details {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
}
.validation-section { :global(.container) {
display: flex; border-bottom: none;
flex-direction: column;
gap: spacing.$unit;
padding: spacing.$unit-2x;
}
.validation-input {
display: flex;
align-items: flex-end;
gap: spacing.$unit-2x;
:global(.detail-item) {
flex: 1;
} }
} }
.validation-error { .error-banner {
color: colors.$error; color: colors.$error;
font-size: typography.$font-small; font-size: typography.$font-small;
padding: spacing.$unit;
background: colors.$error--bg--light;
border-radius: 4px;
}
.validation-success {
color: colors.$wind-text-20;
font-size: typography.$font-small;
padding: spacing.$unit;
background: colors.$wind-bg-20;
border-radius: 4px;
}
.validation-warning {
color: colors.$orange-40;
font-size: typography.$font-small;
padding: spacing.$unit;
background: colors.$orange-90;
border-radius: 4px;
}
.action-buttons {
display: flex;
justify-content: flex-end;
gap: spacing.$unit-2x;
padding: spacing.$unit-2x; padding: spacing.$unit-2x;
border-top: 1px solid colors.$grey-80; background: colors.$error--bg--light;
} }
</style> </style>