hensei-web/src/routes/(app)/[username]/collection/characters/+page.svelte
2025-12-20 17:13:44 -08:00

295 lines
7.8 KiB
Svelte

<script lang="ts">
import type { PageData } from './$types'
import type { CollectionCharacter, CollectionSortKey } from '$lib/types/api/collection'
import { getContext, onDestroy } from 'svelte'
import { createInfiniteQuery } from '@tanstack/svelte-query'
import { collectionQueries } from '$lib/api/queries/collection.queries'
import CollectionFilters, {
type CollectionFilterState
} from '$lib/components/collection/CollectionFilters.svelte'
import CollectionCharacterPane from '$lib/components/collection/CollectionCharacterPane.svelte'
import CollectionCharacterCard from '$lib/components/collection/CollectionCharacterCard.svelte'
import CollectionCharacterRow from '$lib/components/collection/CollectionCharacterRow.svelte'
import SelectableCollectionCard from '$lib/components/collection/SelectableCollectionCard.svelte'
import SelectableCollectionRow from '$lib/components/collection/SelectableCollectionRow.svelte'
import Icon from '$lib/components/Icon.svelte'
import { sidebar } from '$lib/stores/sidebar.svelte'
import { viewMode, type ViewMode } from '$lib/stores/viewMode.svelte'
import { LOADED_IDS_KEY, type LoadedIdsContext } from '$lib/stores/selectionMode.svelte'
import { useInfiniteLoader } from '$lib/stores/loaderState.svelte'
const { data }: { data: PageData } = $props()
// Get loaded IDs context from layout
const loadedIdsContext = getContext<LoadedIdsContext | undefined>(LOADED_IDS_KEY)
// User's element for elemental styling
const userElement = $derived(
data.user?.avatar?.element as 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' | undefined
)
// Filter state
let elementFilters = $state<number[]>([])
let rarityFilters = $state<number[]>([])
let raceFilters = $state<number[]>([])
let proficiencyFilters = $state<number[]>([])
let genderFilters = $state<number[]>([])
// Sort state
let sortBy = $state<CollectionSortKey>('name_asc')
// Sentinel for infinite scroll
let sentinelEl = $state<HTMLElement>()
// Build filters for query - all filters are now server-side for everyone
const queryFilters = $derived({
element: elementFilters.length > 0 ? elementFilters : undefined,
rarity: rarityFilters.length > 0 ? rarityFilters : undefined,
race: raceFilters.length > 0 ? raceFilters : undefined,
proficiency: proficiencyFilters.length > 0 ? proficiencyFilters : undefined,
gender: genderFilters.length > 0 ? genderFilters : undefined,
sort: sortBy
})
// Unified query for any user's collection (privacy enforced server-side)
const collectionQuery = createInfiniteQuery(() => {
const userId = data.user.id
const filters = queryFilters
return collectionQueries.characters(userId, filters)
})
// State-gated infinite scroll (inspired by svelte-infinite)
// Encapsulates intersection observer, state machine, and all reactive effects
const loader = useInfiniteLoader(() => collectionQuery, () => sentinelEl)
// Flatten all characters from pages
const allCharacters = $derived.by((): CollectionCharacter[] => {
if (!collectionQuery.data?.pages) {
return []
}
return collectionQuery.data.pages.flatMap((page) => page.results ?? [])
})
// Provide loaded IDs to layout for "Select all"
$effect(() => {
const ids = allCharacters.map((c) => c.id)
loadedIdsContext?.setIds(ids)
})
// Reset loader state when filters change
$effect(() => {
void queryFilters
loader.reset()
})
// Cleanup on destroy
onDestroy(() => loader.destroy())
const isLoading = $derived(collectionQuery.isLoading)
const isEmpty = $derived(!isLoading && allCharacters.length === 0)
// Current view mode from store
const currentViewMode = $derived(viewMode.collectionView)
function handleFiltersChange(filters: CollectionFilterState) {
elementFilters = filters.element
rarityFilters = filters.rarity
raceFilters = filters.race
proficiencyFilters = filters.proficiency
genderFilters = filters.gender
}
function handleViewModeChange(mode: ViewMode) {
viewMode.setCollectionView(mode)
}
function openCharacterDetails(character: CollectionCharacter) {
const characterName =
typeof character.character?.name === 'string'
? character.character.name
: character.character?.name?.en || 'Character'
sidebar.openWithComponent(characterName, CollectionCharacterPane, {
character,
isOwner: data.isOwner,
onClose: () => sidebar.close()
})
}
</script>
<div class="collection-page">
<!-- Action bar -->
<div class="action-bar">
<CollectionFilters
bind:elementFilters
bind:rarityFilters
bind:raceFilters
bind:proficiencyFilters
bind:genderFilters
bind:sortBy
onFiltersChange={handleFiltersChange}
showFilters={{
element: true,
rarity: true,
season: false,
series: false,
race: true,
proficiency: true,
gender: true
}}
showViewToggle={true}
viewMode={currentViewMode}
onViewModeChange={handleViewModeChange}
element={userElement}
/>
</div>
<!-- Collection grid -->
<div class="grid-area">
{#if isLoading}
<div class="loading-state">
<Icon name="loader-2" size={32} />
<p>Loading collection...</p>
</div>
{:else if isEmpty}
<div class="empty-state">
{#if data.isOwner}
<Icon name="users" size={48} />
<h3>Your collection is empty</h3>
<p>Use the "Add to Collection" button above to get started</p>
{:else}
<Icon name="lock" size={48} />
<p>This collection is empty or private</p>
{/if}
</div>
{:else if currentViewMode === 'grid'}
<div class="character-grid">
{#each allCharacters as character, i (i)}
<SelectableCollectionCard id={character.id} onClick={() => openCharacterDetails(character)}>
<CollectionCharacterCard {character} />
</SelectableCollectionCard>
{/each}
</div>
{:else}
<div class="character-list">
{#each allCharacters as character, i (i)}
<SelectableCollectionRow id={character.id} onClick={() => openCharacterDetails(character)}>
<CollectionCharacterRow {character} />
</SelectableCollectionRow>
{/each}
</div>
{/if}
{#if !isLoading && !isEmpty}
<!-- Sentinel always in DOM to avoid Svelte block tracking issues during rapid updates -->
<div
class="load-more-sentinel"
bind:this={sentinelEl}
class:hidden={!collectionQuery.hasNextPage}
></div>
{#if collectionQuery.isFetchingNextPage}
<div class="loading-more">
<Icon name="loader-2" size={20} />
<span>Loading more...</span>
</div>
{/if}
{/if}
</div>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
.collection-page {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: $unit-2x;
flex-wrap: wrap;
}
.grid-area {
min-height: 400px;
}
.character-grid {
display: grid;
grid-template-columns: repeat(5, 144px);
justify-content: space-between;
gap: $unit-4x $unit-2x;
}
.character-list {
display: flex;
flex-direction: column;
gap: $unit;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
color: var(--text-secondary, #666);
gap: $unit;
:global(svg) {
color: var(--icon-secondary, #999);
}
p {
margin: 0;
}
}
.empty-state h3 {
margin: 0;
color: var(--text-primary, #333);
}
.loading-state :global(svg) {
animation: spin 1s linear infinite;
}
.load-more-sentinel {
height: 1px;
margin-top: $unit;
&.hidden {
display: none;
}
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-2x;
color: var(--text-secondary, #666);
:global(svg) {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>