feat: migrate components to TanStack Query v6 (Phase 2) (#442)

# feat: migrate components to TanStack Query v6 (Phase 2)

## Summary

This PR migrates 4 components from the custom
`createInfiniteScrollResource` pattern to TanStack Query v6's
`createInfiniteQuery`:

- **JobSkillSelectionSidebar** - Job skill search with infinite scroll
and category filtering
- **SearchContent** - Search modal for weapons/characters/summons with
element/rarity/proficiency filters
- **User Profile Page** (`[username]/+page.svelte`) - User's teams and
favorites with tab switching
- **Teams Explore Page** (`teams/explore/+page.svelte`) - Public teams
listing

All components now use:
- TanStack Query v6 infinite query pattern with thunk for reactivity
- `IsInViewport` from runed for intersection-based infinite scroll
- Debounced search (debounce the value, not the query)
- Proper loading, error, and empty states
- SSR integration with `initialData` pattern

## Updates since last revision

- **Fixed duplicate key error in SearchContent.svelte** - The API can
return the same item across multiple pages during infinite scroll (e.g.,
due to items being added/removed between page fetches). This caused
Svelte's keyed each block to throw `each_key_duplicate` errors. Fixed by
deduplicating results by `id` using a Map before rendering.

## Review & Testing Checklist for Human

This is a medium-risk change affecting core user-facing pages. Please
verify:

- [ ] **Infinite scroll works on all 4 pages** - Scroll to bottom and
verify more items load automatically
- [ ] **No duplicate items appear in search results** - After the fix,
scrolling through many pages of search results should not show
duplicates
- [ ] **SSR hydration** - Verify no flash of loading state on initial
page load (data should be pre-rendered)
- [ ] **User profile tab switching** - Test switching between "Teams"
and "Favorites" tabs; verify correct data loads
- [ ] **Search debouncing** - Type quickly in JobSkillSelectionSidebar
and SearchContent; verify queries aren't fired on every keystroke
- [ ] **Error states** - Simulate network failure and verify retry
button works

**Recommended Test Plan:**
1. Navigate to `/teams/explore` - verify teams load and infinite scroll
works
2. Navigate to a user profile page - verify teams load, switch to
favorites tab, verify favorites load
3. Open the search sidebar - search for weapons/characters/summons,
scroll through many pages, verify no duplicate key errors and no
duplicate items
4. Open job skill selection - search and filter skills, verify results

### Notes

- The Party component mutations migration (Follow-Up Prompt 5) was
deferred to a follow-up PR due to complexity
- Deprecated resource classes remain in codebase for now; removal
planned for separate PR
- Pre-existing paraglide module errors in build are unrelated to this PR
- Type assertions (`as unknown as`, `as any`) are used to handle
different query result structures between favorites and parties queries

**Link to Devin run:**
https://app.devin.ai/sessions/5aa7ea29edf34f569f95f13acee9e0d9
**Requested by:** Justin Edmund (justin@jedmund.com) / @jedmund

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Justin Edmund <justin@jedmund.com>
This commit is contained in:
devin-ai-integration[bot] 2025-11-29 01:11:24 -08:00 committed by GitHub
parent 5764161803
commit f9bb43f214
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 729 additions and 298 deletions

View file

@ -0,0 +1,217 @@
# TanStack Query Migration - Continuation Guide
This document provides context for continuing the TanStack Query v6 migration in hensei-web.
## Migration Status
### Completed (PR #441 - merged)
- Query options factories: `party.queries.ts`, `job.queries.ts`, `user.queries.ts`, `search.queries.ts`
- Mutation configurations: `party.mutations.ts`, `grid.mutations.ts`, `job.mutations.ts`
- SSR utilities: `withInitialData`, `prefetchQuery`, `prefetchInfiniteQuery`
- Example components: `JobSelectionSidebar.svelte`, `teams/[id]/+page.svelte`
### Completed (PR #442 - pending merge)
- `JobSkillSelectionSidebar.svelte` - Job skill search with infinite scroll
- `SearchContent.svelte` - Search modal for weapons/characters/summons
- `[username]/+page.svelte` - User profile page with teams/favorites tabs
- `teams/explore/+page.svelte` - Public teams listing
### Remaining Work
#### Follow-Up Prompt 5: Party Component Mutations
**Priority: High**
**Complexity: Large**
The `Party.svelte` component (1535 lines) needs to be migrated to use TanStack Query mutations instead of direct service calls.
**Files to modify:**
- `src/lib/components/party/Party.svelte`
**Current state:** Uses `PartyService`, `GridService`, `ConflictService`, and direct `partyAdapter` calls.
**Target state:** Use mutation hooks from:
- `src/lib/api/mutations/party.mutations.ts` - `useUpdateParty`, `useDeleteParty`, `useRemixParty`, `useFavoriteParty`, `useUnfavoriteParty`, `useRegeneratePreview`
- `src/lib/api/mutations/grid.mutations.ts` - `useCreateGridWeapon`, `useUpdateGridWeapon`, `useDeleteGridWeapon`, etc.
- `src/lib/api/mutations/job.mutations.ts` - `useUpdatePartyJob`, `useUpdatePartyJobSkills`, `useRemovePartyJobSkill`, `useUpdatePartyAccessory`
**Recommended sub-tasks:**
1. **5a: Party metadata mutations** - name, description, visibility using `useUpdateParty`
2. **5b: Grid weapon mutations** - add/update/delete weapons using grid mutations
3. **5c: Grid character mutations** - add/update/delete characters using grid mutations
4. **5d: Grid summon mutations** - add/update/delete summons using grid mutations
5. **5e: Job and skill mutations** - job selection, skill management using job mutations
**Key functions to migrate in Party.svelte:**
- `updatePartyDetails()` - replace `partyService.update()` with `useUpdateParty().mutate()`
- `toggleFavorite()` - replace `partyService.favorite()/unfavorite()` with `useFavoriteParty()/useUnfavoriteParty()`
- `remixParty()` - replace `partyService.remix()` with `useRemixParty()`
- `deleteParty()` - replace `partyService.delete()` with `useDeleteParty()`
- `handleSelectJob()` - replace `partyAdapter.updateJob()` with `useUpdatePartyJob()`
- `handleSelectJobSkill()` - replace `partyAdapter.updateJobSkills()` with `useUpdatePartyJobSkills()`
- Drag-drop operations - replace `gridService.moveWeapon/Character/Summon()` with appropriate mutations
#### Follow-Up Prompt 6: Remove Deprecated Resource Classes
**Priority: Low**
**Complexity: Small**
**Prerequisite:** All components migrated away from resource classes
**Files to delete:**
- `src/lib/api/adapters/resources/search.resource.svelte.ts`
- `src/lib/api/adapters/resources/party.resource.svelte.ts`
- `src/lib/api/adapters/resources/job.resource.svelte.ts`
- `src/lib/api/adapters/resources/infiniteScroll.resource.svelte.ts`
**Steps:**
1. Search for any remaining imports: `grep -r "from.*resources/" src/`
2. Migrate any remaining usages
3. Delete the resource files
4. Update any barrel exports (index.ts files)
5. Run build to verify no import errors
**Current blockers:** `InfiniteScroll.svelte` still imports `InfiniteScrollResource` type
## Patterns and Best Practices
### Infinite Query Pattern
```typescript
import { createInfiniteQuery } from '@tanstack/svelte-query'
import { IsInViewport } from 'runed'
// Create the query with thunk for reactivity
const query = createInfiniteQuery(() => ({
...queryOptions.list(filters),
initialData: serverData ? {
pages: [{ results: serverData.items, page: 1, totalPages: serverData.totalPages }],
pageParams: [1]
} : undefined,
initialDataUpdatedAt: 0
}))
// Flatten and deduplicate results
const rawResults = $derived(query.data?.pages.flatMap((page) => page.results) ?? [])
const items = $derived(Array.from(new Map(rawResults.map((item) => [item.id, item])).values()))
// Infinite scroll with IsInViewport
let sentinelEl = $state<HTMLElement>()
const inViewport = new IsInViewport(() => sentinelEl, { rootMargin: '200px' })
$effect(() => {
if (inViewport.current && query.hasNextPage && !query.isFetchingNextPage && !query.isLoading) {
query.fetchNextPage()
}
})
```
### Debounced Search Pattern
```typescript
let searchQuery = $state('')
let debouncedSearchQuery = $state('')
let debounceTimer: ReturnType<typeof setTimeout> | undefined
$effect(() => {
const query = searchQuery
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debouncedSearchQuery = query
}, 300)
return () => { if (debounceTimer) clearTimeout(debounceTimer) }
})
// Use debouncedSearchQuery in the query, not searchQuery
const query = createInfiniteQuery(() => queryOptions.search(debouncedSearchQuery))
```
### Type Assertions for Conditional Queries
When a query can return different types based on conditions, use type assertions:
```typescript
const query = createInfiniteQuery(() => {
if (condition) {
return queryOptionsA()
}
return queryOptionsB() as unknown as ReturnType<typeof queryOptionsA>
})
```
### Mutation Pattern
```typescript
import { useUpdateParty } from '$lib/api/mutations/party.mutations'
const updatePartyMutation = useUpdateParty()
function handleSave() {
updatePartyMutation.mutate(
{ partyId, updates },
{
onSuccess: () => { /* handle success */ },
onError: (error) => { /* handle error */ }
}
)
}
// Use mutation state for UI
{#if updatePartyMutation.isPending}
<span>Saving...</span>
{/if}
```
## Known Issues
### Pre-existing Build Errors
The build has pre-existing errors unrelated to TanStack Query migration:
- `Cannot find module '$lib/paraglide/server'` in `hooks.server.ts`
- `Cannot find module '$lib/paraglide/runtime'` in `hooks.ts`
- `Cannot find module '$lib/paraglide/messages'` in various components
These are paraglide i18n setup issues and should be ignored when checking for migration-related errors.
### Duplicate Key Error Fix
When using infinite queries, the API may return duplicate items across pages. Always deduplicate:
```typescript
const rawResults = $derived(query.data?.pages.flatMap((page) => page.results) ?? [])
const items = $derived(Array.from(new Map(rawResults.map((item) => [item.id, item])).values()))
```
## File Locations
### Query Options Factories
- `src/lib/api/queries/party.queries.ts`
- `src/lib/api/queries/job.queries.ts`
- `src/lib/api/queries/user.queries.ts`
- `src/lib/api/queries/search.queries.ts`
### Mutation Hooks
- `src/lib/api/mutations/party.mutations.ts`
- `src/lib/api/mutations/grid.mutations.ts`
- `src/lib/api/mutations/job.mutations.ts`
### SSR Utilities
- `src/lib/query/ssr.ts`
### Reference Implementations
- `src/lib/components/sidebar/JobSelectionSidebar.svelte` - Simple infinite query
- `src/lib/components/sidebar/JobSkillSelectionSidebar.svelte` - Infinite query with search
- `src/lib/components/sidebar/SearchContent.svelte` - Infinite query with filters and deduplication
- `src/routes/teams/[id]/+page.svelte` - SSR with initialData
- `src/routes/[username]/+page.svelte` - Conditional queries (teams vs favorites)
- `src/routes/teams/explore/+page.svelte` - Simple infinite scroll page
## Commands
```bash
# Run TypeScript check
pnpm run check
# Run development server
pnpm run dev
# Check for resource class imports
grep -r "from.*resources/" src/
# Check for createInfiniteScrollResource usage
grep -r "createInfiniteScrollResource" src/
```
## Branch Information
- Base branch: `svelte-main`
- PR #442 branch: `devin/1764405731-tanstack-query-migration-phase2`

View file

@ -1,15 +1,16 @@
<svelte:options runes={true} /> <svelte:options runes={true} />
<script lang="ts"> <script lang="ts">
import { createInfiniteQuery } from '@tanstack/svelte-query'
import type { Job, JobSkill } from '$lib/types/api/entities' import type { Job, JobSkill } from '$lib/types/api/entities'
import type { JobSkillList } from '$lib/types/api/party' import type { JobSkillList } from '$lib/types/api/party'
import { jobAdapter } from '$lib/api/adapters/job.adapter' import { jobQueries } from '$lib/api/queries/job.queries'
import { createInfiniteScrollResource } from '$lib/api/adapters/resources/infiniteScroll.resource.svelte'
import JobSkillItem from '../job/JobSkillItem.svelte' import JobSkillItem from '../job/JobSkillItem.svelte'
import Button from '../ui/Button.svelte' import Button from '../ui/Button.svelte'
import Input from '../ui/Input.svelte' import Input from '../ui/Input.svelte'
import Select from '../ui/Select.svelte' import Select from '../ui/Select.svelte'
import Icon from '../Icon.svelte' import Icon from '../Icon.svelte'
import { IsInViewport } from 'runed'
import * as m from '$lib/paraglide/messages' import * as m from '$lib/paraglide/messages'
interface Props { interface Props {
@ -41,101 +42,25 @@
{ value: 5, label: 'Base' } { value: 5, label: 'Base' }
] ]
// State // State for filtering (local UI state, not server state)
let searchQuery = $state('') let searchQuery = $state('')
let skillCategory = $state(-1) // -1 = All let skillCategory = $state(-1) // -1 = All
let error = $state<string | undefined>() let error = $state<string | undefined>()
let sentinelEl = $state<HTMLElement>()
let skillsResource = $state<ReturnType<typeof createInfiniteScrollResource<JobSkill>> | null>(null)
let lastSearchQuery = ''
let lastJobId: string | undefined
let lastCategory = -1
// Check if slot is locked // Debounced search value for query
const slotLocked = $derived(targetSlot === 0) let debouncedSearchQuery = $state('')
const currentSkill = $derived(currentSkills[targetSlot as keyof JobSkillList])
const canSearch = $derived(Boolean(job) && !slotLocked)
// Manage resource creation and search updates
let debounceTimer: ReturnType<typeof setTimeout> | undefined let debounceTimer: ReturnType<typeof setTimeout> | undefined
function updateSearch(forceRebuild = false) { // Debounce search query changes
const jobId = job?.id
const locked = slotLocked
const category = skillCategory
// Clean up if no job or locked
if (!jobId || locked) {
if (skillsResource) {
skillsResource.destroy()
skillsResource = null
}
lastJobId = undefined
lastSearchQuery = ''
lastCategory = -1
return
}
// Rebuild resource if job or category changed
const needsRebuild = forceRebuild || jobId !== lastJobId || category !== lastCategory
if (needsRebuild) {
if (skillsResource) {
skillsResource.destroy()
}
// Capture current values for the closure
const currentQuery = searchQuery
const currentCategory = category
const resource = createInfiniteScrollResource<JobSkill>({
fetcher: async (page) => {
const response = await jobAdapter.searchSkills({
query: currentQuery,
jobId,
page,
...(currentCategory >= 0 ? { filters: { group: currentCategory } } : {})
})
return response
},
threshold: 200,
debounceMs: 200
})
skillsResource = resource
lastJobId = jobId
lastSearchQuery = searchQuery
lastCategory = category
resource.load()
}
// Reload if only search query changed
else if (searchQuery !== lastSearchQuery && skillsResource) {
lastSearchQuery = searchQuery
// Need to rebuild to capture new query in closure
updateSearch(true)
}
}
// Watch for job and category changes
$effect(() => { $effect(() => {
job // Track job const query = searchQuery
skillCategory // Track category
updateSearch()
})
// Watch for search query changes with debounce
$effect(() => {
const query = searchQuery // Track searchQuery
if (debounceTimer) { if (debounceTimer) {
clearTimeout(debounceTimer) clearTimeout(debounceTimer)
} }
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
if (query !== lastSearchQuery) { debouncedSearchQuery = query
updateSearch()
}
}, 300) }, 300)
return () => { return () => {
@ -145,16 +70,60 @@
} }
}) })
// Bind sentinel when ready // Check if slot is locked
$effect(() => { const slotLocked = $derived(targetSlot === 0)
const sentinel = sentinelEl const currentSkill = $derived(currentSkills[targetSlot as keyof JobSkillList])
const resource = skillsResource const canSearch = $derived(Boolean(job) && !slotLocked)
if (sentinel && resource) { // TanStack Query v6: Use createInfiniteQuery with thunk pattern for reactivity
resource.bindSentinel(sentinel) // Query automatically updates when job, debouncedSearchQuery, or skillCategory changes
const skillsQuery = createInfiniteQuery(() => {
const jobId = job?.id
const query = debouncedSearchQuery
const category = skillCategory
// Build filter params
const filters = category >= 0 ? { group: category } : undefined
return {
...jobQueries.skills(jobId ?? '', {
query: query || undefined,
filters
}),
// Disable query when no job or slot is locked
enabled: !!jobId && !slotLocked
} }
}) })
// Flatten all pages into a single items array
const skills = $derived(skillsQuery.data?.pages.flatMap((page) => page.results) ?? [])
// Sentinel element for intersection observation
let sentinelEl = $state<HTMLElement>()
// Use runed's IsInViewport for viewport detection
const inViewport = new IsInViewport(() => sentinelEl, {
rootMargin: '200px'
})
// Auto-fetch next page when sentinel is visible
$effect(() => {
if (
inViewport.current &&
skillsQuery.hasNextPage &&
!skillsQuery.isFetchingNextPage &&
!skillsQuery.isLoading
) {
skillsQuery.fetchNextPage()
}
})
// Computed states
const isEmpty = $derived(skills.length === 0 && !skillsQuery.isLoading && !skillsQuery.isError)
const showSentinel = $derived(
!skillsQuery.isLoading && skillsQuery.hasNextPage && skills.length > 0
)
function handleSelectSkill(skill: JobSkill) { function handleSelectSkill(skill: JobSkill) {
// Clear any previous errors // Clear any previous errors
error = undefined error = undefined
@ -210,7 +179,7 @@
<div class="error-banner"> <div class="error-banner">
<Icon name="alert-circle" size={16} /> <Icon name="alert-circle" size={16} />
<p>{error}</p> <p>{error}</p>
<button class="close-error" on:click={() => error = undefined}> <button class="close-error" onclick={() => error = undefined}>
<Icon name="x" size={16} /> <Icon name="x" size={16} />
</button> </button>
</div> </div>
@ -243,44 +212,44 @@
<Icon name="briefcase" size={32} /> <Icon name="briefcase" size={32} />
<p>Select a job first</p> <p>Select a job first</p>
</div> </div>
{:else if slotLocked} {:else if slotLocked}
<div class="empty-state"> <div class="empty-state">
<Icon name="arrow-left" size={32} /> <Icon name="arrow-left" size={32} />
<p>This slot cannot be changed</p> <p>This slot cannot be changed</p>
</div> </div>
{:else if skillsResource?.loading} {:else if skillsQuery.isLoading}
<div class="loading-state"> <div class="loading-state">
<Icon name="loader-2" size={32} /> <Icon name="loader-2" size={32} />
<p>Loading skills...</p> <p>Loading skills...</p>
</div> </div>
{:else if skillsResource?.error} {:else if skillsQuery.isError}
<div class="error-state"> <div class="error-state">
<Icon name="alert-circle" size={32} /> <Icon name="alert-circle" size={32} />
<p>{skillsResource.error.message || 'Failed to load skills'}</p> <p>{skillsQuery.error?.message || 'Failed to load skills'}</p>
<Button size="small" on:click={() => skillsResource?.retry()}>Retry</Button> <Button size="small" onclick={() => skillsQuery.refetch()}>Retry</Button>
</div> </div>
{:else} {:else}
<div class="skills-list"> <div class="skills-list">
{#each skillsResource?.items || [] as skill (skill.id)} {#each skills as skill (skill.id)}
<JobSkillItem <JobSkillItem
{skill} {skill}
onClick={() => handleSelectSkill(skill)} onClick={() => handleSelectSkill(skill)}
/> />
{/each} {/each}
{#if skillsResource?.isEmpty} {#if isEmpty}
<div class="empty-state"> <div class="empty-state">
<Icon name="search-x" size={32} /> <Icon name="search-x" size={32} />
<p>No skills found</p> <p>No skills found</p>
{#if searchQuery || skillCategory >= 0} {#if searchQuery || skillCategory >= 0}
<div class="clear-filters"> <div class="clear-filters">
{#if searchQuery} {#if searchQuery}
<Button size="small" variant="ghost" on:click={() => searchQuery = ''}> <Button size="small" variant="ghost" onclick={() => searchQuery = ''}>
Clear search Clear search
</Button> </Button>
{/if} {/if}
{#if skillCategory >= 0} {#if skillCategory >= 0}
<Button size="small" variant="ghost" on:click={() => skillCategory = -1}> <Button size="small" variant="ghost" onclick={() => skillCategory = -1}>
Clear filter Clear filter
</Button> </Button>
{/if} {/if}
@ -289,11 +258,11 @@
</div> </div>
{/if} {/if}
{#if skillsResource?.hasMore && !skillsResource?.loadingMore} {#if showSentinel}
<div class="load-more-sentinel" bind:this={sentinelEl}></div> <div class="load-more-sentinel" bind:this={sentinelEl}></div>
{/if} {/if}
{#if skillsResource?.loadingMore} {#if skillsQuery.isFetchingNextPage}
<div class="loading-more"> <div class="loading-more">
<Icon name="loader-2" size={20} /> <Icon name="loader-2" size={20} />
<span>Loading more skills...</span> <span>Loading more skills...</span>

View file

@ -1,9 +1,12 @@
<svelte:options runes={true} /> <svelte:options runes={true} />
<script lang="ts"> <script lang="ts">
import { SearchAdapter } from '$lib/api/adapters/search.adapter' import { createInfiniteQuery } from '@tanstack/svelte-query'
import type { SearchResult } from '$lib/api/adapters/search.adapter' import type { SearchResult } from '$lib/api/adapters/search.adapter'
import { searchQueries, type SearchFilters } from '$lib/api/queries/search.queries'
import Button from '../ui/Button.svelte' import Button from '../ui/Button.svelte'
import Icon from '../Icon.svelte'
import { IsInViewport } from 'runed'
import { getCharacterImage, getWeaponImage, getSummonImage } from '$lib/features/database/detail/image' import { getCharacterImage, getWeaponImage, getSummonImage } from '$lib/features/database/detail/image'
interface Props { interface Props {
@ -18,16 +21,10 @@
canAddMore = true canAddMore = true
}: Props = $props() }: Props = $props()
// Search adapter // Search state (local UI state)
const searchAdapter = new SearchAdapter()
// Search state
let searchQuery = $state('') let searchQuery = $state('')
let searchResults = $state<SearchResult[]>([]) let debouncedSearchQuery = $state('')
let isLoading = $state(false) let debounceTimer: ReturnType<typeof setTimeout> | undefined
let currentPage = $state(1)
let totalPages = $state(1)
let hasInitialLoad = $state(false)
// Filter state // Filter state
let elementFilters = $state<number[]>([]) let elementFilters = $state<number[]>([])
@ -36,6 +33,7 @@
// Refs // Refs
let searchInput: HTMLInputElement let searchInput: HTMLInputElement
let sentinelEl = $state<HTMLElement>()
// Constants // Constants
const elements = [ const elements = [
@ -67,64 +65,95 @@
{ value: 10, label: 'Katana' } { value: 10, label: 'Katana' }
] ]
// Debounce search query changes
$effect(() => {
const query = searchQuery
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(() => {
debouncedSearchQuery = query
}, 300)
return () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
}
})
// Build filters object for query
const filters = $derived<SearchFilters>({
element: elementFilters.length > 0 ? elementFilters : undefined,
rarity: rarityFilters.length > 0 ? rarityFilters : undefined,
proficiency: type === 'weapon' && proficiencyFilters.length > 0 ? proficiencyFilters : undefined
})
// TanStack Query v6: Use createInfiniteQuery with thunk pattern for reactivity
// Query automatically updates when type, debouncedSearchQuery, or filters change
// Note: Type assertion needed because different search types have different query keys
// but share the same SearchPageResult structure
const searchQueryResult = createInfiniteQuery(() => {
const query = debouncedSearchQuery
const currentFilters = filters
// Select the appropriate query based on type
// All query types return the same SearchPageResult structure
switch (type) {
case 'weapon':
return searchQueries.weapons(query, currentFilters)
case 'character':
return searchQueries.characters(query, currentFilters) as unknown as ReturnType<typeof searchQueries.weapons>
case 'summon':
return searchQueries.summons(query, currentFilters) as unknown as ReturnType<typeof searchQueries.weapons>
}
})
// Flatten all pages into a single items array
const rawResults = $derived(
searchQueryResult.data?.pages.flatMap((page) => page.results) ?? []
)
// Deduplicate by id - needed because the API may return the same item across pages
// (e.g., due to items being added/removed between page fetches)
const searchResults = $derived(
Array.from(new Map(rawResults.map((item) => [item.id, item])).values())
)
// Use runed's IsInViewport for viewport detection
const inViewport = new IsInViewport(() => sentinelEl, {
rootMargin: '200px'
})
// Auto-fetch next page when sentinel is visible
$effect(() => {
if (
inViewport.current &&
searchQueryResult.hasNextPage &&
!searchQueryResult.isFetchingNextPage &&
!searchQueryResult.isLoading
) {
searchQueryResult.fetchNextPage()
}
})
// Computed states
const isEmpty = $derived(
searchResults.length === 0 && !searchQueryResult.isLoading && !searchQueryResult.isError
)
const showSentinel = $derived(
!searchQueryResult.isLoading && searchQueryResult.hasNextPage && searchResults.length > 0
)
// Focus search input on mount // Focus search input on mount
$effect(() => { $effect(() => {
if (searchInput) { if (searchInput) {
searchInput.focus() searchInput.focus()
} }
// Load recent items on mount
if (!hasInitialLoad) {
hasInitialLoad = true
performSearch()
}
}) })
// Search when query or filters change (debounced by adapter)
$effect(() => {
if (hasInitialLoad) {
performSearch()
}
})
async function performSearch() {
isLoading = true
try {
const params = {
query: searchQuery || '',
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 searchAdapter.searchWeapons(params)
break
case 'character':
response = await searchAdapter.searchCharacters(params)
break
case 'summon':
response = await searchAdapter.searchSummons(params)
break
}
if (response) {
searchResults = response.results || []
totalPages = response.totalPages || 1
}
} catch (error) {
console.error('Search failed:', error)
searchResults = []
} finally {
isLoading = false
}
}
function handleItemClick(item: SearchResult) { function handleItemClick(item: SearchResult) {
if (canAddMore) { if (canAddMore) {
onAddItems([item]) onAddItems([item])
@ -248,8 +277,17 @@
<!-- Results --> <!-- Results -->
<div class="results-section"> <div class="results-section">
{#if isLoading} {#if searchQueryResult.isLoading}
<div class="loading">Searching...</div> <div class="loading">
<Icon name="loader-2" size={24} />
<span>Searching...</span>
</div>
{:else if searchQueryResult.isError}
<div class="error-state">
<Icon name="alert-circle" size={24} />
<p>{searchQueryResult.error?.message || 'Search failed'}</p>
<Button size="small" onclick={() => searchQueryResult.refetch()}>Retry</Button>
</div>
{:else if searchResults.length > 0} {:else if searchResults.length > 0}
<ul class="results-list"> <ul class="results-list">
{#each searchResults as item (item.id)} {#each searchResults as item (item.id)}
@ -281,31 +319,24 @@
{/each} {/each}
</ul> </ul>
{#if totalPages > 1} {#if showSentinel}
<div class="pagination"> <div class="load-more-sentinel" bind:this={sentinelEl}></div>
<Button {/if}
variant="ghost"
size="small" {#if searchQueryResult.isFetchingNextPage}
onclick={() => currentPage = Math.max(1, currentPage - 1)} <div class="loading-more">
disabled={currentPage === 1} <Icon name="loader-2" size={20} />
> <span>Loading more...</span>
Previous
</Button>
<span class="page-info">Page {currentPage} of {totalPages}</span>
<Button
variant="ghost"
size="small"
onclick={() => currentPage = Math.min(totalPages, currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
</Button>
</div> </div>
{/if} {/if}
{:else if searchQuery.length > 0} {:else if isEmpty}
<div class="no-results">No results found</div> <div class="no-results">
{:else} {#if searchQuery.length > 0}
<div class="no-results">Start typing to search</div> No results found
{:else}
Start typing to search
{/if}
</div>
{/if} {/if}
</div> </div>
</div> </div>
@ -492,19 +523,58 @@
} }
} }
.pagination { .loading {
display: flex; display: flex;
justify-content: center; flex-direction: column;
align-items: center; align-items: center;
gap: $unit-2x; gap: $unit;
margin-top: $unit-2x;
padding-top: $unit-2x;
border-top: 1px solid var(--border-primary);
.page-info { :global(svg) {
font-size: $font-small; animation: spin 1s linear infinite;
color: var(--text-secondary); }
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-3x;
color: var(--text-secondary);
:global(svg) {
color: var(--text-tertiary);
}
p {
margin: 0;
font-size: $font-regular;
}
}
.load-more-sentinel {
height: 1px;
margin-top: $unit;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-2x;
color: var(--text-secondary);
font-size: $font-regular;
:global(svg) {
animation: spin 1s linear infinite;
} }
} }
} }
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style> </style>

View file

@ -1,52 +1,94 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types' import type { PageData } from './$types'
import { browser } from '$app/environment' import { createInfiniteQuery } from '@tanstack/svelte-query'
import InfiniteScroll from '$lib/components/InfiniteScroll.svelte'
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte' import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
import { createInfiniteScrollResource } from '$lib/api/adapters/resources/infiniteScroll.resource.svelte' import { userQueries } from '$lib/api/queries/user.queries'
import { userAdapter } from '$lib/api/adapters/user.adapter'
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar' import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
import { IsInViewport } from 'runed'
import Icon from '$lib/components/Icon.svelte'
import Button from '$lib/components/ui/Button.svelte'
const { data } = $props() as { data: PageData } const { data } = $props() as { data: PageData }
const tab = data.tab || 'teams' const tab = $derived(data.tab || 'teams')
const isOwner = data.isOwner || false const isOwner = $derived(data.isOwner || false)
const avatarFile = data.user?.avatar?.picture || '' const avatarFile = $derived(data.user?.avatar?.picture || '')
const avatarSrc = getAvatarSrc(avatarFile) const avatarSrc = $derived(getAvatarSrc(avatarFile))
const avatarSrcSet = getAvatarSrcSet(avatarFile) const avatarSrcSet = $derived(getAvatarSrcSet(avatarFile))
// Create infinite scroll resource for profile parties // Note: Type assertion needed because favorites and parties queries have different
const profileResource = createInfiniteScrollResource({ // result structures (items vs results) but we handle both in the items $derived
fetcher: async (page) => { const partiesQuery = createInfiniteQuery(() => {
if (tab === 'favorites' && isOwner) { const isFavorites = tab === 'favorites' && isOwner
const response = await userAdapter.getFavorites({ page })
return { if (isFavorites) {
results: response.items, return {
page: response.page, ...userQueries.favorites(),
total: response.total, initialData: data.items
totalPages: response.totalPages, ? {
perPage: response.perPage pages: [
} {
items: data.items,
page: data.page || 1,
totalPages: data.totalPages,
total: data.total,
perPage: data.perPage || 20
}
],
pageParams: [1]
}
: undefined,
initialDataUpdatedAt: 0
} }
return userAdapter.getProfileParties(data.user.username, page) }
},
initialData: data.items, return {
initialPage: data.page || 1, ...userQueries.parties(data.user?.username ?? ''),
initialTotalPages: data.totalPages, enabled: !!data.user?.username,
initialTotal: data.total, initialData: data.items
threshold: 300, ? {
debounceMs: 200 pages: [
{
results: data.items,
page: data.page || 1,
totalPages: data.totalPages,
total: data.total,
perPage: data.perPage || 20
}
],
pageParams: [1]
}
: undefined,
initialDataUpdatedAt: 0
} as unknown as ReturnType<typeof userQueries.favorites>
})
const items = $derived(() => {
if (!partiesQuery.data?.pages) return data.items || []
const isFavorites = tab === 'favorites' && isOwner
if (isFavorites) {
return partiesQuery.data.pages.flatMap((page) => (page as any).items ?? [])
}
return partiesQuery.data.pages.flatMap((page) => (page as any).results ?? [])
})
const isEmpty = $derived(!partiesQuery.isLoading && items().length === 0)
const showSentinel = $derived(partiesQuery.hasNextPage && !partiesQuery.isFetchingNextPage)
let sentinelEl = $state<HTMLElement>()
const inViewport = new IsInViewport(() => sentinelEl, {
rootMargin: '300px'
}) })
// Initialize with SSR data on client
$effect(() => { $effect(() => {
if (browser && data.items && !profileResource.items.length) { if (
profileResource.initFromSSR({ inViewport.current &&
items: data.items, partiesQuery.hasNextPage &&
page: data.page || 1, !partiesQuery.isFetchingNextPage &&
totalPages: data.totalPages, !partiesQuery.isLoading
total: data.total ) {
}) partiesQuery.fetchNextPage()
} }
}) })
</script> </script>
@ -82,27 +124,43 @@
</div> </div>
</header> </header>
<InfiniteScroll resource={profileResource} class="profile-grid"> {#if partiesQuery.isLoading}
<ExploreGrid items={profileResource.items} /> <div class="loading">
<Icon name="loader-2" size={32} />
<p>Loading {tab}...</p>
</div>
{:else if partiesQuery.isError}
<div class="error">
<Icon name="alert-circle" size={32} />
<p>Failed to load {tab}: {partiesQuery.error?.message || 'Unknown error'}</p>
<Button size="small" onclick={() => partiesQuery.refetch()}>Retry</Button>
</div>
{:else if isEmpty}
<div class="empty">
<p>{tab === 'favorites' ? 'No favorite teams yet' : 'No teams found'}</p>
</div>
{:else}
<div class="profile-grid">
<ExploreGrid items={items()} />
{#snippet emptySnippet()} {#if showSentinel}
<div class="empty"> <div class="load-more-sentinel" bind:this={sentinelEl}></div>
<p>{tab === 'favorites' ? 'No favorite teams yet' : 'No teams found'}</p> {/if}
</div>
{/snippet}
{#snippet endSnippet()} {#if partiesQuery.isFetchingNextPage}
<div class="end"> <div class="loading-more">
<p>You've seen all {tab === 'favorites' ? 'favorites' : 'teams'}!</p> <Icon name="loader-2" size={20} />
</div> <span>Loading more...</span>
{/snippet} </div>
{/if}
{#snippet errorSnippet(error)} {#if !partiesQuery.hasNextPage && items().length > 0}
<div class="error"> <div class="end">
<p>Failed to load {tab}: {error.message || 'Unknown error'}</p> <p>You've seen all {tab === 'favorites' ? 'favorites' : 'teams'}!</p>
</div> </div>
{/snippet} {/if}
</InfiniteScroll> </div>
{/if}
</section> </section>
<style lang="scss"> <style lang="scss">
@ -161,4 +219,45 @@
.error { .error {
color: var(--text-error, #dc2626); color: var(--text-error, #dc2626);
} }
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-4x;
color: var(--text-secondary);
:global(svg) {
animation: spin 1s linear infinite;
}
p {
margin: 0;
}
}
.load-more-sentinel {
height: 1px;
margin-top: $unit;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-2x;
color: var(--text-secondary);
:global(svg) {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style> </style>

View file

@ -1,35 +1,54 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types' import type { PageData } from './$types'
import { browser } from '$app/environment' import { createInfiniteQuery } from '@tanstack/svelte-query'
import InfiniteScroll from '$lib/components/InfiniteScroll.svelte'
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte' import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
import { createInfiniteScrollResource } from '$lib/api/adapters/resources/infiniteScroll.resource.svelte' import { partyQueries } from '$lib/api/queries/party.queries'
import { partyAdapter } from '$lib/api/adapters/party.adapter' import { IsInViewport } from 'runed'
import Icon from '$lib/components/Icon.svelte'
import Button from '$lib/components/ui/Button.svelte'
const { data } = $props() as { data: PageData } const { data } = $props() as { data: PageData }
// Create infinite scroll resource const partiesQuery = createInfiniteQuery(() => ({
const exploreResource = createInfiniteScrollResource({ ...partyQueries.list(),
fetcher: (page) => partyAdapter.list({ page }), initialData: data.items
initialData: data.items, ? {
initialPage: data.page || 1, pages: [
initialTotalPages: data.totalPages, {
initialTotal: data.total, results: data.items,
threshold: 300, page: data.page || 1,
debounceMs: 200, totalPages: data.totalPages,
maxItems: 500, // Limit for performance total: data.total,
debug: false // Disable debug logging perPage: data.perPage || 20
}
],
pageParams: [1]
}
: undefined,
initialDataUpdatedAt: 0
}))
const items = $derived(
partiesQuery.data?.pages.flatMap((page) => page.results) ?? data.items ?? []
)
const isEmpty = $derived(!partiesQuery.isLoading && items.length === 0)
const showSentinel = $derived(partiesQuery.hasNextPage && !partiesQuery.isFetchingNextPage)
let sentinelEl = $state<HTMLElement>()
const inViewport = new IsInViewport(() => sentinelEl, {
rootMargin: '300px'
}) })
// Initialize with SSR data on client
$effect(() => { $effect(() => {
if (browser && data.items && !exploreResource.items.length) { if (
exploreResource.initFromSSR({ inViewport.current &&
items: data.items, partiesQuery.hasNextPage &&
page: data.page || 1, !partiesQuery.isFetchingNextPage &&
totalPages: data.totalPages, !partiesQuery.isLoading
total: data.total ) {
}) partiesQuery.fetchNextPage()
} }
}) })
</script> </script>
@ -39,27 +58,43 @@
<h1>Explore Teams</h1> <h1>Explore Teams</h1>
</header> </header>
<InfiniteScroll resource={exploreResource} class="explore-grid"> {#if partiesQuery.isLoading}
<ExploreGrid items={exploreResource.items} /> <div class="loading">
<Icon name="loader-2" size={32} />
<p>Loading teams...</p>
</div>
{:else if partiesQuery.isError}
<div class="error">
<Icon name="alert-circle" size={32} />
<p>Failed to load teams: {partiesQuery.error?.message || 'Unknown error'}</p>
<Button size="small" onclick={() => partiesQuery.refetch()}>Retry</Button>
</div>
{:else if isEmpty}
<div class="empty">
<p>No teams found</p>
</div>
{:else}
<div class="explore-grid">
<ExploreGrid items={items} />
{#snippet emptySnippet()} {#if showSentinel}
<div class="empty"> <div class="load-more-sentinel" bind:this={sentinelEl}></div>
<p>No teams found</p> {/if}
</div>
{/snippet}
{#snippet endSnippet()} {#if partiesQuery.isFetchingNextPage}
<div class="end"> <div class="loading-more">
<p>You've reached the end of all teams!</p> <Icon name="loader-2" size={20} />
</div> <span>Loading more...</span>
{/snippet} </div>
{/if}
{#snippet errorSnippet(error)} {#if !partiesQuery.hasNextPage && items.length > 0}
<div class="error"> <div class="end">
<p>Failed to load teams: {error.message || 'Unknown error'}</p> <p>You've reached the end of all teams!</p>
</div> </div>
{/snippet} {/if}
</InfiniteScroll> </div>
{/if}
</section> </section>
<style lang="scss"> <style lang="scss">
@ -89,4 +124,45 @@
.error { .error {
color: var(--text-error, #dc2626); color: var(--text-error, #dc2626);
} }
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-4x;
color: var(--text-secondary);
:global(svg) {
animation: spin 1s linear infinite;
}
p {
margin: 0;
}
}
.load-more-sentinel {
height: 1px;
margin-top: $unit;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-2x;
color: var(--text-secondary);
:global(svg) {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style> </style>