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:
parent
5764161803
commit
f9bb43f214
5 changed files with 729 additions and 298 deletions
217
TANSTACK_QUERY_MIGRATION_CONTINUATION.md
Normal file
217
TANSTACK_QUERY_MIGRATION_CONTINUATION.md
Normal 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`
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue