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:
parent
b06c43385e
commit
12074203f1
5 changed files with 477 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
346
src/lib/components/sidebar/RaidPartiesPane.svelte
Normal file
346
src/lib/components/sidebar/RaidPartiesPane.svelte
Normal 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>
|
||||
Loading…
Reference in a new issue