hensei-web/src/routes/(app)/[username]/collection/artifacts/+page.svelte
Justin Edmund 34821aa487 split artifact pane into detail and edit views
- CollectionArtifactDetailPane: read-only view with Edit button in header
- CollectionArtifactEditPane: editable form pushed onto pane stack
- ArtifactSkillDisplay: new read-only skill display component
2025-12-03 17:54:18 -08:00

317 lines
7.4 KiB
Svelte

<script lang="ts">
import type { PageData } from './$types'
import type { CollectionArtifact } from '$lib/types/api/artifact'
import { createInfiniteQuery } from '@tanstack/svelte-query'
import { artifactQueries } from '$lib/api/queries/artifact.queries'
import CollectionArtifactDetailPane from '$lib/components/collection/CollectionArtifactDetailPane.svelte'
import CollectionArtifactCard from '$lib/components/collection/CollectionArtifactCard.svelte'
import CollectionArtifactRow from '$lib/components/collection/CollectionArtifactRow.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'
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
const { data }: { data: PageData } = $props()
// Filter state
let elementFilters = $state<number[]>([])
let rarityFilter = $state<'all' | 'standard' | 'quirk'>('all')
// Sentinel for infinite scroll
let sentinelEl = $state<HTMLElement>()
// Build filters for query
const queryFilters = $derived({
element: elementFilters.length > 0 ? elementFilters : undefined,
rarity: rarityFilter !== 'all' ? rarityFilter : undefined
})
// Query for artifacts collection
const collectionQuery = createInfiniteQuery(() => {
const userId = data.user.id
const filters = queryFilters
return artifactQueries.collection(userId, filters)
})
// Flatten all artifacts from pages
const allArtifacts = $derived.by((): CollectionArtifact[] => {
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 && allArtifacts.length === 0)
const showSentinel = $derived(collectionQuery.hasNextPage && !collectionQuery.isFetchingNextPage)
// Current view mode from store
const currentViewMode = $derived(viewMode.collectionView)
function handleViewModeChange(mode: ViewMode) {
viewMode.setCollectionView(mode)
}
function openArtifactDetails(artifact: CollectionArtifact) {
const artifactName =
typeof artifact.artifact?.name === 'string'
? artifact.artifact.name
: artifact.artifact?.name?.en || 'Artifact'
sidebar.openWithComponent(artifactName, CollectionArtifactDetailPane, {
artifact,
isOwner: data.isOwner,
onClose: () => sidebar.close()
})
}
</script>
<div class="collection-page">
<!-- Filters bar -->
<div class="filters-bar">
<SegmentedControl
value={rarityFilter}
onValueChange={(v) => (rarityFilter = v as 'all' | 'standard' | 'quirk')}
variant="blended"
size="small"
>
<Segment value="all">All</Segment>
<Segment value="standard">Standard</Segment>
<Segment value="quirk">Quirk</Segment>
</SegmentedControl>
<div class="view-toggle">
<button
type="button"
class="toggle-btn"
class:active={currentViewMode === 'grid'}
onclick={() => handleViewModeChange('grid')}
aria-label="Grid view"
>
<Icon name="grid-2x2" size={18} />
</button>
<button
type="button"
class="toggle-btn"
class:active={currentViewMode === 'list'}
onclick={() => handleViewModeChange('list')}
aria-label="List view"
>
<Icon name="list" size={18} />
</button>
</div>
</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="gem" size={48} />
<h3>Your artifact collection is empty</h3>
<p>Artifacts will appear here once added</p>
{:else}
<Icon name="lock" size={48} />
<p>This collection is empty or private</p>
{/if}
</div>
{:else if currentViewMode === 'grid'}
<div class="artifact-grid">
{#each allArtifacts as artifact (artifact.id)}
<CollectionArtifactCard
{artifact}
onClick={() => openArtifactDetails(artifact)}
/>
{/each}
</div>
{:else}
<div class="artifact-list">
{#each allArtifacts as artifact (artifact.id)}
<CollectionArtifactRow
{artifact}
onClick={() => openArtifactDetails(artifact)}
/>
{/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 && allArtifacts.length > 0}
<div class="end-message">
<p>
{allArtifacts.length} artifact{allArtifacts.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 *;
@use '$src/themes/layout' as *;
.collection-page {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.filters-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: $unit-2x;
}
.view-toggle {
display: flex;
gap: $unit-fourth;
padding: $unit-fourth;
background: var(--segmented-control-bg, $grey-90);
border-radius: $item-corner;
}
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
padding: $unit-half;
border: none;
background: transparent;
border-radius: $item-corner-small;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
color: var(--text-primary);
}
&.active {
background: var(--unit-bg, white);
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
}
.grid-area {
min-height: 400px;
}
.artifact-grid {
display: grid;
grid-template-columns: repeat(5, 100px);
justify-content: space-between;
gap: $unit-4x;
}
.artifact-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>