convert collection filters from buttons to dropdowns

This commit is contained in:
Justin Edmund 2025-12-02 17:19:31 -08:00
parent 9f18ff0a4d
commit cc2b2c1f95
4 changed files with 209 additions and 278 deletions

View file

@ -2,6 +2,9 @@
import { CHARACTER_SEASON_NAMES, CHARACTER_SERIES_NAMES } from '$lib/types/enums'
import { RACE_LABELS } from '$lib/utils/race'
import { GENDER_LABELS } from '$lib/utils/gender'
import type { CollectionSortKey } from '$lib/types/api/collection'
import MultiSelect from '$lib/components/ui/MultiSelect.svelte'
import Select from '$lib/components/ui/Select.svelte'
interface Props {
elementFilters?: number[]
@ -11,7 +14,9 @@
raceFilters?: number[]
proficiencyFilters?: number[]
genderFilters?: number[]
sortBy?: CollectionSortKey
onFiltersChange?: (filters: CollectionFilterState) => void
onSortChange?: (sort: CollectionSortKey) => void
/** Which filter groups to show */
showFilters?: {
element?: boolean
@ -22,8 +27,8 @@
proficiency?: boolean
gender?: boolean
}
/** Layout mode */
layout?: 'horizontal' | 'vertical'
/** Whether to show the sort dropdown */
showSort?: boolean
}
export interface CollectionFilterState {
@ -44,7 +49,9 @@
raceFilters = $bindable([]),
proficiencyFilters = $bindable([]),
genderFilters = $bindable([]),
sortBy = $bindable<CollectionSortKey>('name_asc'),
onFiltersChange,
onSortChange,
showFilters = {
element: true,
rarity: true,
@ -54,9 +61,19 @@
proficiency: true,
gender: true
},
layout = 'horizontal'
showSort = true
}: Props = $props()
// Sort options
const sortOptions: { value: CollectionSortKey; label: string }[] = [
{ value: 'name_asc', label: 'Name A → Z' },
{ value: 'name_desc', label: 'Name Z → A' },
{ value: 'element_asc', label: 'Element ↑' },
{ value: 'element_desc', label: 'Element ↓' },
{ value: 'proficiency_asc', label: 'Proficiency ↑' },
{ value: 'proficiency_desc', label: 'Proficiency ↓' }
]
// Constants
const elements = [
{ value: 0, label: 'Null', color: '#888' },
@ -77,13 +94,13 @@
const proficiencies = [
{ value: 1, label: 'Sabre' },
{ value: 2, label: 'Dagger' },
{ value: 3, label: 'Spear' },
{ value: 4, label: 'Axe' },
{ value: 5, label: 'Staff' },
{ value: 6, label: 'Gun' },
{ value: 3, label: 'Axe' },
{ value: 4, label: 'Spear' },
{ value: 5, label: 'Bow' },
{ value: 6, label: 'Staff' },
{ value: 7, label: 'Melee' },
{ value: 8, label: 'Bow' },
{ value: 9, label: 'Harp' },
{ value: 8, label: 'Harp' },
{ value: 9, label: 'Gun' },
{ value: 10, label: 'Katana' }
]
@ -124,45 +141,46 @@
})
}
function toggleFilter(
current: number[],
value: number,
setter: (val: number[]) => void
) {
if (current.includes(value)) {
setter(current.filter((v) => v !== value))
} else {
setter([...current, value])
}
function handleElementChange(value: number[]) {
elementFilters = value
emitChange()
}
function toggleElement(value: number) {
toggleFilter(elementFilters, value, (v) => (elementFilters = v))
function handleRarityChange(value: number[]) {
rarityFilters = value
emitChange()
}
function toggleRarity(value: number) {
toggleFilter(rarityFilters, value, (v) => (rarityFilters = v))
function handleSeasonChange(value: number[]) {
seasonFilters = value
emitChange()
}
function toggleSeason(value: number) {
toggleFilter(seasonFilters, value, (v) => (seasonFilters = v))
function handleSeriesChange(value: number[]) {
seriesFilters = value
emitChange()
}
function toggleSeries(value: number) {
toggleFilter(seriesFilters, value, (v) => (seriesFilters = v))
function handleRaceChange(value: number[]) {
raceFilters = value
emitChange()
}
function toggleRace(value: number) {
toggleFilter(raceFilters, value, (v) => (raceFilters = v))
function handleProficiencyChange(value: number[]) {
proficiencyFilters = value
emitChange()
}
function toggleProficiency(value: number) {
toggleFilter(proficiencyFilters, value, (v) => (proficiencyFilters = v))
function handleGenderChange(value: number[]) {
genderFilters = value
emitChange()
}
function toggleGender(value: number) {
toggleFilter(genderFilters, value, (v) => (genderFilters = v))
function handleSortChange(value: CollectionSortKey | undefined) {
if (value) {
sortBy = value
onSortChange?.(value)
}
}
function clearAll() {
@ -187,231 +205,123 @@
)
</script>
<div class="filters" class:horizontal={layout === 'horizontal'} class:vertical={layout === 'vertical'}>
{#if showFilters.element}
<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
type="button"
class="filter-btn element-btn"
class:active={elementFilters.includes(element.value)}
style="--element-color: {element.color}"
onclick={() => toggleElement(element.value)}
aria-pressed={elementFilters.includes(element.value)}
>
{element.label}
</button>
{/each}
</div>
</div>
{/if}
<div class="filters-container">
<div class="filters">
{#if showFilters.element}
<MultiSelect
options={elements}
bind:value={elementFilters}
onValueChange={handleElementChange}
placeholder="Element"
/>
{/if}
{#if showFilters.rarity}
<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
type="button"
class="filter-btn"
class:active={rarityFilters.includes(rarity.value)}
onclick={() => toggleRarity(rarity.value)}
aria-pressed={rarityFilters.includes(rarity.value)}
>
{rarity.label}
</button>
{/each}
</div>
</div>
{/if}
{#if showFilters.rarity}
<MultiSelect
options={rarities}
bind:value={rarityFilters}
onValueChange={handleRarityChange}
placeholder="Rarity"
/>
{/if}
{#if showFilters.season}
<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
type="button"
class="filter-btn"
class:active={seasonFilters.includes(season.value)}
onclick={() => toggleSeason(season.value)}
aria-pressed={seasonFilters.includes(season.value)}
>
{season.label}
</button>
{/each}
</div>
</div>
{/if}
{#if showFilters.season}
<MultiSelect
options={seasons}
bind:value={seasonFilters}
onValueChange={handleSeasonChange}
placeholder="Season"
/>
{/if}
{#if showFilters.series}
<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
type="button"
class="filter-btn"
class:active={seriesFilters.includes(s.value)}
onclick={() => toggleSeries(s.value)}
aria-pressed={seriesFilters.includes(s.value)}
>
{s.label}
</button>
{/each}
</div>
</div>
{/if}
{#if showFilters.series}
<MultiSelect
options={series}
bind:value={seriesFilters}
onValueChange={handleSeriesChange}
placeholder="Series"
/>
{/if}
{#if showFilters.race}
<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
type="button"
class="filter-btn"
class:active={raceFilters.includes(race.value)}
onclick={() => toggleRace(race.value)}
aria-pressed={raceFilters.includes(race.value)}
>
{race.label}
</button>
{/each}
</div>
</div>
{/if}
{#if showFilters.race}
<MultiSelect
options={races}
bind:value={raceFilters}
onValueChange={handleRaceChange}
placeholder="Race"
/>
{/if}
{#if showFilters.proficiency}
<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
type="button"
class="filter-btn"
class:active={proficiencyFilters.includes(prof.value)}
onclick={() => toggleProficiency(prof.value)}
aria-pressed={proficiencyFilters.includes(prof.value)}
>
{prof.label}
</button>
{/each}
</div>
</div>
{/if}
{#if showFilters.proficiency}
<MultiSelect
options={proficiencies}
bind:value={proficiencyFilters}
onValueChange={handleProficiencyChange}
placeholder="Proficiency"
/>
{/if}
{#if showFilters.gender}
<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
type="button"
class="filter-btn"
class:active={genderFilters.includes(gender.value)}
onclick={() => toggleGender(gender.value)}
aria-pressed={genderFilters.includes(gender.value)}
>
{gender.label}
</button>
{/each}
</div>
</div>
{/if}
{#if showFilters.gender}
<MultiSelect
options={genders}
bind:value={genderFilters}
onValueChange={handleGenderChange}
placeholder="Gender"
/>
{/if}
{#if hasActiveFilters}
<button type="button" class="clear-btn" onclick={clearAll}>
Clear filters
</button>
{#if hasActiveFilters}
<button type="button" class="clear-btn" onclick={clearAll}> Clear </button>
{/if}
</div>
{#if showSort}
<div class="sort">
<Select
options={sortOptions}
bind:value={sortBy}
onValueChange={handleSortChange}
size="small"
/>
</div>
{/if}
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
.filters {
display: flex;
gap: $unit-2x;
&.horizontal {
flex-wrap: wrap;
align-items: flex-start;
}
&.vertical {
flex-direction: column;
}
}
.filter-group {
display: flex;
flex-direction: column;
gap: $unit-half;
}
.filter-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary, #666);
letter-spacing: 0.5px;
}
.filter-buttons {
.filters-container {
display: flex;
flex-wrap: wrap;
gap: 4px;
&.wrap {
max-width: 400px;
}
&.proficiency-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
}
align-items: center;
justify-content: space-between;
gap: $unit;
width: 100%;
}
.filter-btn {
padding: 4px 8px;
border: 1px solid var(--border-color, #ddd);
background: var(--button-bg, white);
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
color: var(--text-primary, #333);
white-space: nowrap;
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $unit;
}
&:hover {
background: var(--button-bg-hover, #f5f5f5);
}
.sort {
flex-shrink: 0;
&.active {
background: var(--accent-color, #3366ff);
color: white;
border-color: var(--accent-color, #3366ff);
}
&.element-btn.active {
background: var(--element-color);
border-color: var(--element-color);
:global([data-select-trigger]) {
min-width: 128px;
}
}
.clear-btn {
padding: 4px 12px;
padding: $unit-half $unit;
border: 1px solid var(--border-color, #ddd);
background: transparent;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
color: var(--text-secondary, #666);
align-self: flex-end;
&:hover {
background: var(--button-bg-hover, #f5f5f5);

View file

@ -144,6 +144,17 @@ export interface CollectionJobAccessoryInput {
jobAccessoryId: string
}
/**
* Sort options for collection items
*/
export type CollectionSortKey =
| 'name_asc'
| 'name_desc'
| 'element_asc'
| 'element_desc'
| 'proficiency_asc'
| 'proficiency_desc'
/**
* Filters for listing collection items
*/
@ -153,6 +164,7 @@ export interface CollectionFilters {
race?: number[]
proficiency?: number[]
gender?: number[]
sort?: CollectionSortKey
page?: number
limit?: number
}

View file

@ -5,9 +5,14 @@
import ProfileHeader from '$lib/components/profile/ProfileHeader.svelte'
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
import Button from '$lib/components/ui/Button.svelte'
import Icon from '$lib/components/Icon.svelte'
import AddToCollectionModal from '$lib/components/collection/AddToCollectionModal.svelte'
let { data, children }: { data: LayoutData; children: any } = $props()
let addModalOpen = $state(false)
// Determine active entity type from URL path
const activeEntityType = $derived.by(() => {
const path = $page.url.pathname
@ -24,31 +29,42 @@
</script>
<svelte:head>
<title>{username}'s Collection</title>
<title>{username}</title>
</svelte:head>
<section class="collection">
<ProfileHeader
{username}
avatarPicture={data.user?.avatar?.picture}
title="{username}'s Collection"
title={username}
activeTab="collection"
isOwner={data.isOwner}
/>
<!-- Entity type segmented control -->
<nav class="entity-nav" aria-label="Collection type">
<SegmentedControl value={activeEntityType} onValueChange={handleTabChange} gap={true}>
<Segment value="characters">
Characters
</Segment>
<Segment value="weapons" disabled>
Weapons
</Segment>
<Segment value="summons" disabled>
Summons
</Segment>
<SegmentedControl
value={activeEntityType}
onValueChange={handleTabChange}
variant="blended"
size="small"
>
<Segment value="characters">Characters</Segment>
<Segment value="weapons" disabled>Weapons</Segment>
<Segment value="summons" disabled>Summons</Segment>
</SegmentedControl>
{#if data.isOwner}
<Button
variant="primary"
size="small"
onclick={() => (addModalOpen = true)}
icon="plus"
iconPosition="left"
>
Add characters
</Button>
{/if}
</nav>
<div class="content">
@ -56,6 +72,10 @@
</div>
</section>
{#if data.isOwner}
<AddToCollectionModal userId={data.user.id} bind:open={addModalOpen} />
{/if}
<style lang="scss">
@use '$src/themes/spacing' as *;
@ -64,8 +84,15 @@
}
.entity-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: $unit-2x;
margin-bottom: $unit-2x;
max-width: 500px;
:global(.button) {
align-self: stretch;
}
}
.content {

View file

@ -1,14 +1,12 @@
<script lang="ts">
import type { PageData } from './$types'
import type { CollectionCharacter } from '$lib/types/api/collection'
import type { CollectionCharacter, CollectionSortKey } 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'
@ -25,8 +23,8 @@
let proficiencyFilters = $state<number[]>([])
let genderFilters = $state<number[]>([])
// Modal state
let addModalOpen = $state(false)
// Sort state
let sortBy = $state<CollectionSortKey>('name_asc')
// Sentinel for infinite scroll
let sentinelEl = $state<HTMLElement>()
@ -37,7 +35,8 @@
rarity: rarityFilters.length > 0 ? rarityFilters : undefined,
race: raceFilters.length > 0 ? raceFilters : undefined,
proficiency: proficiencyFilters.length > 0 ? proficiencyFilters : undefined,
gender: genderFilters.length > 0 ? genderFilters : undefined
gender: genderFilters.length > 0 ? genderFilters : undefined,
sort: sortBy
})
// Unified query for any user's collection (privacy enforced server-side)
@ -122,6 +121,7 @@
bind:raceFilters
bind:proficiencyFilters
bind:genderFilters
bind:sortBy
onFiltersChange={handleFiltersChange}
showFilters={{
element: true,
@ -132,15 +132,7 @@
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 -->
@ -155,11 +147,7 @@
{#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>
<p>Use the "Add to Collection" button above to get started</p>
{:else}
<Icon name="lock" size={48} />
<p>This collection is empty or private</p>
@ -216,18 +204,17 @@
{#if !collectionQuery.hasNextPage && allCharacters.length > 0}
<div class="end-message">
<p>{allCharacters.length} character{allCharacters.length === 1 ? '' : 's'} in {data.isOwner ? 'your' : 'this'} collection</p>
<p>
{allCharacters.length} character{allCharacters.length === 1 ? '' : 's'} in {data.isOwner
? 'your'
: 'this'} collection
</p>
</div>
{/if}
{/if}
</div>
</div>
<!-- Add to Collection Modal -->
{#if data.isOwner}
<AddToCollectionModal userId={data.user.id} bind:open={addModalOpen} />
{/if}
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
@ -252,9 +239,10 @@
}
.character-grid {
display: flex;
flex-wrap: wrap;
gap: $unit;
display: grid;
grid-template-columns: repeat(5, 128px);
justify-content: space-between;
gap: $unit-4x;
}
.character-card {
@ -282,7 +270,7 @@
.card-image {
position: relative;
// Character grid images are 280x160 (7:4 ratio)
width: 100px;
width: 100%;
aspect-ratio: 280 / 160;
border-radius: 8px;
overflow: hidden;
@ -306,13 +294,7 @@
}
.character-name {
font-size: $font-small;
text-align: center;
color: $grey-50;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: none;
}
.loading-state,