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:
Justin Edmund 2025-12-03 07:29:38 -08:00
parent 60947a7911
commit d11362ff57
5 changed files with 599 additions and 4 deletions

View file

@ -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">

View file

@ -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
}
}

View 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>

View file

@ -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
}
}

View 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>