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:
parent
e7adc48042
commit
aa16d58175
7 changed files with 629 additions and 38 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
216
src/lib/api/queries/search.queries.ts
Normal file
216
src/lib/api/queries/search.queries.ts
Normal 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
|
||||
})
|
||||
}
|
||||
314
src/lib/components/InfiniteScrollQuery.svelte
Normal file
314
src/lib/components/InfiniteScrollQuery.svelte
Normal 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
25
src/lib/query/keys.ts
Normal 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
|
||||
}
|
||||
}
|
||||
16
src/lib/query/queryClient.ts
Normal file
16
src/lib/query/queryClient.ts
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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 *;
|
||||
|
|
|
|||
Loading…
Reference in a new issue