add equippable characters section to artifact detail pane

This commit is contained in:
Justin Edmund 2025-12-19 01:36:24 -08:00
parent f9243add10
commit 9b15cad7ce
4 changed files with 184 additions and 1 deletions

View file

@ -182,6 +182,29 @@ export const collectionQueries = {
enabled: !!id,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 30
}),
/**
* User's collection characters as a simple list (non-paginated)
* Useful for smaller filtered queries where infinite scroll is overkill
*
* @param userId - The user whose collection to fetch
* @param filters - Optional filters for element, proficiency, etc.
* @param enabled - Whether the query is enabled (default: true)
*/
charactersList: (userId: string, filters?: CollectionFilters, enabled: boolean = true) =>
queryOptions({
queryKey: ['collection', 'characters', 'list', userId, filters] as const,
queryFn: async () => {
const response = await collectionAdapter.listCharacters(userId, {
...filters,
limit: 100
})
return response.results
},
enabled: !!userId && enabled,
staleTime: 1000 * 60 * 2,
gcTime: 1000 * 60 * 15
})
}

View file

@ -23,14 +23,16 @@
import ArtifactGradeDisplay from '$lib/components/artifact/ArtifactGradeDisplay.svelte'
import ArtifactSkillDisplay from '$lib/components/artifact/ArtifactSkillDisplay.svelte'
import CollectionArtifactEditPane from './CollectionArtifactEditPane.svelte'
import EquippableCharactersSection from './EquippableCharactersSection.svelte'
interface Props {
artifact: CollectionArtifact
userId: string
isOwner?: boolean
onClose?: () => void
}
let { artifact: initialArtifact, isOwner = false, onClose }: Props = $props()
let { artifact: initialArtifact, userId, isOwner = false, onClose }: Props = $props()
// Local state that can be updated when returning from edit pane
let artifact = $state(initialArtifact)
@ -164,6 +166,14 @@
</DetailsSection>
{/if}
{#if proficiency}
<EquippableCharactersSection
{userId}
element={artifact.element}
{proficiency}
/>
{/if}
<DetailsSection title="Grade">
<div class="grade-section">
<ArtifactGradeDisplay grade={artifact.grade} />

View file

@ -0,0 +1,149 @@
<svelte:options runes={true} />
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query'
import { collectionQueries } from '$lib/api/queries/collection.queries'
import { getCharacterImage, getCharacterPose } from '$lib/utils/images'
import RichTooltip from '$lib/components/ui/RichTooltip.svelte'
import CharacterTags from '$lib/components/tags/CharacterTags.svelte'
import DetailsSection from '$lib/components/sidebar/details/DetailsSection.svelte'
import Icon from '$lib/components/Icon.svelte'
interface Props {
userId: string
element: number
proficiency: number
}
let { userId, element, proficiency }: Props = $props()
// Query collection characters filtered by element AND proficiency
const filters = $derived({
element: [element],
proficiency: [proficiency]
})
const query = createQuery(() =>
collectionQueries.charactersList(userId, filters, !!userId && !!element && !!proficiency)
)
const characters = $derived(query.data ?? [])
const isLoading = $derived(query.isLoading)
const isEmpty = $derived(!isLoading && characters.length === 0)
// Get character display name
function getDisplayName(character: (typeof characters)[number]): string {
const name = character.character.name
if (typeof name === 'string') return name
return name.en || name.ja || '—'
}
// Get character image with pose
function getImage(character: (typeof characters)[number]): string {
const pose = getCharacterPose(character.uncapLevel, character.transcendenceStep)
return getCharacterImage(character.character.granblueId, 'square', pose)
}
</script>
<DetailsSection title="Equippable Characters">
{#if isLoading}
<div class="loading-state">
<Icon name="loader-2" size={20} />
</div>
{:else if isEmpty}
<div class="empty-state">
<span class="empty-text">No matching characters in collection</span>
</div>
{:else}
<div class="character-grid">
{#each characters as character (character.id)}
<RichTooltip>
{#snippet content()}
<div class="tooltip-content">
<span class="character-name">{getDisplayName(character)}</span>
<CharacterTags character={character.character} />
</div>
{/snippet}
{#snippet children()}
<div class="character-portrait">
<img
src={getImage(character)}
alt={getDisplayName(character)}
loading="lazy"
/>
</div>
{/snippet}
</RichTooltip>
{/each}
</div>
{/if}
</DetailsSection>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/layout' as *;
.loading-state {
display: flex;
justify-content: center;
padding: $unit;
:global(svg) {
animation: spin 1s linear infinite;
color: var(--text-secondary);
}
}
.empty-state {
padding: $unit;
}
.empty-text {
font-size: $font-small;
color: var(--text-tertiary);
}
.character-grid {
display: flex;
flex-wrap: wrap;
gap: $unit-half;
}
.character-portrait {
width: 40px;
height: 40px;
border-radius: $item-corner-small;
overflow: hidden;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&:hover {
box-shadow: 0 0 0 2px var(--accent-color);
}
}
.tooltip-content {
display: flex;
flex-direction: column;
gap: $unit-half;
}
.character-name {
font-weight: $medium;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -177,6 +177,7 @@
CollectionArtifactDetailPane,
{
artifact,
userId: data.user.id,
isOwner: data.isOwner,
onClose: () => sidebar.close()
},