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} /> <svelte:options runes={true} />
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import type { SearchResult } from '$lib/api/resources/search'
import { searchWeapons, searchCharacters, searchSummons, 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 { interface Props {
open?: boolean open?: boolean
@ -20,13 +22,34 @@
canAddMore = true canAddMore = true
}: Props = $props() }: Props = $props()
// Search state // Search state - simple reactive values with Svelte 5 runes
let searchQuery = $state('') let searchQuery = $state('')
let searchResults = $state<SearchResult[]>([]) let debouncedQuery = $state('')
let isLoading = $state(false) let debounceTimer: ReturnType<typeof setTimeout> | undefined
let currentPage = $state(1)
let totalPages = $state(1) // Debounce the search query using $effect
let hasInitialLoad = $state(false) $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 // Filter state
let elementFilters = $state<number[]>([]) let elementFilters = $state<number[]>([])
@ -35,7 +58,6 @@
// Refs // Refs
let searchInput: HTMLInputElement let searchInput: HTMLInputElement
let resultsContainer: HTMLDivElement
// Constants // Constants
const elements = [ const elements = [
@ -67,74 +89,44 @@
{ value: 10, label: 'Katana' } { 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(() => { $effect(() => {
if (open && searchInput) { if (open && searchInput) {
searchInput.focus() 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) { function handleItemClick(item: SearchResult) {
// Only add if we can add more items
if (canAddMore) { if (canAddMore) {
onAddItems([item]) onAddItems([item])
} }
@ -142,7 +134,7 @@
function toggleElementFilter(element: number) { function toggleElementFilter(element: number) {
if (elementFilters.includes(element)) { if (elementFilters.includes(element)) {
elementFilters = elementFilters.filter(e => e !== element) elementFilters = elementFilters.filter((e) => e !== element)
} else { } else {
elementFilters = [...elementFilters, element] elementFilters = [...elementFilters, element]
} }
@ -150,7 +142,7 @@
function toggleRarityFilter(rarity: number) { function toggleRarityFilter(rarity: number) {
if (rarityFilters.includes(rarity)) { if (rarityFilters.includes(rarity)) {
rarityFilters = rarityFilters.filter(r => r !== rarity) rarityFilters = rarityFilters.filter((r) => r !== rarity)
} else { } else {
rarityFilters = [...rarityFilters, rarity] rarityFilters = [...rarityFilters, rarity]
} }
@ -158,7 +150,7 @@
function toggleProficiencyFilter(prof: number) { function toggleProficiencyFilter(prof: number) {
if (proficiencyFilters.includes(prof)) { if (proficiencyFilters.includes(prof)) {
proficiencyFilters = proficiencyFilters.filter(p => p !== prof) proficiencyFilters = proficiencyFilters.filter((p) => p !== prof)
} else { } else {
proficiencyFilters = [...proficiencyFilters, prof] proficiencyFilters = [...proficiencyFilters, prof]
} }
@ -186,14 +178,14 @@
<aside <aside
class="sidebar" class="sidebar"
class:open={open} class:open
aria-hidden={!open} aria-hidden={!open}
aria-label="Search {type}s" aria-label="Search {type}s"
on:keydown={handleKeyDown} onkeydown={handleKeyDown}
> >
<header class="sidebar-header"> <header class="sidebar-header">
<h2>Search {type}s</h2> <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> </header>
<div class="search-section"> <div class="search-section">
@ -217,7 +209,7 @@
class="filter-btn element-btn" class="filter-btn element-btn"
class:active={elementFilters.includes(element.value)} class:active={elementFilters.includes(element.value)}
style="--element-color: {element.color}" style="--element-color: {element.color}"
on:click={() => toggleElementFilter(element.value)} onclick={() => toggleElementFilter(element.value)}
aria-pressed={elementFilters.includes(element.value)} aria-pressed={elementFilters.includes(element.value)}
> >
{element.label} {element.label}
@ -234,7 +226,7 @@
<button <button
class="filter-btn rarity-btn" class="filter-btn rarity-btn"
class:active={rarityFilters.includes(rarity.value)} class:active={rarityFilters.includes(rarity.value)}
on:click={() => toggleRarityFilter(rarity.value)} onclick={() => toggleRarityFilter(rarity.value)}
aria-pressed={rarityFilters.includes(rarity.value)} aria-pressed={rarityFilters.includes(rarity.value)}
> >
{rarity.label} {rarity.label}
@ -252,7 +244,7 @@
<button <button
class="filter-btn prof-btn" class="filter-btn prof-btn"
class:active={proficiencyFilters.includes(prof.value)} class:active={proficiencyFilters.includes(prof.value)}
on:click={() => toggleProficiencyFilter(prof.value)} onclick={() => toggleProficiencyFilter(prof.value)}
aria-pressed={proficiencyFilters.includes(prof.value)} aria-pressed={proficiencyFilters.includes(prof.value)}
> >
{prof.label} {prof.label}
@ -264,63 +256,53 @@
</div> </div>
<!-- Results --> <!-- Results -->
<div class="results-section" bind:this={resultsContainer}> <div class="results-section">
{#if isLoading} <InfiniteScrollQuery query={searchQueryResult} threshold={300}>
<div class="loading">Searching...</div> {#snippet children(resultItems)}
{:else if searchResults.length > 0} {#if resultItems.length > 0}
<ul class="results-list"> <ul class="results-list">
{#each searchResults as item (item.id)} {#each resultItems as item (item.id)}
<li class="result-item"> <li class="result-item">
<button <button
class="result-button" class="result-button"
class:disabled={!canAddMore} class:disabled={!canAddMore}
on:click={() => handleItemClick(item)} onclick={() => handleItemClick(item)}
aria-label="{canAddMore ? 'Add' : 'Grid full - cannot add'} {getItemName(item)}" aria-label="{canAddMore ? 'Add' : 'Grid full - cannot add'} {getItemName(item)}"
disabled={!canAddMore} disabled={!canAddMore}
> >
<img <img src={getImageUrl(item)} alt={getItemName(item)} class="result-image" />
src={getImageUrl(item)}
alt={getItemName(item)}
class="result-image"
/>
<span class="result-name">{getItemName(item)}</span> <span class="result-name">{getItemName(item)}</span>
{#if item.element !== undefined} {#if item.element !== undefined}
<span <span
class="result-element" 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> </span>
{/if} {/if}
</button> </button>
</li> </li>
{/each} {/each}
</ul> </ul>
{/if}
{/snippet}
{#if totalPages > 1} {#snippet loadingSnippet()}
<div class="pagination"> <div class="loading">Searching...</div>
<button {/snippet}
on:click={() => currentPage = Math.max(1, currentPage - 1)}
disabled={currentPage === 1} {#snippet emptySnippet()}
>
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}
<div class="no-results">No results found</div> <div class="no-results">No results found</div>
{:else if !hasInitialLoad} {/snippet}
<div class="empty-state">Loading recent items...</div>
{:else} {#snippet loadingMoreSnippet()}
<div class="no-results">No items found</div> <div class="loading-more">Loading more...</div>
{/if} {/snippet}
{#snippet endSnippet()}
<div class="end-of-results">All results loaded</div>
{/snippet}
</InfiniteScrollQuery>
</div> </div>
</aside> </aside>
@ -331,7 +313,7 @@
background: var(--app-bg, #fff); background: var(--app-bg, #fff);
display: none; display: none;
flex-direction: column; 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; border-left: 1px solid #e0e0e0;
position: sticky; position: sticky;
top: 0; top: 0;
@ -370,7 +352,7 @@
border-radius: 4px; border-radius: 4px;
&:hover { &:hover {
background: rgba(0,0,0,0.05); background: rgba(0, 0, 0, 0.05);
} }
} }
} }
@ -455,13 +437,14 @@
} }
} }
.results-section { .results-section {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 12px; padding: 12px;
.loading, .no-results, .empty-state { .loading,
.no-results,
.empty-state {
text-align: center; text-align: center;
padding: 24px; padding: 24px;
color: #666; color: #666;
@ -492,7 +475,7 @@
&:hover { &:hover {
background: #f5f5f5; background: #f5f5f5;
border-color: #3366ff; 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) { &:active:not(:disabled) {
@ -536,37 +519,18 @@
} }
} }
.pagination { .loading-more {
display: flex; text-align: center;
justify-content: center; padding: 16px;
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;
color: #666; color: #666;
font-size: 14px;
} }
.end-of-results {
text-align: center;
padding: 16px;
color: #999;
font-size: 13px;
} }
} }
</style> </style>