add tanstack query with infinite scroll support

- integrate @tanstack/svelte-query into layout
- add query client factory and query keys
- new InfiniteScrollQuery component for paginated data
- search query options for weapons/characters/summons
- update dev port to 5174
This commit is contained in:
Justin Edmund 2025-11-28 11:00:57 -08:00
parent e7adc48042
commit aa16d58175
7 changed files with 629 additions and 38 deletions

View file

@ -4,7 +4,7 @@
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"dev": "vite dev --port 5174",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
@ -64,6 +64,7 @@
"packageManager": "pnpm@10.15.1+sha512.34e538c329b5553014ca8e8f4535997f96180a1d0f614339357449935350d924e22f8614682191264ec33d1462ac21561aff97f6bb18065351c162c7e8f6de67",
"dependencies": {
"@friendofsvelte/tipex": "^0.0.9",
"@tanstack/svelte-query": "^6.0.9",
"@tiptap/core": "^3.5.1",
"@tiptap/extension-highlight": "^3.5.1",
"@tiptap/extension-link": "^3.5.1",

View file

@ -11,6 +11,9 @@ importers:
'@friendofsvelte/tipex':
specifier: ^0.0.9
version: 0.0.9(highlight.js@11.8.0)(svelte@5.38.7)
'@tanstack/svelte-query':
specifier: ^6.0.9
version: 6.0.9(svelte@5.38.7)
'@tiptap/core':
specifier: ^3.5.1
version: 3.5.1(@tiptap/pm@3.5.1)
@ -834,6 +837,14 @@ packages:
'@swc/helpers@0.5.17':
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
'@tanstack/query-core@5.90.11':
resolution: {integrity: sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==}
'@tanstack/svelte-query@6.0.9':
resolution: {integrity: sha512-ezawzencc07h61M+p8R9Opp2CmpgGwrM05IsIGJiPkr1SrBPW8gDZ9sTdaQbEpzLNXMXaZUkq0MS+61Rw2EfSg==}
peerDependencies:
svelte: ^5.25.0
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@ -2816,8 +2827,8 @@ snapshots:
'@friendofsvelte/tipex@0.0.9(highlight.js@11.8.0)(svelte@5.38.7)':
dependencies:
'@tiptap/core': 2.26.2(@tiptap/pm@2.26.2)
'@tiptap/extension-code-block': 2.26.2(@tiptap/core@3.5.1(@tiptap/pm@3.5.1))(@tiptap/pm@3.5.1)
'@tiptap/extension-code-block-lowlight': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/extension-code-block@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)(highlight.js@11.8.0)(lowlight@2.9.0)
'@tiptap/extension-code-block': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)
'@tiptap/extension-code-block-lowlight': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/extension-code-block@2.26.2(@tiptap/core@3.5.1(@tiptap/pm@3.5.1))(@tiptap/pm@3.5.1))(@tiptap/pm@2.26.2)(highlight.js@11.8.0)(lowlight@2.9.0)
'@tiptap/extension-floating-menu': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)
'@tiptap/extension-image': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))
'@tiptap/extension-link': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)
@ -3267,6 +3278,13 @@ snapshots:
dependencies:
tslib: 2.8.1
'@tanstack/query-core@5.90.11': {}
'@tanstack/svelte-query@6.0.9(svelte@5.38.7)':
dependencies:
'@tanstack/query-core': 5.90.11
svelte: 5.38.7
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.27.1
@ -3323,10 +3341,10 @@ snapshots:
dependencies:
'@tiptap/extension-list': 3.5.1(@tiptap/core@3.5.1(@tiptap/pm@3.5.1))(@tiptap/pm@3.5.1)
'@tiptap/extension-code-block-lowlight@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/extension-code-block@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)(highlight.js@11.8.0)(lowlight@2.9.0)':
'@tiptap/extension-code-block-lowlight@2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/extension-code-block@2.26.2(@tiptap/core@3.5.1(@tiptap/pm@3.5.1))(@tiptap/pm@3.5.1))(@tiptap/pm@2.26.2)(highlight.js@11.8.0)(lowlight@2.9.0)':
dependencies:
'@tiptap/core': 2.26.2(@tiptap/pm@2.26.2)
'@tiptap/extension-code-block': 2.26.2(@tiptap/core@3.5.1(@tiptap/pm@3.5.1))(@tiptap/pm@3.5.1)
'@tiptap/extension-code-block': 2.26.2(@tiptap/core@2.26.2(@tiptap/pm@2.26.2))(@tiptap/pm@2.26.2)
'@tiptap/pm': 2.26.2
highlight.js: 11.8.0
lowlight: 2.9.0
@ -3336,11 +3354,6 @@ snapshots:
'@tiptap/core': 2.26.2(@tiptap/pm@2.26.2)
'@tiptap/pm': 2.26.2
'@tiptap/extension-code-block@2.26.2(@tiptap/core@3.5.1(@tiptap/pm@3.5.1))(@tiptap/pm@3.5.1)':
dependencies:
'@tiptap/core': 3.5.1(@tiptap/pm@3.5.1)
'@tiptap/pm': 3.5.1
'@tiptap/extension-code-block@3.5.1(@tiptap/core@3.5.1(@tiptap/pm@3.5.1))(@tiptap/pm@3.5.1)':
dependencies:
'@tiptap/core': 3.5.1(@tiptap/pm@3.5.1)

