hensei-web/src/routes/(app)/database/summons/new/+page.svelte

388 lines
9.4 KiB
Svelte
Raw Blame History

<svelte:options runes={true} />
<script lang="ts">
// SvelteKit imports
import { goto } from '$app/navigation'
// Components
import SummonUncapSection from '$lib/features/database/summons/sections/SummonUncapSection.svelte'
import SummonTaxonomySection from '$lib/features/database/summons/sections/SummonTaxonomySection.svelte'
import SummonStatsSection from '$lib/features/database/summons/sections/SummonStatsSection.svelte'
import DetailsContainer from '$lib/components/ui/DetailsContainer.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 ValidatedInput from '$lib/components/ui/ValidatedInput.svelte'
import TagInput from '$lib/components/ui/TagInput.svelte'
import { entityAdapter } from '$lib/api/adapters/entity.adapter'
import { getRarityOptions } from '$lib/utils/rarity'
// Always in edit mode for new summon
const editMode = true
let isSaving = $state(false)
let saveError = $state<string | null>(null)
// Validation state for canCreate check
let granblueIdValid = $state(false)
let granblueIdExistsInDb = $state(false)
// Empty summon for new creation
const emptySummon = {
id: '',
name: { en: '', jp: '' },
granblueId: '',
summonId: '',
rarity: 3,
element: 0,
series: '',
hp: { minHp: 0, maxHp: 0, maxHpFlb: 0, maxHpUlb: 0, maxHpXlb: 0 },
atk: { minAtk: 0, maxAtk: 0, maxAtkFlb: 0, maxAtkUlb: 0, maxAtkXlb: 0 },
uncap: { flb: false, ulb: false, transcendence: false },
subaura: false,
limit: false,
maxLevel: 100
}
// Editable fields
let editData = $state({
// Basic Info
name: '',
nameJp: '',
granblueId: '',
summonId: '',
rarity: 3,
// Taxonomy
element: 0,
series: '',
promotions: [] as number[],
// Stats
minHp: 0,
maxHp: 0,
maxHpFlb: 0,
maxHpUlb: 0,
maxHpTranscendence: 0,
minAtk: 0,
maxAtk: 0,
maxAtkFlb: 0,
maxAtkUlb: 0,
maxAtkTranscendence: 0,
maxLevel: 100,
// Uncap
flb: false,
ulb: false,
transcendence: false,
subaura: false,
limit: false,
// Dates
releaseDate: '',
flbDate: '',
ulbDate: '',
transcendenceDate: '',
// Links
wikiEn: '',
wikiJa: '',
gamewith: '',
kamigame: '',
// Nicknames
nicknamesEn: [] as string[],
nicknamesJp: [] as string[]
})
const rarityOptions = getRarityOptions()
// Validation is required before create
const canCreate = $derived(
granblueIdValid &&
!granblueIdExistsInDb &&
editData.name.trim() !== '' &&
editData.granblueId.trim() !== ''
)
async function validateGranblueId(value: string): Promise<{ valid: boolean; message: string }> {
if (!value || value.length !== 10) {
granblueIdValid = false
granblueIdExistsInDb = false
return { valid: false, message: 'Granblue ID must be exactly 10 digits' }
}
try {
const result = await entityAdapter.validateSummonGranblueId(value)
if (!result.valid) {
granblueIdValid = false
granblueIdExistsInDb = false
return { valid: false, message: result.error || 'Invalid Granblue ID' }
}
if (result.existsInDb) {
granblueIdValid = true
granblueIdExistsInDb = true
return { valid: false, message: 'A summon with this Granblue ID already exists' }
}
granblueIdValid = true
granblueIdExistsInDb = false
return { valid: true, message: 'Valid Granblue ID - images found on server' }
} catch (error) {
granblueIdValid = false
granblueIdExistsInDb = false
console.error('Validation error:', error)
return { valid: false, message: 'Failed to validate Granblue ID' }
}
}
async function createSummon() {
if (!canCreate) return
isSaving = true
saveError = null
try {
// Map transcendence stats to xlb for API
const payload = {
// Basic Info
granblue_id: editData.granblueId,
name_en: editData.name,
name_jp: editData.nameJp,
summon_id: editData.summonId || undefined,
rarity: editData.rarity,
// Taxonomy
element: editData.element,
series: editData.series || undefined,
// Stats - note: transcendence maps to xlb
min_hp: editData.minHp,
max_hp: editData.maxHp,
max_hp_flb: editData.maxHpFlb,
max_hp_ulb: editData.maxHpUlb,
max_hp_xlb: editData.maxHpTranscendence,
min_atk: editData.minAtk,
max_atk: editData.maxAtk,
max_atk_flb: editData.maxAtkFlb,
max_atk_ulb: editData.maxAtkUlb,
max_atk_xlb: editData.maxAtkTranscendence,
max_level: editData.maxLevel,
// Uncap
flb: editData.flb,
ulb: editData.ulb,
transcendence: editData.transcendence,
subaura: editData.subaura,
limit: editData.limit,
// Dates
release_date: editData.releaseDate || null,
flb_date: editData.flbDate || null,
ulb_date: editData.ulbDate || null,
transcendence_date: editData.transcendenceDate || null,
// Links
wiki_en: editData.wikiEn,
wiki_ja: editData.wikiJa,
gamewith: editData.gamewith,
kamigame: editData.kamigame,
// Nicknames
nicknames_en: editData.nicknamesEn,
nicknames_jp: editData.nicknamesJp
}
const newSummon = await entityAdapter.createSummon(payload)
await goto(`/database/summons/${newSummon.id}`)
} catch (error) {
saveError = 'Failed to create summon. Please try again.'
console.error('Create error:', error)
} finally {
isSaving = false
}
}
function handleCancel() {
goto('/database/summons')
}
</script>
<div class="page">
<SidebarHeader title="New Summon">
{#snippet leftAccessory()}
<Button variant="secondary" size="small" onclick={handleCancel}>Cancel</Button>
{/snippet}
{#snippet rightAccessory()}
<Button
variant="primary"
size="small"
onclick={createSummon}
disabled={!canCreate || isSaving}
>
{isSaving ? 'Saving...' : 'Save'}
</Button>
{/snippet}
</SidebarHeader>
{#if saveError}
<div class="error-banner">{saveError}</div>
{/if}
<section class="details">
<DetailsContainer title="Basic Info">
<DetailItem label="Granblue ID">
<ValidatedInput
bind:value={editData.granblueId}
placeholder="2040001000"
onValidate={validateGranblueId}
minLength={10}
contained
alignRight={false}
/>
</DetailItem>
<DetailItem
label="Name (EN)"
bind:value={editData.name}
editable={true}
type="text"
placeholder="Summon name"
/>
<DetailItem
label="Name (JP)"
bind:value={editData.nameJp}
editable={true}
type="text"
placeholder="<22><>
"
/>
<DetailItem
label="Rarity"
bind:value={editData.rarity}
editable={true}
type="select"
options={rarityOptions}
/>
<DetailItem
label="Summon ID"
sublabel="Internal game identifier (if known)"
bind:value={editData.summonId}
editable={true}
type="text"
placeholder="Optional"
/>
</DetailsContainer>
<SummonUncapSection summon={emptySummon} {editMode} bind:editData />
<SummonTaxonomySection summon={emptySummon} {editMode} bind:editData />
<SummonStatsSection summon={emptySummon} {editMode} bind:editData />
<DetailsContainer title="Nicknames">
<DetailItem label="Nicknames (EN)">
<TagInput bind:value={editData.nicknamesEn} placeholder="Add nickname..." contained />
</DetailItem>
<DetailItem label="Nicknames (JP)">
<TagInput bind:value={editData.nicknamesJp} placeholder="ニックネームを入力" contained />
</DetailItem>
</DetailsContainer>
<DetailsContainer title="Dates">
<DetailItem
label="Release Date"
bind:value={editData.releaseDate}
editable={true}
type="text"
placeholder="YYYY-MM-DD"
/>
{#if editData.flb}
<DetailItem
label="FLB Date"
bind:value={editData.flbDate}
editable={true}
type="text"
placeholder="YYYY-MM-DD"
/>
{/if}
{#if editData.ulb}
<DetailItem
label="ULB Date"
bind:value={editData.ulbDate}
editable={true}
type="text"
placeholder="YYYY-MM-DD"
/>
{/if}
{#if editData.transcendence}
<DetailItem
label="Transcendence Date"
bind:value={editData.transcendenceDate}
editable={true}
type="text"
placeholder="YYYY-MM-DD"
/>
{/if}
</DetailsContainer>
<DetailsContainer title="Links">
<DetailItem
label="Wiki (EN)"
bind:value={editData.wikiEn}
editable={true}
type="text"
placeholder="https://gbf.wiki/..."
width="480px"
/>
<DetailItem
label="Wiki (JP)"
bind:value={editData.wikiJa}
editable={true}
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>
<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;
.page {
background: white;
border-radius: layout.$card-corner;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.details {
display: flex;
flex-direction: column;
}
.error-banner {
color: colors.$error;
font-size: typography.$font-small;
padding: spacing.$unit-2x;
background: colors.$error--bg--light;
}
</style>