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:
Justin Edmund 2025-12-02 09:32:28 -08:00
parent 8f28ad8d8f
commit a9de4a60c0
2 changed files with 547 additions and 0 deletions

View 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>

View 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>