View file

@ -0,0 +1,216 @@
/**
* Search Query Options Factory
*
* Provides type-safe, reusable query configurations for search operations
* using TanStack Query v6 patterns.
*
* @module api/queries/search
*/
import { infiniteQueryOptions } from '@tanstack/svelte-query'
import {
searchWeapons,
searchCharacters,
searchSummons,
type SearchParams
} from '$lib/api/resources/search'
/**
* Filter configuration for search queries
*/
export interface SearchFilters {
element?: number[]
rarity?: number[]
proficiency?: number[]
proficiency2?: number[]
subaura?: boolean
extra?: boolean
}
/**
* Standard page result format for infinite queries
*/
export interface SearchPageResult {
results: Array<{
id: string
granblue_id: string
name: { en?: string; ja?: string }
element?: number
rarity?: number
proficiency?: number
series?: number
image_url?: string
searchable_type: 'Weapon' | 'Character' | 'Summon'
}>
page: number
totalPages: number
}
/**
* Builds search parameters from query string and filters
*/
function buildSearchParams(
query: string,
filters: SearchFilters | undefined,
page: number,
locale: 'en' | 'ja' = 'en'
): SearchParams {
const params: SearchParams = {
page,
locale
}
// Only include query if not empty
if (query && query.trim().length > 0) {
params.query = query.trim()
}
// Build filters object with only defined values
if (filters) {
const apiFilters: NonNullable<SearchParams['filters']> = {}
if (filters.element && filters.element.length > 0) {
apiFilters.element = filters.element
}
if (filters.rarity && filters.rarity.length > 0) {
apiFilters.rarity = filters.rarity
}
if (filters.proficiency && filters.proficiency.length > 0) {
apiFilters.proficiency1 = filters.proficiency
}
if (filters.proficiency2 && filters.proficiency2.length > 0) {
apiFilters.proficiency2 = filters.proficiency2
}
if (filters.subaura !== undefined) {
apiFilters.subaura = filters.subaura
}
if (filters.extra !== undefined) {
apiFilters.extra = filters.extra
}
// Only include filters if any were set
if (Object.keys(apiFilters).length > 0) {
params.filters = apiFilters
}
}
return params
}
/**
* Search query options factory
*
* Provides infinite query configurations for all search types.
* These can be used with `createInfiniteQuery` or for prefetching.
*
* @example
* ```typescript
* import { createInfiniteQuery } from '@tanstack/svelte-query'
* import { searchQueries } from '$lib/api/queries/search.queries'
*
* // In a component
* let query = $state('')
* let filters = $state({ element: [1, 2] })
*
* const weaponSearch = createInfiniteQuery(() =>
* searchQueries.weapons(query, filters)
* )
* ```
*/
export const searchQueries = {
/**
* Weapon search infinite query options
*
* @param query - Search query string
* @param filters - Optional filter configuration
* @param locale - Locale for results (default: 'en')
* @returns Infinite query options for weapon search
*/
weapons: (query: string = '', filters?: SearchFilters, locale: 'en' | 'ja' = 'en') =>
infiniteQueryOptions({
queryKey: ['search', 'weapons', query, filters, locale] as const,
queryFn: async ({ pageParam }): Promise<SearchPageResult> => {
const params = buildSearchParams(query, filters, pageParam, locale)
const response = await searchWeapons(params)
return {
results: response.results,
page: response.meta?.page ?? response.page ?? pageParam,
totalPages: response.meta?.total_pages ?? response.total_pages ?? 1
}
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1
}
return undefined
},
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
}),
/**
* Character search infinite query options
*
* @param query - Search query string
* @param filters - Optional filter configuration
* @param locale - Locale for results (default: 'en')
* @returns Infinite query options for character search
*/
characters: (query: string = '', filters?: SearchFilters, locale: 'en' | 'ja' = 'en') =>
infiniteQueryOptions({
queryKey: ['search', 'characters', query, filters, locale] as const,
queryFn: async ({ pageParam }): Promise<SearchPageResult> => {
const params = buildSearchParams(query, filters, pageParam, locale)
const response = await searchCharacters(params)
return {
results: response.results,
page: response.meta?.page ?? response.page ?? pageParam,
totalPages: response.meta?.total_pages ?? response.total_pages ?? 1
}
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1
}
return undefined
},
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
}),
/**
* Summon search infinite query options
*
* @param query - Search query string
* @param filters - Optional filter configuration
* @param locale - Locale for results (default: 'en')
* @returns Infinite query options for summon search
*/
summons: (query: string = '', filters?: SearchFilters, locale: 'en' | 'ja' = 'en') =>
infiniteQueryOptions({
queryKey: ['search', 'summons', query, filters, locale] as const,
queryFn: async ({ pageParam }): Promise<SearchPageResult> => {
const params = buildSearchParams(query, filters, pageParam, locale)
const response = await searchSummons(params)
return {
results: response.results,
page: response.meta?.page ?? response.page ?? pageParam,
totalPages: response.meta?.total_pages ?? response.total_pages ?? 1
}
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1
}
return undefined
},
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
})
}

View file

@ -0,0 +1,314 @@
<script lang="ts">
import type { CreateInfiniteQueryResult, InfiniteData } from '@tanstack/svelte-query'
import type { Snippet } from 'svelte'
import { IsInViewport } from 'runed'
interface PageData {
results: unknown[]
page: number
totalPages: number
}
interface Props {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
query: CreateInfiniteQueryResult<InfiniteData<PageData, any>, Error>
children: Snippet<[unknown[]]>
loadingSnippet?: Snippet
loadingMoreSnippet?: Snippet
errorSnippet?: Snippet<[Error]>
emptySnippet?: Snippet
endSnippet?: Snippet
class?: string
threshold?: number
}
let {
query,
children,
loadingSnippet,
loadingMoreSnippet,
errorSnippet,
emptySnippet,
endSnippet,
class: className = '',
threshold = 200
}: Props = $props()
// Sentinel element for intersection observation
let sentinel = $state<HTMLElement>()
// Use runed's IsInViewport for viewport detection
const inViewport = new IsInViewport(() => sentinel, {
rootMargin: `${threshold}px`
})
// Auto-fetch next page when sentinel is visible
$effect(() => {
if (
inViewport.current &&
query.hasNextPage &&
!query.isFetchingNextPage &&
!query.isLoading
) {
query.fetchNextPage()
}
})
// Flatten all pages into a single items array
const items = $derived(query.data?.pages.flatMap((page) => page.results) ?? [])
// Computed states
const isEmpty = $derived(items.length === 0 && !query.isLoading && !query.isError)
const isLoadingInitial = $derived(query.isLoading && !query.isFetchingNextPage)
const showSentinel = $derived(
!query.isLoading && query.hasNextPage && items.length > 0
)
const showEnd = $derived(
!query.hasNextPage && !isEmpty && !query.isLoading && items.length > 0
)
// Accessibility announcements
function announceToScreenReader(message: string) {
const announcement = document.createElement('div')
announcement.setAttribute('role', 'status')
announcement.setAttribute('aria-live', 'polite')
announcement.setAttribute('aria-atomic', 'true')
announcement.className = 'sr-only'
announcement.textContent = message
document.body.appendChild(announcement)
setTimeout(() => announcement.remove(), 1000)
}
$effect(() => {
if (query.isFetchingNextPage) {
announceToScreenReader('Loading more items...')
}
})
$effect(() => {
if (!query.hasNextPage && items.length > 0) {
announceToScreenReader('All items have been loaded')
}
})
</script>
<div class="infinite-scroll-container {className}">
<!-- Main content -->
{#if !isLoadingInitial}
{@render children(items)}
{/if}
<!-- Loading indicator for initial load -->
{#if isLoadingInitial}
{#if loadingSnippet}
{@render loadingSnippet()}
{:else}
<div class="loading-initial">
<span class="spinner" aria-hidden="true"></span>
<span>Loading...</span>
</div>
{/if}
{/if}
<!-- Empty state -->
{#if isEmpty}
{#if emptySnippet}
{@render emptySnippet()}
{:else}
<div class="empty-state">
<p>No items found</p>
</div>
{/if}
{/if}
<!-- Sentinel element for intersection observer -->
{#if showSentinel}
<div bind:this={sentinel} class="sentinel" aria-hidden="true"></div>
{/if}
<!-- Loading more indicator -->
{#if query.isFetchingNextPage}
{#if loadingMoreSnippet}
{@render loadingMoreSnippet()}
{:else}
<div class="loading-more" aria-busy="true">
<span class="spinner" aria-hidden="true"></span>
<span>Loading more...</span>
</div>
{/if}
{/if}
<!-- Error state with retry -->
{#if query.isError && !query.isFetchingNextPage}
{#if errorSnippet}
{@render errorSnippet(query.error)}
{:else}
<div class="error-state" role="alert">
<p>Failed to load items</p>
<button class="retry-button" onclick={() => query.refetch()} aria-label="Retry loading items">
Try Again
</button>
</div>
{/if}
{/if}
<!-- End of list indicator -->
{#if showEnd}
{#if endSnippet}
{@render endSnippet()}
{:else}
<div class="end-state">
<p>No more items to load</p>
</div>
{/if}
{/if}
<!-- Fallback load more button for accessibility -->
{#if query.hasNextPage && !query.isFetchingNextPage && !query.isLoading && items.length > 0}
<button
class="load-more-fallback"
onclick={() => query.fetchNextPage()}
aria-label="Load more items"
>
Load More
</button>
{/if}
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/layout' as *;
.infinite-scroll-container {
position: relative;
width: 100%;
}
.sentinel {
height: 1px;
margin-top: -200px;
pointer-events: none;
}
.loading-initial,
.loading-more,
.error-state,
.empty-state,
.end-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $unit-4x;
text-align: center;
gap: $unit;
}
.loading-initial,
.loading-more {
color: var(--text-secondary);
}
.error-state {
color: var(--text-error, #dc2626);
p {
margin: 0 0 $unit 0;
}
}
.empty-state,
.end-state {
color: var(--text-tertiary);
p {
margin: 0;
}
}
.spinner {
display: inline-block;
width: 24px;
height: 24px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--primary-color, #3366ff);
border-radius: 50%;
animation: spin 1s linear infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
opacity: 0.8;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.retry-button {
padding: $unit $unit-2x;
background: var(--button-bg, #3366ff);
color: var(--button-text, white);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: inherit;
font-family: inherit;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
&:active {
transform: translateY(1px);
}
}
.load-more-fallback {
display: block;
margin: $unit-2x auto;
padding: $unit $unit-2x;
background: var(--button-bg, #f3f4f6);
color: var(--button-text, #1f2937);
border: 1px solid var(--button-border, #e5e7eb);
border-radius: 4px;
cursor: pointer;
font-size: inherit;
font-family: inherit;
transition: all 0.2s;
// Only show for keyboard/screen reader users by default
&:not(:focus) {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
&:hover {
background: var(--button-bg-hover, #e5e7eb);
}
}
// Screen reader only content
:global(.sr-only) {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>

25
src/lib/query/keys.ts Normal file
View file

@ -0,0 +1,25 @@
/**
* Query key factory for type-safe cache keys
* Keys are structured hierarchically for easy invalidation
*/
export const queryKeys = {
search: {
all: ['search'] as const,
weapons: (query: string, filters?: Record<string, unknown>) =>
[...queryKeys.search.all, 'weapons', query, filters] as const,
characters: (query: string, filters?: Record<string, unknown>) =>
[...queryKeys.search.all, 'characters', query, filters] as const,
summons: (query: string, filters?: Record<string, unknown>) =>
[...queryKeys.search.all, 'summons', query, filters] as const
},
parties: {
all: ['parties'] as const,
explore: () => [...queryKeys.parties.all, 'explore'] as const,
user: (username: string) => [...queryKeys.parties.all, 'user', username] as const
},
jobs: {
all: ['jobs'] as const,
skills: (jobId: string, query?: string, filters?: Record<string, unknown>) =>
[...queryKeys.jobs.all, 'skills', jobId, query, filters] as const
}
}

View file

@ -0,0 +1,16 @@
import { QueryClient } from '@tanstack/svelte-query'
import { browser } from '$app/environment'
export function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
enabled: browser,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
retry: 2,
refetchOnWindowFocus: false
}
}
})
}

View file

@ -10,6 +10,10 @@
import { beforeNavigate, afterNavigate } from '$app/navigation'
import { authStore } from '$lib/stores/auth.store'
import { browser } from '$app/environment'
import { QueryClientProvider } from '@tanstack/svelte-query'
import { createQueryClient } from '$lib/query/queryClient'
const queryClient = createQueryClient()
// Get `data` and `children` from the router via $props()
// Use a more flexible type that allows additional properties from child pages
@ -105,36 +109,38 @@
<link rel="icon" href={favicon} />
</svelte:head>
<Tooltip.Provider>
<div class="app-container" class:sidebar-open={sidebar.isOpen}>
<div class="main-pane">
<div class="nav-blur-background"></div>
<div class="main-navigation">
<Navigation
isAuthenticated={data?.isAuthenticated}
account={data?.account}
currentUser={data?.currentUser}
/>
<QueryClientProvider client={queryClient}>
<Tooltip.Provider>
<div class="app-container" class:sidebar-open={sidebar.isOpen}>
<div class="main-pane">
<div class="nav-blur-background"></div>
<div class="main-navigation">
<Navigation
isAuthenticated={data?.isAuthenticated}
account={data?.account}
currentUser={data?.currentUser}
/>
</div>
<main class="main-content" bind:this={mainContent}>
{@render children?.()}
</main>
</div>
<main class="main-content" bind:this={mainContent}>
{@render children?.()}
</main>
</div>
<Sidebar
open={sidebar.isOpen}
title={sidebar.title}
onclose={() => sidebar.close()}
scrollable={sidebar.scrollable}
>
{#if sidebar.component}
<svelte:component this={sidebar.component} {...sidebar.componentProps} />
{:else if sidebar.content}
{@render sidebar.content()}
{/if}
</Sidebar>
</div>
</Tooltip.Provider>
<Sidebar
open={sidebar.isOpen}
title={sidebar.title}
onclose={() => sidebar.close()}
scrollable={sidebar.scrollable}
>
{#if sidebar.component}
<svelte:component this={sidebar.component} {...sidebar.componentProps} />
{:else if sidebar.content}
{@render sidebar.content()}
{/if}
</Sidebar>
</div>
</Tooltip.Provider>
</QueryClientProvider>
<style lang="scss">
@use '$src/themes/effects' as *;