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
|
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
|
* 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
|
* Performs atomic batch grid updates
|
||||||
* Supports move, swap, and remove operations on grid items
|
* Supports move, swap, and remove operations on grid items
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
import { queryOptions, infiniteQueryOptions } from '@tanstack/svelte-query'
|
import { queryOptions, infiniteQueryOptions } from '@tanstack/svelte-query'
|
||||||
import {
|
import {
|
||||||
partyAdapter,
|
partyAdapter,
|
||||||
type ListUserPartiesParams
|
type ListUserPartiesParams,
|
||||||
|
type ListRaidPartiesParams
|
||||||
} from '$lib/api/adapters/party.adapter'
|
} from '$lib/api/adapters/party.adapter'
|
||||||
import type { Party } from '$lib/types/api/party'
|
import type { Party } from '$lib/types/api/party'
|
||||||
|
|
||||||
|
|
@ -33,6 +34,16 @@ export interface ListPartiesParams {
|
||||||
per?: number
|
per?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter options for raid parties query
|
||||||
|
*/
|
||||||
|
export interface RaidPartiesFilters {
|
||||||
|
element?: number
|
||||||
|
fullAuto?: boolean
|
||||||
|
autoGuard?: boolean
|
||||||
|
chargeAttack?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Party query options factory
|
* Party query options factory
|
||||||
*
|
*
|
||||||
|
|
@ -139,6 +150,42 @@ export const partyQueries = {
|
||||||
gcTime: 1000 * 60 * 15 // 15 minutes
|
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
|
* Party preview status query options
|
||||||
*
|
*
|
||||||
|
|
@ -179,6 +226,9 @@ export const partyKeys = {
|
||||||
userLists: () => [...partyKeys.all, 'user'] as const,
|
userLists: () => [...partyKeys.all, 'user'] as const,
|
||||||
userList: (username: string, params?: Omit<ListUserPartiesParams, 'username'>) =>
|
userList: (username: string, params?: Omit<ListUserPartiesParams, 'username'>) =>
|
||||||
[...partyKeys.userLists(), username, params] as const,
|
[...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,
|
details: () => ['party'] as const,
|
||||||
detail: (shortcode: string) => [...partyKeys.details(), shortcode] as const,
|
detail: (shortcode: string) => [...partyKeys.details(), shortcode] as const,
|
||||||
preview: (shortcode: string) => [...partyKeys.detail(shortcode), 'preview'] as const
|
preview: (shortcode: string) => [...partyKeys.detail(shortcode), 'preview'] as const
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@
|
||||||
import RaidTile from './RaidTile.svelte'
|
import RaidTile from './RaidTile.svelte'
|
||||||
import BattleTile from './BattleTile.svelte'
|
import BattleTile from './BattleTile.svelte'
|
||||||
import VideoTile from './VideoTile.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 {
|
interface Props {
|
||||||
party: Party
|
party: Party
|
||||||
|
|
@ -25,11 +28,25 @@
|
||||||
// Video tile only shown when there's a video (no empty placeholder)
|
// Video tile only shown when there's a video (no empty placeholder)
|
||||||
const showVideo = $derived(hasVideo)
|
const showVideo = $derived(hasVideo)
|
||||||
// Battle tile always shown - settings have default values
|
// 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>
|
</script>
|
||||||
|
|
||||||
<div class="party-info-grid">
|
<div class="party-info-grid">
|
||||||
<!-- Row 1: Description + Video -->
|
<!-- Row 1: Description + Video -->
|
||||||
<div class="row row-1">
|
<div class="row row-1" class:single={!showVideo}>
|
||||||
{#if showDescription}
|
{#if showDescription}
|
||||||
<DescriptionTile description={party.description} onOpen={onOpenDescription} />
|
<DescriptionTile description={party.description} onOpen={onOpenDescription} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -40,7 +57,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Row 2: Battle + Raid -->
|
<!-- Row 2: Battle + Raid -->
|
||||||
<div class="row row-2">
|
<div class="row row-2" class:single={!showRaid}>
|
||||||
<BattleTile
|
<BattleTile
|
||||||
fullAuto={party.fullAuto}
|
fullAuto={party.fullAuto}
|
||||||
autoGuard={party.autoGuard}
|
autoGuard={party.autoGuard}
|
||||||
|
|
@ -55,7 +72,7 @@
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if showRaid}
|
{#if showRaid}
|
||||||
<RaidTile raid={party.raid} />
|
<RaidTile raid={party.raid} onclick={handleRaidClick} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -77,8 +94,7 @@
|
||||||
.row-1 {
|
.row-1 {
|
||||||
grid-template-columns: 2fr 1fr;
|
grid-template-columns: 2fr 1fr;
|
||||||
|
|
||||||
// If only one item, let it take full width
|
&.single {
|
||||||
&:has(> :only-child) {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -86,7 +102,7 @@
|
||||||
.row-2 {
|
.row-2 {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
|
||||||
&:has(> :only-child) {
|
&.single {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,13 @@
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
raid?: Raid
|
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(() => {
|
const raidName = $derived(() => {
|
||||||
if (!raid) return null
|
if (!raid) return null
|
||||||
|
|
@ -19,7 +23,7 @@
|
||||||
const elementLabel = $derived(raid ? getElementLabel(raid.element) : null)
|
const elementLabel = $derived(raid ? getElementLabel(raid.element) : null)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<InfoTile label="Raid" class="raid-tile">
|
<InfoTile label="Raid" class="raid-tile" {clickable} {onclick}>
|
||||||
{#if raid}
|
{#if raid}
|
||||||
<div class="raid-info">
|
<div class="raid-info">
|
||||||
<img src={getRaidImage(raid.slug)} alt="" class="raid-image" />
|
<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