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} />
|
<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>
|
||||||
Loading…
Reference in a new issue