add quantity counter and selectable components for weapons/summons

- QuantityCounter: +/- buttons for multi-copy selection
- SelectableWeaponCard/Row: weapon selection with quantity
- SelectableSummonCard/Row: summon selection with quantity
- AddToCollectionModal: support entityType prop, Map for quantities
- CollectionFilters: entityType-aware filter visibility
This commit is contained in:
Justin Edmund 2025-12-03 07:19:04 -08:00
parent 13a3905776
commit 957dd16e5e
11 changed files with 1454 additions and 95 deletions

View file

@ -159,12 +159,15 @@ export const collectionQueries = {
/**
* Get IDs of characters already in a user's collection
* Used to filter out owned characters in the add modal
*
* @param userId - The user whose collection to fetch
* @param enabled - Whether the query is enabled (default: true)
*/
collectedCharacterIds: (userId: string) =>
collectedCharacterIds: (userId: string, enabled: boolean = true) =>
queryOptions({
queryKey: ['collection', 'characters', 'ids', userId] as const,
queryFn: () => collectionAdapter.getCollectedCharacterIds(userId),
enabled: !!userId,
enabled: !!userId && enabled,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30 // 30 minutes
}),

View file

@ -6,7 +6,11 @@
type SearchFilters,
type SearchPageResult
} from '$lib/api/queries/search.queries'
import { useAddCharactersToCollection } from '$lib/api/mutations/collection.mutations'
import {
useAddCharactersToCollection,
useAddWeaponsToCollection,
useAddSummonsToCollection
} from '$lib/api/mutations/collection.mutations'
import Dialog from '$lib/components/ui/Dialog.svelte'
import Button from '$lib/components/ui/Button.svelte'
import Icon from '$lib/components/Icon.svelte'
@ -14,17 +18,25 @@
type CollectionFilterState
} from './CollectionFilters.svelte'
import SelectableCharacterCard from './SelectableCharacterCard.svelte'
import SelectableCharacterRow from './SelectableCharacterRow.svelte'
import SelectableWeaponCard from './SelectableWeaponCard.svelte'
import SelectableWeaponRow from './SelectableWeaponRow.svelte'
import SelectableSummonCard from './SelectableSummonCard.svelte'
import SelectableSummonRow from './SelectableSummonRow.svelte'
import { IsInViewport } from 'runed'
import { viewMode, type ViewMode } from '$lib/stores/viewMode.svelte'
type SearchResultItem = SearchPageResult['results'][number]
type EntityType = 'character' | 'weapon' | 'summon'
interface Props {
userId: string
entityType?: EntityType
open?: boolean
onOpenChange?: (open: boolean) => void
}
let { userId, open = $bindable(false), onOpenChange }: Props = $props()
let { userId, entityType = 'character', open = $bindable(false), onOpenChange }: Props = $props()
// Search state
let searchQuery = $state('')
@ -38,15 +50,25 @@
let proficiencyFilters = $state<number[]>([])
let genderFilters = $state<number[]>([])
// Selection state
// Selection state - characters use Set<string>, weapons/summons use Map<string, number> for quantities
let selectedIds = $state<Set<string>>(new Set())
let selectedQuantities = $state<Map<string, number>>(new Map())
let showOnlySelected = $state(false)
// Refs
let sentinelEl = $state<HTMLElement>()
// Get IDs of characters already in collection
const collectedIdsQuery = createQuery(() => collectionQueries.collectedCharacterIds(userId))
// Entity type display names
const entityNames: Record<EntityType, { singular: string; plural: string }> = {
character: { singular: 'character', plural: 'characters' },
weapon: { singular: 'weapon', plural: 'weapons' },
summon: { singular: 'summon', plural: 'summons' }
}
// Get IDs of characters already in collection (only used for characters)
const collectedIdsQuery = createQuery(() =>
collectionQueries.collectedCharacterIds(userId, entityType === 'character')
)
// Build filters for search (using SearchFilters type from search.queries)
const searchFilters = $derived<SearchFilters>({
@ -54,24 +76,32 @@
rarity: rarityFilters.length > 0 ? rarityFilters : undefined,
season: seasonFilters.length > 0 ? seasonFilters : undefined,
characterSeries: seriesFilters.length > 0 ? seriesFilters : undefined,
// Note: Race and gender filters would need API support
proficiency: proficiencyFilters.length > 0 ? proficiencyFilters : undefined
})
// Search query with infinite scroll using the factory pattern
// No debouncing - TanStack Query's staleTime handles caching
// Search query with infinite scroll - dynamic based on entity type
const searchResults = createInfiniteQuery(() => {
// Capture current reactive values synchronously for dependency tracking
const query = searchQuery
const filters = searchFilters
const excludeIds = collectedIdsQuery.data ?? []
const isEnabled = open && !collectedIdsQuery.isLoading
return searchQueries.characters(query, filters, 'en', excludeIds, isEnabled)
if (entityType === 'character') {
const excludeIds = collectedIdsQuery.data ?? []
const isEnabled = open && !collectedIdsQuery.isLoading
return searchQueries.characters(query, filters, 'en', excludeIds, isEnabled)
} else if (entityType === 'weapon') {
return {
...searchQueries.weapons(query, filters, 'en'),
enabled: open
}
} else {
return {
...searchQueries.summons(query, filters, 'en'),
enabled: open
}
}
})
// Flatten results and deduplicate by ID
// (API may return duplicates across pages)
const allResults = $derived.by(() => {
const pages = searchResults.data?.pages ?? []
const seen = new Set<string>()
@ -90,14 +120,29 @@
})
// Filter to show only selected if enabled
const displayedResults = $derived(
showOnlySelected
? allResults.filter((r) => selectedIds.has(r.id))
: allResults
)
const displayedResults = $derived.by(() => {
if (!showOnlySelected) return allResults
// Add mutation
const addMutation = useAddCharactersToCollection()
if (entityType === 'character') {
return allResults.filter((r) => selectedIds.has(r.id))
} else {
return allResults.filter((r) => (selectedQuantities.get(r.id) ?? 0) > 0)
}
})
// Add mutations
const addCharacterMutation = useAddCharactersToCollection()
const addWeaponMutation = useAddWeaponsToCollection()
const addSummonMutation = useAddSummonsToCollection()
// Current mutation based on entity type
const currentMutation = $derived(
entityType === 'character'
? addCharacterMutation
: entityType === 'weapon'
? addWeaponMutation
: addSummonMutation
)
// Infinite scroll
const inViewport = new IsInViewport(() => sentinelEl, {
@ -116,23 +161,29 @@
}
})
// Reset state when modal closes
// Reset state when modal closes or entity type changes
$effect(() => {
if (!open) {
selectedIds = new Set()
showOnlySelected = false
searchQuery = ''
elementFilters = []
rarityFilters = []
seasonFilters = []
seriesFilters = []
raceFilters = []
proficiencyFilters = []
genderFilters = []
resetState()
}
})
function toggleSelection(character: SearchResultItem) {
function resetState() {
selectedIds = new Set()
selectedQuantities = new Map()
showOnlySelected = false
searchQuery = ''
elementFilters = []
rarityFilters = []
seasonFilters = []
seriesFilters = []
raceFilters = []
proficiencyFilters = []
genderFilters = []
}
// Character toggle (binary selection)
function toggleCharacterSelection(character: SearchResultItem) {
const newSet = new Set(selectedIds)
if (newSet.has(character.id)) {
newSet.delete(character.id)
@ -142,6 +193,17 @@
selectedIds = newSet
}
// Weapon/Summon quantity change
function handleQuantityChange(item: SearchResultItem, quantity: number) {
const newMap = new Map(selectedQuantities)
if (quantity <= 0) {
newMap.delete(item.id)
} else {
newMap.set(item.id, quantity)
}
selectedQuantities = newMap
}
function handleFiltersChange(filters: CollectionFilterState) {
elementFilters = filters.element
rarityFilters = filters.rarity
@ -157,28 +219,89 @@
}
async function handleAdd() {
if (selectedIds.size === 0) return
const inputs = Array.from(selectedIds).map((characterId) => ({
characterId,
uncapLevel: 0,
transcendenceStep: 0
}))
try {
await addMutation.mutateAsync(inputs)
if (entityType === 'character') {
if (selectedIds.size === 0) return
const inputs = Array.from(selectedIds).map((characterId) => ({
characterId,
uncapLevel: 4,
transcendenceStep: 0
}))
await addCharacterMutation.mutateAsync(inputs)
} else if (entityType === 'weapon') {
if (selectedQuantities.size === 0) return
const inputs = Array.from(selectedQuantities.entries()).map(([weaponId, quantity]) => ({
weaponId,
quantity,
uncapLevel: 3,
transcendenceStep: 0
}))
await addWeaponMutation.mutateAsync(inputs)
} else {
if (selectedQuantities.size === 0) return
const inputs = Array.from(selectedQuantities.entries()).map(([summonId, quantity]) => ({
summonId,
quantity,
uncapLevel: 3,
transcendenceStep: 0
}))
await addSummonMutation.mutateAsync(inputs)
}
open = false
onOpenChange?.(false)
} catch (error) {
console.error('Failed to add characters:', error)
console.error(`Failed to add ${entityNames[entityType].plural}:`, error)
}
}
const selectedCount = $derived(selectedIds.size)
const isLoading = $derived(searchResults.isLoading || collectedIdsQuery.isLoading)
// Selected count
const selectedCount = $derived(
entityType === 'character'
? selectedIds.size
: Array.from(selectedQuantities.values()).reduce((sum, qty) => sum + qty, 0)
)
// Total items selected (for weapons/summons, this is unique items, not total quantity)
const selectedItemCount = $derived(
entityType === 'character' ? selectedIds.size : selectedQuantities.size
)
const isLoading = $derived(
searchResults.isLoading || (entityType === 'character' && collectedIdsQuery.isLoading)
)
// View mode from store
const currentViewMode = $derived(viewMode.modalView)
function handleViewModeChange(mode: ViewMode) {
viewMode.setModalView(mode)
}
// Dialog title based on entity type
const dialogTitle = $derived(`Add ${entityNames[entityType].plural.charAt(0).toUpperCase() + entityNames[entityType].plural.slice(1)} to Collection`)
// Placeholder text based on entity type
const searchPlaceholder = $derived(`Search ${entityNames[entityType].plural} by name...`)
// Footer text based on entity type
const selectedText = $derived.by(() => {
if (entityType === 'character') {
return `${selectedCount} ${selectedCount === 1 ? entityNames[entityType].singular : entityNames[entityType].plural} selected`
} else {
// For weapons/summons, show both item count and total quantity
if (selectedItemCount === 0) return ''
const itemText = `${selectedItemCount} ${selectedItemCount === 1 ? 'item' : 'items'}`
const qtyText = selectedCount > selectedItemCount ? ` (${selectedCount} total)` : ''
return `${itemText}${qtyText} selected`
}
})
</script>
<Dialog bind:open {onOpenChange} title="Add Characters to Collection" size="large">
<Dialog bind:open {onOpenChange} title={dialogTitle} size="large">
{#snippet children()}
<div class="modal-content">
<!-- Search input -->
@ -187,7 +310,7 @@
<input
type="text"
bind:value={searchQuery}
placeholder="Search characters by name..."
placeholder={searchPlaceholder}
class="search-input"
/>
</div>
@ -195,6 +318,7 @@
<!-- Filters -->
<div class="filters-bar">
<CollectionFilters
{entityType}
bind:elementFilters
bind:rarityFilters
bind:seasonFilters
@ -203,40 +327,92 @@
bind:proficiencyFilters
bind:genderFilters
onFiltersChange={handleFiltersChange}
showSort={false}
showViewToggle={true}
viewMode={currentViewMode}
onViewModeChange={handleViewModeChange}
/>
</div>
<!-- Results grid -->
<!-- Results -->
<div class="results-area">
{#if isLoading}
<div class="loading-state">
<Icon name="loader-2" size={32} />
<p>Loading characters...</p>
<p>Loading {entityNames[entityType].plural}...</p>
</div>
{:else if displayedResults.length === 0}
<div class="empty-state">
{#if showOnlySelected}
<p>No characters selected</p>
<p>No {entityNames[entityType].plural} selected</p>
<Button variant="ghost" size="small" onclick={toggleShowSelected}>
Show all characters
Show all {entityNames[entityType].plural}
</Button>
{:else if searchQuery || Object.values(searchFilters).some((v) => v)}
<p>No characters match your search</p>
<p>No {entityNames[entityType].plural} match your search</p>
{:else}
<p>Start searching to find characters</p>
<p>Start searching to find {entityNames[entityType].plural}</p>
{/if}
</div>
{:else if currentViewMode === 'grid'}
<div class="results-grid">
{#if entityType === 'character'}
{#each displayedResults as character (character.id)}
<SelectableCharacterCard
{character}
selected={selectedIds.has(character.id)}
onToggle={toggleCharacterSelection}
/>
{/each}
{:else if entityType === 'weapon'}
{#each displayedResults as weapon (weapon.id)}
<SelectableWeaponCard
{weapon}
quantity={selectedQuantities.get(weapon.id) ?? 0}
onQuantityChange={handleQuantityChange}
/>
{/each}
{:else}
{#each displayedResults as summon (summon.id)}
<SelectableSummonCard
{summon}
quantity={selectedQuantities.get(summon.id) ?? 0}
onQuantityChange={handleQuantityChange}
/>
{/each}
{/if}
</div>
{:else}
<div class="results-grid">
{#each displayedResults as character (character.id)}
<SelectableCharacterCard
{character}
selected={selectedIds.has(character.id)}
onToggle={toggleSelection}
/>
{/each}
<div class="results-list">
{#if entityType === 'character'}
{#each displayedResults as character (character.id)}
<SelectableCharacterRow
{character}
selected={selectedIds.has(character.id)}
onToggle={toggleCharacterSelection}
/>
{/each}
{:else if entityType === 'weapon'}
{#each displayedResults as weapon (weapon.id)}
<SelectableWeaponRow
{weapon}
quantity={selectedQuantities.get(weapon.id) ?? 0}
onQuantityChange={handleQuantityChange}
/>
{/each}
{:else}
{#each displayedResults as summon (summon.id)}
<SelectableSummonRow
{summon}
quantity={selectedQuantities.get(summon.id) ?? 0}
onQuantityChange={handleQuantityChange}
/>
{/each}
{/if}
</div>
{/if}
{#if displayedResults.length > 0}
{#if !showOnlySelected && searchResults.hasNextPage}
<div class="load-more-sentinel" bind:this={sentinelEl}></div>
{/if}
@ -262,7 +438,7 @@
class:active={showOnlySelected}
onclick={toggleShowSelected}
>
{selectedCount} character{selectedCount === 1 ? '' : 's'} selected
{selectedText}
</button>
{/if}
</div>
@ -272,10 +448,10 @@
</Button>
<Button
variant="primary"
disabled={selectedCount === 0 || addMutation.isPending}
disabled={selectedCount === 0 || currentMutation.isPending}
onclick={handleAdd}
>
{#if addMutation.isPending}
{#if currentMutation.isPending}
<Icon name="loader-2" size={16} />
Adding...
{:else}
@ -348,6 +524,13 @@
padding: $unit 0;
}
.results-list {
display: flex;
flex-direction: column;
gap: $unit-half;
padding: $unit 0;
}
.loading-state,
.empty-state {
display: flex;

View file

@ -0,0 +1,108 @@
<script lang="ts">
import type { CollectionCharacter } from '$lib/types/api/collection'
import { getCharacterImageWithPose } from '$lib/utils/images'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import perpetuityFilled from '$src/assets/icons/perpetuity/filled.svg'
interface Props {
character: CollectionCharacter
onClick?: () => void
}
let { character, onClick }: Props = $props()
const imageUrl = $derived(
getCharacterImageWithPose(
character.character?.granblueId,
'grid',
character.uncapLevel,
character.transcendenceStep
)
)
const displayName = $derived.by(() => {
const name = character.character?.name
if (!name) return '—'
if (typeof name === 'string') return name
return name.en || name.ja || '—'
})
</script>
<button type="button" class="character-card" onclick={onClick}>
<div class="card-image">
{#if character.perpetuity}
<img
class="perpetuity-badge"
src={perpetuityFilled}
alt="Perpetuity Ring"
title="Perpetuity Ring"
/>
{/if}
<img class="character-image" src={imageUrl} alt={displayName} loading="lazy" />
</div>
<UncapIndicator
type="character"
uncapLevel={character.uncapLevel}
transcendenceStage={character.transcendenceStep}
special={character.character?.special}
flb={character.character?.uncap?.flb}
ulb={character.character?.uncap?.ulb}
transcendence={!character.character?.special && character.character?.uncap?.ulb}
/>
<span class="character-name">{displayName}</span>
</button>
<style lang="scss">
@use '$src/themes/spacing' as *;
.character-card {
display: flex;
flex-direction: column;
align-items: center;
gap: $unit-half;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
&:focus-visible {
outline: 2px solid var(--accent-color, #3366ff);
outline-offset: 2px;
border-radius: 8px;
}
}
.card-image {
position: relative;
width: 100%;
aspect-ratio: 280 / 160;
border-radius: 8px;
overflow: hidden;
background: var(--card-bg, #f5f5f5);
}
.perpetuity-badge {
position: absolute;
top: -$unit-half;
right: $unit;
width: $unit-3x;
height: $unit-3x;
z-index: 10;
}
.character-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.character-name {
display: none;
}
</style>

View file

@ -0,0 +1,185 @@
<script lang="ts">
import type { CollectionCharacter } from '$lib/types/api/collection'
import { getCharacterImageWithPose } from '$lib/utils/images'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import ElementLabel from '$lib/components/labels/ElementLabel.svelte'
import perpetuityFilled from '$src/assets/icons/perpetuity/filled.svg'
interface Props {
character: CollectionCharacter
onClick?: () => void
}
let { character, onClick }: Props = $props()
const imageUrl = $derived(
getCharacterImageWithPose(
character.character?.granblueId,
'grid',
character.uncapLevel,
character.transcendenceStep
)
)
const displayName = $derived.by(() => {
const name = character.character?.name
if (!name) return '—'
if (typeof name === 'string') return name
return name.en || name.ja || '—'
})
const element = $derived(character.character?.element)
const awakeningDisplay = $derived.by(() => {
if (!character.awakening) return null
const type = character.awakening.type?.name?.en || 'Balanced'
const level = character.awakening.level || 1
// Abbreviate type names
const abbrev =
type === 'Balanced'
? 'BAL'
: type === 'Attack'
? 'ATK'
: type === 'Defense'
? 'DEF'
: type === 'Multiattack'
? 'DA/TA'
: type.slice(0, 3).toUpperCase()
return `${abbrev} ${level}`
})
</script>
<button type="button" class="character-row" onclick={onClick}>
<div class="thumbnail">
<img src={imageUrl} alt={displayName} loading="lazy" />
</div>
<div class="name-cell">
<span class="name">{displayName}</span>
{#if character.perpetuity}
<img
class="perpetuity-badge"
src={perpetuityFilled}
alt="Perpetuity Ring"
title="Perpetuity Ring"
/>
{/if}
</div>
<div class="element-cell">
<ElementLabel {element} size="medium" />
</div>
<div class="uncap-cell">
<UncapIndicator
type="character"
uncapLevel={character.uncapLevel}
transcendenceStage={character.transcendenceStep}
special={character.character?.special}
flb={character.character?.uncap?.flb}
ulb={character.character?.uncap?.ulb}
transcendence={!character.character?.special && character.character?.uncap?.ulb}
/>
</div>
<div class="awakening-cell">
{#if awakeningDisplay}
<span class="awakening">{awakeningDisplay}</span>
{:else}
<span class="awakening-placeholder"></span>
{/if}
</div>
</button>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/typography' as *;
.character-row {
display: flex;
align-items: center;
gap: $unit-2x;
padding: $unit $unit-2x $unit $unit;
border: none;
background: var(--list-cell-bg);
cursor: pointer;
width: 100%;
text-align: left;
border-radius: 12px;
transition:
background 0.15s,
box-shadow 0.15s;
&:hover {
background: var(--list-cell-bg-hover);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.08);
}
&:focus-visible {
outline: 2px solid var(--accent-color, #3366ff);
outline-offset: -2px;
}
}
.thumbnail {
width: 100px;
aspect-ratio: 280 / 160;
border-radius: 6px;
overflow: hidden;
background: var(--card-bg, #f5f5f5);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.name-cell {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: $unit;
}
.name {
font-size: $font-regular;
font-weight: $medium;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.perpetuity-badge {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.uncap-cell {
width: 100px;
display: flex;
justify-content: center;
flex-shrink: 0;
}
.awakening-cell {
width: 64px;
display: flex;
justify-content: flex-end;
flex-shrink: 0;
}
.awakening {
font-size: $font-small;
color: var(--text-secondary);
font-weight: $medium;
}
.awakening-placeholder {
color: var(--text-secondary);
}
</style>

View file

@ -3,10 +3,16 @@
import { RACE_LABELS } from '$lib/utils/race'
import { GENDER_LABELS } from '$lib/utils/gender'
import type { CollectionSortKey } from '$lib/types/api/collection'
import type { ViewMode } from '$lib/stores/viewMode.svelte'
import MultiSelect from '$lib/components/ui/MultiSelect.svelte'
import Select from '$lib/components/ui/Select.svelte'
import Icon from '$lib/components/Icon.svelte'
type EntityType = 'character' | 'weapon' | 'summon'
interface Props {
/** Entity type to determine which filters to show */
entityType?: EntityType
elementFilters?: number[]
rarityFilters?: number[]
seasonFilters?: number[]
@ -17,7 +23,7 @@
sortBy?: CollectionSortKey
onFiltersChange?: (filters: CollectionFilterState) => void
onSortChange?: (sort: CollectionSortKey) => void
/** Which filter groups to show */
/** Which filter groups to show (overrides entityType defaults) */
showFilters?: {
element?: boolean
rarity?: boolean
@ -29,6 +35,12 @@
}
/** Whether to show the sort dropdown */
showSort?: boolean
/** Current view mode */
viewMode?: ViewMode
/** Callback when view mode changes */
onViewModeChange?: (mode: ViewMode) => void
/** Whether to show the view toggle */
showViewToggle?: boolean
}
export interface CollectionFilterState {
@ -41,7 +53,39 @@
gender: number[]
}
// Default filter visibility based on entity type
const defaultFiltersByEntity: Record<EntityType, Props['showFilters']> = {
character: {
element: true,
rarity: true,
season: true,
series: true,
race: true,
proficiency: true,
gender: true
},
weapon: {
element: true,
rarity: true,
season: false,
series: true, // Weapon series
race: false,
proficiency: true, // Weapon type
gender: false
},
summon: {
element: true,
rarity: true,
season: false,
series: false,
race: false,
proficiency: false,
gender: false
}
}
let {
entityType = 'character',
elementFilters = $bindable([]),
rarityFilters = $bindable([]),
seasonFilters = $bindable([]),
@ -52,18 +96,19 @@
sortBy = $bindable<CollectionSortKey>('name_asc'),
onFiltersChange,
onSortChange,
showFilters = {
element: true,
rarity: true,
season: true,
series: true,
race: true,
proficiency: true,
gender: true
},
showSort = true
showFilters,
showSort = true,
viewMode = 'grid',
onViewModeChange,
showViewToggle = false
}: Props = $props()
// Compute effective filter visibility (explicit showFilters overrides entityType defaults)
const effectiveShowFilters = $derived({
...defaultFiltersByEntity[entityType],
...showFilters
})
// Sort options
const sortOptions: { value: CollectionSortKey; label: string }[] = [
{ value: 'name_asc', label: 'Name A → Z' },
@ -207,7 +252,7 @@
<div class="filters-container">
<div class="filters">
{#if showFilters.element}
{#if effectiveShowFilters.element}
<MultiSelect
options={elements}
bind:value={elementFilters}
@ -216,7 +261,7 @@
/>
{/if}
{#if showFilters.rarity}
{#if effectiveShowFilters.rarity}
<MultiSelect
options={rarities}
bind:value={rarityFilters}
@ -225,7 +270,7 @@
/>
{/if}
{#if showFilters.season}
{#if effectiveShowFilters.season}
<MultiSelect
options={seasons}
bind:value={seasonFilters}
@ -234,16 +279,16 @@
/>
{/if}
{#if showFilters.series}
{#if effectiveShowFilters.series}
<MultiSelect
options={series}
bind:value={seriesFilters}
onValueChange={handleSeriesChange}
placeholder="Series"
placeholder={entityType === 'weapon' ? 'Weapon Series' : 'Series'}
/>
{/if}
{#if showFilters.race}
{#if effectiveShowFilters.race}
<MultiSelect
options={races}
bind:value={raceFilters}
@ -252,16 +297,16 @@
/>
{/if}
{#if showFilters.proficiency}
{#if effectiveShowFilters.proficiency}
<MultiSelect
options={proficiencies}
bind:value={proficiencyFilters}
onValueChange={handleProficiencyChange}
placeholder="Proficiency"
placeholder={entityType === 'weapon' ? 'Weapon Type' : 'Proficiency'}
/>
{/if}
{#if showFilters.gender}
{#if effectiveShowFilters.gender}
<MultiSelect
options={genders}
bind:value={genderFilters}
@ -275,16 +320,43 @@
{/if}
</div>
{#if showSort}
<div class="sort">
<Select
options={sortOptions}
bind:value={sortBy}
onValueChange={handleSortChange}
size="small"
/>
</div>
{/if}
<div class="right-controls">
{#if showViewToggle}
<div class="view-toggle">
<button
type="button"
class="view-btn"
class:active={viewMode === 'grid'}
onclick={() => onViewModeChange?.('grid')}
aria-label="Grid view"
aria-pressed={viewMode === 'grid'}
>
<Icon name="grid-2x2" size={18} />
</button>
<button
type="button"
class="view-btn"
class:active={viewMode === 'list'}
onclick={() => onViewModeChange?.('list')}
aria-label="List view"
aria-pressed={viewMode === 'list'}
>
<Icon name="list" size={18} />
</button>
</div>
{/if}
{#if showSort}
<div class="sort">
<Select
options={sortOptions}
bind:value={sortBy}
onValueChange={handleSortChange}
size="small"
/>
</div>
{/if}
</div>
</div>
<style lang="scss">
@ -306,6 +378,46 @@
gap: $unit;
}
.right-controls {
display: flex;
align-items: center;
gap: $unit;
flex-shrink: 0;
}
.view-toggle {
display: flex;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
overflow: hidden;
}
.view-btn {
display: flex;
align-items: center;
justify-content: center;
padding: $unit-half;
border: none;
background: transparent;
color: var(--text-secondary, #666);
cursor: pointer;
transition: background 0.15s, color 0.15s;
&:hover {
background: var(--button-bg-hover, #f5f5f5);
color: var(--text-primary, #333);
}
&.active {
background: var(--accent-color, #3366ff);
color: white;
}
&:first-child {
border-right: 1px solid var(--border-color, #ddd);
}
}
.sort {
flex-shrink: 0;

View file

@ -0,0 +1,122 @@
<script lang="ts">
/**
* QuantityCounter - Compact +/- counter for selecting quantities
*
* Used in the add to collection modal for weapons/summons where
* users can add multiple copies of the same item.
*/
interface Props {
value?: number
min?: number
max?: number
onChange?: (value: number) => void
}
let { value = 0, min = 0, max = 99, onChange }: Props = $props()
function increment() {
if (value < max) {
onChange?.(value + 1)
}
}
function decrement() {
if (value > min) {
onChange?.(value - 1)
}
}
function handleKeyDown(e: KeyboardEvent, action: 'increment' | 'decrement') {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (action === 'increment') {
increment()
} else {
decrement()
}
}
}
</script>
<div class="quantity-counter" class:active={value > 0}>
<button
type="button"
class="counter-btn"
onclick={decrement}
onkeydown={(e) => handleKeyDown(e, 'decrement')}
disabled={value <= min}
aria-label="Decrease quantity"
>
</button>
<span class="value" aria-live="polite">{value}</span>
<button
type="button"
class="counter-btn"
onclick={increment}
onkeydown={(e) => handleKeyDown(e, 'increment')}
disabled={value >= max}
aria-label="Increase quantity"
>
+
</button>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/effects' as *;
.quantity-counter {
display: inline-flex;
align-items: center;
gap: 2px;
background: var(--surface-overlay, rgba(0, 0, 0, 0.4));
border-radius: 4px;
padding: 2px;
@include smooth-transition(0.15s, all);
&.active {
background: var(--accent-color, #3366ff);
}
}
.counter-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
background: transparent;
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
border-radius: 2px;
@include smooth-transition(0.1s, all);
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.2);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&:focus-visible {
outline: 2px solid var(--focus-ring, white);
outline-offset: 1px;
}
}
.value {
min-width: 20px;
text-align: center;
font-size: 12px;
font-weight: 600;
color: white;
}
</style>

View file

@ -0,0 +1,146 @@
<script lang="ts">
import { getCharacterImage } from '$lib/utils/images'
import Icon from '$lib/components/Icon.svelte'
import ElementLabel from '$lib/components/labels/ElementLabel.svelte'
import type { SearchPageResult } from '$lib/api/queries/search.queries'
type SearchResultItem = SearchPageResult['results'][number]
interface Props {
character: SearchResultItem
selected?: boolean
onToggle?: (character: SearchResultItem) => void
}
let { character, selected = false, onToggle }: Props = $props()
const imageUrl = $derived(getCharacterImage(character.granblueId, 'grid', '01'))
const name = $derived(
typeof character.name === 'string'
? character.name
: character.name?.en || character.name?.ja || 'Unknown'
)
const element = $derived(character.element)
function handleClick() {
onToggle?.(character)
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleClick()
}
}
</script>
<button
type="button"
class="row"
class:selected
onclick={handleClick}
onkeydown={handleKeyDown}
aria-pressed={selected}
aria-label="{selected ? 'Deselect' : 'Select'} {name}"
>
<div class="checkbox">
{#if selected}
<Icon name="check" size={14} />
{/if}
</div>
<div class="thumbnail">
<img src={imageUrl} alt={name} loading="lazy" />
</div>
<span class="name">{name}</span>
<div class="element-cell">
<ElementLabel {element} size="medium" />
</div>
</button>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/typography' as *;
.row {
display: flex;
align-items: center;
gap: $unit;
padding: $unit $unit-2x $unit $unit;
border: none;
background: var(--list-cell-bg);
cursor: pointer;
width: 100%;
text-align: left;
border-radius: 12px;
transition:
background 0.15s,
box-shadow 0.15s;
&:hover {
background: var(--list-cell-bg-hover);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.08);
}
&:focus-visible {
outline: 2px solid var(--accent-color, #3366ff);
outline-offset: -2px;
}
&.selected {
background: rgba(51, 102, 255, 0.1);
&:hover {
background: rgba(51, 102, 255, 0.15);
}
}
}
.checkbox {
width: 18px;
height: 18px;
border: 2px solid var(--border-color, #ccc);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition:
background 0.15s,
border-color 0.15s;
.selected & {
background: var(--accent-color, #3366ff);
border-color: var(--accent-color, #3366ff);
color: white;
}
}
.thumbnail {
width: 100px;
aspect-ratio: 280 / 160;
border-radius: 6px;
overflow: hidden;
background: var(--card-bg, #f5f5f5);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.name {
flex: 1;
font-size: $font-regular;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -0,0 +1,107 @@
<script lang="ts">
/**
* SelectableSummonCard - Grid view summon selection with quantity counter
*
* Used in the add to collection modal for selecting summons with
* quantity support (users can own multiple copies).
*/
import { getSummonImage } from '$lib/utils/images'
import QuantityCounter from './QuantityCounter.svelte'
import type { SearchPageResult } from '$lib/api/queries/search.queries'
type SearchResultItem = SearchPageResult['results'][number]
interface Props {
summon: SearchResultItem
quantity?: number
onQuantityChange?: (summon: SearchResultItem, quantity: number) => void
}
let { summon, quantity = 0, onQuantityChange }: Props = $props()
const imageUrl = $derived(getSummonImage(summon.granblueId, 'grid'))
const name = $derived(
typeof summon.name === 'string'
? summon.name
: summon.name?.en || summon.name?.ja || 'Unknown'
)
function handleQuantityChange(value: number) {
onQuantityChange?.(summon, value)
}
function handleClick() {
// Clicking the card increments quantity (convenience)
onQuantityChange?.(summon, quantity + 1)
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleClick()
}
}
</script>
<div
class="card"
class:selected={quantity > 0}
role="button"
tabindex="0"
onclick={handleClick}
onkeydown={handleKeyDown}
aria-label="Select {name}, current quantity: {quantity}"
>
<img src={imageUrl} alt={name} class="image" loading="lazy" />
<div class="counter-overlay" onclick|stopPropagation={() => {}}>
<QuantityCounter value={quantity} onChange={handleQuantityChange} />
</div>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/effects' as *;
.card {
position: relative;
width: 100px;
// Summon grid images are typically square
aspect-ratio: 1 / 1;
padding: 0;
border: 2px solid transparent;
border-radius: 8px;
background: var(--card-bg, #f5f5f5);
cursor: pointer;
overflow: hidden;
flex-shrink: 0;
@include smooth-transition(0.15s, all);
&:hover {
border-color: var(--accent-color, #3366ff);
transform: scale(1.02);
}
&:focus-visible {
outline: 2px solid var(--focus-ring, #3366ff);
outline-offset: 2px;
}
&.selected {
border-color: var(--accent-color, #3366ff);
box-shadow: 0 0 0 2px var(--accent-color, #3366ff);
}
}
.image {
width: 100%;
height: 100%;
object-fit: contain;
}
.counter-overlay {
position: absolute;
bottom: 4px;
right: 4px;
}
</style>

View file

@ -0,0 +1,143 @@
<script lang="ts">
/**
* SelectableSummonRow - List view summon selection with quantity counter
*
* Used in the add to collection modal for selecting summons with
* quantity support (users can own multiple copies).
*/
import { getSummonImage } from '$lib/utils/images'
import ElementLabel from '$lib/components/labels/ElementLabel.svelte'
import QuantityCounter from './QuantityCounter.svelte'
import type { SearchPageResult } from '$lib/api/queries/search.queries'
type SearchResultItem = SearchPageResult['results'][number]
interface Props {
summon: SearchResultItem
quantity?: number
onQuantityChange?: (summon: SearchResultItem, quantity: number) => void
}
let { summon, quantity = 0, onQuantityChange }: Props = $props()
const imageUrl = $derived(getSummonImage(summon.granblueId, 'grid'))
const name = $derived(
typeof summon.name === 'string'
? summon.name
: summon.name?.en || summon.name?.ja || 'Unknown'
)
const element = $derived(summon.element)
function handleQuantityChange(value: number) {
onQuantityChange?.(summon, value)
}
function handleClick() {
// Clicking the row increments quantity (convenience)
onQuantityChange?.(summon, quantity + 1)
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleClick()
}
}
</script>
<div
class="row"
class:selected={quantity > 0}
role="button"
tabindex="0"
onclick={handleClick}
onkeydown={handleKeyDown}
aria-label="Select {name}, current quantity: {quantity}"
>
<div class="counter-cell" onclick|stopPropagation={() => {}}>
<QuantityCounter value={quantity} onChange={handleQuantityChange} />
</div>
<div class="thumbnail">
<img src={imageUrl} alt={name} loading="lazy" />
</div>
<span class="name">{name}</span>
<div class="element-cell">
<ElementLabel {element} size="medium" />
</div>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/typography' as *;
.row {
display: flex;
align-items: center;
gap: $unit;
padding: $unit $unit-2x $unit $unit;
border: none;
background: var(--list-cell-bg);
cursor: pointer;
width: 100%;
text-align: left;
border-radius: 12px;
transition:
background 0.15s,
box-shadow 0.15s;
&:hover {
background: var(--list-cell-bg-hover);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.08);
}
&:focus-visible {
outline: 2px solid var(--accent-color, #3366ff);
outline-offset: -2px;
}
&.selected {
background: rgba(51, 102, 255, 0.1);
&:hover {
background: rgba(51, 102, 255, 0.15);
}
}
}
.counter-cell {
flex-shrink: 0;
}
.thumbnail {
width: 80px;
aspect-ratio: 1 / 1;
border-radius: 6px;
overflow: hidden;
background: var(--card-bg, #f5f5f5);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.name {
flex: 1;
font-size: $font-regular;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.element-cell {
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,107 @@
<script lang="ts">
/**
* SelectableWeaponCard - Grid view weapon selection with quantity counter
*
* Used in the add to collection modal for selecting weapons with
* quantity support (users can own multiple copies).
*/
import { getWeaponImage } from '$lib/utils/images'
import QuantityCounter from './QuantityCounter.svelte'
import type { SearchPageResult } from '$lib/api/queries/search.queries'
type SearchResultItem = SearchPageResult['results'][number]
interface Props {
weapon: SearchResultItem
quantity?: number
onQuantityChange?: (weapon: SearchResultItem, quantity: number) => void
}
let { weapon, quantity = 0, onQuantityChange }: Props = $props()
const imageUrl = $derived(getWeaponImage(weapon.granblueId, 'grid'))
const name = $derived(
typeof weapon.name === 'string'
? weapon.name
: weapon.name?.en || weapon.name?.ja || 'Unknown'
)
function handleQuantityChange(value: number) {
onQuantityChange?.(weapon, value)
}
function handleClick() {
// Clicking the card increments quantity (convenience)
onQuantityChange?.(weapon, quantity + 1)
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleClick()
}
}
</script>
<div
class="card"
class:selected={quantity > 0}
role="button"
tabindex="0"
onclick={handleClick}
onkeydown={handleKeyDown}
aria-label="Select {name}, current quantity: {quantity}"
>
<img src={imageUrl} alt={name} class="image" loading="lazy" />
<div class="counter-overlay" onclick|stopPropagation={() => {}}>
<QuantityCounter value={quantity} onChange={handleQuantityChange} />
</div>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/effects' as *;
.card {
position: relative;
width: 100px;
// Weapon grid images are typically square or close to it
aspect-ratio: 1 / 1;
padding: 0;
border: 2px solid transparent;
border-radius: 8px;
background: var(--card-bg, #f5f5f5);
cursor: pointer;
overflow: hidden;
flex-shrink: 0;
@include smooth-transition(0.15s, all);
&:hover {
border-color: var(--accent-color, #3366ff);
transform: scale(1.02);
}
&:focus-visible {
outline: 2px solid var(--focus-ring, #3366ff);
outline-offset: 2px;
}
&.selected {
border-color: var(--accent-color, #3366ff);
box-shadow: 0 0 0 2px var(--accent-color, #3366ff);
}
}
.image {
width: 100%;
height: 100%;
object-fit: contain;
}
.counter-overlay {
position: absolute;
bottom: 4px;
right: 4px;
}
</style>

View file

@ -0,0 +1,143 @@
<script lang="ts">
/**
* SelectableWeaponRow - List view weapon selection with quantity counter
*
* Used in the add to collection modal for selecting weapons with
* quantity support (users can own multiple copies).
*/
import { getWeaponImage } from '$lib/utils/images'
import ElementLabel from '$lib/components/labels/ElementLabel.svelte'
import QuantityCounter from './QuantityCounter.svelte'
import type { SearchPageResult } from '$lib/api/queries/search.queries'
type SearchResultItem = SearchPageResult['results'][number]
interface Props {
weapon: SearchResultItem
quantity?: number
onQuantityChange?: (weapon: SearchResultItem, quantity: number) => void
}
let { weapon, quantity = 0, onQuantityChange }: Props = $props()
const imageUrl = $derived(getWeaponImage(weapon.granblueId, 'grid'))
const name = $derived(
typeof weapon.name === 'string'
? weapon.name
: weapon.name?.en || weapon.name?.ja || 'Unknown'
)
const element = $derived(weapon.element)
function handleQuantityChange(value: number) {
onQuantityChange?.(weapon, value)
}
function handleClick() {
// Clicking the row increments quantity (convenience)
onQuantityChange?.(weapon, quantity + 1)
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleClick()
}
}
</script>
<div
class="row"
class:selected={quantity > 0}
role="button"
tabindex="0"
onclick={handleClick}
onkeydown={handleKeyDown}
aria-label="Select {name}, current quantity: {quantity}"
>
<div class="counter-cell" onclick|stopPropagation={() => {}}>
<QuantityCounter value={quantity} onChange={handleQuantityChange} />
</div>
<div class="thumbnail">
<img src={imageUrl} alt={name} loading="lazy" />
</div>
<span class="name">{name}</span>
<div class="element-cell">
<ElementLabel {element} size="medium" />
</div>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/typography' as *;
.row {
display: flex;
align-items: center;
gap: $unit;
padding: $unit $unit-2x $unit $unit;
border: none;
background: var(--list-cell-bg);
cursor: pointer;
width: 100%;
text-align: left;
border-radius: 12px;
transition:
background 0.15s,
box-shadow 0.15s;
&:hover {
background: var(--list-cell-bg-hover);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.08);
}
&:focus-visible {
outline: 2px solid var(--accent-color, #3366ff);
outline-offset: -2px;
}
&.selected {
background: rgba(51, 102, 255, 0.1);
&:hover {
background: rgba(51, 102, 255, 0.15);
}
}
}
.counter-cell {
flex-shrink: 0;
}
.thumbnail {
width: 80px;
aspect-ratio: 1 / 1;
border-radius: 6px;
overflow: hidden;
background: var(--card-bg, #f5f5f5);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.name {
flex: 1;
font-size: $font-regular;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.element-cell {
flex-shrink: 0;
}
</style>