add AddToCollectionModal and SelectableCharacterCard
Large modal for batch-selecting characters to add to collection. Features server-side search, filtering, multi-select with visual feedback, and "X selected" link to filter to selection only.
This commit is contained in:
parent
8f28ad8d8f
commit
a9de4a60c0
2 changed files with 547 additions and 0 deletions
447
src/lib/components/collection/AddToCollectionModal.svelte
Normal file
447
src/lib/components/collection/AddToCollectionModal.svelte
Normal file
|
|
@ -0,0 +1,447 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query'
|
||||||
|
import { searchAdapter, type SearchResult } from '$lib/api/adapters/search.adapter'
|
||||||
|
import { collectionQueries } from '$lib/api/queries/collection.queries'
|
||||||
|
import { useAddCharactersToCollection } 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'
|
||||||
|
import CollectionFilters, {
|
||||||
|
type CollectionFilterState
|
||||||
|
} from './CollectionFilters.svelte'
|
||||||
|
import SelectableCharacterCard from './SelectableCharacterCard.svelte'
|
||||||
|
import { IsInViewport } from 'runed'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open = $bindable(false), onOpenChange }: Props = $props()
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
let searchQuery = $state('')
|
||||||
|
let debouncedQuery = $state('')
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
let elementFilters = $state<number[]>([])
|
||||||
|
let rarityFilters = $state<number[]>([])
|
||||||
|
let seasonFilters = $state<number[]>([])
|
||||||
|
let seriesFilters = $state<number[]>([])
|
||||||
|
let raceFilters = $state<number[]>([])
|
||||||
|
let proficiencyFilters = $state<number[]>([])
|
||||||
|
let genderFilters = $state<number[]>([])
|
||||||
|
|
||||||
|
// Selection state
|
||||||
|
let selectedIds = $state<Set<string>>(new Set())
|
||||||
|
let showOnlySelected = $state(false)
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
let sentinelEl = $state<HTMLElement>()
|
||||||
|
|
||||||
|
// Get IDs of characters already in collection
|
||||||
|
const collectedIdsQuery = createQuery(() => collectionQueries.collectedCharacterIds())
|
||||||
|
|
||||||
|
// Build filters for search
|
||||||
|
const searchFilters = $derived({
|
||||||
|
element: elementFilters.length > 0 ? elementFilters : undefined,
|
||||||
|
rarity: rarityFilters.length > 0 ? rarityFilters : undefined,
|
||||||
|
season: seasonFilters.length > 0 ? seasonFilters : undefined,
|
||||||
|
characterSeries: seriesFilters.length > 0 ? seriesFilters : undefined,
|
||||||
|
// Note: Race, proficiency, and gender filters would need API support
|
||||||
|
// For now we filter client-side or skip if API doesn't support
|
||||||
|
proficiency1: proficiencyFilters.length > 0 ? proficiencyFilters : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
// Search query with infinite scroll
|
||||||
|
const searchResults = createInfiniteQuery(() => ({
|
||||||
|
queryKey: ['search', 'characters', 'collection', debouncedQuery, searchFilters] as const,
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
const response = await searchAdapter.searchCharacters({
|
||||||
|
query: debouncedQuery || undefined,
|
||||||
|
page: pageParam,
|
||||||
|
per: 60,
|
||||||
|
filters: searchFilters,
|
||||||
|
exclude: collectedIdsQuery.data ?? []
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
results: response.results ?? [],
|
||||||
|
page: response.meta?.page ?? pageParam,
|
||||||
|
totalPages: response.meta?.totalPages ?? 1,
|
||||||
|
total: response.meta?.count ?? 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
if (lastPage.page < lastPage.totalPages) {
|
||||||
|
return lastPage.page + 1
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
enabled: open && !collectedIdsQuery.isLoading
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Flatten results
|
||||||
|
const allResults = $derived(
|
||||||
|
searchResults.data?.pages.flatMap((page) => page.results) ?? []
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filter to show only selected if enabled
|
||||||
|
const displayedResults = $derived(
|
||||||
|
showOnlySelected
|
||||||
|
? allResults.filter((r) => selectedIds.has(r.id))
|
||||||
|
: allResults
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add mutation
|
||||||
|
const addMutation = useAddCharactersToCollection()
|
||||||
|
|
||||||
|
// Debounce search input
|
||||||
|
$effect(() => {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
debouncedQuery = searchQuery
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
return () => clearTimeout(debounceTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Infinite scroll
|
||||||
|
const inViewport = new IsInViewport(() => sentinelEl, {
|
||||||
|
rootMargin: '200px'
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (
|
||||||
|
inViewport.current &&
|
||||||
|
searchResults.hasNextPage &&
|
||||||
|
!searchResults.isFetchingNextPage &&
|
||||||
|
!searchResults.isLoading &&
|
||||||
|
!showOnlySelected
|
||||||
|
) {
|
||||||
|
searchResults.fetchNextPage()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset state when modal closes
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
selectedIds = new Set()
|
||||||
|
showOnlySelected = false
|
||||||
|
searchQuery = ''
|
||||||
|
debouncedQuery = ''
|
||||||
|
elementFilters = []
|
||||||
|
rarityFilters = []
|
||||||
|
seasonFilters = []
|
||||||
|
seriesFilters = []
|
||||||
|
raceFilters = []
|
||||||
|
proficiencyFilters = []
|
||||||
|
genderFilters = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleSelection(character: SearchResult) {
|
||||||
|
const newSet = new Set(selectedIds)
|
||||||
|
if (newSet.has(character.id)) {
|
||||||
|
newSet.delete(character.id)
|
||||||
|
} else {
|
||||||
|
newSet.add(character.id)
|
||||||
|
}
|
||||||
|
selectedIds = newSet
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFiltersChange(filters: CollectionFilterState) {
|
||||||
|
elementFilters = filters.element
|
||||||
|
rarityFilters = filters.rarity
|
||||||
|
seasonFilters = filters.season
|
||||||
|
seriesFilters = filters.series
|
||||||
|
raceFilters = filters.race
|
||||||
|
proficiencyFilters = filters.proficiency
|
||||||
|
genderFilters = filters.gender
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleShowSelected() {
|
||||||
|
showOnlySelected = !showOnlySelected
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
open = false
|
||||||
|
onOpenChange?.(false)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add characters:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCount = $derived(selectedIds.size)
|
||||||
|
const isLoading = $derived(searchResults.isLoading || collectedIdsQuery.isLoading)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open {onOpenChange} title="Add Characters to Collection" size="large">
|
||||||
|
{#snippet children()}
|
||||||
|
<div class="modal-content">
|
||||||
|
<!-- Search input -->
|
||||||
|
<div class="search-bar">
|
||||||
|
<Icon name="search" size={18} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
placeholder="Search characters by name..."
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="filters-bar">
|
||||||
|
<CollectionFilters
|
||||||
|
bind:elementFilters
|
||||||
|
bind:rarityFilters
|
||||||
|
bind:seasonFilters
|
||||||
|
bind:seriesFilters
|
||||||
|
bind:raceFilters
|
||||||
|
bind:proficiencyFilters
|
||||||
|
bind:genderFilters
|
||||||
|
onFiltersChange={handleFiltersChange}
|
||||||
|
layout="horizontal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results grid -->
|
||||||
|
<div class="results-area">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading-state">
|
||||||
|
<Icon name="loader-2" size={32} />
|
||||||
|
<p>Loading characters...</p>
|
||||||
|
</div>
|
||||||
|
{:else if displayedResults.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
{#if showOnlySelected}
|
||||||
|
<p>No characters selected</p>
|
||||||
|
<Button variant="ghost" size="small" onclick={toggleShowSelected}>
|
||||||
|
Show all characters
|
||||||
|
</Button>
|
||||||
|
{:else if searchQuery || Object.values(searchFilters).some((v) => v)}
|
||||||
|
<p>No characters match your search</p>
|
||||||
|
{:else}
|
||||||
|
<p>Start searching to find characters</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="results-grid">
|
||||||
|
{#each displayedResults as character (character.id)}
|
||||||
|
<SelectableCharacterCard
|
||||||
|
{character}
|
||||||
|
selected={selectedIds.has(character.id)}
|
||||||
|
onToggle={toggleSelection}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !showOnlySelected && searchResults.hasNextPage}
|
||||||
|
<div class="load-more-sentinel" bind:this={sentinelEl}></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if searchResults.isFetchingNextPage}
|
||||||
|
<div class="loading-more">
|
||||||
|
<Icon name="loader-2" size={20} />
|
||||||
|
<span>Loading more...</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div class="footer-left">
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selected-link"
|
||||||
|
class:active={showOnlySelected}
|
||||||
|
onclick={toggleShowSelected}
|
||||||
|
>
|
||||||
|
{selectedCount} character{selectedCount === 1 ? '' : 's'} selected
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="footer-right">
|
||||||
|
<Button variant="ghost" onclick={() => (open = false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={selectedCount === 0 || addMutation.isPending}
|
||||||
|
onclick={handleAdd}
|
||||||
|
>
|
||||||
|
{#if addMutation.isPending}
|
||||||
|
<Icon name="loader-2" size={16} />
|
||||||
|
Adding...
|
||||||
|
{:else}
|
||||||
|
Add to Collection
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit-half $unit;
|
||||||
|
background: var(--input-bg, #f5f5f5);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: $unit-half 0;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-bar {
|
||||||
|
padding-bottom: $unit;
|
||||||
|
border-bottom: 1px solid var(--border-color, #eee);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-sentinel {
|
||||||
|
height: 1px;
|
||||||
|
margin-top: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit-2x;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-left {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-right {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent-color, #3366ff);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: $unit-half $unit;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-bg-hover, #f0f0f0);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--accent-color, #3366ff);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--accent-color-hover, #2255ee);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
100
src/lib/components/collection/SelectableCharacterCard.svelte
Normal file
100
src/lib/components/collection/SelectableCharacterCard.svelte
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getCharacterImage } from '$lib/utils/images'
|
||||||
|
import Icon from '$lib/components/Icon.svelte'
|
||||||
|
import type { SearchResult } from '$lib/api/adapters/search.adapter'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
character: SearchResult
|
||||||
|
selected?: boolean
|
||||||
|
onToggle?: (character: SearchResult) => 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'
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
onToggle?.(character)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="card"
|
||||||
|
class:selected
|
||||||
|
onclick={handleClick}
|
||||||
|
onkeydown={handleKeyDown}
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label="{selected ? 'Deselect' : 'Select'} {name}"
|
||||||
|
>
|
||||||
|
<img src={imageUrl} alt={name} class="image" loading="lazy" />
|
||||||
|
{#if selected}
|
||||||
|
<div class="check-overlay">
|
||||||
|
<Icon name="check" size={24} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/effects' as *;
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--card-bg, #f5f5f5);
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
@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: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(51, 102, 255, 0.6);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue