add /database/characters/new page + API methods
- new page with granblue_id validation - role check (>= 7) on server - API methods: validate, create, download images - permanent edit mode with create button
This commit is contained in:
parent
b58cbbe72f
commit
5f5b579ff0
3 changed files with 493 additions and 0 deletions
|
|
@ -188,6 +188,61 @@ export interface Summon {
|
|||
subAuraDescription?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from character granblue_id validation
|
||||
*/
|
||||
export interface CharacterValidationResult {
|
||||
valid: boolean
|
||||
granblueId: string
|
||||
existsInDb: boolean
|
||||
error?: string
|
||||
imageUrls?: {
|
||||
main?: string
|
||||
grid?: string
|
||||
square?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for creating a new character
|
||||
*/
|
||||
export interface CreateCharacterPayload {
|
||||
granblue_id: string
|
||||
name_en: string
|
||||
name_jp?: string
|
||||
rarity?: number
|
||||
element?: number
|
||||
race1?: number | null
|
||||
race2?: number | null
|
||||
gender?: number
|
||||
proficiency1?: number
|
||||
proficiency2?: number
|
||||
min_hp?: number
|
||||
max_hp?: number
|
||||
max_hp_flb?: number
|
||||
min_atk?: number
|
||||
max_atk?: number
|
||||
max_atk_flb?: number
|
||||
flb?: boolean
|
||||
ulb?: boolean
|
||||
special?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from character image download status
|
||||
*/
|
||||
export interface CharacterDownloadStatus {
|
||||
status: 'queued' | 'processing' | 'completed' | 'failed' | 'not_found'
|
||||
progress?: number
|
||||
imagesDownloaded?: number
|
||||
imagesTotal?: number
|
||||
error?: string
|
||||
characterId?: string
|
||||
granblueId?: string
|
||||
images?: Record<string, string[]>
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity adapter for accessing canonical game data
|
||||
*/
|
||||
|
|
@ -290,6 +345,95 @@ export class EntityAdapter extends BaseAdapter {
|
|||
this.clearCache('/weapon_keys')
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Character Creation & Image Download Methods
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Validates a character granblue_id by checking if images exist on GBF servers
|
||||
* Requires editor role (>= 7)
|
||||
*/
|
||||
async validateCharacterGranblueId(granblueId: string): Promise<CharacterValidationResult> {
|
||||
const response = await this.request<{
|
||||
valid: boolean
|
||||
granblue_id: string
|
||||
exists_in_db: boolean
|
||||
error?: string
|
||||
image_urls?: {
|
||||
main?: string
|
||||
grid?: string
|
||||
square?: string
|
||||
}
|
||||
}>(`/characters/validate/${granblueId}`, {
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
return {
|
||||
valid: response.valid,
|
||||
granblueId: response.granblue_id,
|
||||
existsInDb: response.exists_in_db,
|
||||
error: response.error,
|
||||
imageUrls: response.image_urls
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new character record
|
||||
* Requires editor role (>= 7)
|
||||
*/
|
||||
async createCharacter(payload: CreateCharacterPayload): Promise<Character> {
|
||||
return this.request<Character>('/characters', {
|
||||
method: 'POST',
|
||||
body: { character: payload }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers async image download for a character
|
||||
* Requires editor role (>= 7)
|
||||
*/
|
||||
async downloadCharacterImages(
|
||||
characterId: string,
|
||||
options?: { force?: boolean; size?: 'all' | string }
|
||||
): Promise<{ status: string; characterId: string; message: string }> {
|
||||
return this.request(`/characters/${characterId}/download_images`, {
|
||||
method: 'POST',
|
||||
body: { options }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the status of an ongoing character image download
|
||||
* Requires editor role (>= 7)
|
||||
*/
|
||||
async getCharacterDownloadStatus(characterId: string): Promise<CharacterDownloadStatus> {
|
||||
const response = await this.request<{
|
||||
status: string
|
||||
progress?: number
|
||||
images_downloaded?: number
|
||||
images_total?: number
|
||||
error?: string
|
||||
character_id?: string
|
||||
granblue_id?: string
|
||||
images?: Record<string, string[]>
|
||||
updated_at?: string
|
||||
}>(`/characters/${characterId}/download_status`, {
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
return {
|
||||
status: response.status as CharacterDownloadStatus['status'],
|
||||
progress: response.progress,
|
||||
imagesDownloaded: response.images_downloaded,
|
||||
imagesTotal: response.images_total,
|
||||
error: response.error,
|
||||
characterId: response.character_id,
|
||||
granblueId: response.granblue_id,
|
||||
images: response.images,
|
||||
updatedAt: response.updated_at
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
15
src/routes/(app)/database/characters/new/+page.server.ts
Normal file
15
src/routes/(app)/database/characters/new/+page.server.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { PageServerLoad } from './$types'
|
||||
import { redirect } from '@sveltejs/kit'
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
const parentData = await parent()
|
||||
|
||||
// Require editor role (>= 7) to access this page
|
||||
if (!parentData.role || parentData.role < 7) {
|
||||
throw redirect(302, '/database/characters')
|
||||
}
|
||||
|
||||
return {
|
||||
role: parentData.role
|
||||
}
|
||||
}
|
||||
334
src/routes/(app)/database/characters/new/+page.svelte
Normal file
334
src/routes/(app)/database/characters/new/+page.svelte
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
// SvelteKit imports
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
// 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 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 Button from '$lib/components/ui/Button.svelte'
|
||||
import { getCharacterImage } from '$lib/utils/images'
|
||||
import { entityAdapter } from '$lib/api/adapters/entity.adapter'
|
||||
|
||||
// Types
|
||||
import type { PageData } from './$types'
|
||||
|
||||
let { data }: { data: PageData } = $props()
|
||||
|
||||
// Always in edit mode for new character
|
||||
const editMode = true
|
||||
const canEdit = true
|
||||
|
||||
let isSaving = $state(false)
|
||||
let saveError = $state<string | null>(null)
|
||||
let saveSuccess = $state(false)
|
||||
|
||||
// Validation state
|
||||
let isValidating = $state(false)
|
||||
let validationError = $state<string | null>(null)
|
||||
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
|
||||
const emptyCharacter = {
|
||||
id: '',
|
||||
name: { en: '', jp: '' },
|
||||
granblueId: '',
|
||||
characterId: null,
|
||||
rarity: 3,
|
||||
element: 0,
|
||||
race: [],
|
||||
gender: 0,
|
||||
proficiency: [0, 0],
|
||||
hp: { minHp: 0, maxHp: 0, maxHpFlb: 0 },
|
||||
atk: { minAtk: 0, maxAtk: 0, maxAtkFlb: 0 },
|
||||
uncap: { flb: false, ulb: false, transcendence: false },
|
||||
special: false
|
||||
}
|
||||
|
||||
// Editable fields
|
||||
let editData = $state({
|
||||
name: '',
|
||||
granblueId: '',
|
||||
characterId: null as number | null,
|
||||
rarity: 3,
|
||||
element: 0,
|
||||
race1: null as number | null,
|
||||
race2: null as number | null,
|
||||
gender: 0,
|
||||
proficiency1: 0,
|
||||
proficiency2: 0,
|
||||
minHp: 0,
|
||||
maxHp: 0,
|
||||
maxHpFlb: 0,
|
||||
minAtk: 0,
|
||||
maxAtk: 0,
|
||||
maxAtkFlb: 0,
|
||||
flb: false,
|
||||
ulb: false,
|
||||
transcendence: false,
|
||||
special: false
|
||||
})
|
||||
|
||||
// Validation is required before create
|
||||
const canCreate = $derived(
|
||||
validationResult?.valid === true &&
|
||||
!validationResult?.existsInDb &&
|
||||
editData.name.trim() !== '' &&
|
||||
editData.granblueId.trim() !== ''
|
||||
)
|
||||
|
||||
// Get preview image from validation or placeholder
|
||||
const previewImage = $derived(
|
||||
validationResult?.imageUrls?.grid || getCharacterImage(editData.granblueId, 'grid', '01')
|
||||
)
|
||||
|
||||
async function validateGranblueId() {
|
||||
if (!editData.granblueId || editData.granblueId.length !== 10) {
|
||||
validationError = 'Granblue ID must be exactly 10 digits'
|
||||
validationResult = null
|
||||
return
|
||||
}
|
||||
|
||||
isValidating = true
|
||||
validationError = null
|
||||
validationResult = null
|
||||
|
||||
try {
|
||||
const result = await entityAdapter.validateCharacterGranblueId(editData.granblueId)
|
||||
validationResult = {
|
||||
valid: result.valid,
|
||||
existsInDb: result.existsInDb,
|
||||
imageUrls: result.imageUrls
|
||||
}
|
||||
|
||||
if (!result.valid) {
|
||||
validationError = result.error || 'Invalid Granblue ID'
|
||||
} else if (result.existsInDb) {
|
||||
validationError = 'A character with this Granblue ID already exists'
|
||||
}
|
||||
} catch (error) {
|
||||
validationError = 'Failed to validate Granblue ID'
|
||||
console.error('Validation error:', error)
|
||||
} finally {
|
||||
isValidating = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createCharacter() {
|
||||
if (!canCreate) return
|
||||
|
||||
isSaving = true
|
||||
saveError = null
|
||||
saveSuccess = false
|
||||
|
||||
try {
|
||||
// Prepare the data for API
|
||||
const payload = {
|
||||
granblue_id: editData.granblueId,
|
||||
name_en: editData.name,
|
||||
name_jp: '', // Can be added later
|
||||
rarity: editData.rarity,
|
||||
element: editData.element,
|
||||
race1: editData.race1,
|
||||
race2: editData.race2,
|
||||
gender: editData.gender,
|
||||
proficiency1: editData.proficiency1,
|
||||
proficiency2: editData.proficiency2,
|
||||
min_hp: editData.minHp,
|
||||
max_hp: editData.maxHp,
|
||||
max_hp_flb: editData.maxHpFlb,
|
||||
min_atk: editData.minAtk,
|
||||
max_atk: editData.maxAtk,
|
||||
max_atk_flb: editData.maxAtkFlb,
|
||||
flb: editData.flb,
|
||||
ulb: editData.ulb,
|
||||
special: editData.special
|
||||
}
|
||||
|
||||
const newCharacter = await entityAdapter.createCharacter(payload)
|
||||
|
||||
saveSuccess = true
|
||||
|
||||
// Redirect to the new character's page
|
||||
await goto(`/database/characters/${newCharacter.id}`)
|
||||
} catch (error) {
|
||||
saveError = 'Failed to create character. Please try again.'
|
||||
console.error('Create error:', error)
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
goto('/database/characters')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<DetailScaffold
|
||||
type="character"
|
||||
item={{ ...emptyCharacter, name: { en: editData.name || 'New Character', jp: '' } }}
|
||||
image={previewImage}
|
||||
showEdit={false}
|
||||
editMode={true}
|
||||
{isSaving}
|
||||
{saveSuccess}
|
||||
{saveError}
|
||||
onSave={createCharacter}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<section class="details">
|
||||
<!-- Granblue ID Validation Section -->
|
||||
<DetailsContainer title="Granblue ID Validation">
|
||||
<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}
|
||||
<div class="validation-error">{validationError}</div>
|
||||
{/if}
|
||||
|
||||
{#if validationResult?.valid && !validationResult.existsInDb}
|
||||
<div class="validation-success">
|
||||
Valid Granblue ID - images found on server
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if validationResult?.existsInDb}
|
||||
<div class="validation-warning">
|
||||
A character with this ID already exists in the database
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</DetailsContainer>
|
||||
|
||||
<!-- Basic Info -->
|
||||
<DetailsContainer title="Basic Info">
|
||||
<DetailItem
|
||||
label="Name (EN)"
|
||||
bind:value={editData.name}
|
||||
editable={true}
|
||||
type="text"
|
||||
placeholder="Character name"
|
||||
/>
|
||||
</DetailsContainer>
|
||||
|
||||
<CharacterMetadataSection character={emptyCharacter} {editMode} bind:editData />
|
||||
<CharacterUncapSection character={emptyCharacter} {editMode} bind:editData />
|
||||
<CharacterTaxonomySection character={emptyCharacter} {editMode} bind:editData />
|
||||
<CharacterStatsSection character={emptyCharacter} {editMode} bind:editData />
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<Button variant="secondary" onclick={handleCancel}>Cancel</Button>
|
||||
<Button variant="primary" onclick={createCharacter} disabled={!canCreate || isSaving}>
|
||||
{isSaving ? 'Creating...' : 'Create Character'}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</DetailScaffold>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.validation-section {
|
||||
display: flex;
|
||||
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 {
|
||||
color: colors.$error;
|
||||
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;
|
||||
border-top: 1px solid colors.$grey-80;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue