refactor SearchSidebar with tanstack query

- replace manual fetch with createInfiniteQuery
- debounced search input with reactive filters
- pagination handled automatically via infinite scroll
- cleaner state management via query result
This commit is contained in:
Justin Edmund 2025-11-28 11:03:42 -08:00
parent aa16d58175
commit e582629552

View file

@ -1,8 +1,10 @@
<svelte:options runes={true} />
<script lang="ts">
import { onMount } from 'svelte'
import { searchWeapons, searchCharacters, searchSummons, type SearchResult } from '$lib/api/resources/search'
import type { SearchResult } from '$lib/api/resources/search'
import { createInfiniteQuery } from '@tanstack/svelte-query'
import { searchQueries, type SearchFilters } from '$lib/api/queries/search.queries'
import InfiniteScrollQuery from '$lib/components/InfiniteScrollQuery.svelte'
interface Props {
open?: boolean
@ -20,13 +22,34 @@
canAddMore = true
}: Props = $props()
// Search state
// Search state - simple reactive values with Svelte 5 runes
let searchQuery = $state('')
let searchResults = $state<SearchResult[]>([])
let isLoading = $state(false)
let currentPage = $state(1)
let totalPages = $state(1)
let hasInitialLoad = $state(false)
let debouncedQuery = $state('')
let debounceTimer: ReturnType<typeof setTimeout> | undefined
// Debounce the search query using $effect
$effect(() => {
const query = searchQuery
if (debounceTimer) {
clearTimeout(debounceTimer)
}
// Skip single character searches
if (query.length === 1) {
return
}
debounceTimer = setTimeout(() => {
debouncedQuery = query
}, 300)
return () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
}
})
// Filter state
let elementFilters = $state<number[]>([])
@ -35,7 +58,6 @@
// Refs
let searchInput: HTMLInputElement
let resultsContainer: HTMLDivElement
// Constants
const elements = [
@ -67,74 +89,44 @@
{ value: 10, label: 'Katana' }
]
// Focus search input and load recent items when opened
// Build filters object reactively using $derived
const currentFilters = $derived<SearchFilters>({
element: elementFilters.length > 0 ? elementFilters : undefined,
rarity: rarityFilters.length > 0 ? rarityFilters : undefined,
proficiency: type === 'weapon' && proficiencyFilters.length > 0 ? proficiencyFilters : undefined,
proficiency2: type === 'character' && proficiencyFilters.length > 0 ? proficiencyFilters : undefined
})
// TanStack Query v6 - Use query options pattern for type safety and reusability
// The thunk (function) wrapper is required for Svelte 5 runes reactivity
const searchQueryResult = createInfiniteQuery(() => {
// Select the appropriate query options based on type
const baseOptions = (() => {
switch (type) {
case 'weapon':
return searchQueries.weapons(debouncedQuery, currentFilters)
case 'character':
return searchQueries.characters(debouncedQuery, currentFilters)
case 'summon':
return searchQueries.summons(debouncedQuery, currentFilters)
}
})()
// Merge with component-specific options (like enabled)
return {
...baseOptions,
enabled: open // Only fetch when sidebar is open
}
})
// Focus search input when opened
$effect(() => {
if (open && searchInput) {
searchInput.focus()
}
// Load recent items when opening if we haven't already
if (open && !hasInitialLoad) {
hasInitialLoad = true
performSearch()
}
})
// Search when query or filters change
$effect(() => {
// Always search if we have filters or a search query
// If no query and no filters, still search to show recent items
if (searchQuery.length >= 2 || elementFilters.length > 0 || rarityFilters.length > 0 || proficiencyFilters.length > 0) {
performSearch()
} else if (searchQuery.length === 1) {
// Don't search with just 1 character
return
} else if (searchQuery.length === 0 && elementFilters.length === 0 && rarityFilters.length === 0 && proficiencyFilters.length === 0) {
// Load recent items when no search criteria
if (hasInitialLoad) {
performSearch()
}
}
})
async function performSearch() {
isLoading = true
try {
const params = {
query: searchQuery || undefined, // Don't send empty string
page: currentPage,
filters: {
element: elementFilters.length > 0 ? elementFilters : undefined,
rarity: rarityFilters.length > 0 ? rarityFilters : undefined,
proficiency1: type === 'weapon' && proficiencyFilters.length > 0 ? proficiencyFilters : undefined
}
}
let response
switch (type) {
case 'weapon':
response = await searchWeapons(params)
break
case 'character':
response = await searchCharacters(params)
break
case 'summon':
response = await searchSummons(params)
break
}
searchResults = response.results
totalPages = response.total_pages
} catch (error) {
console.error('Search failed:', error)
searchResults = []
} finally {
isLoading = false
}
}
function handleItemClick(item: SearchResult) {
// Only add if we can add more items
if (canAddMore) {
onAddItems([item])
}
@ -142,7 +134,7 @@
function toggleElementFilter(element: number) {
if (elementFilters.includes(element)) {
elementFilters = elementFilters.filter(e => e !== element)
elementFilters = elementFilters.filter((e) => e !== element)
} else {
elementFilters = [...elementFilters, element]
}
@ -150,7 +142,7 @@
function toggleRarityFilter(rarity: number) {
if (rarityFilters.includes(rarity)) {
rarityFilters = rarityFilters.filter(r => r !== rarity)
rarityFilters = rarityFilters.filter((r) => r !== rarity)
} else {
rarityFilters = [...rarityFilters, rarity]
}
@ -158,7 +150,7 @@
function toggleProficiencyFilter(prof: number) {
if (proficiencyFilters.includes(prof)) {
proficiencyFilters = proficiencyFilters.filter(p => p !== prof)
proficiencyFilters = proficiencyFilters.filter((p) => p !== prof)
} else {
proficiencyFilters = [...proficiencyFilters, prof]
}
@ -186,14 +178,14 @@
<aside
class="sidebar"
class:open={open}
class:open
aria-hidden={!open}
aria-label="Search {type}s"
on:keydown={handleKeyDown}
onkeydown={handleKeyDown}
>
<header class="sidebar-header">
<h2>Search {type}s</h2>
<button class="close-btn" on:click={onClose} aria-label="Close">×</button>
<button class="close-btn" onclick={onClose} aria-label="Close">×</button>
</header>
<div class="search-section">
@ -217,7 +209,7 @@
class="filter-btn element-btn"
class:active={elementFilters.includes(element.value)}
style="--element-color: {element.color}"
on:click={() => toggleElementFilter(element.value)}
onclick={() => toggleElementFilter(element.value)}
aria-pressed={elementFilters.includes(element.value)}
>
{element.label}
@ -234,7 +226,7 @@
<button
class="filter-btn rarity-btn"
class:active={rarityFilters.includes(rarity.value)}
on:click={() => toggleRarityFilter(rarity.value)}
onclick={() => toggleRarityFilter(rarity.value)}
aria-pressed={rarityFilters.includes(rarity.value)}
>
{rarity.label}
@ -252,7 +244,7 @@
<button
class="filter-btn prof-btn"
class:active={proficiencyFilters.includes(prof.value)}
on:click={() => toggleProficiencyFilter(prof.value)}
onclick={() => toggleProficiencyFilter(prof.value)}
aria-pressed={proficiencyFilters.includes(prof.value)}
>
{prof.label}
@ -264,63 +256,53 @@
</div>
<!-- Results -->
<div class="results-section" bind:this={resultsContainer}>
{#if isLoading}
<div class="loading">Searching...</div>
{:else if searchResults.length > 0}
<div class="results-section">
<InfiniteScrollQuery query={searchQueryResult} threshold={300}>
{#snippet children(resultItems)}
{#if resultItems.length > 0}
<ul class="results-list">
{#each searchResults as item (item.id)}
{#each resultItems as item (item.id)}
<li class="result-item">
<button
class="result-button"
class:disabled={!canAddMore}
on:click={() => handleItemClick(item)}
onclick={() => handleItemClick(item)}
aria-label="{canAddMore ? 'Add' : 'Grid full - cannot add'} {getItemName(item)}"
disabled={!canAddMore}
>
<img
src={getImageUrl(item)}
alt={getItemName(item)}
class="result-image"
/>
<img src={getImageUrl(item)} alt={getItemName(item)} class="result-image" />
<span class="result-name">{getItemName(item)}</span>
{#if item.element !== undefined}
<span
class="result-element"
style="color: {elements.find(e => e.value === item.element)?.color}"
style="color: {elements.find((e) => e.value === item.element)?.color}"
>
{elements.find(e => e.value === item.element)?.label}
{elements.find((e) => e.value === item.element)?.label}
</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
{/snippet}
{#if totalPages > 1}
<div class="pagination">
<button
on:click={() => currentPage = Math.max(1, currentPage - 1)}
disabled={currentPage === 1}
>
Previous
</button>
<span>Page {currentPage} of {totalPages}</span>
<button
on:click={() => currentPage = Math.min(totalPages, currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
</button>
</div>
{/if}
{:else if searchQuery.length > 0}
{#snippet loadingSnippet()}
<div class="loading">Searching...</div>
{/snippet}
{#snippet emptySnippet()}
<div class="no-results">No results found</div>
{:else if !hasInitialLoad}
<div class="empty-state">Loading recent items...</div>
{:else}
<div class="no-results">No items found</div>
{/if}
{/snippet}
{#snippet loadingMoreSnippet()}
<div class="loading-more">Loading more...</div>
{/snippet}
{#snippet endSnippet()}
<div class="end-of-results">All results loaded</div>
{/snippet}
</InfiniteScrollQuery>
</div>
</aside>
@ -331,7 +313,7 @@
background: var(--app-bg, #fff);
display: none;
flex-direction: column;
box-shadow: -2px 0 8px rgba(0,0,0,0.1);
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
border-left: 1px solid #e0e0e0;
position: sticky;
top: 0;
@ -370,7 +352,7 @@
border-radius: 4px;
&:hover {
background: rgba(0,0,0,0.05);
background: rgba(0, 0, 0, 0.05);
}
}
}
@ -455,13 +437,14 @@
}
}
.results-section {
flex: 1;
overflow-y: auto;
padding: 12px;
.loading, .no-results, .empty-state {
.loading,
.no-results,
.empty-state {
text-align: center;
padding: 24px;
color: #666;
@ -492,7 +475,7 @@
&:hover {
background: #f5f5f5;
border-color: #3366ff;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
&:active:not(:disabled) {
@ -536,37 +519,18 @@
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #e0e0e0;
button {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
&:hover:not(:disabled) {
background: #f0f0f0;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
span {
font-size: 13px;
.loading-more {
text-align: center;
padding: 16px;
color: #666;
font-size: 14px;
}
.end-of-results {
text-align: center;
padding: 16px;
color: #999;
font-size: 13px;
}
}
</style>