add collection page route and character pane components

- Add collection route structure at [username]/collection/characters
- Create CharacterEditPane as shared component for character customizations
- Create CollectionCharacterPane with Info and My Collection tabs
- Add character grid with filters and infinite scroll
- Fix CollectionFilters a11y warnings
This commit is contained in:
Justin Edmund 2025-12-02 09:43:31 -08:00
parent a9de4a60c0
commit dea784780a
8 changed files with 1233 additions and 14 deletions

View file

@ -0,0 +1,303 @@
<script lang="ts">
/**
* CollectionCharacterPane - Details and edit pane for collection characters
*
* Displays character information with two views:
* - "Info" tab: Shows base character stats, skills, etc.
* - "My Collection" tab: Shows user's customizations (rings, earring, awakening, etc.)
*
* The "My Collection" tab includes an edit mode using CharacterEditPane.
*/
import type { CollectionCharacter, ExtendedMastery } from '$lib/types/api/collection'
import { useUpdateCollectionCharacter } from '$lib/api/mutations/collection.mutations'
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
import ItemHeader from '$lib/components/sidebar/details/ItemHeader.svelte'
import BasicInfoSection from '$lib/components/sidebar/details/BasicInfoSection.svelte'
import StatsSection from '$lib/components/sidebar/details/StatsSection.svelte'
import SkillsSection from '$lib/components/sidebar/details/SkillsSection.svelte'
import CharacterEditPane, {
type CharacterEditValues,
type CharacterEditUpdates
} from '$lib/components/sidebar/CharacterEditPane.svelte'
import Button from '$lib/components/ui/Button.svelte'
import Icon from '$lib/components/Icon.svelte'
interface Props {
character: CollectionCharacter
isOwner: boolean
onClose?: () => void
}
let { character, isOwner, onClose }: Props = $props()
// Tab state
let selectedTab = $state<'info' | 'collection'>('collection')
// Edit mode state
let isEditing = $state(false)
// Update mutation
const updateMutation = useUpdateCollectionCharacter()
// Derived values
const characterData = $derived(character.character)
// Current edit values from the collection character
const currentValues = $derived<CharacterEditValues>({
awakening: character.awakening
? {
type: character.awakening.type,
level: character.awakening.level
}
: null,
rings: [
character.ring1 ?? { modifier: 1, strength: 0 },
character.ring2 ?? { modifier: 2, strength: 0 },
character.ring3 ?? { modifier: 0, strength: 0 },
character.ring4 ?? { modifier: 0, strength: 0 }
],
earring: character.earring,
perpetuity: character.perpetuity
})
// Element name for theming
const ELEMENT_MAP: Record<number, 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'> = {
1: 'wind',
2: 'fire',
3: 'water',
4: 'earth',
5: 'dark',
6: 'light'
}
const elementName = $derived(
characterData?.element ? ELEMENT_MAP[characterData.element] : undefined
)
async function handleSave(updates: CharacterEditUpdates) {
try {
// Transform updates to API format
const input: Record<string, unknown> = {}
// Handle awakening
if (updates.awakening !== undefined) {
if (updates.awakening === null) {
input.awakeningId = null
input.awakeningLevel = null
} else {
input.awakeningId = updates.awakening.id
input.awakeningLevel = updates.awakening.level
}
}
// Handle rings (API expects ring1, ring2, ring3, ring4)
if (updates.rings) {
updates.rings.forEach((ring, index) => {
const key = `ring${index + 1}` as keyof typeof input
input[key] = ring
})
}
// Handle earring
if (updates.earring !== undefined) {
input.earring = updates.earring
}
// Handle perpetuity
if (updates.perpetuity !== undefined) {
input.perpetuity = updates.perpetuity
}
await updateMutation.mutateAsync({
id: character.id,
input
})
isEditing = false
} catch (error) {
console.error('Failed to update collection character:', error)
}
}
function handleCancel() {
isEditing = false
}
function handleTabChange(value: string) {
selectedTab = value as 'info' | 'collection'
// Exit edit mode when switching tabs
if (isEditing) {
isEditing = false
}
}
function displayRing(ring: ExtendedMastery | null): string {
if (!ring || ring.modifier === 0) return '—'
// TODO: Add proper ring modifier labels
return `Modifier ${ring.modifier}: ${ring.strength}`
}
function displayAwakening(): string {
if (!character.awakening) return '—'
const name =
typeof character.awakening.type.name === 'string'
? character.awakening.type.name
: character.awakening.type.name?.en || 'Unknown'
return `${name} Lv.${character.awakening.level}`
}
</script>
<div class="collection-character-pane">
<ItemHeader
type="character"
item={character as any}
itemData={characterData}
gridUncapLevel={character.uncapLevel}
gridTranscendence={character.transcendenceStep}
/>
<!-- Tab navigation -->
<div class="tab-nav">
<SegmentedControl value={selectedTab} onValueChange={handleTabChange} variant="background" grow>
<Segment value="info">Info</Segment>
<Segment value="collection">My Collection</Segment>
</SegmentedControl>
</div>
<div class="pane-content">
{#if selectedTab === 'info'}
<!-- Info view: base character data -->
<div class="info-view">
<BasicInfoSection type="character" itemData={characterData} />
<StatsSection
itemData={characterData}
gridUncapLevel={character.uncapLevel}
gridTranscendence={character.transcendenceStep}
/>
<SkillsSection type="character" itemData={characterData} />
</div>
{:else if isEditing}
<!-- Edit mode -->
<CharacterEditPane
{characterData}
{currentValues}
showPerpetuity={true}
onSave={handleSave}
onCancel={handleCancel}
saving={updateMutation.isPending}
/>
{:else}
<!-- My Collection view: user's customizations -->
<div class="collection-view">
<div class="customization-section">
<h4>Awakening</h4>
<p>{displayAwakening()}</p>
</div>
<div class="customization-section">
<h4>Over Mastery Rings</h4>
<div class="rings-list">
<p>Ring 1: {displayRing(character.ring1)}</p>
<p>Ring 2: {displayRing(character.ring2)}</p>
<p>Ring 3: {displayRing(character.ring3)}</p>
<p>Ring 4: {displayRing(character.ring4)}</p>
</div>
</div>
<div class="customization-section">
<h4>Aetherial Mastery</h4>
<p>{displayRing(character.earring)}</p>
</div>
<div class="customization-section">
<h4>Perpetuity Ring</h4>
<p>{character.perpetuity ? 'Equipped' : 'Not equipped'}</p>
</div>
{#if isOwner}
<div class="edit-button-container">
<Button
variant="primary"
element={elementName}
elementStyle={!!elementName}
onclick={() => (isEditing = true)}
>
<Icon name="pencil" size={16} />
Edit Customizations
</Button>
</div>
{/if}
</div>
{/if}
</div>
</div>
<style lang="scss">
@use '$src/themes/spacing' as spacing;
@use '$src/themes/colors' as colors;
@use '$src/themes/typography' as typography;
.collection-character-pane {
display: flex;
flex-direction: column;
height: 100%;
color: var(--text-primary, colors.$grey-10);
}
.tab-nav {
padding: 0 spacing.$unit-2x spacing.$unit-2x;
}
.pane-content {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.info-view {
display: flex;
flex-direction: column;
gap: spacing.$unit-4x;
padding: 0 spacing.$unit-2x;
}
.collection-view {
display: flex;
flex-direction: column;
gap: spacing.$unit-3x;
padding: 0 spacing.$unit-2x;
}
.customization-section {
h4 {
font-size: typography.$font-small;
font-weight: typography.$bold;
text-transform: uppercase;
color: var(--text-secondary, colors.$grey-50);
margin: 0 0 spacing.$unit-half;
letter-spacing: 0.5px;
}
p {
margin: 0;
color: var(--text-primary, colors.$grey-10);
}
}
.rings-list {
display: flex;
flex-direction: column;
gap: spacing.$unit-half;
p {
margin: 0;
}
}
.edit-button-container {
margin-top: spacing.$unit-2x;
padding-top: spacing.$unit-2x;
border-top: 1px solid var(--border-secondary, colors.$grey-80);
}
</style>

View file

@ -189,8 +189,8 @@
<div class="filters" class:horizontal={layout === 'horizontal'} class:vertical={layout === 'vertical'}>
{#if showFilters.element}
<div class="filter-group">
<label class="filter-label">Element</label>
<div class="filter-group" role="group" aria-label="Element filters">
<span class="filter-label">Element</span>
<div class="filter-buttons">
{#each elements as element}
<button
@ -209,8 +209,8 @@
{/if}
{#if showFilters.rarity}
<div class="filter-group">
<label class="filter-label">Rarity</label>
<div class="filter-group" role="group" aria-label="Rarity filters">
<span class="filter-label">Rarity</span>
<div class="filter-buttons">
{#each rarities as rarity}
<button
@ -228,8 +228,8 @@
{/if}
{#if showFilters.season}
<div class="filter-group">
<label class="filter-label">Season</label>
<div class="filter-group" role="group" aria-label="Season filters">
<span class="filter-label">Season</span>
<div class="filter-buttons">
{#each seasons as season}
<button
@ -247,8 +247,8 @@
{/if}
{#if showFilters.series}
<div class="filter-group">
<label class="filter-label">Series</label>
<div class="filter-group" role="group" aria-label="Series filters">
<span class="filter-label">Series</span>
<div class="filter-buttons wrap">
{#each series as s}
<button
@ -266,8 +266,8 @@
{/if}
{#if showFilters.race}
<div class="filter-group">
<label class="filter-label">Race</label>
<div class="filter-group" role="group" aria-label="Race filters">
<span class="filter-label">Race</span>
<div class="filter-buttons">
{#each races as race}
<button
@ -285,8 +285,8 @@
{/if}
{#if showFilters.proficiency}
<div class="filter-group">
<label class="filter-label">Proficiency</label>
<div class="filter-group" role="group" aria-label="Proficiency filters">
<span class="filter-label">Proficiency</span>
<div class="filter-buttons proficiency-grid">
{#each proficiencies as prof}
<button
@ -304,8 +304,8 @@
{/if}
{#if showFilters.gender}
<div class="filter-group">
<label class="filter-label">Gender</label>
<div class="filter-group" role="group" aria-label="Gender filters">
<span class="filter-label">Gender</span>
<div class="filter-buttons">
{#each genders as gender}
<button

View file

@ -0,0 +1,282 @@
<script lang="ts">
/**
* CharacterEditPane - Unified character edit component
*
* This component provides all edit controls for character customization:
* - Awakening selection and level
* - Over Mastery rings (4 slots)
* - Aetherial Mastery (earring)
* - Perpetuity ring toggle
*
* Used by both:
* - Party grid character editing (EditCharacterSidebar)
* - Collection character editing (CollectionCharacterPane)
*
* The caller is responsible for handling the save action with their specific mutation.
*/
import type { Character, Awakening } from '$lib/types/api/entities'
import type { ExtendedMastery } from '$lib/types/api/collection'
import DetailsSection from './details/DetailsSection.svelte'
import AwakeningSelect from './edit/AwakeningSelect.svelte'
import RingsSelect from './edit/RingsSelect.svelte'
import EarringSelect from './edit/EarringSelect.svelte'
import PerpetuityToggle from './edit/PerpetuityToggle.svelte'
import Button from '$lib/components/ui/Button.svelte'
export interface CharacterEditValues {
awakening?: {
type?: Awakening
level: number
} | null
rings: ExtendedMastery[]
earring?: ExtendedMastery | null
perpetuity: boolean
}
export interface CharacterEditUpdates {
awakening?: {
id: string
level: number
} | null
rings: ExtendedMastery[]
earring?: ExtendedMastery | null
perpetuity?: boolean
}
interface Props {
/** The base character data (for awakenings list, element, etc.) */
characterData: Character | undefined
/** Current values for all edit fields */
currentValues: CharacterEditValues
/** Whether to show the perpetuity toggle (false for MC/position 0) */
showPerpetuity?: boolean
/** Callback when save is clicked, receives API-formatted updates */
onSave?: (updates: CharacterEditUpdates) => void
/** Callback when cancel is clicked */
onCancel?: () => void
/** Whether save is in progress (disables buttons) */
saving?: boolean
}
let {
characterData,
currentValues,
showPerpetuity = true,
onSave,
onCancel,
saving = false
}: Props = $props()
// Internal state - initialized from currentValues
let selectedAwakening = $state<Awakening | undefined>(currentValues.awakening?.type)
let awakeningLevel = $state(currentValues.awakening?.level ?? 1)
let rings = $state<ExtendedMastery[]>(
currentValues.rings.length > 0
? currentValues.rings
: [
{ modifier: 1, strength: 0 },
{ modifier: 2, strength: 0 },
{ modifier: 0, strength: 0 },
{ modifier: 0, strength: 0 }
]
)
let earring = $state<ExtendedMastery | undefined>(currentValues.earring ?? undefined)
let perpetuity = $state(currentValues.perpetuity)
// Re-initialize when currentValues changes (e.g., switching between characters)
$effect(() => {
selectedAwakening = currentValues.awakening?.type
awakeningLevel = currentValues.awakening?.level ?? 1
rings =
currentValues.rings.length > 0
? currentValues.rings
: [
{ modifier: 1, strength: 0 },
{ modifier: 2, strength: 0 },
{ modifier: 0, strength: 0 },
{ modifier: 0, strength: 0 }
]
earring = currentValues.earring ?? undefined
perpetuity = currentValues.perpetuity
})
// Derived conditions
const characterElement = $derived(characterData?.element)
const hasAwakening = $derived((characterData?.awakenings?.length ?? 0) > 0)
const availableAwakenings = $derived(characterData?.awakenings ?? [])
const maxAwakeningLevel = 10
// Element name for theming
const ELEMENT_MAP: Record<number, 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'> = {
1: 'wind',
2: 'fire',
3: 'water',
4: 'earth',
5: 'dark',
6: 'light'
}
const elementName = $derived(characterElement ? ELEMENT_MAP[characterElement] : undefined)
// Awakening slug to UUID map (awakenings from API have id: null, only slugs)
const AWAKENING_MAP: Record<string, string> = {
'character-balanced': 'b1847c82-ece0-4d7a-8af1-c7868d90f34a',
'character-atk': '6e233877-8cda-4c8f-a091-3db6f68749e2',
'character-def': 'c95441de-f949-4a62-b02b-101aa2e0a638',
'character-multi': 'e36b0573-79c3-4dd2-9524-c95def4bbb1a'
}
function handleSave() {
const updates: CharacterEditUpdates = {
rings,
perpetuity: showPerpetuity ? perpetuity : undefined
}
// Format awakening for API
if (hasAwakening) {
if (selectedAwakening?.slug) {
const awakeningId = AWAKENING_MAP[selectedAwakening.slug]
if (awakeningId) {
updates.awakening = {
id: awakeningId,
level: awakeningLevel
}
}
} else {
updates.awakening = null
}
}
// Format earring for API
if (earring) {
updates.earring = earring
}
onSave?.(updates)
}
function handleCancel() {
// Reset to original values
selectedAwakening = currentValues.awakening?.type
awakeningLevel = currentValues.awakening?.level ?? 1
rings =
currentValues.rings.length > 0
? currentValues.rings
: [
{ modifier: 1, strength: 0 },
{ modifier: 2, strength: 0 },
{ modifier: 0, strength: 0 },
{ modifier: 0, strength: 0 }
]
earring = currentValues.earring ?? undefined
perpetuity = currentValues.perpetuity
onCancel?.()
}
</script>
<div class="character-edit-pane">
<div class="edit-sections">
{#if hasAwakening && availableAwakenings.length > 0}
<DetailsSection title="Awakening">
<div class="section-content">
<AwakeningSelect
awakenings={availableAwakenings}
value={selectedAwakening}
level={awakeningLevel}
maxLevel={maxAwakeningLevel}
onAwakeningChange={(awakening) => {
selectedAwakening = awakening
}}
onLevelChange={(level) => {
awakeningLevel = level
}}
/>
</div>
</DetailsSection>
{/if}
<DetailsSection title="Over Mastery Rings">
<div class="section-content">
<RingsSelect
{rings}
onChange={(newRings) => {
rings = newRings
}}
/>
</div>
</DetailsSection>
<DetailsSection title="Aetherial Mastery">
<div class="section-content">
<EarringSelect
value={earring}
element={characterElement}
onChange={(newEarring) => {
earring = newEarring
}}
/>
</div>
</DetailsSection>
{#if showPerpetuity}
<DetailsSection title="Perpetuity">
<div class="section-content">
<PerpetuityToggle
value={perpetuity}
element={elementName}
onChange={(value) => {
perpetuity = value
}}
/>
</div>
</DetailsSection>
{/if}
</div>
<div class="edit-footer">
<Button variant="secondary" onclick={handleCancel} disabled={saving}>Cancel</Button>
<Button
variant="primary"
element={elementName}
elementStyle={!!elementName}
onclick={handleSave}
disabled={saving}
>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
<style lang="scss">
@use '$src/themes/spacing' as spacing;
.character-edit-pane {
display: flex;
flex-direction: column;
height: 100%;
gap: spacing.$unit-2x;
}
.edit-sections {
flex: 1;
display: flex;
flex-direction: column;
gap: spacing.$unit-3x;
overflow-y: auto;
}
.section-content {
padding: spacing.$unit;
}
.edit-footer {
display: flex;
gap: spacing.$unit-2x;
padding: spacing.$unit-2x;
border-top: 1px solid var(--border-secondary);
flex-shrink: 0;
:global(button) {
flex: 1;
}
}
</style>

View file

@ -0,0 +1,24 @@
import type { LayoutServerLoad } from './$types'
import { error } from '@sveltejs/kit'
import { userAdapter } from '$lib/api/adapters/user.adapter'
export const load: LayoutServerLoad = async ({ params, locals }) => {
const username = params.username
const isOwner = locals.session?.account?.username === username
try {
// Get basic user info
const userInfo = await userAdapter.getInfo(username)
return {
user: userInfo,
isOwner
}
} catch (e: any) {
// 403 means collection is private
if (e?.status === 403) {
throw error(403, 'This collection is private')
}
throw error(e?.status || 502, e?.message || 'Failed to load user')
}
}

View file

@ -0,0 +1,142 @@
<script lang="ts">
import type { LayoutData } from './$types'
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
let { data, children }: { data: LayoutData; children: any } = $props()
const avatarFile = $derived(data.user?.avatar?.picture || '')
const avatarSrc = $derived(getAvatarSrc(avatarFile))
const avatarSrcSet = $derived(getAvatarSrcSet(avatarFile))
// Determine active tab from URL path
const activeTab = $derived.by(() => {
const path = $page.url.pathname
if (path.includes('/weapons')) return 'weapons'
if (path.includes('/summons')) return 'summons'
return 'characters'
})
const username = $derived(data.user?.username || $page.params.username)
function handleTabChange(value: string) {
goto(`/${username}/collection/${value}`)
}
</script>
<svelte:head>
<title>{username}'s Collection</title>
</svelte:head>
<section class="collection">
<header class="header">
{#if data.user?.avatar?.picture}
<img
class="avatar"
alt={`Avatar of ${username}`}
src={avatarSrc}
srcset={avatarSrcSet}
width="64"
height="64"
/>
{:else}
<div class="avatar" aria-hidden="true"></div>
{/if}
<div class="header-content">
<h1>{username}'s Collection</h1>
<nav class="tabs" aria-label="Profile sections">
<a href="/{username}" data-sveltekit-preload-data="hover">Teams</a>
<a href="/{username}/collection/characters" class="active">Collection</a>
</nav>
</div>
</header>
<!-- Entity type segmented control -->
<nav class="entity-nav" aria-label="Collection type">
<SegmentedControl value={activeTab} onValueChange={handleTabChange} gap={true}>
<Segment value="characters">
Characters
</Segment>
<Segment value="weapons" disabled>
Weapons
</Segment>
<Segment value="summons" disabled>
Summons
</Segment>
</SegmentedControl>
</nav>
<div class="content">
{@render children()}
</div>
</section>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
.collection {
padding: $unit-2x 0;
max-width: 1400px;
margin: 0 auto;
}
.header {
display: flex;
align-items: center;
gap: $unit-2x;
margin-bottom: $unit-2x;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: $grey-80;
border: 1px solid $grey-75;
object-fit: cover;
flex-shrink: 0;
}
.header-content {
flex: 1;
}
h1 {
margin: 0 0 $unit-half;
font-size: 24px;
}
.tabs {
display: flex;
gap: $unit-2x;
}
.tabs a {
text-decoration: none;
color: var(--text-secondary);
padding-bottom: 2px;
border-bottom: 2px solid transparent;
&:hover {
color: var(--text-primary);
}
&.active {
border-color: var(--accent-color, #3366ff);
color: var(--accent-color, #3366ff);
}
}
.entity-nav {
margin-bottom: $unit-2x;
max-width: 500px;
}
.content {
min-height: 400px;
}
</style>

View file

@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit'
import type { PageLoad } from './$types'
export const load: PageLoad = async ({ params }) => {
// Redirect to characters as the default collection tab
throw redirect(307, `/${params.username}/collection/characters`)
}

View file

@ -0,0 +1,23 @@
import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'
import { collectionAdapter } from '$lib/api/adapters/collection.adapter'
export const load: PageServerLoad = async ({ parent }) => {
const { user, isOwner } = await parent()
try {
// Fetch the user's public character collection
const characters = await collectionAdapter.getPublicCharacters(user.id)
return {
characters,
isOwner
}
} catch (e: any) {
// 403 means collection is private
if (e?.status === 403) {
throw error(403, 'This collection is private')
}
throw error(e?.status || 502, e?.message || 'Failed to load collection')
}
}

View file

@ -0,0 +1,438 @@
<script lang="ts">
import type { PageData } from './$types'
import type { CollectionCharacter } 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 AddToCollectionModal from '$lib/components/collection/AddToCollectionModal.svelte'
import CollectionCharacterPane from '$lib/components/collection/CollectionCharacterPane.svelte'
import Button from '$lib/components/ui/Button.svelte'
import Icon from '$lib/components/Icon.svelte'
import { IsInViewport } from 'runed'
import { getCharacterImageWithPose } from '$lib/utils/images'
import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import { sidebar } from '$lib/stores/sidebar.svelte'
import perpetuityFilled from '$src/assets/icons/perpetuity/filled.svg'
const { data }: { data: PageData } = $props()
// Filter state
let elementFilters = $state<number[]>([])
let rarityFilters = $state<number[]>([])
let raceFilters = $state<number[]>([])
let proficiencyFilters = $state<number[]>([])
let genderFilters = $state<number[]>([])
// Modal state
let addModalOpen = $state(false)
// 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
})
// For owner, use the authenticated collection query with infinite scroll
// For non-owner, use the server-loaded public data
const collectionQuery = createInfiniteQuery(() => ({
...collectionQueries.characters(queryFilters),
enabled: data.isOwner,
initialData: data.isOwner
? undefined
: {
pages: [
{
results: data.characters || [],
page: 1,
totalPages: 1,
total: data.characters?.length || 0,
perPage: data.characters?.length || 20
}
],
pageParams: [1]
}
}))
// Flatten all characters from pages
const allCharacters = $derived.by((): CollectionCharacter[] => {
if (!data.isOwner) {
return data.characters || []
}
if (!collectionQuery.data?.pages) {
return []
}
return collectionQuery.data.pages.flatMap((page) => page.results ?? [])
})
// Client-side filtering for non-API-supported filters
const filteredCharacters = $derived.by((): CollectionCharacter[] => {
let result = allCharacters
// Apply element filter (client-side for non-owner)
if (elementFilters.length > 0) {
result = result.filter((c) => elementFilters.includes(c.character?.element ?? 0))
}
// Apply rarity filter (client-side for non-owner)
if (rarityFilters.length > 0) {
result = result.filter((c) => rarityFilters.includes(c.character?.rarity ?? 0))
}
// Apply race filter (client-side) - race is nested object
if (raceFilters.length > 0) {
result = result.filter((c) => {
const race1 = c.character?.race?.race1 ?? 0
const race2 = c.character?.race?.race2 ?? 0
return raceFilters.includes(race1) || (race2 && raceFilters.includes(race2))
})
}
// Apply proficiency filter (client-side) - proficiency is an array
if (proficiencyFilters.length > 0) {
result = result.filter((c) => {
const proficiencies = c.character?.proficiency ?? []
return proficiencies.some((p) => proficiencyFilters.includes(p))
})
}
// Apply gender filter (client-side)
if (genderFilters.length > 0) {
result = result.filter((c) => genderFilters.includes(c.character?.gender ?? 0))
}
return result
})
// Infinite scroll
const inViewport = new IsInViewport(() => sentinelEl, {
rootMargin: '200px'
})
$effect(() => {
if (
data.isOwner &&
inViewport.current &&
collectionQuery.hasNextPage &&
!collectionQuery.isFetchingNextPage &&
!collectionQuery.isLoading
) {
collectionQuery.fetchNextPage()
}
})
const isLoading = $derived(data.isOwner && collectionQuery.isLoading)
const isEmpty = $derived(!isLoading && filteredCharacters.length === 0)
const showSentinel = $derived(
data.isOwner && collectionQuery.hasNextPage && !collectionQuery.isFetchingNextPage
)
function handleFiltersChange(filters: CollectionFilterState) {
elementFilters = filters.element
rarityFilters = filters.rarity
raceFilters = filters.race
proficiencyFilters = filters.proficiency
genderFilters = filters.gender
}
function getImageUrl(character: CollectionCharacter): string {
return getCharacterImageWithPose(
character.character?.granblueId,
'grid',
character.uncapLevel,
character.transcendenceStep
)
}
function displayName(character: CollectionCharacter): string {
const name = character.character?.name
if (!name) return '—'
if (typeof name === 'string') return name
return name.en || name.ja || '—'
}
function openCharacterDetails(character: CollectionCharacter) {
const characterName =
typeof character.character?.name === 'string'
? character.character.name
: character.character?.name?.en || 'Character'
sidebar.openWithComponent(characterName, CollectionCharacterPane, {
character,
isOwner: data.isOwner,
onClose: () => sidebar.close()
})
}
</script>
<div class="collection-page">
<!-- Action bar -->
<div class="action-bar">
<CollectionFilters
bind:elementFilters
bind:rarityFilters
bind:raceFilters
bind:proficiencyFilters
bind:genderFilters
onFiltersChange={handleFiltersChange}
showFilters={{
element: true,
rarity: true,
season: false,
series: false,
race: true,
proficiency: true,
gender: true
}}
layout="horizontal"
/>
{#if data.isOwner}
<Button variant="primary" onclick={() => (addModalOpen = true)}>
<Icon name="plus" size={16} />
Add to Collection
</Button>
{/if}
</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="users" size={48} />
<h3>Your collection is empty</h3>
<p>Add characters to start building your collection</p>
<Button variant="primary" onclick={() => (addModalOpen = true)}>
<Icon name="plus" size={16} />
Add Characters
</Button>
{:else}
<Icon name="lock" size={48} />
<p>This collection is empty or private</p>
{/if}
</div>
{:else}
<div class="character-grid">
{#each filteredCharacters as character (character.id)}
<button
type="button"
class="character-card"
onclick={() => openCharacterDetails(character)}
>
<div class="card-image">
{#if character.perpetuity}
<img
class="perpetuity-badge"
src={perpetuityFilled}
alt="Perpetuity Ring"
title="Perpetuity Ring"
/>
{/if}
<img
class="character-image"
src={getImageUrl(character)}
alt={displayName(character)}
loading="lazy"
/>
</div>
<UncapIndicator
type="character"
uncapLevel={character.uncapLevel}
transcendenceStage={character.transcendenceStep}
special={character.character?.special}
flb={character.character?.uncap?.flb}
ulb={character.character?.uncap?.ulb}
transcendence={!character.character?.special && character.character?.uncap?.ulb}
/>
<span class="character-name">{displayName(character)}</span>
</button>
{/each}
</div>
{#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 data.isOwner && !collectionQuery.hasNextPage && filteredCharacters.length > 0}
<div class="end-message">
<p>{filteredCharacters.length} character{filteredCharacters.length === 1 ? '' : 's'} in your collection</p>
</div>
{/if}
{/if}
</div>
</div>
<!-- Add to Collection Modal -->
{#if data.isOwner}
<AddToCollectionModal bind:open={addModalOpen} />
{/if}
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/rep' as rep;
.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;
}
.character-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: $unit;
}
.character-card {
display: flex;
flex-direction: column;
align-items: center;
gap: $unit-half;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
&:focus-visible {
outline: 2px solid var(--accent-color, #3366ff);
outline-offset: 2px;
border-radius: 8px;
}
}
.card-image {
position: relative;
width: 100%;
@include rep.aspect(rep.$char-cell-w, rep.$char-cell-h);
border-radius: 8px;
overflow: hidden;
background: var(--card-bg, #f5f5f5);
}
.perpetuity-badge {
position: absolute;
top: -$unit-half;
right: $unit;
width: $unit-3x;
height: $unit-3x;
z-index: 10;
}
.character-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.character-name {
font-size: $font-small;
text-align: center;
color: $grey-50;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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>