hensei-web/src/routes/teams/explore/+page.svelte
Devin AI f520457e28 feat: migrate components to TanStack Query v6
- Migrate JobSkillSelectionSidebar to use createInfiniteQuery with jobQueries.skills()
- Migrate SearchContent to use createInfiniteQuery with searchQueries
- Migrate user profile page ([username]/+page.svelte) to use createInfiniteQuery with SSR
- Migrate teams explore page to use createInfiniteQuery with partyQueries.list()

All components now use:
- TanStack Query v6 infinite query pattern
- Debounced search (debounce the value, not the query)
- IsInViewport from runed for infinite scroll detection
- Proper loading, error, and empty states
- Type-safe query options from query factories

Co-Authored-By: Justin Edmund <justin@jedmund.com>
2025-11-29 08:52:32 +00:00

168 lines
3.7 KiB
Svelte

<script lang="ts">
import type { PageData } from './$types'
import { createInfiniteQuery } from '@tanstack/svelte-query'
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
import { partyQueries } from '$lib/api/queries/party.queries'
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 partiesQuery = createInfiniteQuery(() => ({
...partyQueries.list(),
initialData: data.items
? {
pages: [
{
results: data.items,
page: data.page || 1,
totalPages: data.totalPages,
total: data.total,
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'
})
$effect(() => {
if (
inViewport.current &&
partiesQuery.hasNextPage &&
!partiesQuery.isFetchingNextPage &&
!partiesQuery.isLoading
) {
partiesQuery.fetchNextPage()
}
})
</script>
<section class="explore">
<header>
<h1>Explore Teams</h1>
</header>
{#if partiesQuery.isLoading}
<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} />
{#if showSentinel}
<div class="load-more-sentinel" bind:this={sentinelEl}></div>
{/if}
{#if partiesQuery.isFetchingNextPage}
<div class="loading-more">
<Icon name="loader-2" size={20} />
<span>Loading more...</span>
</div>
{/if}
{#if !partiesQuery.hasNextPage && items.length > 0}
<div class="end">
<p>You've reached the end of all teams!</p>
</div>
{/if}
</div>
{/if}
</section>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
.explore {
padding: $unit-2x 0;
}
h1 {
margin: 0 0 $unit-2x 0;
}
.empty,
.end,
.error {
text-align: center;
padding: $unit-4x;
color: var(--text-secondary);
p {
margin: 0;
}
}
.error {
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>