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:
parent
aa16d58175
commit
e582629552
1 changed files with 466 additions and 502 deletions
|
|
@ -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>
|
||||
Loading…
Reference in a new issue