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:
parent
13a3905776
commit
957dd16e5e
11 changed files with 1454 additions and 95 deletions
|
|
@ -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
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
108
src/lib/components/collection/CollectionCharacterCard.svelte
Normal file
108
src/lib/components/collection/CollectionCharacterCard.svelte
Normal 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>
|
||||
185
src/lib/components/collection/CollectionCharacterRow.svelte
Normal file
185
src/lib/components/collection/CollectionCharacterRow.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
122
src/lib/components/collection/QuantityCounter.svelte
Normal file
122
src/lib/components/collection/QuantityCounter.svelte
Normal 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>
|
||||
146
src/lib/components/collection/SelectableCharacterRow.svelte
Normal file
146
src/lib/components/collection/SelectableCharacterRow.svelte
Normal 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>
|
||||
107
src/lib/components/collection/SelectableSummonCard.svelte
Normal file
107
src/lib/components/collection/SelectableSummonCard.svelte
Normal 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>
|
||||
143
src/lib/components/collection/SelectableSummonRow.svelte
Normal file
143
src/lib/components/collection/SelectableSummonRow.svelte
Normal 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>
|
||||
107
src/lib/components/collection/SelectableWeaponCard.svelte
Normal file
107
src/lib/components/collection/SelectableWeaponCard.svelte
Normal 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>
|
||||
143
src/lib/components/collection/SelectableWeaponRow.svelte
Normal file
143
src/lib/components/collection/SelectableWeaponRow.svelte
Normal 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>
|
||||
Loading…
Reference in a new issue