improve import form components

- fix Select reactivity with external value changes
- add series/promotions to taxonomy sections
- add onDataChange callbacks to uncap sections
- add game CDN image helpers for batch imports
- expand suggestion type definitions
This commit is contained in:
Justin Edmund 2025-12-14 19:35:20 -08:00
parent 37f2178d4a
commit e1ba34048a
11 changed files with 359 additions and 211 deletions

View file

@ -550,6 +550,8 @@ export interface CharacterSuggestions {
maxAtkFlb?: number maxAtkFlb?: number
flb?: boolean flb?: boolean
ulb?: boolean ulb?: boolean
/** Series array (e.g., [2] for Grand, [3] for Zodiac) */
series?: number[]
releaseDate?: string releaseDate?: string
flbDate?: string flbDate?: string
ulbDate?: string ulbDate?: string
@ -570,11 +572,15 @@ export interface WeaponSuggestions {
minHp?: number minHp?: number
maxHp?: number maxHp?: number
maxHpFlb?: number maxHpFlb?: number
maxHpUlb?: number
minAtk?: number minAtk?: number
maxAtk?: number maxAtk?: number
maxAtkFlb?: number maxAtkFlb?: number
maxAtkUlb?: number
flb?: boolean flb?: boolean
ulb?: boolean ulb?: boolean
/** Series name (e.g., "Revenant", "Optimus") */
series?: string
releaseDate?: string releaseDate?: string
flbDate?: string flbDate?: string
ulbDate?: string ulbDate?: string
@ -596,11 +602,16 @@ export interface SummonSuggestions {
minHp?: number minHp?: number
maxHp?: number maxHp?: number
maxHpFlb?: number maxHpFlb?: number
maxHpUlb?: number
minAtk?: number minAtk?: number
maxAtk?: number maxAtk?: number
maxAtkFlb?: number maxAtkFlb?: number
maxAtkUlb?: number
flb?: boolean flb?: boolean
ulb?: boolean ulb?: boolean
transcendence?: boolean
/** Series name (e.g., "Optimus", "Arcarum") */
series?: string
subaura?: boolean subaura?: boolean
releaseDate?: string releaseDate?: string
flbDate?: string flbDate?: string

View file

@ -64,11 +64,16 @@
const selected = $derived(options.find((opt) => opt.value === value)) const selected = $derived(options.find((opt) => opt.value === value))
// Local string value for Bits UI (which manages its own internal state)
let internalValue = $state<string | undefined>(
value !== undefined && value !== null ? String(value) : undefined
)
// Sync external value changes to internal state
$effect(() => { $effect(() => {
console.log('[Select] value:', value, typeof value) internalValue = value !== undefined && value !== null ? String(value) : undefined
console.log('[Select] options:', options.map(o => ({ value: o.value, type: typeof o.value })))
console.log('[Select] selected:', selected)
}) })
const hasWrapper = $derived(label || error) const hasWrapper = $derived(label || error)
const fieldsetClasses = $derived( const fieldsetClasses = $derived(
@ -106,7 +111,7 @@
<SelectPrimitive.Root <SelectPrimitive.Root
type="single" type="single"
{...value !== undefined && value !== null ? { value: String(value) } : {}} bind:value={internalValue}
onValueChange={handleValueChange} onValueChange={handleValueChange}
{disabled} {disabled}
items={stringOptions} items={stringOptions}
@ -191,7 +196,7 @@
{:else} {:else}
<SelectPrimitive.Root <SelectPrimitive.Root
type="single" type="single"
{...value !== undefined && value !== null ? { value: String(value) } : {}} bind:value={internalValue}
onValueChange={handleValueChange} onValueChange={handleValueChange}
{disabled} {disabled}
items={stringOptions} items={stringOptions}

View file

@ -1,189 +1,221 @@
<svelte:options runes={true} /> <svelte:options runes={true} />
<script lang="ts"> <script lang="ts">
import type { CharacterSuggestions } from '$lib/api/adapters/entity.adapter' import type { CharacterSuggestions } from '$lib/api/adapters/entity.adapter'
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 SuggestionDetailItem from '$lib/components/ui/SuggestionDetailItem.svelte' import SuggestionDetailItem from '$lib/components/ui/SuggestionDetailItem.svelte'
import ElementLabel from '$lib/components/labels/ElementLabel.svelte' import MultiSelect from '$lib/components/ui/MultiSelect.svelte'
import ProficiencyLabel from '$lib/components/labels/ProficiencyLabel.svelte' import ElementLabel from '$lib/components/labels/ElementLabel.svelte'
import { getElementLabel, getElementOptions } from '$lib/utils/element' import ProficiencyLabel from '$lib/components/labels/ProficiencyLabel.svelte'
import { getRaceLabel, getRaceOptions } from '$lib/utils/race' import { getElementLabel, getElementOptions } from '$lib/utils/element'
import { getGenderLabel, getGenderOptions } from '$lib/utils/gender' import { getRaceLabel, getRaceOptions } from '$lib/utils/race'
import { getProficiencyOptions } from '$lib/utils/proficiency' import { getGenderLabel, getGenderOptions } from '$lib/utils/gender'
import { import { getProficiencyOptions } from '$lib/utils/proficiency'
CharacterSeason, import {
CharacterSeries, CharacterSeason,
CHARACTER_SEASON_NAMES, CharacterSeries,
CHARACTER_SERIES_NAMES, CHARACTER_SEASON_NAMES,
getSeasonName, CHARACTER_SERIES_NAMES,
getSeriesNames PROMOTION_NAMES,
} from '$lib/types/enums' getSeasonName,
getSeriesNames,
getPromotionNames
} from '$lib/types/enums'
type ElementName = 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' type ElementName = 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
interface Props { interface Props {
character: any character: any
editMode?: boolean editMode?: boolean
editData?: any editData?: any
// Suggestion support for batch import // Suggestion support for batch import
suggestions?: CharacterSuggestions suggestions?: CharacterSuggestions
dismissedSuggestions?: Set<string> dismissedSuggestions?: Set<string>
onAcceptSuggestion?: (field: string, value: any) => void onAcceptSuggestion?: (field: string, value: any) => void
onDismissSuggestion?: (field: string) => void onDismissSuggestion?: (field: string) => void
} }
let { let {
character, character,
editMode = false, editMode = false,
editData = $bindable(), editData = $bindable(),
suggestions, suggestions,
dismissedSuggestions, dismissedSuggestions,
onAcceptSuggestion, onAcceptSuggestion,
onDismissSuggestion onDismissSuggestion
}: Props = $props() }: Props = $props()
const elementOptions = getElementOptions() const elementOptions = getElementOptions()
const raceOptions = getRaceOptions() const raceOptions = getRaceOptions()
const genderOptions = getGenderOptions() const genderOptions = getGenderOptions()
const proficiencyOptions = getProficiencyOptions() const proficiencyOptions = getProficiencyOptions()
// Season options (nullable, so include a "None" option) // Season options (nullable, so include a "None" option)
const seasonOptions = [ const seasonOptions = [
{ value: 0, label: 'None' }, { value: 0, label: 'None' },
...Object.entries(CHARACTER_SEASON_NAMES).map(([value, label]) => ({ ...Object.entries(CHARACTER_SEASON_NAMES).map(([value, label]) => ({
value: Number(value), value: Number(value),
label label
})) }))
] ]
// Series options for multiselect // Series options for multiselect
const seriesOptions = Object.entries(CHARACTER_SERIES_NAMES).map(([value, label]) => ({ const seriesOptions = Object.entries(CHARACTER_SERIES_NAMES).map(([value, label]) => ({
value: Number(value), value: Number(value),
label label
})) }))
// Get element name for checkbox theming // Promotion options for multiselect
const elementName = $derived.by((): ElementName | undefined => { const promotionOptions = Object.entries(PROMOTION_NAMES).map(([value, label]) => ({
const el = editMode ? editData?.element : character?.element value: Number(value),
const label = getElementLabel(el) label
return label !== '—' && label !== 'Null' ? (label.toLowerCase() as ElementName) : undefined }))
})
// Format series for display // Get element name for checkbox theming
function formatSeriesDisplay(series: number[]): string { const elementName = $derived.by((): ElementName | undefined => {
if (!series || series.length === 0) return '—' const el = editMode ? editData?.element : character?.element
return getSeriesNames(series).join(', ') const label = getElementLabel(el)
} return label !== '—' && label !== 'Null' ? (label.toLowerCase() as ElementName) : undefined
})
// Format series for display
function formatSeriesDisplay(series: number[]): string {
if (!series || series.length === 0) return '—'
return getSeriesNames(series).join(', ')
}
// Format promotions for display
function formatPromotionsDisplay(promotions: number[]): string {
if (!promotions || promotions.length === 0) return '—'
return getPromotionNames(promotions).join(', ')
}
</script> </script>
<DetailsContainer title="Details"> <DetailsContainer title="Details">
{#if editMode} {#if editMode}
<SuggestionDetailItem <SuggestionDetailItem
label="Element" label="Element"
bind:value={editData.element} bind:value={editData.element}
editable={true} editable={true}
type="select" type="select"
options={elementOptions} options={elementOptions}
suggestion={suggestions?.element} suggestion={suggestions?.element}
dismissedSuggestion={dismissedSuggestions?.has('element')} dismissedSuggestion={dismissedSuggestions?.has('element')}
onAcceptSuggestion={() => onAcceptSuggestion?.('element', suggestions?.element)} onAcceptSuggestion={() => onAcceptSuggestion?.('element', suggestions?.element)}
onDismissSuggestion={() => onDismissSuggestion?.('element')} onDismissSuggestion={() => onDismissSuggestion?.('element')}
/> />
<SuggestionDetailItem <SuggestionDetailItem
label="Race 1" label="Race 1"
bind:value={editData.race1} bind:value={editData.race1}
editable={true} editable={true}
type="select" type="select"
options={raceOptions} options={raceOptions}
suggestion={suggestions?.race1} suggestion={suggestions?.race1}
dismissedSuggestion={dismissedSuggestions?.has('race1')} dismissedSuggestion={dismissedSuggestions?.has('race1')}
onAcceptSuggestion={() => onAcceptSuggestion?.('race1', suggestions?.race1)} onAcceptSuggestion={() => onAcceptSuggestion?.('race1', suggestions?.race1)}
onDismissSuggestion={() => onDismissSuggestion?.('race1')} onDismissSuggestion={() => onDismissSuggestion?.('race1')}
/> />
<SuggestionDetailItem <SuggestionDetailItem
label="Race 2" label="Race 2"
bind:value={editData.race2} bind:value={editData.race2}
editable={true} editable={true}
type="select" type="select"
options={raceOptions} options={raceOptions}
suggestion={suggestions?.race2} suggestion={suggestions?.race2}
dismissedSuggestion={dismissedSuggestions?.has('race2')} dismissedSuggestion={dismissedSuggestions?.has('race2')}
onAcceptSuggestion={() => onAcceptSuggestion?.('race2', suggestions?.race2)} onAcceptSuggestion={() => onAcceptSuggestion?.('race2', suggestions?.race2)}
onDismissSuggestion={() => onDismissSuggestion?.('race2')} onDismissSuggestion={() => onDismissSuggestion?.('race2')}
/> />
<SuggestionDetailItem <SuggestionDetailItem
label="Gender" label="Gender"
bind:value={editData.gender} bind:value={editData.gender}
editable={true} editable={true}
type="select" type="select"
options={genderOptions} options={genderOptions}
suggestion={suggestions?.gender} suggestion={suggestions?.gender}
dismissedSuggestion={dismissedSuggestions?.has('gender')} dismissedSuggestion={dismissedSuggestions?.has('gender')}
onAcceptSuggestion={() => onAcceptSuggestion?.('gender', suggestions?.gender)} onAcceptSuggestion={() => onAcceptSuggestion?.('gender', suggestions?.gender)}
onDismissSuggestion={() => onDismissSuggestion?.('gender')} onDismissSuggestion={() => onDismissSuggestion?.('gender')}
/> />
<SuggestionDetailItem <SuggestionDetailItem
label="Proficiency 1" label="Proficiency 1"
bind:value={editData.proficiency1} bind:value={editData.proficiency1}
editable={true} editable={true}
type="select" type="select"
options={proficiencyOptions} options={proficiencyOptions}
suggestion={suggestions?.proficiency1} suggestion={suggestions?.proficiency1}
dismissedSuggestion={dismissedSuggestions?.has('proficiency1')} dismissedSuggestion={dismissedSuggestions?.has('proficiency1')}
onAcceptSuggestion={() => onAcceptSuggestion?.('proficiency1', suggestions?.proficiency1)} onAcceptSuggestion={() => onAcceptSuggestion?.('proficiency1', suggestions?.proficiency1)}
onDismissSuggestion={() => onDismissSuggestion?.('proficiency1')} onDismissSuggestion={() => onDismissSuggestion?.('proficiency1')}
/> />
<SuggestionDetailItem <SuggestionDetailItem
label="Proficiency 2" label="Proficiency 2"
bind:value={editData.proficiency2} bind:value={editData.proficiency2}
editable={true} editable={true}
type="select" type="select"
options={proficiencyOptions} options={proficiencyOptions}
suggestion={suggestions?.proficiency2} suggestion={suggestions?.proficiency2}
dismissedSuggestion={dismissedSuggestions?.has('proficiency2')} dismissedSuggestion={dismissedSuggestions?.has('proficiency2')}
onAcceptSuggestion={() => onAcceptSuggestion?.('proficiency2', suggestions?.proficiency2)} onAcceptSuggestion={() => onAcceptSuggestion?.('proficiency2', suggestions?.proficiency2)}
onDismissSuggestion={() => onDismissSuggestion?.('proficiency2')} onDismissSuggestion={() => onDismissSuggestion?.('proficiency2')}
/> />
<DetailItem <DetailItem
label="Season" label="Season"
bind:value={editData.season} bind:value={editData.season}
editable={true} editable={true}
type="select" type="select"
options={seasonOptions} options={seasonOptions}
/> />
<DetailItem <DetailItem
label="Series" label="Series"
bind:value={editData.series} bind:value={editData.series}
editable={true} editable={true}
type="multiselect" type="multiselect"
options={seriesOptions} options={seriesOptions}
element={elementName} element={elementName}
/> />
<DetailItem <DetailItem
label="Gacha Available" label="Gacha Available"
sublabel="Can be pulled from gacha" sublabel="Can be pulled from gacha"
bind:value={editData.gacha_available} bind:value={editData.gacha_available}
editable={true} editable={true}
type="checkbox" type="checkbox"
element={elementName} element={elementName}
/> />
{:else} <DetailItem
<DetailItem label="Element"> label="Promotions"
<ElementLabel element={character.element} size="medium" /> sublabel="Gacha pools where this character appears"
</DetailItem> editable={true}
<DetailItem label="Race 1" value={getRaceLabel(character.race?.[0])} /> >
<DetailItem label="Race 2" value={getRaceLabel(character.race?.[1])} /> <MultiSelect
<DetailItem label="Gender" value={getGenderLabel(character.gender)} /> size="medium"
<DetailItem label="Proficiency 1"> options={promotionOptions}
<ProficiencyLabel proficiency={character.proficiency?.[0] ?? 0} size="medium" /> bind:value={editData.promotions}
</DetailItem> placeholder="Select promotions"
<DetailItem label="Proficiency 2"> contained
<ProficiencyLabel proficiency={character.proficiency?.[1] ?? 0} size="medium" /> />
</DetailItem> </DetailItem>
<DetailItem label="Season" value={getSeasonName(character.season) || '—'} /> {:else}
<DetailItem label="Series" value={formatSeriesDisplay(character.series)} /> <DetailItem label="Element">
<DetailItem label="Gacha Available" value={character.gachaAvailable ? 'Yes' : 'No'} /> <ElementLabel element={character.element} size="medium" />
{/if} </DetailItem>
<DetailItem label="Race 1" value={getRaceLabel(character.race?.[0])} />
<DetailItem label="Race 2" value={getRaceLabel(character.race?.[1])} />
<DetailItem label="Gender" value={getGenderLabel(character.gender)} />
<DetailItem label="Proficiency 1">
<ProficiencyLabel proficiency={character.proficiency?.[0] ?? 0} size="medium" />
</DetailItem>
<DetailItem label="Proficiency 2">
<ProficiencyLabel proficiency={character.proficiency?.[1] ?? 0} size="medium" />
</DetailItem>
<DetailItem label="Season" value={getSeasonName(character.season) || '—'} />
<DetailItem label="Series" value={formatSeriesDisplay(character.series)} />
<DetailItem label="Gacha Available" value={character.gachaAvailable ? 'Yes' : 'No'} />
<DetailItem
label="Promotions"
sublabel="Gacha pools where this character appears"
value={formatPromotionsDisplay(character.promotions)}
/>
{/if}
</DetailsContainer> </DetailsContainer>

View file

@ -20,6 +20,8 @@
dismissedSuggestions?: Set<string> dismissedSuggestions?: Set<string>
onAcceptSuggestion?: (field: string, value: any) => void onAcceptSuggestion?: (field: string, value: any) => void
onDismissSuggestion?: (field: string) => void onDismissSuggestion?: (field: string) => void
// Callback when editData is modified (for triggering reactivity in parent)
onDataChange?: () => void
} }
let { let {
@ -29,7 +31,8 @@
suggestions, suggestions,
dismissedSuggestions, dismissedSuggestions,
onAcceptSuggestion, onAcceptSuggestion,
onDismissSuggestion onDismissSuggestion,
onDataChange
}: Props = $props() }: Props = $props()
const uncap = $derived( const uncap = $derived(
@ -60,6 +63,7 @@
editData.ulb = false editData.ulb = false
editData.transcendence = false editData.transcendence = false
} }
onDataChange?.()
} }
function handleUlbChange(checked: boolean) { function handleUlbChange(checked: boolean) {
@ -70,6 +74,7 @@
// Unchecking ULB should also uncheck Transcendence // Unchecking ULB should also uncheck Transcendence
editData.transcendence = false editData.transcendence = false
} }
onDataChange?.()
} }
function handleTranscendenceChange(checked: boolean) { function handleTranscendenceChange(checked: boolean) {
@ -78,6 +83,7 @@
if (!editData.ulb) editData.ulb = true if (!editData.ulb) editData.ulb = true
if (!editData.flb) editData.flb = true if (!editData.flb) editData.flb = true
} }
onDataChange?.()
} }
function handleSpecialChange(checked: boolean) { function handleSpecialChange(checked: boolean) {
@ -87,6 +93,7 @@
editData.ulb = false editData.ulb = false
editData.transcendence = false editData.transcendence = false
} }
onDataChange?.()
} }
</script> </script>

