refactor character section components

- move series from taxonomy to metadata section
- rename uncap label to "Uncap Level"
- show all uncap flags in view mode
This commit is contained in:
Justin Edmund 2025-12-15 12:48:51 -08:00
parent 34c3dd6aa6
commit 32c4880180
3 changed files with 231 additions and 183 deletions

View file

@ -7,9 +7,10 @@
import SuggestionDetailItem from '$lib/components/ui/SuggestionDetailItem.svelte' import SuggestionDetailItem from '$lib/components/ui/SuggestionDetailItem.svelte'
import CopyableText from '$lib/components/ui/CopyableText.svelte' import CopyableText from '$lib/components/ui/CopyableText.svelte'
import Select from '$lib/components/ui/Select.svelte' import Select from '$lib/components/ui/Select.svelte'
import MultiSelect from '$lib/components/ui/MultiSelect.svelte'
import { getRarityLabel, getRarityOptions } from '$lib/utils/rarity' import { getRarityLabel, getRarityOptions } from '$lib/utils/rarity'
import { getWeaponImage } from '$lib/utils/images' import { getWeaponImage } from '$lib/utils/images'
import { CHARACTER_SEASON_NAMES, getSeasonName } from '$lib/types/enums' import { CHARACTER_SEASON_NAMES, CHARACTER_SERIES_NAMES, getSeasonName, getSeriesNames } from '$lib/types/enums'
interface Props { interface Props {
character: any character: any
@ -43,10 +44,36 @@
})) }))
] ]
// Series options for multiselect
const seriesOptions = Object.entries(CHARACTER_SERIES_NAMES).map(([value, label]) => ({
value: Number(value),
label
}))
function formatPromotions(promotionNames: string[] | undefined): string { function formatPromotions(promotionNames: string[] | undefined): string {
if (!promotionNames || promotionNames.length === 0) return '—' if (!promotionNames || promotionNames.length === 0) return '—'
return promotionNames.join(', ') return promotionNames.join(', ')
} }
// Format series for display - use API-provided seriesNames if available
function formatSeriesDisplay(): string {
// Use pre-computed seriesNames from API if available
if (character.seriesNames && character.seriesNames.length > 0) {
return character.seriesNames.join(', ')
}
// Fallback for legacy integer array
if (Array.isArray(character.series) && character.series.length > 0) {
const first = character.series[0]
if (typeof first === 'number') {
return getSeriesNames(character.series as number[]).join(', ')
}
// CharacterSeriesRef[] - extract names
return (character.series as Array<{ name: { en?: string } }>)
.map((s) => s.name?.en || 'Unknown')
.join(', ')
}
return '—'
}
</script> </script>
<DetailsContainer title="Metadata"> <DetailsContainer title="Metadata">
@ -62,6 +89,47 @@
onAcceptSuggestion={() => onAcceptSuggestion?.('rarity', suggestions?.rarity)} onAcceptSuggestion={() => onAcceptSuggestion?.('rarity', suggestions?.rarity)}
onDismissSuggestion={() => onDismissSuggestion?.('rarity')} onDismissSuggestion={() => onDismissSuggestion?.('rarity')}
/> />
<DetailItem
label="Granblue ID"
bind:value={editData.granblueId}
editable={true}
type="text"
placeholder="Granblue ID"
/>
<DetailItem
label="Character ID"
sublabel="Separate multiple IDs with commas"
bind:value={editData.characterId}
editable={true}
type="text"
placeholder="Character IDs"
/>
{#if character.recruitedBy}
<DetailItem label="Recruited By">
<a href="/database/weapons/{character.recruitedBy.granblueId}" class="recruited-by-link">
<img
src={getWeaponImage(character.recruitedBy.granblueId, 'square')}
alt={character.recruitedBy.name.en || 'Recruiting weapon'}
class="recruited-by-image"
/>
<span class="recruited-by-name">{character.recruitedBy.name.en}</span>
</a>
</DetailItem>
<DetailItem
label="Promotions"
sublabel="Gacha pools from recruiting weapon"
value={formatPromotions(character.recruitedBy.promotionNames)}
/>
{/if}
<DetailItem label="Series" editable={true}>
<MultiSelect
size="medium"
options={seriesOptions}
bind:value={editData.series}
placeholder="Select series"
contained
/>
</DetailItem>
<DetailItem <DetailItem
label="Season" label="Season"
sublabel="Used to disambiguate characters with the same name" sublabel="Used to disambiguate characters with the same name"
@ -74,19 +142,8 @@
contained contained
/> />
</DetailItem> </DetailItem>
<DetailItem
label="Character ID"
sublabel="Separate multiple IDs with commas"
bind:value={editData.characterId}
editable={true}
type="text"
placeholder="Character IDs"
/>
{:else} {:else}
<DetailItem label="Rarity" value={getRarityLabel(character.rarity)} /> <DetailItem label="Rarity" value={getRarityLabel(character.rarity)} />
{#if character.season}
<DetailItem label="Season" value={getSeasonName(character.season) || '—'} />
{/if}
<DetailItem label="Granblue ID"> <DetailItem label="Granblue ID">
{#if character.granblueId} {#if character.granblueId}
<CopyableText value={character.granblueId} /> <CopyableText value={character.granblueId} />
@ -116,6 +173,8 @@
value={formatPromotions(character.recruitedBy.promotionNames)} value={formatPromotions(character.recruitedBy.promotionNames)}
/> />
{/if} {/if}
<DetailItem label="Series" value={formatSeriesDisplay()} />
<DetailItem label="Season" value={getSeasonName(character.season) || '—'} />
{/if} {/if}
</DetailsContainer> </DetailsContainer>

View file

@ -5,14 +5,12 @@
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 { getElementOptions } from '$lib/utils/element' import { getElementOptions } from '$lib/utils/element'
import { getRaceLabel, getRaceOptions } from '$lib/utils/race' import { getRaceLabel, getRaceOptions } from '$lib/utils/race'
import { getGenderLabel, getGenderOptions } from '$lib/utils/gender' import { getGenderLabel, getGenderOptions } from '$lib/utils/gender'
import { getProficiencyOptions } from '$lib/utils/proficiency' import { getProficiencyOptions } from '$lib/utils/proficiency'
import { CHARACTER_SERIES_NAMES, getSeriesNames } from '$lib/types/enums'
interface Props { interface Props {
character: any character: any
@ -39,18 +37,6 @@
const raceOptions = getRaceOptions() const raceOptions = getRaceOptions()
const genderOptions = getGenderOptions() const genderOptions = getGenderOptions()
const proficiencyOptions = getProficiencyOptions() const proficiencyOptions = getProficiencyOptions()
// Series options for multiselect
const seriesOptions = Object.entries(CHARACTER_SERIES_NAMES).map(([value, label]) => ({
value: Number(value),
label
}))
// Format series for display
function formatSeriesDisplay(series: number[]): string {
if (!series || series.length === 0) return '—'
return getSeriesNames(series).join(', ')
}
</script> </script>
<DetailsContainer title="Details"> <DetailsContainer title="Details">
@ -121,18 +107,6 @@
onAcceptSuggestion={() => onAcceptSuggestion?.('proficiency2', suggestions?.proficiency2)} onAcceptSuggestion={() => onAcceptSuggestion?.('proficiency2', suggestions?.proficiency2)}
onDismissSuggestion={() => onDismissSuggestion?.('proficiency2')} onDismissSuggestion={() => onDismissSuggestion?.('proficiency2')}
/> />
<DetailItem
label="Series"
editable={true}
>
<MultiSelect
size="medium"
options={seriesOptions}
bind:value={editData.series}
placeholder="Select series"
contained
/>
</DetailItem>
{:else} {:else}
<DetailItem label="Element"> <DetailItem label="Element">
<ElementLabel element={character.element} size="medium" /> <ElementLabel element={character.element} size="medium" />
@ -146,6 +120,5 @@
<DetailItem label="Proficiency 2"> <DetailItem label="Proficiency 2">
<ProficiencyLabel proficiency={character.proficiency?.[1] ?? 0} size="medium" /> <ProficiencyLabel proficiency={character.proficiency?.[1] ?? 0} size="medium" />
</DetailItem> </DetailItem>
<DetailItem label="Series" value={formatSeriesDisplay(character.series)} />
{/if} {/if}
</DetailsContainer> </DetailsContainer>

View file

@ -1,166 +1,182 @@
<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 UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte' import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import { getCharacterMaxUncapLevel } from '$lib/utils/uncap' import { getCharacterMaxUncapLevel } from '$lib/utils/uncap'
import { getElementLabel } from '$lib/utils/element' import { getElementLabel } from '$lib/utils/element'
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
// Callback when editData is modified (for triggering reactivity in parent) // Callback when editData is modified (for triggering reactivity in parent)
onDataChange?: () => void onDataChange?: () => void
} }
let { let {
character, character,
editMode = false, editMode = false,
editData = $bindable(), editData = $bindable(),
suggestions, suggestions,
dismissedSuggestions, dismissedSuggestions,
onAcceptSuggestion, onAcceptSuggestion,
onDismissSuggestion, onDismissSuggestion,
onDataChange onDataChange
}: Props = $props() }: Props = $props()
const uncap = $derived( const uncap = $derived(
editMode editMode
? { flb: editData.flb, ulb: editData.ulb, transcendence: editData.transcendence } ? { flb: editData.flb, ulb: editData.ulb, transcendence: editData.transcendence }
: (character?.uncap ?? {}) : (character?.uncap ?? {})
) )
const flb = $derived(uncap.flb ?? false) const flb = $derived(uncap.flb ?? false)
const ulb = $derived(uncap.ulb ?? false) const ulb = $derived(uncap.ulb ?? false)
const transcendence = $derived(uncap.transcendence ?? false) const transcendence = $derived(uncap.transcendence ?? false)
const special = $derived(editMode ? editData.special : (character?.special ?? false)) const special = $derived(editMode ? editData.special : (character?.special ?? false))
const uncapLevel = $derived(getCharacterMaxUncapLevel({ special, uncap })) const uncapLevel = $derived(getCharacterMaxUncapLevel({ special, uncap }))
const transcendenceStage = $derived(transcendence ? 5 : 0) const transcendenceStage = $derived(transcendence ? 5 : 0)
// Get element name for checkbox theming // Get element name for checkbox theming
const elementName = $derived.by((): ElementName | undefined => { const elementName = $derived.by((): ElementName | undefined => {
const el = editMode ? editData.element : character?.element const el = editMode ? editData.element : character?.element
const label = getElementLabel(el) const label = getElementLabel(el)
return label !== '—' && label !== 'Null' return label !== '—' && label !== 'Null' ? (label.toLowerCase() as ElementName) : undefined
? (label.toLowerCase() as ElementName) })
: undefined
})
// Auto-check/uncheck uncap levels in hierarchy: Transcendence > ULB > FLB // Auto-check/uncheck uncap levels in hierarchy: Transcendence > ULB > FLB
function handleFlbChange(checked: boolean) { function handleFlbChange(checked: boolean) {
if (!checked) { if (!checked) {
// Unchecking FLB should also uncheck ULB and Transcendence // Unchecking FLB should also uncheck ULB and Transcendence
editData.ulb = false editData.ulb = false
editData.transcendence = false editData.transcendence = false
} }
onDataChange?.() onDataChange?.()
} }
function handleUlbChange(checked: boolean) { function handleUlbChange(checked: boolean) {
if (checked && !editData.flb) { if (checked && !editData.flb) {
// Checking ULB should also check FLB // Checking ULB should also check FLB
editData.flb = true editData.flb = true
} else if (!checked) { } else if (!checked) {
// Unchecking ULB should also uncheck Transcendence // Unchecking ULB should also uncheck Transcendence
editData.transcendence = false editData.transcendence = false
} }
onDataChange?.() onDataChange?.()
} }
function handleTranscendenceChange(checked: boolean) { function handleTranscendenceChange(checked: boolean) {
if (checked) { if (checked) {
// Checking Transcendence should also check ULB and FLB // Checking Transcendence should also check ULB and FLB
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?.() onDataChange?.()
} }
function handleSpecialChange(checked: boolean) { function handleSpecialChange(checked: boolean) {
if (checked) { if (checked) {
// Special characters (Story SRs) don't have standard uncap levels // Special characters (Story SRs) don't have standard uncap levels
editData.flb = false editData.flb = false
editData.ulb = false editData.ulb = false
editData.transcendence = false editData.transcendence = false
} }
onDataChange?.() onDataChange?.()
} }
</script> </script>
<DetailsContainer title="Uncap"> <DetailsContainer title="Uncap">
{#if character?.uncap || editMode} <DetailItem label="Uncap Level">
<DetailItem label="Uncap"> <UncapIndicator
<UncapIndicator type="character"
type="character" {uncapLevel}
{uncapLevel} {transcendenceStage}
{transcendenceStage} {flb}
{flb} {ulb}
{ulb} {transcendence}
{transcendence} {special}
{special} editable={false}
editable={false} />
/> </DetailItem>
</DetailItem>
{/if}
{#if editMode} {#if !editMode}
<SuggestionDetailItem <DetailItem label="FLB" value={flb ? 'Yes' : 'No'} />
label="FLB" <DetailItem label="ULB" value={ulb ? 'Yes' : 'No'} />
bind:value={editData.flb} <DetailItem label="Transcendence" value={transcendence ? 'Yes' : 'No'} />
editable={true} <DetailItem label="Special" value={special ? 'Yes' : 'No'} />
type="checkbox" {:else}
element={elementName} <SuggestionDetailItem
onchange={handleFlbChange} label="FLB"
suggestion={suggestions?.flb} bind:value={editData.flb}
dismissedSuggestion={dismissedSuggestions?.has('flb')} editable={true}
onAcceptSuggestion={() => onAcceptSuggestion?.('flb', suggestions?.flb)} type="checkbox"
onDismissSuggestion={() => onDismissSuggestion?.('flb')} element={elementName}
/> onchange={handleFlbChange}
<SuggestionDetailItem suggestion={suggestions?.flb}
label="ULB" dismissedSuggestion={dismissedSuggestions?.has('flb')}
bind:value={editData.ulb} onAcceptSuggestion={() => onAcceptSuggestion?.('flb', suggestions?.flb)}
editable={true} onDismissSuggestion={() => onDismissSuggestion?.('flb')}
type="checkbox" />
element={elementName} <SuggestionDetailItem
onchange={handleUlbChange} label="ULB"
suggestion={suggestions?.ulb} bind:value={editData.ulb}
dismissedSuggestion={dismissedSuggestions?.has('ulb')} editable={true}
onAcceptSuggestion={() => onAcceptSuggestion?.('ulb', suggestions?.ulb)} type="checkbox"
onDismissSuggestion={() => onDismissSuggestion?.('ulb')} element={elementName}
/> onchange={handleUlbChange}
<DetailItem label="Transcendence" bind:value={editData.transcendence} editable={true} type="checkbox" element={elementName} onchange={handleTranscendenceChange} /> suggestion={suggestions?.ulb}
<div class="special-field"> dismissedSuggestion={dismissedSuggestions?.has('ulb')}
<DetailItem label="Special" bind:value={editData.special} editable={true} type="checkbox" element={elementName} onchange={handleSpecialChange} /> onAcceptSuggestion={() => onAcceptSuggestion?.('ulb', suggestions?.ulb)}
<p class="special-note">This is for Story SRs. Don't check this unless something really weird happens.</p> onDismissSuggestion={() => onDismissSuggestion?.('ulb')}
</div> />
{/if} <DetailItem
label="Transcendence"
bind:value={editData.transcendence}
editable={true}
type="checkbox"
element={elementName}
onchange={handleTranscendenceChange}
/>
<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}
</DetailsContainer> </DetailsContainer>
<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;
.special-field { .special-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.special-note { .special-note {
font-size: typography.$font-small; font-size: typography.$font-small;
color: colors.$grey-50; color: colors.$grey-50;
margin: 0; margin: 0;
padding: 0 spacing.$unit spacing.$unit; padding-bottom: spacing.$unit;
} }
</style> </style>