add equippable characters section to artifact detail pane
This commit is contained in:
parent
f9243add10
commit
9b15cad7ce
4 changed files with 184 additions and 1 deletions
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
149
src/lib/components/collection/EquippableCharactersSection.svelte
Normal file
149
src/lib/components/collection/EquippableCharactersSection.svelte
Normal 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>
|
||||
|
|
@ -177,6 +177,7 @@
|
|||
CollectionArtifactDetailPane,
|
||||
{
|
||||
artifact,
|
||||
userId: data.user.id,
|
||||
isOwner: data.isOwner,
|
||||
onClose: () => sidebar.close()
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue