add weapons and summons collection routes with layout updates
- Create weapons route with page, server load, and grid/list views - Create summons route with page, server load, and grid/list views - Enable weapons/summons tabs in collection layout (remove disabled) - Add dynamic "Add" button text based on active entity type - Pass entityType to AddToCollectionModal based on current route
This commit is contained in:
parent
60947a7911
commit
d11362ff57
5 changed files with 599 additions and 4 deletions
|
|
@ -21,6 +21,16 @@
|
|||
return 'characters'
|
||||
})
|
||||
|
||||
// Map entity type to singular form for modal
|
||||
const modalEntityType = $derived.by(() => {
|
||||
if (activeEntityType === 'weapons') return 'weapon'
|
||||
if (activeEntityType === 'summons') return 'summon'
|
||||
return 'character'
|
||||
})
|
||||
|
||||
// Dynamic button text
|
||||
const addButtonText = $derived(`Add ${activeEntityType}`)
|
||||
|
||||
const username = $derived(data.user?.username || $page.params.username)
|
||||
|
||||
function handleTabChange(value: string) {
|
||||
|
|
@ -50,8 +60,8 @@
|
|||
size="small"
|
||||
>
|
||||
<Segment value="characters">Characters</Segment>
|
||||
<Segment value="weapons" disabled>Weapons</Segment>
|
||||
<Segment value="summons" disabled>Summons</Segment>
|
||||
<Segment value="weapons">Weapons</Segment>
|
||||
<Segment value="summons">Summons</Segment>
|
||||
</SegmentedControl>
|
||||
|
||||
{#if data.isOwner}
|
||||
|
|
@ -62,7 +72,7 @@
|
|||
icon="plus"
|
||||
iconPosition="left"
|
||||
>
|
||||
Add characters
|
||||
{addButtonText}
|
||||
</Button>
|
||||
{/if}
|
||||
</nav>
|
||||
|
|
@ -73,7 +83,11 @@
|
|||
</section>
|
||||
|
||||
{#if data.isOwner}
|
||||
<AddToCollectionModal userId={data.user.id} bind:open={addModalOpen} />
|
||||
<AddToCollectionModal
|
||||
userId={data.user.id}
|
||||
entityType={modalEntityType}
|
||||
bind:open={addModalOpen}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import type { PageServerLoad } from './$types'
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
const { user, isOwner } = await parent()
|
||||
|
||||
// User info comes from layout, collection data is fetched client-side via TanStack Query
|
||||
// The unified API endpoint handles privacy checks server-side
|
||||
return {
|
||||
user,
|
||||
isOwner
|
||||
}
|
||||
}
|
||||
275
src/routes/(app)/[username]/collection/summons/+page.svelte
Normal file
275
src/routes/(app)/[username]/collection/summons/+page.svelte
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from './$types'
|
||||
import type { CollectionSummon, CollectionSortKey } from '$lib/types/api/collection'
|
||||
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 CollectionSummonPane from '$lib/components/collection/CollectionSummonPane.svelte'
|
||||
import CollectionSummonCard from '$lib/components/collection/CollectionSummonCard.svelte'
|
||||
import CollectionSummonRow from '$lib/components/collection/CollectionSummonRow.svelte'
|
||||
import Icon from '$lib/components/Icon.svelte'
|
||||
import { IsInViewport } from 'runed'
|
||||
import { sidebar } from '$lib/stores/sidebar.svelte'
|
||||
import { viewMode, type ViewMode } from '$lib/stores/viewMode.svelte'
|
||||
|
||||
const { data }: { data: PageData } = $props()
|
||||
|
||||
// Filter state
|
||||
let elementFilters = $state<number[]>([])
|
||||
let rarityFilters = $state<number[]>([])
|
||||
|
||||
// Sort state
|
||||
let sortBy = $state<CollectionSortKey>('name_asc')
|
||||
|
||||
// Sentinel for infinite scroll
|
||||
let sentinelEl = $state<HTMLElement>()
|
||||
|
||||
// Build filters for query
|
||||
const queryFilters = $derived({
|
||||
element: elementFilters.length > 0 ? elementFilters : undefined,
|
||||
rarity: rarityFilters.length > 0 ? rarityFilters : undefined,
|
||||
sort: sortBy
|
||||
})
|
||||
|
||||
// Query for summons collection
|
||||
const collectionQuery = createInfiniteQuery(() => {
|
||||
const userId = data.user.id
|
||||
const filters = queryFilters
|
||||
return collectionQueries.summons(userId, filters)
|
||||
})
|
||||
|
||||
// Flatten all summons from pages
|
||||
const allSummons = $derived.by((): CollectionSummon[] => {
|
||||
if (!collectionQuery.data?.pages) {
|
||||
return []
|
||||
}
|
||||
return collectionQuery.data.pages.flatMap((page) => page.results ?? [])
|
||||
})
|
||||
|
||||
// Infinite scroll
|
||||
const inViewport = new IsInViewport(() => sentinelEl, {
|
||||
rootMargin: '200px'
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (
|
||||
inViewport.current &&
|
||||
collectionQuery.hasNextPage &&
|
||||
!collectionQuery.isFetchingNextPage &&
|
||||
!collectionQuery.isLoading
|
||||
) {
|
||||
collectionQuery.fetchNextPage()
|
||||
}
|
||||
})
|
||||
|
||||
const isLoading = $derived(collectionQuery.isLoading)
|
||||
const isEmpty = $derived(!isLoading && allSummons.length === 0)
|
||||
const showSentinel = $derived(collectionQuery.hasNextPage && !collectionQuery.isFetchingNextPage)
|
||||
|
||||
// Current view mode from store
|
||||
const currentViewMode = $derived(viewMode.collectionView)
|
||||
|
||||
function handleFiltersChange(filters: CollectionFilterState) {
|
||||
elementFilters = filters.element
|
||||
rarityFilters = filters.rarity
|
||||
}
|
||||
|
||||
function handleViewModeChange(mode: ViewMode) {
|
||||
viewMode.setCollectionView(mode)
|
||||
}
|
||||
|
||||
function openSummonDetails(summon: CollectionSummon) {
|
||||
const summonName =
|
||||
typeof summon.summon?.name === 'string'
|
||||
? summon.summon.name
|
||||
: summon.summon?.name?.en || 'Summon'
|
||||
|
||||
sidebar.openWithComponent(summonName, CollectionSummonPane, {
|
||||
summon,
|
||||
isOwner: data.isOwner,
|
||||
onClose: () => sidebar.close()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="collection-page">
|
||||
<!-- Action bar -->
|
||||
<div class="action-bar">
|
||||
<CollectionFilters
|
||||
entityType="summon"
|
||||
bind:elementFilters
|
||||
bind:rarityFilters
|
||||
bind:sortBy
|
||||
onFiltersChange={handleFiltersChange}
|
||||
showViewToggle={true}
|
||||
viewMode={currentViewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
/>
|
||||
</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="star" size={48} />
|
||||
<h3>Your summon collection is empty</h3>
|
||||
<p>Use the "Add summons" 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="summon-grid">
|
||||
{#each allSummons as summon (summon.id)}
|
||||
<CollectionSummonCard
|
||||
{summon}
|
||||
onClick={() => openSummonDetails(summon)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="summon-list">
|
||||
{#each allSummons as summon (summon.id)}
|
||||
<CollectionSummonRow
|
||||
{summon}
|
||||
onClick={() => openSummonDetails(summon)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isLoading && !isEmpty}
|
||||
{#if showSentinel}
|
||||
<div class="load-more-sentinel" bind:this={sentinelEl}></div>
|
||||
{/if}
|
||||
|
||||
{#if collectionQuery.isFetchingNextPage}
|
||||
<div class="loading-more">
|
||||
<Icon name="loader-2" size={20} />
|
||||
<span>Loading more...</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !collectionQuery.hasNextPage && allSummons.length > 0}
|
||||
<div class="end-message">
|
||||
<p>
|
||||
{allSummons.length} summon{allSummons.length === 1 ? '' : 's'} in {data.isOwner
|
||||
? 'your'
|
||||
: 'this'} collection
|
||||
</p>
|
||||
</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;
|
||||
}
|
||||
|
||||
.summon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 128px);
|
||||
justify-content: space-between;
|
||||
gap: $unit-4x;
|
||||
}
|
||||
|
||||
.summon-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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.end-message {
|
||||
text-align: center;
|
||||
padding: $unit-2x;
|
||||
color: var(--text-secondary, #666);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import type { PageServerLoad } from './$types'
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
const { user, isOwner } = await parent()
|
||||
|
||||
// User info comes from layout, collection data is fetched client-side via TanStack Query
|
||||
// The unified API endpoint handles privacy checks server-side
|
||||
return {
|
||||
user,
|
||||
isOwner
|
||||
}
|
||||
}
|
||||
282
src/routes/(app)/[username]/collection/weapons/+page.svelte
Normal file
282
src/routes/(app)/[username]/collection/weapons/+page.svelte
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from './$types'
|
||||
import type { CollectionWeapon, CollectionSortKey } from '$lib/types/api/collection'
|
||||
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 CollectionWeaponPane from '$lib/components/collection/CollectionWeaponPane.svelte'
|
||||
import CollectionWeaponCard from '$lib/components/collection/CollectionWeaponCard.svelte'
|
||||
import CollectionWeaponRow from '$lib/components/collection/CollectionWeaponRow.svelte'
|
||||
import Icon from '$lib/components/Icon.svelte'
|
||||
import { IsInViewport } from 'runed'
|
||||
import { sidebar } from '$lib/stores/sidebar.svelte'
|
||||
import { viewMode, type ViewMode } from '$lib/stores/viewMode.svelte'
|
||||
|
||||
const { data }: { data: PageData } = $props()
|
||||
|
||||
// Filter state
|
||||
let elementFilters = $state<number[]>([])
|
||||
let rarityFilters = $state<number[]>([])
|
||||
let proficiencyFilters = $state<number[]>([])
|
||||
let seriesFilters = $state<number[]>([])
|
||||
|
||||
// Sort state
|
||||
let sortBy = $state<CollectionSortKey>('name_asc')
|
||||
|
||||
// Sentinel for infinite scroll
|
||||
let sentinelEl = $state<HTMLElement>()
|
||||
|
||||
// Build filters for query
|
||||
const queryFilters = $derived({
|
||||
element: elementFilters.length > 0 ? elementFilters : undefined,
|
||||
rarity: rarityFilters.length > 0 ? rarityFilters : undefined,
|
||||
proficiency: proficiencyFilters.length > 0 ? proficiencyFilters : undefined,
|
||||
sort: sortBy
|
||||
})
|
||||
|
||||
// Query for weapons collection
|
||||
const collectionQuery = createInfiniteQuery(() => {
|
||||
const userId = data.user.id
|
||||
const filters = queryFilters
|
||||
return collectionQueries.weapons(userId, filters)
|
||||
})
|
||||
|
||||
// Flatten all weapons from pages
|
||||
const allWeapons = $derived.by((): CollectionWeapon[] => {
|
||||
if (!collectionQuery.data?.pages) {
|
||||
return []
|
||||
}
|
||||
return collectionQuery.data.pages.flatMap((page) => page.results ?? [])
|
||||
})
|
||||
|
||||
// Infinite scroll
|
||||
const inViewport = new IsInViewport(() => sentinelEl, {
|
||||
rootMargin: '200px'
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (
|
||||
inViewport.current &&
|
||||
collectionQuery.hasNextPage &&
|
||||
!collectionQuery.isFetchingNextPage &&
|
||||
!collectionQuery.isLoading
|
||||
) {
|
||||
collectionQuery.fetchNextPage()
|
||||
}
|
||||
})
|
||||
|
||||
const isLoading = $derived(collectionQuery.isLoading)
|
||||
const isEmpty = $derived(!isLoading && allWeapons.length === 0)
|
||||
const showSentinel = $derived(collectionQuery.hasNextPage && !collectionQuery.isFetchingNextPage)
|
||||
|
||||
// Current view mode from store
|
||||
const currentViewMode = $derived(viewMode.collectionView)
|
||||
|
||||
function handleFiltersChange(filters: CollectionFilterState) {
|
||||
elementFilters = filters.element
|
||||
rarityFilters = filters.rarity
|
||||
proficiencyFilters = filters.proficiency
|
||||
seriesFilters = filters.series
|
||||
}
|
||||
|
||||
function handleViewModeChange(mode: ViewMode) {
|
||||
viewMode.setCollectionView(mode)
|
||||
}
|
||||
|
||||
function openWeaponDetails(weapon: CollectionWeapon) {
|
||||
const weaponName =
|
||||
typeof weapon.weapon?.name === 'string'
|
||||
? weapon.weapon.name
|
||||
: weapon.weapon?.name?.en || 'Weapon'
|
||||
|
||||
sidebar.openWithComponent(weaponName, CollectionWeaponPane, {
|
||||
weapon,
|
||||
isOwner: data.isOwner,
|
||||
onClose: () => sidebar.close()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="collection-page">
|
||||
<!-- Action bar -->
|
||||
<div class="action-bar">
|
||||
<CollectionFilters
|
||||
entityType="weapon"
|
||||
bind:elementFilters
|
||||
bind:rarityFilters
|
||||
bind:proficiencyFilters
|
||||
bind:seriesFilters
|
||||
bind:sortBy
|
||||
onFiltersChange={handleFiltersChange}
|
||||
showViewToggle={true}
|
||||
viewMode={currentViewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
/>
|
||||
</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="sword" size={48} />
|
||||
<h3>Your weapon collection is empty</h3>
|
||||
<p>Use the "Add weapons" 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="weapon-grid">
|
||||
{#each allWeapons as weapon (weapon.id)}
|
||||
<CollectionWeaponCard
|
||||
{weapon}
|
||||
onClick={() => openWeaponDetails(weapon)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="weapon-list">
|
||||
{#each allWeapons as weapon (weapon.id)}
|
||||
<CollectionWeaponRow
|
||||
{weapon}
|
||||
onClick={() => openWeaponDetails(weapon)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isLoading && !isEmpty}
|
||||
{#if showSentinel}
|
||||
<div class="load-more-sentinel" bind:this={sentinelEl}></div>
|
||||
{/if}
|
||||
|
||||
{#if collectionQuery.isFetchingNextPage}
|
||||
<div class="loading-more">
|
||||
<Icon name="loader-2" size={20} />
|
||||
<span>Loading more...</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !collectionQuery.hasNextPage && allWeapons.length > 0}
|
||||
<div class="end-message">
|
||||
<p>
|
||||
{allWeapons.length} weapon{allWeapons.length === 1 ? '' : 's'} in {data.isOwner
|
||||
? 'your'
|
||||
: 'this'} collection
|
||||
</p>
|
||||
</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;
|
||||
}
|
||||
|
||||
.weapon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 128px);
|
||||
justify-content: space-between;
|
||||
gap: $unit-4x;
|
||||
}
|
||||
|
||||
.weapon-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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.end-message {
|
||||
text-align: center;
|
||||
padding: $unit-2x;
|
||||
color: var(--text-secondary, #666);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue