add raid parties pane

click raid tile to see other parties for same raid
filters for element and battle settings
This commit is contained in:
Justin Edmund 2025-12-21 12:29:49 -08:00
parent b06c43385e
commit 12074203f1
5 changed files with 477 additions and 9 deletions

View file

@ -64,6 +64,19 @@ export interface ListUserPartiesParams {
summonId?: string
}
/**
* Parameters for listing parties by raid
*/
export interface ListRaidPartiesParams {
raidId: string
page?: number
per?: number
element?: number
fullAuto?: boolean
autoGuard?: boolean
chargeAttack?: boolean
}
/**
* Grid operation for batch updates
*/
@ -207,6 +220,45 @@ export class PartyAdapter extends BaseAdapter {
})
}
/**
* Lists public parties for a specific raid
*/
async listRaidParties(params: ListRaidPartiesParams): Promise<PaginatedResponse<Party>> {
const { raidId, element, fullAuto, autoGuard, chargeAttack, ...rest } = params
// Build query with raid filter and convert booleans to API format
const query: Record<string, unknown> = {
...rest,
raid: raidId
}
if (element !== undefined && element >= 0) query.element = element
if (fullAuto !== undefined) query.full_auto = fullAuto ? 1 : 0
if (autoGuard !== undefined) query.auto_guard = autoGuard ? 1 : 0
if (chargeAttack !== undefined) query.charge_attack = chargeAttack ? 1 : 0
const response = await this.request<{
results: Party[]
meta?: {
count?: number
totalPages?: number
perPage?: number
}
}>('/parties', {
method: 'GET',
query,
cacheTTL: 30000
})
return {
results: response.results,
page: params.page || 1,
total: response.meta?.count || 0,
totalPages: response.meta?.totalPages || 1,
perPage: response.meta?.perPage || 20
}
}
/**
* Performs atomic batch grid updates
* Supports move, swap, and remove operations on grid items

View file

@ -10,7 +10,8 @@
import { queryOptions, infiniteQueryOptions } from '@tanstack/svelte-query'
import {
partyAdapter,
type ListUserPartiesParams
type ListUserPartiesParams,
type ListRaidPartiesParams
} from '$lib/api/adapters/party.adapter'
import type { Party } from '$lib/types/api/party'
@ -33,6 +34,16 @@ export interface ListPartiesParams {
per?: number
}
/**
* Filter options for raid parties query
*/
export interface RaidPartiesFilters {
element?: number
fullAuto?: boolean
autoGuard?: boolean
chargeAttack?: boolean
}
/**
* Party query options factory
*
@ -139,6 +150,42 @@ export const partyQueries = {
gcTime: 1000 * 60 * 15 // 15 minutes
}),
/**
* Parties by raid infinite query options
*
* @param raidId - Raid ID to filter parties by
* @param filters - Optional filter parameters (element, battle settings)
* @returns Infinite query options for listing parties by raid
*/
raidParties: (raidId: string, filters?: RaidPartiesFilters) =>
infiniteQueryOptions({
queryKey: ['parties', 'raid', raidId, filters] as const,
queryFn: async ({ pageParam }): Promise<PartyPageResult> => {
const response = await partyAdapter.listRaidParties({
raidId,
...filters,
page: pageParam
})
return {
results: response.results,
page: response.page,
totalPages: response.totalPages,
total: response.total,
perPage: response.perPage
}
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1
}
return undefined
},
enabled: !!raidId,
staleTime: 1000 * 60 * 2, // 2 minutes
gcTime: 1000 * 60 * 15 // 15 minutes
}),
/**
* Party preview status query options
*
@ -179,6 +226,9 @@ export const partyKeys = {
userLists: () => [...partyKeys.all, 'user'] as const,
userList: (username: string, params?: Omit<ListUserPartiesParams, 'username'>) =>
[...partyKeys.userLists(), username, params] as const,
raidLists: () => [...partyKeys.all, 'raid'] as const,
raidList: (raidId: string, filters?: RaidPartiesFilters) =>
[...partyKeys.raidLists(), raidId, filters] as const,
details: () => ['party'] as const,
detail: (shortcode: string) => [...partyKeys.details(), shortcode] as const,
preview: (shortcode: string) => [...partyKeys.detail(shortcode), 'preview'] as const

View file

@ -4,6 +4,9 @@
import RaidTile from './RaidTile.svelte'
import BattleTile from './BattleTile.svelte'
import VideoTile from './VideoTile.svelte'
import { sidebar } from '$lib/stores/sidebar.svelte'
import RaidPartiesPane from '$lib/components/sidebar/RaidPartiesPane.svelte'
import { getRaidImage } from '$lib/utils/images'
interface Props {
party: Party
@ -25,11 +28,25 @@
// Video tile only shown when there's a video (no empty placeholder)
const showVideo = $derived(hasVideo)
// Battle tile always shown - settings have default values
function handleRaidClick() {
if (!party.raid) return
const raidName =
typeof party.raid.name === 'string'
? party.raid.name
: party.raid.name?.en || 'Raid Parties'
sidebar.openWithComponent(raidName, RaidPartiesPane, { raid: party.raid }, {
scrollable: true,
image: getRaidImage(party.raid.slug)
})
}
</script>
<div class="party-info-grid">
<!-- Row 1: Description + Video -->
<div class="row row-1">
<div class="row row-1" class:single={!showVideo}>
{#if showDescription}
<DescriptionTile description={party.description} onOpen={onOpenDescription} />
{/if}
@ -40,7 +57,7 @@
</div>
<!-- Row 2: Battle + Raid -->
<div class="row row-2">
<div class="row row-2" class:single={!showRaid}>
<BattleTile
fullAuto={party.fullAuto}
autoGuard={party.autoGuard}
@ -55,7 +72,7 @@
/>
{#if showRaid}
<RaidTile raid={party.raid} />
<RaidTile raid={party.raid} onclick={handleRaidClick} />
{/if}
</div>
</div>
@ -77,8 +94,7 @@
.row-1 {
grid-template-columns: 2fr 1fr;
// If only one item, let it take full width
&:has(> :only-child) {
&.single {
grid-template-columns: 1fr;
}
}
@ -86,7 +102,7 @@
.row-2 {
grid-template-columns: repeat(2, 1fr);
&:has(> :only-child) {
&.single {
grid-template-columns: 1fr;
}
}

View file

@ -6,9 +6,13 @@
interface Props {
raid?: Raid
onclick?: () => void
}
let { raid }: Props = $props()
let { raid, onclick }: Props = $props()
// Only clickable if raid exists and onclick is provided
const clickable = $derived(!!raid && !!onclick)
const raidName = $derived(() => {
if (!raid) return null
@ -19,7 +23,7 @@
const elementLabel = $derived(raid ? getElementLabel(raid.element) : null)
</script>
<InfoTile label="Raid" class="raid-tile">
<InfoTile label="Raid" class="raid-tile" {clickable} {onclick}>
{#if raid}
<div class="raid-info">
<img src={getRaidImage(raid.slug)} alt="" class="raid-image" />

View file

@ -0,0 +1,346 @@
<script lang="ts">
/**
* RaidPartiesPane - Shows parties that use a specific raid
*
* Displays a filterable list of public parties for a given raid.
* Filters include: Element, Full Auto, Charge Attack, Auto Guard
*/
import { createInfiniteQuery } from '@tanstack/svelte-query'
import { onDestroy } from 'svelte'
import type { Raid } from '$lib/types/api/entities'
import { partyQueries, type RaidPartiesFilters } from '$lib/api/queries/party.queries'
import { useInfiniteLoader } from '$lib/stores/loaderState.svelte'
import GridRep from '$lib/components/reps/GridRep.svelte'
import Icon from '$lib/components/Icon.svelte'
interface Props {
raid: Raid
}
let { raid }: Props = $props()
// Filter state
let elementFilter = $state<number | undefined>(undefined)
let fullAutoFilter = $state<boolean | undefined>(undefined)
let chargeAttackFilter = $state<boolean | undefined>(undefined)
let autoGuardFilter = $state<boolean | undefined>(undefined)
// Sentinel for infinite scroll
let sentinelEl = $state<HTMLElement>()
// Build filters object
const filters = $derived<RaidPartiesFilters>({
element: elementFilter,
fullAuto: fullAutoFilter,
chargeAttack: chargeAttackFilter,
autoGuard: autoGuardFilter
})
// Query for parties
const partiesQuery = createInfiniteQuery(() => partyQueries.raidParties(raid.id, filters))
// Infinite loader
const loader = useInfiniteLoader(
() => partiesQuery,
() => sentinelEl,
{ rootMargin: '200px' }
)
// Reset loader when filters change
$effect(() => {
void filters
loader.reset()
})
onDestroy(() => loader.destroy())
// Flatten results
const parties = $derived(partiesQuery.data?.pages.flatMap((page) => page.results) ?? [])
const isEmpty = $derived(
parties.length === 0 && !partiesQuery.isLoading && !partiesQuery.isError
)
// Element filter options
const elementOptions = [
{ value: undefined, label: 'All' },
{ value: 1, label: 'Wind' },
{ value: 2, label: 'Fire' },
{ value: 3, label: 'Water' },
{ value: 4, label: 'Earth' },
{ value: 5, label: 'Dark' },
{ value: 6, label: 'Light' }
]
// Battle setting definitions
const battleSettings = [
{
key: 'chargeAttack',
label: 'CA',
get value() {
return chargeAttackFilter
},
set: (v: boolean | undefined) => (chargeAttackFilter = v)
},
{
key: 'fullAuto',
label: 'FA',
get value() {
return fullAutoFilter
},
set: (v: boolean | undefined) => (fullAutoFilter = v)
},
{
key: 'autoGuard',
label: 'AG',
get value() {
return autoGuardFilter
},
set: (v: boolean | undefined) => (autoGuardFilter = v)
}
]
function toggleBattleSetting(setting: (typeof battleSettings)[0]) {
const current = setting.value
// Cycle: undefined -> true -> false -> undefined
if (current === undefined) setting.set(true)
else if (current === true) setting.set(false)
else setting.set(undefined)
}
function getBattleSettingLabel(setting: (typeof battleSettings)[0]): string {
const value = setting.value
if (value === undefined) return setting.label
return `${setting.label} ${value ? 'On' : 'Off'}`
}
</script>
<div class="raid-parties-pane">
<!-- Filters -->
<div class="filters-section">
<!-- Element filter -->
<div class="filter-group">
<span class="filter-label">Element</span>
<div class="filter-buttons">
{#each elementOptions as option (option.label)}
<button
type="button"
class="filter-btn element-btn"
class:active={elementFilter === option.value}
onclick={() => (elementFilter = option.value)}
aria-pressed={elementFilter === option.value}
>
{option.label}
</button>
{/each}
</div>
</div>
<!-- Battle settings filter -->
<div class="filter-group">
<span class="filter-label">Battle</span>
<div class="filter-buttons">
{#each battleSettings as setting (setting.key)}
<button
type="button"
class="filter-btn battle-btn"
class:active={setting.value !== undefined}
class:on={setting.value === true}
class:off={setting.value === false}
onclick={() => toggleBattleSetting(setting)}
aria-pressed={setting.value !== undefined}
>
{getBattleSettingLabel(setting)}
</button>
{/each}
</div>
</div>
</div>
<!-- Party list -->
<div class="parties-list">
{#if partiesQuery.isLoading && parties.length === 0}
<div class="loading-state">
<Icon name="loader-2" size={24} />
<span>Loading parties...</span>
</div>
{:else if partiesQuery.isError}
<div class="error-state">
<Icon name="alert-circle" size={24} />
<p>Failed to load parties</p>
<button type="button" onclick={() => partiesQuery.refetch()}>Retry</button>
</div>
{:else if isEmpty}
<div class="empty-state">
<p>No parties found for this raid</p>
</div>
{:else}
<div class="parties-grid">
{#each parties as party (party.id)}
<GridRep {party} />
{/each}
</div>
<div
class="load-more-sentinel"
bind:this={sentinelEl}
class:hidden={!partiesQuery.hasNextPage}
></div>
{#if partiesQuery.isFetchingNextPage}
<div class="loading-more">
<Icon name="loader-2" size={20} />
<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 *;
.raid-parties-pane {
display: flex;
flex-direction: column;
height: 100%;
}
.filters-section {
display: flex;
flex-direction: column;
gap: $unit-2x;
padding: $unit-2x;
background: var(--sidebar-bg);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.filter-group {
display: flex;
flex-direction: column;
gap: $unit;
}
.filter-label {
font-size: $font-tiny;
font-weight: $bold;
text-transform: uppercase;
color: var(--text-secondary);
letter-spacing: 0.5px;
}
.filter-buttons {
display: flex;
flex-wrap: wrap;
gap: $unit-half;
}
.filter-btn {
padding: $unit-half $unit;
border: 1px solid var(--border-subtle);
background: var(--button-bg);
border-radius: $input-corner;
font-size: $font-small;
cursor: pointer;
transition: all 0.15s ease;
color: var(--text-primary);
&:hover {
background: var(--button-bg-hover);
border-color: var(--border-medium);
}
&.active {
background: var(--accent-blue);
color: white;
border-color: var(--accent-blue);
}
}
.battle-btn {
&.on {
background: var(--full-auto-bg);
color: var(--full-auto-text);
border-color: var(--full-auto-bg);
}
&.off {
background: var(--input-bg);
color: var(--text-secondary);
border-color: var(--border-medium);
}
}
.parties-list {
flex: 1;
overflow-y: auto;
padding: $unit-2x;
background: var(--page-bg);
}
.parties-grid {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.loading-state,
.empty-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-4x;
color: var(--text-tertiary);
font-style: italic;
}
.error-state button {
padding: $unit $unit-2x;
border: 1px solid var(--border-subtle);
border-radius: $input-corner;
background: var(--button-bg);
cursor: pointer;
color: var(--text-primary);
&:hover {
background: var(--button-bg-hover);
}
}
.load-more-sentinel {
height: 1px;
margin-top: $unit;
&.hidden {
display: none;
}
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-2x;
color: var(--text-secondary);
:global(svg) {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>