diff --git a/package.json b/package.json index 8e6e24d0..c25ca315 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65875aa4..0ca5841c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/src/lib/api/queries/search.queries.ts b/src/lib/api/queries/search.queries.ts new file mode 100644 index 00000000..5eaf6b1e --- /dev/null +++ b/src/lib/api/queries/search.queries.ts @@ -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 = {} + + 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 => { + 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 => { + 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 => { + 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 + }) +} diff --git a/src/lib/components/InfiniteScrollQuery.svelte b/src/lib/components/InfiniteScrollQuery.svelte new file mode 100644 index 00000000..9ba1b494 --- /dev/null +++ b/src/lib/components/InfiniteScrollQuery.svelte @@ -0,0 +1,314 @@ + + +
+ + {#if !isLoadingInitial} + {@render children(items)} + {/if} + + + {#if isLoadingInitial} + {#if loadingSnippet} + {@render loadingSnippet()} + {:else} +
+ + Loading... +
+ {/if} + {/if} + + + {#if isEmpty} + {#if emptySnippet} + {@render emptySnippet()} + {:else} +
+

No items found

+
+ {/if} + {/if} + + + {#if showSentinel} + + {/if} + + + {#if query.isFetchingNextPage} + {#if loadingMoreSnippet} + {@render loadingMoreSnippet()} + {:else} +
+ + Loading more... +
+ {/if} + {/if} + + + {#if query.isError && !query.isFetchingNextPage} + {#if errorSnippet} + {@render errorSnippet(query.error)} + {:else} + + {/if} + {/if} + + + {#if showEnd} + {#if endSnippet} + {@render endSnippet()} + {:else} +
+

No more items to load

+
+ {/if} + {/if} + + + {#if query.hasNextPage && !query.isFetchingNextPage && !query.isLoading && items.length > 0} + + {/if} +
+ + diff --git a/src/lib/query/keys.ts b/src/lib/query/keys.ts new file mode 100644 index 00000000..12bd7727 --- /dev/null +++ b/src/lib/query/keys.ts @@ -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) => + [...queryKeys.search.all, 'weapons', query, filters] as const, + characters: (query: string, filters?: Record) => + [...queryKeys.search.all, 'characters', query, filters] as const, + summons: (query: string, filters?: Record) => + [...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) => + [...queryKeys.jobs.all, 'skills', jobId, query, filters] as const + } +} diff --git a/src/lib/query/queryClient.ts b/src/lib/query/queryClient.ts new file mode 100644 index 00000000..0ba65f2c --- /dev/null +++ b/src/lib/query/queryClient.ts @@ -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 + } + } + }) +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a3644abd..2e5253e2 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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 @@ - -
-
- - - + sidebar.close()} + scrollable={sidebar.scrollable} + > + {#if sidebar.component} + + {:else if sidebar.content} + {@render sidebar.content()} + {/if} + +
+ +