add artifact selection components for pane stack

This commit is contained in:
Justin Edmund 2025-12-03 16:22:33 -08:00
parent e7354479f7
commit 2b572d07a7
2 changed files with 461 additions and 0 deletions

View file

@ -0,0 +1,374 @@
<svelte:options runes={true} />
<script lang="ts">
/**
* ArtifactSelectionPane - Pane for selecting artifacts from user's collection
*
* Shows paginated list of user's collection artifacts with filtering:
* - By element (match character's element)
* - By proficiency (match character's proficiencies)
*/
import type { CollectionArtifact } from '$lib/types/api/artifact'
import type { Character } from '$lib/types/api/entities'
import { createInfiniteQuery } from '@tanstack/svelte-query'
import { artifactQueries } from '$lib/api/queries/artifact.queries'
import { IsInViewport } from 'runed'
import { getArtifactImage } from '$lib/utils/images'
import ElementLabel from '$lib/components/labels/ElementLabel.svelte'
import ProficiencyLabel from '$lib/components/labels/ProficiencyLabel.svelte'
import Icon from '$lib/components/Icon.svelte'
import Button from '$lib/components/ui/Button.svelte'
interface Props {
/** User ID whose collection to load */
userId: string
/** Character to filter by (for element/proficiency matching) */
character?: Character
/** Currently equipped artifact ID (to highlight) */
currentArtifactId?: string
/** Callback when an artifact is selected */
onSelect?: (artifact: CollectionArtifact) => void
/** Callback when equip is confirmed */
onEquip?: (artifact: CollectionArtifact) => void
}
let { userId, character, currentArtifactId, onSelect, onEquip }: Props = $props()
// Filter state - auto-filter by character's element
let elementFilter = $state<number | undefined>(character?.element)
// Sentinel for infinite scroll
let sentinelEl = $state<HTMLElement>()
// Build query filters
const queryFilters = $derived({
element: elementFilter
})
// Query for user's collection artifacts
const collectionQuery = createInfiniteQuery(() =>
artifactQueries.collection(userId, queryFilters)
)
// Flatten all artifacts
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)
// Get display name for artifact
function getDisplayName(artifact: CollectionArtifact): string {
const name = artifact.artifact?.name
if (!name) return '—'
if (typeof name === 'string') return name
return name.en || name.ja || '—'
}
// Get proficiency for artifact
function getProficiency(artifact: CollectionArtifact): number | undefined {
const isQuirk = artifact.artifact?.rarity === 'quirk'
return ((isQuirk ? artifact.proficiency : artifact.artifact?.proficiency) ?? undefined)
}
function handleSelect(artifact: CollectionArtifact) {
onSelect?.(artifact)
}
function handleEquip(artifact: CollectionArtifact) {
onEquip?.(artifact)
}
</script>
<div class="artifact-selection-pane">
<!-- Filters -->
<div class="filters">
<label class="filter-label">
<input
type="checkbox"
checked={elementFilter !== undefined}
onchange={(e) => {
elementFilter = e.currentTarget.checked ? character?.element : undefined
}}
/>
<span>Match element</span>
</label>
</div>
<!-- Artifact list -->
<div class="artifact-list">
{#if isLoading}
<div class="loading-state">
<Icon name="loader-2" size={24} />
<p>Loading artifacts...</p>
</div>
{:else if isEmpty}
<div class="empty-state">
<Icon name="gem" size={32} />
<p>No artifacts in collection</p>
</div>
{:else}
{#each allArtifacts as artifact (artifact.id)}
{@const isEquipped = artifact.id === currentArtifactId}
{@const imageUrl = getArtifactImage(artifact.artifact?.granblueId)}
{@const displayName = getDisplayName(artifact)}
{@const proficiency = getProficiency(artifact)}
{@const gradeLetter = artifact.grade?.letter}
<button
type="button"
class="artifact-item"
class:equipped={isEquipped}
onclick={() => handleSelect(artifact)}
>
<div class="artifact-image">
<img src={imageUrl} alt={displayName} />
</div>
<div class="artifact-info">
<span class="artifact-name">{displayName}</span>
<div class="artifact-meta">
<ElementLabel element={artifact.element} size="small" />
<ProficiencyLabel {proficiency} size="small" />
<span class="artifact-level">Lv.{artifact.level}</span>
{#if gradeLetter}
<span class="grade-badge grade-{gradeLetter.toLowerCase()}">{gradeLetter}</span>
{/if}
</div>
</div>
{#if isEquipped}
<span class="equipped-badge">Equipped</span>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="equip-btn-wrapper" onclick={(e) => e.stopPropagation()}>
<Button variant="primary" size="small" onclick={() => handleEquip(artifact)}>
Equip
</Button>
</span>
{/if}
</button>
{/each}
{#if showSentinel}
<div class="load-more-sentinel" bind:this={sentinelEl}></div>
{/if}
{#if collectionQuery.isFetchingNextPage}
<div class="loading-more">
<Icon name="loader-2" size={16} />
<span>Loading more...</span>
</div>
{/if}
{/if}
</div>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/layout' as *;
.artifact-selection-pane {
display: flex;
flex-direction: column;
height: 100%;
}
.filters {
padding: $unit-2x;
border-bottom: 1px solid var(--border-secondary);
flex-shrink: 0;
}
.filter-label {
display: flex;
align-items: center;
gap: $unit;
font-size: $font-small;
color: var(--text-secondary);
cursor: pointer;
input {
accent-color: var(--accent-color, #3366ff);
}
}
.artifact-list {
flex: 1;
overflow-y: auto;
padding: $unit;
}
.artifact-item {
display: flex;
align-items: center;
gap: $unit-2x;
width: 100%;
padding: $unit $unit-2x;
border: none;
background: var(--list-cell-bg);
cursor: pointer;
text-align: left;
border-radius: $item-corner;
transition: background 0.15s;
margin-bottom: $unit;
&:hover {
background: var(--list-cell-bg-hover);
}
&.equipped {
background: var(--card-bg-selected, rgba(51, 102, 255, 0.1));
border: 1px solid var(--accent-color, #3366ff);
}
}
.artifact-image {
width: 40px;
height: 40px;
border-radius: $item-corner;
overflow: hidden;
background: var(--card-bg, #f5f5f5);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.artifact-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: $unit-fourth;
}
.artifact-name {
font-size: $font-small;
font-weight: $medium;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.artifact-meta {
display: flex;
align-items: center;
gap: $unit;
}
.artifact-level {
font-size: $font-tiny;
color: var(--text-secondary);
}
.grade-badge {
font-size: $font-tiny;
font-weight: $bold;
padding: 1px 4px;
border-radius: 3px;
line-height: 1;
&.grade-s {
background: linear-gradient(135deg, #ffd700, #ffb347);
color: #6b4c00;
}
&.grade-a {
background: linear-gradient(135deg, #4ade80, #22c55e);
color: #14532d;
}
&.grade-b {
background: linear-gradient(135deg, #60a5fa, #3b82f6);
color: #1e3a5f;
}
&.grade-c,
&.grade-d {
background: var(--grey-80, #e9e9e9);
color: var(--grey-40, #444);
}
&.grade-f {
background: linear-gradient(135deg, #f87171, #ef4444);
color: #7f1d1d;
}
}
.equipped-badge {
font-size: $font-tiny;
color: var(--accent-color, #3366ff);
font-weight: $medium;
flex-shrink: 0;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: var(--text-secondary);
gap: $unit;
:global(svg) {
color: var(--icon-secondary);
}
p {
margin: 0;
}
}
.loading-state :global(svg) {
animation: spin 1s linear infinite;
}
.load-more-sentinel {
height: 1px;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-2x;
color: var(--text-secondary);
font-size: $font-small;
:global(svg) {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,87 @@
<svelte:options runes={true} />
<script lang="ts">
/**
* CharacterArtifactSection - Artifact management section for character edit
*
* Shows current artifact (if equipped) with option to change/remove,
* or prompts to equip an artifact if none is equipped.
*
* Uses pane stack for artifact selection flow.
*/
import type { GridArtifact, CollectionArtifact } from '$lib/types/api/artifact'
import type { Character } from '$lib/types/api/entities'
import ArtifactSummary from './modifications/ArtifactSummary.svelte'
import DisclosureRow from '$lib/components/ui/DisclosureRow.svelte'
import Button from '$lib/components/ui/Button.svelte'
interface Props {
/** Currently equipped artifact (if any) */
artifact?: GridArtifact | CollectionArtifact | null
/** The character this artifact is being equipped to (for filtering) */
character?: Character
/** Callback to open artifact selection pane */
onSelectArtifact?: () => void
/** Callback to remove currently equipped artifact */
onRemoveArtifact?: () => void
/** Whether artifact can be changed */
editable?: boolean
/** Whether a save operation is in progress */
saving?: boolean
}
let {
artifact,
character,
onSelectArtifact,
onRemoveArtifact,
editable = true,
saving = false
}: Props = $props()
const hasArtifact = $derived(!!artifact)
</script>
<div class="artifact-section">
{#if hasArtifact && artifact}
<ArtifactSummary {artifact} />
{#if editable}
<div class="artifact-actions">
<Button variant="secondary" size="small" onclick={onSelectArtifact} disabled={saving}>
Change
</Button>
<Button variant="ghost" size="small" onclick={onRemoveArtifact} disabled={saving}>
Remove
</Button>
</div>
{/if}
{:else if editable}
<DisclosureRow label="Equip Artifact" onclick={onSelectArtifact} disabled={saving} />
{:else}
<p class="no-artifact">No artifact equipped</p>
{/if}
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/typography' as *;
.artifact-section {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.artifact-actions {
display: flex;
gap: $unit;
margin-top: $unit;
}
.no-artifact {
margin: 0;
font-size: $font-small;
color: var(--text-secondary);
}
</style>