View file

@ -1,12 +1,16 @@
<svelte:options runes={true} /> <svelte:options runes={true} />
<script lang="ts"> <script lang="ts">
import { createQuery } from '@tanstack/svelte-query'
import type { SummonSuggestions } from '$lib/api/adapters/entity.adapter' import type { SummonSuggestions } from '$lib/api/adapters/entity.adapter'
import { entityQueries } from '$lib/api/queries/entity.queries'
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 SuggestionDetailItem from '$lib/components/ui/SuggestionDetailItem.svelte' import SuggestionDetailItem from '$lib/components/ui/SuggestionDetailItem.svelte'
import MultiSelect from '$lib/components/ui/MultiSelect.svelte'
import ElementLabel from '$lib/components/labels/ElementLabel.svelte' import ElementLabel from '$lib/components/labels/ElementLabel.svelte'
import { getElementLabel, getElementOptions } from '$lib/utils/element' import { getElementLabel, getElementOptions } from '$lib/utils/element'
import type { SummonSeriesRef } from '$lib/types/api/summonSeries'
import { PROMOTION_NAMES, getPromotionNames } from '$lib/types/enums' import { PROMOTION_NAMES, getPromotionNames } from '$lib/types/enums'
type ElementName = 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' type ElementName = 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
@ -32,8 +36,23 @@
onDismissSuggestion onDismissSuggestion
}: Props = $props() }: Props = $props()
// Fetch summon series list from API
const summonSeriesQuery = createQuery(() => entityQueries.summonSeriesList())
const elementOptions = getElementOptions() const elementOptions = getElementOptions()
// Build series options from fetched data
const seriesOptions = $derived.by(() => {
const series = summonSeriesQuery.data ?? []
return [
{ value: '', label: 'None' },
...series.map((s) => ({
value: s.id,
label: s.name.en
}))
]
})
// Promotion options for multiselect // Promotion options for multiselect
const promotionOptions = Object.entries(PROMOTION_NAMES).map(([value, label]) => ({ const promotionOptions = Object.entries(PROMOTION_NAMES).map(([value, label]) => ({
value: Number(value), value: Number(value),
@ -47,6 +66,12 @@
return label !== '—' && label !== 'Null' ? (label.toLowerCase() as ElementName) : undefined return label !== '—' && label !== 'Null' ? (label.toLowerCase() as ElementName) : undefined
}) })
// Format series label for display mode
function formatSeriesLabel(series: SummonSeriesRef | null | undefined): string {
if (!series) return '—'
return series.name?.en || '—'
}
// Format promotions for display // Format promotions for display
function formatPromotionsDisplay(promotions: number[]): string { function formatPromotionsDisplay(promotions: number[]): string {
if (!promotions || promotions.length === 0) return '—' if (!promotions || promotions.length === 0) return '—'
@ -71,23 +96,27 @@
label="Series" label="Series"
bind:value={editData.series} bind:value={editData.series}
editable={true} editable={true}
type="text" type="select"
placeholder="Series name" options={seriesOptions}
/>
<DetailItem
label="Promotions"
sublabel="Gacha pools where this summon appears"
bind:value={editData.promotions}
editable={true}
type="multiselect"
options={promotionOptions}
element={elementName}
/> />
<DetailItem label="Promotions" sublabel="Gacha pools where this summon appears" editable={true}>
<MultiSelect
size="medium"
options={promotionOptions}
bind:value={editData.promotions}
placeholder="Select promotions"
contained
/>
</DetailItem>
{:else} {:else}
<DetailItem label="Element"> <DetailItem label="Element">
<ElementLabel element={summon.element} size="medium" /> <ElementLabel element={summon.element} size="medium" />
</DetailItem> </DetailItem>
<DetailItem label="Series" value={summon.series || '—'} /> <DetailItem label="Series" value={formatSeriesLabel(summon.series)} />
<DetailItem label="Promotions" sublabel="Gacha pools where this summon appears" value={formatPromotionsDisplay(summon.promotions)} /> <DetailItem
label="Promotions"
sublabel="Gacha pools where this summon appears"
value={formatPromotionsDisplay(summon.promotions)}
/>
{/if} {/if}
</DetailsContainer> </DetailsContainer>

View file

@ -20,6 +20,8 @@
dismissedSuggestions?: Set<string> dismissedSuggestions?: Set<string>
onAcceptSuggestion?: (field: string, value: any) => void onAcceptSuggestion?: (field: string, value: any) => void
onDismissSuggestion?: (field: string) => void onDismissSuggestion?: (field: string) => void
// Callback when editData is modified (for triggering reactivity in parent)
onDataChange?: () => void
} }
let { let {
@ -29,7 +31,8 @@
suggestions, suggestions,
dismissedSuggestions, dismissedSuggestions,
onAcceptSuggestion, onAcceptSuggestion,
onDismissSuggestion onDismissSuggestion,
onDataChange
}: Props = $props() }: Props = $props()
const uncap = $derived( const uncap = $derived(
@ -58,6 +61,7 @@
editData.ulb = false editData.ulb = false
editData.transcendence = false editData.transcendence = false
} }
onDataChange?.()
} }
function handleUlbChange(checked: boolean) { function handleUlbChange(checked: boolean) {
@ -68,6 +72,7 @@
// Unchecking ULB should also uncheck Transcendence // Unchecking ULB should also uncheck Transcendence
editData.transcendence = false editData.transcendence = false
} }
onDataChange?.()
} }
function handleTranscendenceChange(checked: boolean) { function handleTranscendenceChange(checked: boolean) {
@ -76,6 +81,7 @@
if (!editData.ulb) editData.ulb = true if (!editData.ulb) editData.ulb = true
if (!editData.flb) editData.flb = true if (!editData.flb) editData.flb = true
} }
onDataChange?.()
} }
</script> </script>

