port sidebar and modal components to useInfiniteLoader

This commit is contained in:
Justin Edmund 2025-12-20 15:20:20 -08:00
parent 133cd9ec5b
commit fbc9f339be
4 changed files with 85 additions and 78 deletions

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query' import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query'
import { onDestroy } from 'svelte'
import { collectionQueries } from '$lib/api/queries/collection.queries' import { collectionQueries } from '$lib/api/queries/collection.queries'
import { import {
searchQueries, searchQueries,
@ -26,7 +27,7 @@
import SelectableWeaponRow from './SelectableWeaponRow.svelte' import SelectableWeaponRow from './SelectableWeaponRow.svelte'
import SelectableSummonCard from './SelectableSummonCard.svelte' import SelectableSummonCard from './SelectableSummonCard.svelte'
import SelectableSummonRow from './SelectableSummonRow.svelte' import SelectableSummonRow from './SelectableSummonRow.svelte'
import { IsInViewport } from 'runed' import { useInfiniteLoader } from '$lib/stores/loaderState.svelte'
import { viewMode, type ViewMode } from '$lib/stores/viewMode.svelte' import { viewMode, type ViewMode } from '$lib/stores/viewMode.svelte'
type SearchResultItem = SearchPageResult['results'][number] type SearchResultItem = SearchPageResult['results'][number]
@ -153,22 +154,18 @@
: addSummonMutation : addSummonMutation
) )
// Infinite scroll // State-gated infinite scroll
const inViewport = new IsInViewport(() => sentinelEl, { const loader = useInfiniteLoader(() => searchResults, () => sentinelEl, { rootMargin: '200px' })
rootMargin: '200px'
// Reset loader when filters or showOnlySelected changes
$effect(() => {
void searchFilters
void showOnlySelected
loader.reset()
}) })
$effect(() => { // Cleanup on destroy
if ( onDestroy(() => loader.destroy())
inViewport.current &&
searchResults.hasNextPage &&
!searchResults.isFetchingNextPage &&
!searchResults.isLoading &&
!showOnlySelected
) {
searchResults.fetchNextPage()
}
})
// Reset state when modal closes or entity type changes // Reset state when modal closes or entity type changes
$effect(() => { $effect(() => {
@ -438,9 +435,11 @@
{/if} {/if}
{#if displayedResults.length > 0} {#if displayedResults.length > 0}
{#if !showOnlySelected && searchResults.hasNextPage} <div
<div class="load-more-sentinel" bind:this={sentinelEl}></div> class="load-more-sentinel"
{/if} bind:this={sentinelEl}
class:hidden={showOnlySelected || !searchResults.hasNextPage}
></div>
{#if searchResults.isFetchingNextPage} {#if searchResults.isFetchingNextPage}
<div class="loading-more"> <div class="loading-more">
@ -569,6 +568,10 @@
.load-more-sentinel { .load-more-sentinel {
height: 1px; height: 1px;
margin-top: $unit; margin-top: $unit;
&.hidden {
display: none;
}
} }
.loading-more { .loading-more {

View file

@ -11,8 +11,9 @@
import type { CollectionArtifact } from '$lib/types/api/artifact' import type { CollectionArtifact } from '$lib/types/api/artifact'
import type { Character } from '$lib/types/api/entities' import type { Character } from '$lib/types/api/entities'
import { createInfiniteQuery } from '@tanstack/svelte-query' import { createInfiniteQuery } from '@tanstack/svelte-query'
import { onDestroy } from 'svelte'
import { artifactQueries } from '$lib/api/queries/artifact.queries' import { artifactQueries } from '$lib/api/queries/artifact.queries'
import { IsInViewport } from 'runed' import { useInfiniteLoader } from '$lib/stores/loaderState.svelte'
import { getArtifactImage } from '$lib/utils/images' import { getArtifactImage } from '$lib/utils/images'
import ElementLabel from '$lib/components/labels/ElementLabel.svelte' import ElementLabel from '$lib/components/labels/ElementLabel.svelte'
import ProficiencyLabel from '$lib/components/labels/ProficiencyLabel.svelte' import ProficiencyLabel from '$lib/components/labels/ProficiencyLabel.svelte'
@ -56,25 +57,20 @@
return collectionQuery.data.pages.flatMap((page) => page.results ?? []) return collectionQuery.data.pages.flatMap((page) => page.results ?? [])
}) })
// Infinite scroll // State-gated infinite scroll
const inViewport = new IsInViewport(() => sentinelEl, { const loader = useInfiniteLoader(() => collectionQuery, () => sentinelEl, { rootMargin: '200px' })
rootMargin: '200px'
// Reset loader when filters change
$effect(() => {
void queryFilters
loader.reset()
}) })
$effect(() => { // Cleanup on destroy
if ( onDestroy(() => loader.destroy())
inViewport.current &&
collectionQuery.hasNextPage &&
!collectionQuery.isFetchingNextPage &&
!collectionQuery.isLoading
) {
collectionQuery.fetchNextPage()
}
})
const isLoading = $derived(collectionQuery.isLoading) const isLoading = $derived(collectionQuery.isLoading)
const isEmpty = $derived(!isLoading && allArtifacts.length === 0) const isEmpty = $derived(!isLoading && allArtifacts.length === 0)
const showSentinel = $derived(collectionQuery.hasNextPage && !collectionQuery.isFetchingNextPage)
// Get display name for artifact // Get display name for artifact
function getDisplayName(artifact: CollectionArtifact): string { function getDisplayName(artifact: CollectionArtifact): string {
@ -168,9 +164,11 @@
</button> </button>
{/each} {/each}
{#if showSentinel} <div
<div class="load-more-sentinel" bind:this={sentinelEl}></div> class="load-more-sentinel"
{/if} bind:this={sentinelEl}
class:hidden={!collectionQuery.hasNextPage}
></div>
{#if collectionQuery.isFetchingNextPage} {#if collectionQuery.isFetchingNextPage}
<div class="loading-more"> <div class="loading-more">
@ -347,6 +345,10 @@
.load-more-sentinel { .load-more-sentinel {
height: 1px; height: 1px;
&.hidden {
display: none;
}
} }
.loading-more { .loading-more {

View file

@ -2,6 +2,7 @@
<script lang="ts"> <script lang="ts">
import { createInfiniteQuery } from '@tanstack/svelte-query' import { createInfiniteQuery } from '@tanstack/svelte-query'
import { onDestroy } from 'svelte'
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 { jobQueries } from '$lib/api/queries/job.queries' import { jobQueries } from '$lib/api/queries/job.queries'
@ -10,7 +11,7 @@
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 { useInfiniteLoader } from '$lib/stores/loaderState.svelte'
import * as m from '$lib/paraglide/messages' import * as m from '$lib/paraglide/messages'
interface Props { interface Props {
@ -101,28 +102,21 @@
// Sentinel element for intersection observation // Sentinel element for intersection observation
let sentinelEl = $state<HTMLElement>() let sentinelEl = $state<HTMLElement>()
// Use runed's IsInViewport for viewport detection // State-gated infinite scroll
const inViewport = new IsInViewport(() => sentinelEl, { const loader = useInfiniteLoader(() => skillsQuery, () => sentinelEl, { rootMargin: '200px' })
rootMargin: '200px'
// Reset loader when filters change
$effect(() => {
void debouncedSearchQuery
void skillCategory
loader.reset()
}) })
// Auto-fetch next page when sentinel is visible // Cleanup on destroy
$effect(() => { onDestroy(() => loader.destroy())
if (
inViewport.current &&
skillsQuery.hasNextPage &&
!skillsQuery.isFetchingNextPage &&
!skillsQuery.isLoading
) {
skillsQuery.fetchNextPage()
}
})
// Computed states // Computed states
const isEmpty = $derived(skills.length === 0 && !skillsQuery.isLoading && !skillsQuery.isError) 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
@ -258,9 +252,11 @@
</div> </div>
{/if} {/if}
{#if showSentinel} <div
<div class="load-more-sentinel" bind:this={sentinelEl}></div> class="load-more-sentinel"
{/if} bind:this={sentinelEl}
class:hidden={!skillsQuery.hasNextPage}
></div>
{#if skillsQuery.isFetchingNextPage} {#if skillsQuery.isFetchingNextPage}
<div class="loading-more"> <div class="loading-more">
@ -421,6 +417,10 @@
.load-more-sentinel { .load-more-sentinel {
height: 1px; height: 1px;
margin-top: $unit; margin-top: $unit;
&.hidden {
display: none;
}
} }
.loading-more { .loading-more {

View file

@ -2,13 +2,14 @@
<script lang="ts"> <script lang="ts">
import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query' import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query'
import { onDestroy } from 'svelte'
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 { searchQueries, type SearchFilters } from '$lib/api/queries/search.queries'
import { collectionQueries } from '$lib/api/queries/collection.queries' import { collectionQueries } from '$lib/api/queries/collection.queries'
import Button from '../ui/Button.svelte' import Button from '../ui/Button.svelte'
import Icon from '../Icon.svelte' import Icon from '../Icon.svelte'
import CharacterTags from '$lib/components/tags/CharacterTags.svelte' import CharacterTags from '$lib/components/tags/CharacterTags.svelte'
import { IsInViewport } from 'runed' import { useInfiniteLoader } from '$lib/stores/loaderState.svelte'
import { getCharacterImage, getWeaponImage, getSummonImage } from '$lib/features/database/detail/image' import { getCharacterImage, getWeaponImage, getSummonImage } from '$lib/features/database/detail/image'
import type { AddItemResult, SearchMode } from '$lib/types/api/search' import type { AddItemResult, SearchMode } from '$lib/types/api/search'
import type { CollectionCharacter, CollectionWeapon, CollectionSummon } from '$lib/types/api/collection' import type { CollectionCharacter, CollectionWeapon, CollectionSummon } from '$lib/types/api/collection'
@ -245,35 +246,30 @@
return deduped as AddItemResult[] return deduped as AddItemResult[]
}) })
// Use runed's IsInViewport for viewport detection
const inViewport = new IsInViewport(() => sentinelEl, {
rootMargin: '200px'
})
// Get the active query based on search mode // Get the active query based on search mode
const activeQuery = $derived( const activeQuery = $derived(
searchMode === 'collection' && authUserId ? collectionQueryResult : searchQueryResult searchMode === 'collection' && authUserId ? collectionQueryResult : searchQueryResult
) )
// Auto-fetch next page when sentinel is visible // State-gated infinite scroll
// Type assertion needed because activeQuery is a union of different query types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loader = useInfiniteLoader(() => activeQuery as any, () => sentinelEl, { rootMargin: '200px' })
// Reset loader when search mode or filters change
$effect(() => { $effect(() => {
if ( void searchMode
inViewport.current && void filters
activeQuery.hasNextPage && loader.reset()
!activeQuery.isFetchingNextPage &&
!activeQuery.isLoading
) {
activeQuery.fetchNextPage()
}
}) })
// Cleanup on destroy
onDestroy(() => loader.destroy())
// Computed states // Computed states
const isEmpty = $derived( const isEmpty = $derived(
searchResults.length === 0 && !activeQuery.isLoading && !activeQuery.isError searchResults.length === 0 && !activeQuery.isLoading && !activeQuery.isError
) )
const showSentinel = $derived(
!activeQuery.isLoading && activeQuery.hasNextPage && searchResults.length > 0
)
// Focus search input on mount // Focus search input on mount
$effect(() => { $effect(() => {
@ -477,9 +473,11 @@
{/each} {/each}
</ul> </ul>
{#if showSentinel} <div
<div class="load-more-sentinel" bind:this={sentinelEl}></div> class="load-more-sentinel"
{/if} bind:this={sentinelEl}
class:hidden={!activeQuery.hasNextPage}
></div>
{#if activeQuery.isFetchingNextPage} {#if activeQuery.isFetchingNextPage}
<div class="loading-more"> <div class="loading-more">
@ -771,6 +769,10 @@
.load-more-sentinel { .load-more-sentinel {
height: 1px; height: 1px;
margin-top: $unit; margin-top: $unit;
&.hidden {
display: none;
}
} }
.loading-more { .loading-more {