add extra_prerequisite and forge chain to weapon database pages

This commit is contained in:
Justin Edmund 2025-12-21 22:22:57 -08:00
parent 33b578ec21
commit a01c6e8d31
8 changed files with 604 additions and 7 deletions

View file

@ -0,0 +1,364 @@
<!-- WeaponTypeahead Component - Async weapon search with Svelecte -->
<svelte:options runes={true} />
<script lang="ts">
import Svelecte from 'svelecte'
import Icon from '../Icon.svelte'
import { searchAdapter, type SearchResult } from '$lib/api/adapters/search.adapter'
import { getWeaponGridImage } from '$lib/utils/images'
interface WeaponOption {
id: string
label: string
granblueId: string
element?: number
}
interface Props {
/** Selected weapon granblue ID (e.g. "1040001000") */
value?: string | null
/** Initial weapon data for display (when loading existing value) */
initialWeapon?: { id: string; name: string; granblueId: string } | null
/** Callback when value changes */
onValueChange?: (granblueId: string | null) => void
/** Placeholder text */
placeholder?: string
/** Disabled state */
disabled?: boolean
/** Component size */
size?: 'small' | 'medium' | 'large'
/** Clear button visible */
clearable?: boolean
/** Minimum characters before search */
minQuery?: number
/** Use contained styling (for use inside containers) */
contained?: boolean
}
let {
value = $bindable(null),
initialWeapon = null,
onValueChange,
placeholder = 'Search weapons...',
disabled = false,
size = 'medium',
clearable = true,
minQuery = 2,
contained = false
}: Props = $props()
let searchResults = $state<WeaponOption[]>([])
// Only used when user selects something NEW (different from initialWeapon)
let userSelectedOption = $state<WeaponOption | null>(null)
let isLoading = $state(false)
let searchTimeout: ReturnType<typeof setTimeout> | null = null
// Clear userSelectedOption when value is cleared
$effect(() => {
if (!value) {
userSelectedOption = null
}
})
// Derive options: include initialWeapon or userSelectedOption so Svelecte can find the value
const options = $derived.by(() => {
const results = [...searchResults]
// If user selected something new, prioritize that
const userSelected = userSelectedOption
if (userSelected && !results.find((o) => o.granblueId === userSelected.granblueId)) {
return [userSelected, ...results]
}
// Otherwise, include initialWeapon if we have a value matching it
if (value && initialWeapon && initialWeapon.granblueId === value) {
const initOption: WeaponOption = {
id: initialWeapon.id,
label: initialWeapon.name,
granblueId: initialWeapon.granblueId
}
if (!results.find((o) => o.granblueId === initOption.granblueId)) {
return [initOption, ...results]
}
}
return results
})
const typeaheadClasses = $derived(
['weapon-typeahead', size, contained && 'contained', disabled && 'disabled']
.filter(Boolean)
.join(' ')
)
async function searchWeapons(query: string) {
if (query.length < minQuery) {
searchResults = []
return
}
isLoading = true
try {
const response = await searchAdapter.searchWeapons({
query,
per: 20,
locale: 'en'
})
searchResults = response.results.map((result: SearchResult) => ({
id: result.id,
label: result.name?.en || result.name?.ja || result.granblueId,
granblueId: result.granblueId,
element: result.element
}))
} catch (error) {
console.error('Weapon search error:', error)
searchResults = []
} finally {
isLoading = false
}
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement | null
const query = target?.value ?? ''
// Debounce the search
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
searchWeapons(query)
}, 300)
}
function handleChange(selected: WeaponOption | null) {
const newValue = selected?.granblueId || null
value = newValue
// Only track as userSelectedOption if it's different from initialWeapon
if (selected && initialWeapon && selected.granblueId === initialWeapon.granblueId) {
userSelectedOption = null // Use initialWeapon instead
} else {
userSelectedOption = selected
}
onValueChange?.(newValue)
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class={typeaheadClasses} oninput={handleInput}>
<Svelecte
{options}
value={value}
labelField="label"
valueField="granblueId"
searchable={true}
{placeholder}
{disabled}
{clearable}
onChange={handleChange}
>
{#snippet toggleIcon(dropdownShow)}
<Icon name="chevron-down-small" size={14} class="chevron" />
{/snippet}
{#snippet option(opt)}
{@const weapon = opt as WeaponOption}
<div class="option-item">
<img
src={getWeaponGridImage(weapon.granblueId, weapon.element)}
alt=""
class="option-image"
/>
<span class="option-label">{weapon.label}</span>
</div>
{/snippet}
{#snippet selection(sel)}
{@const weapon = (sel as WeaponOption[])[0]}
{#if weapon}
<div class="selection-item">
<img
src={getWeaponGridImage(weapon.granblueId, weapon.element)}
alt=""
class="selection-image"
/>
<span class="selection-label">{weapon.label}</span>
</div>
{/if}
{/snippet}
</Svelecte>
{#if isLoading}
<span class="loading-indicator">...</span>
{/if}
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/mixins' as *;
@use '$src/themes/effects' as *;
.weapon-typeahead {
position: relative;
width: 100%;
// Svelecte CSS variable overrides
--sv-bg: var(--input-bg);
--sv-border-color: transparent;
--sv-border: 1px solid var(--sv-border-color);
--sv-active-border: 1px solid #{$blue};
--sv-active-outline: none;
--sv-border-radius: #{$input-corner};
--sv-min-height: #{$unit-4x};
--sv-placeholder-color: var(--text-tertiary);
--sv-color: var(--text-primary);
--sv-dropdown-bg: var(--dialog-bg);
--sv-dropdown-border-radius: #{$card-corner};
--sv-dropdown-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
--sv-dropdown-offset: #{$unit-half};
--sv-item-color: var(--text-primary);
--sv-item-active-bg: var(--option-bg-hover);
--sv-item-selected-bg: var(--option-bg-hover);
--sv-icon-color: var(--text-tertiary);
--sv-icon-hover-color: var(--text-primary);
&.disabled {
opacity: 0.5;
pointer-events: none;
}
// Target Svelecte control for hover states
:global(.sv-control) {
padding: calc($unit-half + 1px) $unit calc($unit-half + 1px) $unit-half;
@include smooth-transition($duration-quick, background-color, border-color);
}
&:hover:not(.disabled) :global(.sv-control) {
background-color: var(--input-bg-hover);
}
// Contained variant
&.contained {
--sv-bg: var(--select-contained-bg);
&:hover:not(.disabled) :global(.sv-control) {
background-color: var(--select-contained-bg-hover);
}
}
// Style the dropdown
:global(.sv_dropdown) {
border: 1px solid rgba(0, 0, 0, 0.1);
max-height: 40vh;
z-index: 102;
}
// Style dropdown items
:global(.sv-item) {
border-radius: $item-corner-small;
padding: $unit $unit-2x;
gap: $unit;
@include smooth-transition($duration-quick, background-color);
}
// Style the input text
:global(.sv-input--text) {
font-family: var(--font-family);
}
// Style the indicator buttons
:global(.sv-btn-indicator) {
color: var(--text-tertiary);
@include smooth-transition($duration-quick, color);
&:hover {
color: var(--text-primary);
}
}
// Style our custom chevron icon
:global(.chevron) {
flex-shrink: 0;
color: var(--text-tertiary);
}
// Hide the separator bar between buttons
:global(.sv-btn-separator) {
display: none;
}
// Custom option item styling
.option-item {
display: flex;
align-items: center;
gap: $unit;
}
.option-image {
width: 32px;
height: 32px;
border-radius: $item-corner-small;
flex-shrink: 0;
object-fit: contain;
}
.option-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Custom selection item styling (shown in input when value selected)
.selection-item {
display: flex;
align-items: center;
gap: $unit-half;
}
.selection-image {
width: 24px;
height: 24px;
border-radius: $item-corner-small;
flex-shrink: 0;
object-fit: contain;
}
.selection-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// Size variants
.weapon-typeahead.small {
--sv-min-height: #{$unit-3x};
--sv-font-size: #{$font-small};
}
.weapon-typeahead.medium {
--sv-min-height: #{$unit-4x};
--sv-font-size: #{$font-regular};
}
.weapon-typeahead.large {
--sv-min-height: calc(#{$unit} * 6);
--sv-font-size: #{$font-large};
}
.loading-indicator {
position: absolute;
right: $unit-3x;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
font-size: $font-small;
pointer-events: none;
}
</style>

View file

@ -0,0 +1,159 @@
<svelte:options runes={true} />
<script lang="ts">
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import WeaponTypeahead from '$lib/components/ui/WeaponTypeahead.svelte'
import { getWeaponGridImage } from '$lib/utils/images'
interface Props {
weapon: any
editMode?: boolean
editData?: any
}
let { weapon, editMode = false, editData = $bindable() }: Props = $props()
// Get forge chain for display (only in view mode)
const forgeChain = $derived(weapon?.forgeChain ?? [])
const forgedFrom = $derived(weapon?.forgedFrom ?? null)
const forgeOrder = $derived(editMode ? editData?.forgeOrder : weapon?.forgeOrder)
// Check if weapon has any forge data
const hasForgeData = $derived(
forgeChain.length > 0 || forgedFrom != null || forgeOrder != null || editMode
)
// Get initial weapon data for typeahead
const initialForgedFrom = $derived.by(() => {
if (!forgedFrom) return null
return {
id: forgedFrom.id,
name: forgedFrom.name?.en || forgedFrom.name?.ja || forgedFrom.granblueId,
granblueId: forgedFrom.granblueId
}
})
</script>
{#if hasForgeData}
<DetailsContainer title="Forge Chain">
{#if editMode}
<DetailItem label="Forged From" sublabel="The weapon this was forged from">
<WeaponTypeahead
bind:value={editData.forgedFrom}
initialWeapon={initialForgedFrom}
placeholder="Search for base weapon..."
contained
/>
</DetailItem>
<DetailItem
label="Forge Order"
sublabel="Position in chain (0=base, 1=first forge, etc.)"
bind:value={editData.forgeOrder}
editable={true}
type="number"
/>
{:else}
{#if forgeChain.length > 0}
<DetailItem label="Forge Chain">
<div class="forge-chain">
{#each forgeChain as chainWeapon, index}
<a
href="/database/weapons/{chainWeapon.granblueId}"
class="chain-item"
class:current={chainWeapon.granblueId === weapon.granblueId}
>
<img
src={getWeaponGridImage(chainWeapon.granblueId, weapon.element)}
alt=""
class="chain-image"
/>
<span class="chain-name">{chainWeapon.name?.en || chainWeapon.name?.ja}</span>
<span class="chain-order">({chainWeapon.forgeOrder})</span>
</a>
{#if index < forgeChain.length - 1}
<span class="chain-arrow"></span>
{/if}
{/each}
</div>
</DetailItem>
{/if}
{#if forgedFrom && forgeChain.length === 0}
<DetailItem label="Forged From">
<a href="/database/weapons/{forgedFrom.granblueId}" class="forged-from-link">
{forgedFrom.name?.en || forgedFrom.name?.ja}
</a>
</DetailItem>
{/if}
{#if forgeOrder != null}
<DetailItem label="Forge Order" value={forgeOrder.toString()} />
{/if}
{/if}
</DetailsContainer>
{/if}
<style lang="scss">
@use '$src/themes/spacing' as spacing;
@use '$src/themes/colors' as colors;
@use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
.forge-chain {
display: flex;
align-items: center;
gap: spacing.$unit;
flex-wrap: wrap;
}
.chain-item {
display: flex;
align-items: center;
gap: spacing.$unit-half;
padding: spacing.$unit-half spacing.$unit;
background: var(--card-bg);
border-radius: layout.$item-corner-small;
text-decoration: none;
color: var(--text-primary);
transition: background-color 0.15s ease;
&:hover {
background: var(--card-bg-hover);
}
&.current {
background: colors.$blue--bg--light;
outline: 1px solid colors.$blue;
}
}
.chain-image {
width: 24px;
height: 24px;
object-fit: contain;
}
.chain-name {
font-size: typography.$font-small;
}
.chain-order {
font-size: typography.$font-xs;
color: var(--text-tertiary);
}
.chain-arrow {
color: var(--text-tertiary);
font-size: typography.$font-small;
}
.forged-from-link {
color: colors.$blue;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
</style>

View file

@ -46,6 +46,22 @@
const uncapLevel = $derived(transcendence ? 6 : ulb ? 5 : flb ? 4 : 3)
const transcendenceStage = $derived(transcendence ? 5 : 0)
// Extra prerequisite options for dropdown
const extraPrerequisiteOptions = [
{ value: '', label: 'None' },
{ value: 3, label: 'MLB' },
{ value: 4, label: 'FLB' },
{ value: 5, label: 'ULB' },
{ value: 6, label: 'Transcendence' }
]
// Get label for extra prerequisite value
function getExtraPrerequisiteLabel(value: number | null | undefined): string {
if (value == null) return '—'
const option = extraPrerequisiteOptions.find((o) => o.value === value)
return option?.label ?? '—'
}
// Get element name for checkbox theming
const elementName = $derived.by((): ElementName | undefined => {
const el = editMode ? editData.element : weapon?.element
@ -132,5 +148,15 @@
element={elementName}
onchange={handleTranscendenceChange}
/>
<DetailItem
label="Extra Prerequisite"
sublabel="Min uncap for Additional Weapons"
bind:value={editData.extraPrerequisite}
editable={true}
type="select"
options={extraPrerequisiteOptions}
/>
{:else if weapon?.uncap?.extraPrerequisite != null}
<DetailItem label="Extra Prerequisite" value={getExtraPrerequisiteLabel(weapon.uncap.extraPrerequisite)} />
{/if}
</DetailsContainer>

View file

@ -42,6 +42,7 @@ export interface Weapon {
flb: boolean
ulb: boolean
transcendence: boolean
extraPrerequisite?: number | null
}
transcendenceHp?: number
transcendenceAtk?: number
@ -57,6 +58,10 @@ export interface Weapon {
kamigame?: string
nicknames?: { en?: string[]; ja?: string[] }
recruits?: string | { id: string; granblueId: string; name: LocalizedName }
// Forge chain fields
forgeOrder?: number | null
forgedFrom?: { id: string; granblueId: string; name: LocalizedName } | null
forgeChain?: Array<{ id: string; granblueId: string; name: LocalizedName; forgeOrder: number }> | null
}
// Character entity from CharacterBlueprint

View file

@ -25,6 +25,7 @@
import WeaponTaxonomySection from '$lib/features/database/weapons/sections/WeaponTaxonomySection.svelte'
import WeaponStatsSection from '$lib/features/database/weapons/sections/WeaponStatsSection.svelte'
import WeaponGachaSection from '$lib/features/database/weapons/sections/WeaponGachaSection.svelte'
import WeaponForgeSection from '$lib/features/database/weapons/sections/WeaponForgeSection.svelte'
import EntityImagesTab from '$lib/features/database/detail/tabs/EntityImagesTab.svelte'
import EntityRawDataTab from '$lib/features/database/detail/tabs/EntityRawDataTab.svelte'
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
@ -205,6 +206,7 @@
<WeaponUncapSection {weapon} />
<WeaponTaxonomySection {weapon} />
<WeaponStatsSection {weapon} />
<WeaponForgeSection {weapon} />
<DetailsContainer title="Nicknames">
<DetailItem label="English">

View file

@ -17,6 +17,7 @@
import WeaponTaxonomySection from '$lib/features/database/weapons/sections/WeaponTaxonomySection.svelte'
import WeaponStatsSection from '$lib/features/database/weapons/sections/WeaponStatsSection.svelte'
import WeaponGachaSection from '$lib/features/database/weapons/sections/WeaponGachaSection.svelte'
import WeaponForgeSection from '$lib/features/database/weapons/sections/WeaponForgeSection.svelte'
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
import DetailItem from '$lib/components/ui/DetailItem.svelte'
import TagInput from '$lib/components/ui/TagInput.svelte'
@ -74,6 +75,7 @@
flb: false,
ulb: false,
transcendence: false,
extraPrerequisite: '' as number | '',
extra: false,
limit: false,
ax: false,
@ -88,7 +90,10 @@
kamigame: '',
nicknamesEn: [] as string[],
nicknamesJp: [] as string[],
recruits: ''
recruits: '',
// Forge chain fields
forgedFrom: '' as string | null,
forgeOrder: null as number | null
})
// Populate edit data when weapon loads
@ -117,6 +122,7 @@
flb: weapon.uncap?.flb || false,
ulb: weapon.uncap?.ulb || false,
transcendence: weapon.uncap?.transcendence || false,
extraPrerequisite: weapon.uncap?.extraPrerequisite ?? '',
extra: weapon.extra || false,
limit: Boolean(weapon.limit),
ax: weapon.ax || false,
@ -131,7 +137,10 @@
kamigame: weapon.kamigame || '',
nicknamesEn: weapon.nicknames?.en || [],
nicknamesJp: weapon.nicknames?.ja || [],
recruits: typeof weapon.recruits === 'string' ? weapon.recruits : (weapon.recruits?.granblueId ?? '')
recruits: typeof weapon.recruits === 'string' ? weapon.recruits : (weapon.recruits?.granblueId ?? ''),
// Forge chain fields
forgedFrom: weapon.forgedFrom?.granblueId || null,
forgeOrder: weapon.forgeOrder ?? null
}
}
})
@ -165,6 +174,7 @@
flb: editData.flb,
ulb: editData.ulb,
transcendence: editData.transcendence,
extra_prerequisite: editData.extraPrerequisite === '' ? null : editData.extraPrerequisite,
extra: editData.extra,
limit: editData.limit,
ax: editData.ax,
@ -179,7 +189,10 @@
kamigame: editData.kamigame,
nicknames_en: editData.nicknamesEn,
nicknames_jp: editData.nicknamesJp,
recruits: editData.recruits || undefined
recruits: editData.recruits || undefined,
// Forge chain fields
forged_from: editData.forgedFrom || null,
forge_order: editData.forgeOrder
}
await entityAdapter.updateWeapon(weapon.id, payload)
@ -226,6 +239,7 @@
<WeaponUncapSection {weapon} {editMode} bind:editData />
<WeaponTaxonomySection {weapon} {editMode} bind:editData />
<WeaponStatsSection {weapon} {editMode} bind:editData />
<WeaponForgeSection {weapon} {editMode} bind:editData />
<DetailsContainer title="Nicknames">
<DetailItem label="Nicknames (EN)">

View file

@ -20,6 +20,7 @@
import WeaponStatsSection from '$lib/features/database/weapons/sections/WeaponStatsSection.svelte'
import WeaponMetadataSection from '$lib/features/database/weapons/sections/WeaponMetadataSection.svelte'
import WeaponGachaSection from '$lib/features/database/weapons/sections/WeaponGachaSection.svelte'
import WeaponForgeSection from '$lib/features/database/weapons/sections/WeaponForgeSection.svelte'
import TabbedEntitySelector from '$lib/features/database/import/TabbedEntitySelector.svelte'
import type { EntityTab } from '$lib/features/database/import/TabbedEntitySelector.svelte'
import DetailsContainer from '$lib/components/ui/DetailsContainer.svelte'
@ -123,6 +124,7 @@
flb: suggestions?.flb ?? false,
ulb: suggestions?.ulb ?? false,
transcendence: suggestions?.transcendence ?? false,
extraPrerequisite: '' as number | '',
extra: false,
limit: false,
ax: false,
@ -136,7 +138,10 @@
kamigame: suggestions?.kamigame ?? '',
nicknamesEn: [] as string[],
nicknamesJp: [] as string[],
recruits: suggestions?.recruits ?? null
recruits: suggestions?.recruits ?? null,
// Forge chain
forgedFrom: null as string | null,
forgeOrder: null as number | null
}
}
@ -279,6 +284,7 @@
flb: formData.flb,
ulb: formData.ulb,
transcendence: formData.transcendence,
extra_prerequisite: formData.extraPrerequisite === '' ? null : formData.extraPrerequisite,
extra: formData.extra,
limit: formData.limit,
ax: formData.ax,
@ -293,7 +299,10 @@
nicknames_en: formData.nicknamesEn,
nicknames_jp: formData.nicknamesJp,
recruits: formData.recruits,
wiki_raw: wikiRawByPage[selectedWikiPage] || undefined
wiki_raw: wikiRawByPage[selectedWikiPage] || undefined,
// Forge chain
forged_from: formData.forgedFrom || null,
forge_order: formData.forgeOrder
}
const newWeapon = await entityAdapter.createWeapon(payload)
@ -510,6 +519,12 @@
onDismissSuggestion={handleDismissSuggestion}
/>
<WeaponForgeSection
weapon={emptyWeapon}
editMode={true}
bind:editData={formDataByPage[selectedWikiPage]}
/>
<DetailsContainer title="Nicknames">
<DetailItem label="Nicknames (EN)">
<TagInput bind:value={formDataByPage[selectedWikiPage].nicknamesEn} placeholder="Add nickname..." contained />

View file

@ -12,6 +12,7 @@
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 WeaponForgeSection from '$lib/features/database/weapons/sections/WeaponForgeSection.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'
@ -79,6 +80,7 @@
flb: false,
ulb: false,
transcendence: false,
extraPrerequisite: '' as number | '',
extra: false,
limit: false,
ax: false,
@ -100,7 +102,11 @@
nicknamesJp: [] as string[],
// Recruits (Character ID)
recruits: null as string | null
recruits: null as string | null,
// Forge chain
forgedFrom: null as string | null,
forgeOrder: null as number | null
})
const rarityOptions = getRarityOptions()
@ -183,6 +189,7 @@
flb: editData.flb,
ulb: editData.ulb,
transcendence: editData.transcendence,
extra_prerequisite: editData.extraPrerequisite === '' ? null : editData.extraPrerequisite,
extra: editData.extra,
limit: editData.limit,
ax: editData.ax,
@ -204,7 +211,11 @@
nicknames_jp: editData.nicknamesJp,
// Recruits
recruits: editData.recruits
recruits: editData.recruits,
// Forge chain
forged_from: editData.forgedFrom || null,
forge_order: editData.forgeOrder
}
const newWeapon = await entityAdapter.createWeapon(payload)
@ -285,6 +296,7 @@
<WeaponUncapSection weapon={emptyWeapon} {editMode} bind:editData />
<WeaponTaxonomySection weapon={emptyWeapon} {editMode} bind:editData />
<WeaponStatsSection weapon={emptyWeapon} {editMode} bind:editData />
<WeaponForgeSection weapon={emptyWeapon} {editMode} bind:editData />
<DetailsContainer title="Nicknames">
<DetailItem label="Nicknames (EN)">