View file

@ -7,6 +7,7 @@
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 SuggestionDetailItem from '$lib/components/ui/SuggestionDetailItem.svelte' import SuggestionDetailItem from '$lib/components/ui/SuggestionDetailItem.svelte'
import MultiSelect from '$lib/components/ui/MultiSelect.svelte'
import ElementLabel from '$lib/components/labels/ElementLabel.svelte' import ElementLabel from '$lib/components/labels/ElementLabel.svelte'
import ProficiencyLabel from '$lib/components/labels/ProficiencyLabel.svelte' import ProficiencyLabel from '$lib/components/labels/ProficiencyLabel.svelte'
import { getElementLabel, getElementOptions } from '$lib/utils/element' import { getElementLabel, getElementOptions } from '$lib/utils/element'
@ -138,15 +139,15 @@
type="checkbox" type="checkbox"
element={elementName} element={elementName}
/> />
<DetailItem <DetailItem label="Promotions" sublabel="Gacha pools where this weapon appears" editable={true}>
label="Promotions" <MultiSelect
sublabel="Gacha pools where this weapon appears" size="medium"
bind:value={editData.promotions} options={promotionOptions}
editable={true} bind:value={editData.promotions}
type="multiselect" placeholder="Select promotions"
options={promotionOptions} contained
element={elementName} />
/> </DetailItem>
{:else} {:else}
<DetailItem label="Element"> <DetailItem label="Element">
<ElementLabel element={weapon.element} size="medium" /> <ElementLabel element={weapon.element} size="medium" />
@ -158,9 +159,21 @@
/> />
</DetailItem> </DetailItem>
<DetailItem label="Series" value={formatSeriesLabel(weapon.series)} /> <DetailItem label="Series" value={formatSeriesLabel(weapon.series)} />
<DetailItem label="Extra" sublabel="Can be placed in Additional Weapons" value={weapon.extra ? 'Yes' : 'No'} /> <DetailItem
<DetailItem label="Limit" sublabel="Only one copy can be placed in a team" value={weapon.limit ? 'Yes' : 'No'} /> label="Extra"
sublabel="Can be placed in Additional Weapons"
value={weapon.extra ? 'Yes' : 'No'}
/>
<DetailItem
label="Limit"
sublabel="Only one copy can be placed in a team"
value={weapon.limit ? 'Yes' : 'No'}
/>
<DetailItem label="AX Skills" sublabel="Can have AX Skills" value={weapon.ax ? 'Yes' : 'No'} /> <DetailItem label="AX Skills" sublabel="Can have AX Skills" value={weapon.ax ? 'Yes' : 'No'} />
<DetailItem label="Promotions" sublabel="Gacha pools where this weapon appears" value={formatPromotionsDisplay(weapon.promotions)} /> <DetailItem
label="Promotions"
sublabel="Gacha pools where this weapon appears"
value={formatPromotionsDisplay(weapon.promotions)}
/>
{/if} {/if}
</DetailsContainer> </DetailsContainer>

