weapons: add creation page and update section components

Create /database/weapons/new route with full weapon creation form:
- Granblue ID validation
- Basic info (name, rarity)
- Uncap settings with cascade logic (Transcendence → ULB → FLB)
- Taxonomy (element, proficiency, series, new_series)
- Stats (HP/ATK with FLB/ULB variants)
- Caps (max_level, max_skill_level, max_awakening_level)
- Nicknames via TagInput
- Dates and external links

Update section components:
- WeaponUncapSection: Add cascade logic, extra/limit/ax fields
- WeaponTaxonomySection: Add series dropdowns with options
- WeaponStatsSection: Add ULB stats, caps section

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-12-01 03:08:52 -08:00
parent d1c40ee38d
commit fdc50906dc
4 changed files with 707 additions and 64 deletions

View file

@ -1,39 +1,143 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
let { weapon, editMode = false, editData = $bindable<any>() }:
{ weapon: any; editMode?: boolean; editData?: any } = $props()
interface Props {
weapon: any
editMode?: boolean
editData?: any
}
const flb = $derived(editMode ? Boolean(editData.flb) : Boolean(weapon?.uncap?.flb))
let { weapon, editMode = false, editData = $bindable() }: Props = $props()
const flb = $derived(editMode ? Boolean(editData.flb) : Boolean(weapon?.uncap?.flb))
const ulb = $derived(editMode ? Boolean(editData.ulb) : Boolean(weapon?.uncap?.ulb))
</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" />
{:else}
<DetailItem label="Base HP" value={weapon.hp?.min_hp} />
<DetailItem label="Max HP" value={weapon.hp?.max_hp} />
{#if flb}
<DetailItem label="Max HP (FLB)" value={weapon.hp?.max_hp_flb} />
{/if}
{/if}
{#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"
/>
{#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}
<DetailItem label="Base HP" value={weapon.hp?.minHp} />
<DetailItem label="Max HP" value={weapon.hp?.maxHp} />
{#if flb}
<DetailItem label="Max HP (FLB)" value={weapon.hp?.maxHpFlb} />
{/if}
{#if ulb}
<DetailItem label="Max HP (ULB)" value={weapon.hp?.maxHpUlb} />
{/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={weapon.atk?.min_atk} />
<DetailItem label="Max Attack" value={weapon.atk?.max_atk} />
{#if flb}
<DetailItem label="Max Attack (FLB)" value={weapon.atk?.max_atk_flb} />
{/if}
{/if}
{#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"
/>
{#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}
<DetailItem label="Base Attack" value={weapon.atk?.minAtk} />
<DetailItem label="Max Attack" value={weapon.atk?.maxAtk} />
{#if flb}
<DetailItem label="Max Attack (FLB)" value={weapon.atk?.maxAtkFlb} />
{/if}
{#if ulb}
<DetailItem label="Max Attack (ULB)" value={weapon.atk?.maxAtkUlb} />
{/if}
{/if}
</DetailsContainer>
<DetailsContainer title="Caps">
{#if editMode}
<DetailItem
label="Max Level"
bind:value={editData.maxLevel}
editable={true}
type="number"
placeholder="100"
/>
<DetailItem
label="Max Skill Level"
bind:value={editData.maxSkillLevel}
editable={true}
type="number"
placeholder="10"
/>
<DetailItem
label="Max Awakening Level"
bind:value={editData.maxAwakeningLevel}
editable={true}
type="number"
placeholder="0"
/>
{:else}
<DetailItem label="Max Level" value={weapon.maxLevel} />
{#if weapon.maxSkillLevel}
<DetailItem label="Max Skill Level" value={weapon.maxSkillLevel} />
{/if}
{#if weapon.maxAwakeningLevel}
<DetailItem label="Max Awakening Level" value={weapon.maxAwakeningLevel} />
{/if}
{/if}
</DetailsContainer>

View file

@ -1,29 +1,75 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import { getElementLabel, getElementOptions } from '$lib/utils/element'
import { getProficiencyLabel, getProficiencyOptions } from '$lib/utils/proficiency'
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import { getElementLabel, getElementOptions } from '$lib/utils/element'
import { getProficiencyLabel, getProficiencyOptions } from '$lib/utils/proficiency'
import { getWeaponSeriesOptions, getWeaponSeriesSlug } from '$lib/utils/weaponSeries'
let { weapon, editMode = false, editData = $bindable<any>() }:
{ weapon: any; editMode?: boolean; editData?: any } = $props()
interface Props {
weapon: any
editMode?: boolean
editData?: any
}
const elementOptions = getElementOptions()
const proficiencyOptions = getProficiencyOptions()
let { weapon, editMode = false, editData = $bindable() }: Props = $props()
const elementOptions = getElementOptions()
const proficiencyOptions = getProficiencyOptions()
const seriesOptions = [{ value: 0, label: 'None' }, ...getWeaponSeriesOptions()]
</script>
<DetailsContainer title="Details">
{#if editMode}
<DetailItem label="Element" bind:value={editData.element} editable={true} type="select" options={elementOptions} />
<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(weapon.element)} />
<DetailItem label="Proficiency 1" value={getProficiencyLabel(Array.isArray(weapon.proficiency) ? weapon.proficiency[0] : weapon.proficiency)} />
{#if Array.isArray(weapon.proficiency) && weapon.proficiency[1] !== undefined}
<DetailItem label="Proficiency 2" value={getProficiencyLabel(weapon.proficiency[1])} />
{/if}
{/if}
{#if editMode}
<DetailItem
label="Element"
bind:value={editData.element}
editable={true}
type="select"
options={elementOptions}
/>
<DetailItem
label="Proficiency"
bind:value={editData.proficiency}
editable={true}
type="select"
options={proficiencyOptions}
/>
<DetailItem
label="Series"
bind:value={editData.series}
editable={true}
type="select"
options={seriesOptions}
/>
<DetailItem
label="New Series"
sublabel="Secondary series classification"
bind:value={editData.newSeries}
editable={true}
type="select"
options={seriesOptions}
/>
{:else}
<DetailItem label="Element" value={getElementLabel(weapon.element)} />
<DetailItem
label="Proficiency"
value={getProficiencyLabel(
Array.isArray(weapon.proficiency) ? weapon.proficiency[0] : weapon.proficiency
)}
/>
{#if weapon.series}
{@const seriesLabel = getWeaponSeriesSlug(weapon.series)}
<DetailItem
label="Series"
value={seriesLabel
? seriesLabel
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
: String(weapon.series)}
/>
{/if}
{/if}
</DetailsContainer>

View file

@ -1,27 +1,131 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import { getMaxUncapLevel } from '$lib/utils/uncap'
import { getElementLabel } from '$lib/utils/element'
let { weapon, editMode = false, editData = $bindable<any>() }:
{ weapon: any; editMode?: boolean; editData?: any } = $props()
type ElementName = 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
const flb = $derived(editMode ? Boolean(editData.flb) : Boolean(weapon?.uncap?.flb))
const ulb = $derived(editMode ? Boolean(editData.ulb) : Boolean(weapon?.uncap?.ulb))
const transcendence = $derived(editMode ? Boolean(editData.transcendence) : Boolean(weapon?.uncap?.transcendence))
interface Props {
weapon: any
editMode?: boolean
editData?: any
}
let { weapon, editMode = false, editData = $bindable() }: Props = $props()
const uncap = $derived(
editMode
? { flb: editData.flb, ulb: editData.ulb, transcendence: editData.transcendence }
: (weapon?.uncap ?? {})
)
const flb = $derived(uncap.flb ?? false)
const ulb = $derived(uncap.ulb ?? false)
const transcendence = $derived(uncap.transcendence ?? false)
const uncapLevel = $derived(getMaxUncapLevel({ uncap }))
const transcendenceStage = $derived(transcendence ? 5 : 0)
// Get element name for checkbox theming
const elementName = $derived.by((): ElementName | undefined => {
const el = editMode ? editData.element : weapon?.element
const label = getElementLabel(el)
return label !== '—' && label !== 'Null' ? (label.toLowerCase() as ElementName) : 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
}
}
</script>
<DetailsContainer title="Uncap">
<DetailItem label="Uncap">
<UncapIndicator type="weapon" {flb} {ulb} {transcendence} editable={false} />
</DetailItem>
{#if weapon?.uncap || editMode}
<DetailItem label="Uncap">
<UncapIndicator
type="weapon"
{uncapLevel}
{transcendenceStage}
{flb}
{ulb}
{transcendence}
editable={false}
/>
</DetailItem>
{/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" />
{/if}
{#if editMode}
<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}
onchange={handleUlbChange}
/>
<DetailItem
label="Transcendence"
bind:value={editData.transcendence}
editable={true}
type="checkbox"
element={elementName}
onchange={handleTranscendenceChange}
/>
<DetailItem
label="Extra"
sublabel="Has additional slot (Opus, etc.)"
bind:value={editData.extra}
editable={true}
type="checkbox"
element={elementName}
/>
<DetailItem
label="Limit"
sublabel="Limited availability"
bind:value={editData.limit}
editable={true}
type="checkbox"
element={elementName}
/>
<DetailItem
label="AX Skills"
sublabel="Can have AX skill slots"
bind:value={editData.ax}
editable={true}
type="checkbox"
element={elementName}
/>
{/if}
</DetailsContainer>

View file

@ -0,0 +1,389 @@
<svelte:options runes={true} />
<script lang="ts">
// SvelteKit imports
import { goto } from '$app/navigation'
// Components
import WeaponUncapSection from '$lib/features/database/weapons/sections/WeaponUncapSection.svelte'
import WeaponTaxonomySection from '$lib/features/database/weapons/sections/WeaponTaxonomySection.svelte'
import WeaponStatsSection from '$lib/features/database/weapons/sections/WeaponStatsSection.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 weapon
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 weapon for new creation
const emptyWeapon = {
id: '',
name: { en: '', jp: '' },
granblueId: '',
rarity: 3,
element: 0,
proficiency: 0,
series: 0,
hp: { minHp: 0, maxHp: 0, maxHpFlb: 0, maxHpUlb: 0 },
atk: { minAtk: 0, maxAtk: 0, maxAtkFlb: 0, maxAtkUlb: 0 },
uncap: { flb: false, ulb: false, transcendence: false },
maxLevel: 100
}
// Editable fields
let editData = $state({
// Basic Info
name: '',
nameJp: '',
granblueId: '',
rarity: 3,
// Taxonomy
element: 0,
proficiency: 0,
series: 0,
newSeries: 0,
// Stats
minHp: 0,
maxHp: 0,
maxHpFlb: 0,
maxHpUlb: 0,
minAtk: 0,
maxAtk: 0,
maxAtkFlb: 0,
maxAtkUlb: 0,
maxLevel: 100,
maxSkillLevel: 10,
maxAwakeningLevel: 0,
// Uncap
flb: false,
ulb: false,
transcendence: false,
extra: false,
limit: false,
ax: false,
// Dates
releaseDate: '',
flbDate: '',
ulbDate: '',
transcendenceDate: '',
// Links
wikiEn: '',
wikiJa: '',
gamewith: '',
kamigame: '',
// Nicknames
nicknamesEn: [] as string[],
nicknamesJp: [] as string[],
// Recruits (Character ID)
recruits: null as string | null
})
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.validateWeaponGranblueId(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 weapon 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 createWeapon() {
if (!canCreate) return
isSaving = true
saveError = null
try {
const payload = {
// Basic Info
granblue_id: editData.granblueId,
name_en: editData.name,
name_jp: editData.nameJp || undefined,
rarity: editData.rarity,
// Taxonomy
element: editData.element,
proficiency: editData.proficiency,
series: editData.series || undefined,
new_series: editData.newSeries || undefined,
// Stats
min_hp: editData.minHp,
max_hp: editData.maxHp,
max_hp_flb: editData.maxHpFlb,
max_hp_ulb: editData.maxHpUlb,
min_atk: editData.minAtk,
max_atk: editData.maxAtk,
max_atk_flb: editData.maxAtkFlb,
max_atk_ulb: editData.maxAtkUlb,
max_level: editData.maxLevel,
max_skill_level: editData.maxSkillLevel,
max_awakening_level: editData.maxAwakeningLevel,
// Uncap
flb: editData.flb,
ulb: editData.ulb,
transcendence: editData.transcendence,
extra: editData.extra,
limit: editData.limit,
ax: editData.ax,
// 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,
// Recruits
recruits: editData.recruits
}
const newWeapon = await entityAdapter.createWeapon(payload)
await goto(`/database/weapons/${newWeapon.id}`)
} catch (error) {
saveError = 'Failed to create weapon. Please try again.'
console.error('Create error:', error)
} finally {
isSaving = false
}
}
function handleCancel() {
goto('/database/weapons')
}
</script>
<div class="page">
<SidebarHeader title="New Weapon">
{#snippet leftAccessory()}
<Button variant="secondary" size="small" onclick={handleCancel}>Cancel</Button>
{/snippet}
{#snippet rightAccessory()}
<Button
variant="primary"
size="small"
onclick={createWeapon}
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="1040001000"
onValidate={validateGranblueId}
minLength={10}
contained
alignRight={false}
/>
</DetailItem>
<DetailItem
label="Name (EN)"
bind:value={editData.name}
editable={true}
type="text"
placeholder="Weapon 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}
/>
</DetailsContainer>
<WeaponUncapSection weapon={emptyWeapon} {editMode} bind:editData />
<WeaponTaxonomySection weapon={emptyWeapon} {editMode} bind:editData />
<WeaponStatsSection weapon={emptyWeapon} {editMode} bind:editData />
<DetailsContainer title="Nicknames">
<DetailItem label="Nicknames (EN)">
<TagInput bind:value={editData.nicknamesEn} placeholder="Add nickname..." />
</DetailItem>
<DetailItem label="Nicknames (JP)">
<TagInput bind:value={editData.nicknamesJp} placeholder="ニックネーム..." />
</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;
:global(.container) {
border-bottom: none;
}
}
.error-banner {
color: colors.$error;
font-size: typography.$font-small;
padding: spacing.$unit-2x;
background: colors.$error--bg--light;
}
</style>