View file

@ -19,6 +19,8 @@
dismissedSuggestions?: Set<string> dismissedSuggestions?: Set<string>
onAcceptSuggestion?: (field: string, value: any) => void onAcceptSuggestion?: (field: string, value: any) => void
onDismissSuggestion?: (field: string) => void onDismissSuggestion?: (field: string) => void
// Callback when editData is modified (for triggering reactivity in parent)
onDataChange?: () => void
} }
let { let {
@ -28,7 +30,8 @@
suggestions, suggestions,
dismissedSuggestions, dismissedSuggestions,
onAcceptSuggestion, onAcceptSuggestion,
onDismissSuggestion onDismissSuggestion,
onDataChange
}: Props = $props() }: Props = $props()
const uncap = $derived( const uncap = $derived(
@ -57,6 +60,7 @@
editData.ulb = false editData.ulb = false
editData.transcendence = false editData.transcendence = false
} }
onDataChange?.()
} }
function handleUlbChange(checked: boolean) { function handleUlbChange(checked: boolean) {
@ -67,6 +71,7 @@
// Unchecking ULB should also uncheck Transcendence // Unchecking ULB should also uncheck Transcendence
editData.transcendence = false editData.transcendence = false
} }
onDataChange?.()
} }
function handleTranscendenceChange(checked: boolean) { function handleTranscendenceChange(checked: boolean) {
@ -75,6 +80,7 @@
if (!editData.ulb) editData.ulb = true if (!editData.ulb) editData.ulb = true
if (!editData.flb) editData.flb = true if (!editData.flb) editData.flb = true
} }
onDataChange?.()
} }
</script> </script>

View file

@ -389,6 +389,41 @@ export function getArtifactImage(
return `${getBasePath()}/${directory}/${granblueId}.jpg` return `${getBasePath()}/${directory}/${granblueId}.jpg`
} }
// ===== Game CDN Images =====
// For new items not yet in our AWS CDN (used in batch import)
const GAME_CDN_BASE = 'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets'
/**
* Get character image from the game CDN
* Used for batch imports where images aren't yet in AWS
*/
export function getGameCdnCharacterImage(
id: string | number | null | undefined,
pose: string = '01'
): string {
if (!id) return getPlaceholderImage('character', 'square')
return `${GAME_CDN_BASE}/npc/s/${id}_${pose}.jpg`
}
/**
* Get weapon image from the game CDN
* Used for batch imports where images aren't yet in AWS
*/
export function getGameCdnWeaponImage(id: string | number | null | undefined): string {
if (!id) return getPlaceholderImage('weapon', 'square')
return `${GAME_CDN_BASE}/weapon/s/${id}.jpg`
}
/**
* Get summon image from the game CDN
* Used for batch imports where images aren't yet in AWS
*/
export function getGameCdnSummonImage(id: string | number | null | undefined): string {
if (!id) return getPlaceholderImage('summon', 'square')
return `${GAME_CDN_BASE}/summon/s/${id}.jpg`
}
// ===== Other Game Images ===== // ===== Other Game Images =====
/** /**

View file

@ -77,7 +77,8 @@
gamewith: '', gamewith: '',
kamigame: '', kamigame: '',
nicknamesEn: [] as string[], nicknamesEn: [] as string[],
nicknamesJp: [] as string[] nicknamesJp: [] as string[],
promotions: [] as number[]
}) })
// Populate edit data when summon loads // Populate edit data when summon loads
@ -89,7 +90,7 @@
granblueId: summon.granblueId || '', granblueId: summon.granblueId || '',
rarity: summon.rarity || 3, rarity: summon.rarity || 3,
element: summon.element || 0, element: summon.element || 0,
series: summon.series?.toString() ?? '', series: summon.series?.id || '',
minHp: summon.hp?.minHp || 0, minHp: summon.hp?.minHp || 0,
maxHp: summon.hp?.maxHp || 0, maxHp: summon.hp?.maxHp || 0,
maxHpFlb: summon.hp?.maxHpFlb || 0, maxHpFlb: summon.hp?.maxHpFlb || 0,
@ -115,7 +116,8 @@
gamewith: summon.gamewith || '', gamewith: summon.gamewith || '',
kamigame: summon.kamigame || '', kamigame: summon.kamigame || '',
nicknamesEn: summon.nicknames?.en || [], nicknamesEn: summon.nicknames?.en || [],
nicknamesJp: summon.nicknames?.ja || [] nicknamesJp: summon.nicknames?.ja || [],
promotions: summon.promotions || []
} }
} }
}) })
@ -159,7 +161,8 @@
gamewith: editData.gamewith, gamewith: editData.gamewith,
kamigame: editData.kamigame, kamigame: editData.kamigame,
nicknames_en: editData.nicknamesEn, nicknames_en: editData.nicknamesEn,
nicknames_jp: editData.nicknamesJp nicknames_jp: editData.nicknamesJp,
promotions: editData.promotions
} }
await entityAdapter.updateSummon(summon.id, payload) await entityAdapter.updateSummon(summon.id, payload)

View file

@ -157,6 +157,7 @@
// Taxonomy // Taxonomy
element: editData.element, element: editData.element,
series: editData.series || undefined, series: editData.series || undefined,
promotions: editData.promotions,
// Stats - note: transcendence maps to xlb // Stats - note: transcendence maps to xlb
min_hp: editData.minHp, min_hp: editData.minHp,