gw event page improvements

- total honors instead of total score
- muted style for players with existing scores
- by-event score endpoints
This commit is contained in:
Justin Edmund 2025-12-04 03:02:58 -08:00
parent e7dfca992a
commit f4d04a7073
3 changed files with 1210 additions and 0 deletions

View file

@ -0,0 +1,322 @@
import { BaseAdapter } from './base.adapter'
import { DEFAULT_ADAPTER_CONFIG } from './config'
import type { RequestOptions } from './types'
import type {
GwEvent,
CrewGwParticipation,
GwCrewScore,
GwIndividualScore,
CreateGwEventInput,
UpdateGwEventInput,
UpdateParticipationRankingInput,
CreateCrewScoreInput,
UpdateCrewScoreInput,
CreateIndividualScoreInput,
BatchIndividualScoresInput
} from '$lib/types/api/gw'
/**
* Adapter for Guild War (Unite and Fight) API operations
*/
export class GwAdapter extends BaseAdapter {
// ==================== GW Event Operations ====================
/**
* Get all GW events
*/
async getEvents(options?: RequestOptions): Promise<GwEvent[]> {
const response = await this.request<{ gwEvents: GwEvent[] }>('/gw_events', options)
return response.gwEvents
}
/**
* Get a single GW event
*/
async getEvent(eventId: string, options?: RequestOptions): Promise<GwEvent> {
const response = await this.request<{ gwEvent: GwEvent }>(`/gw_events/${eventId}`, options)
return response.gwEvent
}
/**
* Create a GW event (admin only)
*/
async createEvent(input: CreateGwEventInput, options?: RequestOptions): Promise<GwEvent> {
const response = await this.request<{ gwEvent: GwEvent }>('/gw_events', {
...options,
method: 'POST',
body: JSON.stringify({ gw_event: input })
})
this.clearCache('/gw_events')
return response.gwEvent
}
/**
* Update a GW event (admin only)
*/
async updateEvent(eventId: string, input: UpdateGwEventInput, options?: RequestOptions): Promise<GwEvent> {
const response = await this.request<{ gwEvent: GwEvent }>(`/gw_events/${eventId}`, {
...options,
method: 'PUT',
body: JSON.stringify({ gw_event: input })
})
this.clearCache('/gw_events')
this.clearCache(`/gw_events/${eventId}`)
return response.gwEvent
}
// ==================== Participation Operations ====================
/**
* Join a GW event (creates participation for current crew)
*/
async joinEvent(eventId: string, options?: RequestOptions): Promise<CrewGwParticipation> {
const response = await this.request<{ participation: CrewGwParticipation }>(
`/gw_events/${eventId}/participations`,
{
...options,
method: 'POST'
}
)
this.clearCache('/crew/gw_participations')
return response.participation
}
/**
* Get all crew's GW participations
*/
async getParticipations(options?: RequestOptions): Promise<CrewGwParticipation[]> {
const response = await this.request<{ participations: CrewGwParticipation[] }>(
'/crew/gw_participations',
options
)
return response.participations
}
/**
* Get a single participation with scores
*/
async getParticipation(participationId: string, options?: RequestOptions): Promise<CrewGwParticipation> {
const response = await this.request<{ crewGwParticipation: CrewGwParticipation }>(
`/crew/gw_participations/${participationId}`,
options
)
return response.crewGwParticipation
}
/**
* Get event and participation by event ID or event number
* Returns the event (if found), the crew's participation (if any), members active during the event, and phantom players
*/
async getEventWithParticipation(
eventIdOrNumber: string | number,
options?: RequestOptions
): Promise<{
gwEvent: GwEvent | null
participation: CrewGwParticipation | null
membersDuringEvent: Array<{ id: string; user?: { id: string; username: string }; retired: boolean }>
phantomPlayers: Array<{ id: string; name: string; retired: boolean }>
}> {
const response = await this.request<{
gwEvent: GwEvent | null
crewGwParticipation: CrewGwParticipation | null
membersDuringEvent: Array<{ id: string; user?: { id: string; username: string }; retired: boolean }>
phantomPlayers: Array<{ id: string; name: string; retired: boolean }>
}>(`/crew/gw_participations/by_event/${eventIdOrNumber}`, options)
return {
gwEvent: response.gwEvent,
participation: response.crewGwParticipation,
membersDuringEvent: response.membersDuringEvent ?? [],
phantomPlayers: response.phantomPlayers ?? []
}
}
/**
* Update participation rankings
*/
async updateParticipationRanking(
participationId: string,
input: UpdateParticipationRankingInput,
options?: RequestOptions
): Promise<CrewGwParticipation> {
const response = await this.request<{ participation: CrewGwParticipation }>(
`/crew/gw_participations/${participationId}`,
{
...options,
method: 'PUT',
body: JSON.stringify({ participation: input })
}
)
this.clearCache('/crew/gw_participations')
this.clearCache(`/crew/gw_participations/${participationId}`)
return response.participation
}
// ==================== Crew Score Operations ====================
/**
* Add a crew score for a round
*/
async addCrewScore(
participationId: string,
input: CreateCrewScoreInput,
options?: RequestOptions
): Promise<GwCrewScore> {
const response = await this.request<{ crewScore: GwCrewScore }>(
`/crew/gw_participations/${participationId}/crew_scores`,
{
...options,
method: 'POST',
body: JSON.stringify({ crew_score: input })
}
)
this.clearCache(`/crew/gw_participations/${participationId}`)
return response.crewScore
}
/**
* Update a crew score
*/
async updateCrewScore(
participationId: string,
scoreId: string,
input: UpdateCrewScoreInput,
options?: RequestOptions
): Promise<GwCrewScore> {
const response = await this.request<{ crewScore: GwCrewScore }>(
`/crew/gw_participations/${participationId}/crew_scores/${scoreId}`,
{
...options,
method: 'PUT',
body: JSON.stringify({ crew_score: input })
}
)
this.clearCache(`/crew/gw_participations/${participationId}`)
return response.crewScore
}
// ==================== Individual Score Operations ====================
/**
* Add an individual score
*/
async addIndividualScore(
participationId: string,
input: CreateIndividualScoreInput,
options?: RequestOptions
): Promise<GwIndividualScore> {
const response = await this.request<{ individualScore: GwIndividualScore }>(
`/crew/gw_participations/${participationId}/individual_scores`,
{
...options,
method: 'POST',
body: JSON.stringify({ individual_score: input })
}
)
this.clearCache(`/crew/gw_participations/${participationId}`)
return response.individualScore
}
/**
* Batch add individual scores
*/
async batchAddIndividualScores(
participationId: string,
input: BatchIndividualScoresInput,
options?: RequestOptions
): Promise<GwIndividualScore[]> {
const response = await this.request<{ individualScores: GwIndividualScore[] }>(
`/crew/gw_participations/${participationId}/individual_scores/batch`,
{
...options,
method: 'POST',
body: JSON.stringify(input)
}
)
this.clearCache(`/crew/gw_participations/${participationId}`)
return response.individualScores
}
/**
* Update an individual score
*/
async updateIndividualScore(
participationId: string,
scoreId: string,
input: Partial<CreateIndividualScoreInput>,
options?: RequestOptions
): Promise<GwIndividualScore> {
const response = await this.request<{ individualScore: GwIndividualScore }>(
`/crew/gw_participations/${participationId}/individual_scores/${scoreId}`,
{
...options,
method: 'PUT',
body: JSON.stringify({ individual_score: input })
}
)
this.clearCache(`/crew/gw_participations/${participationId}`)
return response.individualScore
}
/**
* Delete an individual score
*/
async deleteIndividualScore(
participationId: string,
scoreId: string,
options?: RequestOptions
): Promise<void> {
await this.request<void>(
`/crew/gw_participations/${participationId}/individual_scores/${scoreId}`,
{
...options,
method: 'DELETE'
}
)
this.clearCache(`/crew/gw_participations/${participationId}`)
}
// ==================== Individual Score Operations (by Event) ====================
// These endpoints auto-create participation if needed (officers only)
/**
* Add an individual score by event ID (auto-creates participation)
*/
async addIndividualScoreByEvent(
eventId: string,
input: CreateIndividualScoreInput,
options?: RequestOptions
): Promise<GwIndividualScore> {
const response = await this.request<{ individualScore: GwIndividualScore }>(
`/crew/gw_events/${eventId}/individual_scores`,
{
...options,
method: 'POST',
body: JSON.stringify({ individual_score: input })
}
)
this.clearCache('/crew/gw_participations')
return response.individualScore
}
/**
* Batch add individual scores by event ID (auto-creates participation)
*/
async batchAddIndividualScoresByEvent(
eventId: string,
input: BatchIndividualScoresInput,
options?: RequestOptions
): Promise<GwIndividualScore[]> {
const response = await this.request<{ individualScores: GwIndividualScore[] }>(
`/crew/gw_events/${eventId}/individual_scores/batch`,
{
...options,
method: 'POST',
body: JSON.stringify(input)
}
)
this.clearCache('/crew/gw_participations')
return response.individualScores
}
}
export const gwAdapter = new GwAdapter(DEFAULT_ADAPTER_CONFIG)

156
src/lib/types/api/gw.ts Normal file
View file

@ -0,0 +1,156 @@
// Guild War (Unite and Fight) types based on Rails blueprints
// These define the GW event and scoring structure
import type { CrewMembership, PhantomPlayer } from './crew'
// GW round numbers
// 0 = Preliminaries
// 1 = Interlude
// 2-5 = Finals Day 1-4
export type GwRound = 0 | 1 | 2 | 3 | 4 | 5
// Round labels for display
export const GW_ROUND_LABELS: Record<GwRound, string> = {
0: 'Preliminaries',
1: 'Interlude',
2: 'Finals Day 1',
3: 'Finals Day 2',
4: 'Finals Day 3',
5: 'Finals Day 4'
}
// GwEvent from GwEventBlueprint
export interface GwEvent {
id: string
element: number // Uses GranblueEnums.ELEMENTS (0-5)
startDate: string
endDate: string
eventNumber: number // GW #XX
createdAt?: string
updatedAt?: string
}
// CrewGwParticipation from CrewGwParticipationBlueprint
export interface CrewGwParticipation {
id: string
preliminaryRanking: number | null
finalRanking: number | null
createdAt?: string
// From :with_event view
gwEvent?: GwEvent
// From :with_scores view
crewScores?: GwCrewScore[]
individualScores?: GwIndividualScore[]
}
// GwCrewScore from GwCrewScoreBlueprint
export interface GwCrewScore {
id: string
round: GwRound
crewScore: number
opponentScore: number | null
opponentName: string | null
opponentGranblueId: string | null
victory: boolean | null
createdAt?: string
}
// Player type in individual scores
export type PlayerType = 'member' | 'phantom'
// GwIndividualScore from GwIndividualScoreBlueprint
export interface GwIndividualScore {
id: string
round: GwRound
score: number
isCumulative: boolean
playerName: string
playerType: PlayerType
createdAt?: string
// From :with_member view
member?: CrewMembership
phantom?: PhantomPlayer
}
// Input types for mutations
export interface CreateGwEventInput {
element: number
startDate: string
endDate: string
eventNumber: number
}
export interface UpdateGwEventInput {
element?: number
startDate?: string
endDate?: string
eventNumber?: number
}
export interface UpdateParticipationRankingInput {
preliminaryRanking?: number
finalRanking?: number
}
export interface CreateCrewScoreInput {
round: GwRound
crewScore: number
opponentScore?: number
opponentName?: string
opponentGranblueId?: string
victory?: boolean
}
export interface UpdateCrewScoreInput {
crewScore?: number
opponentScore?: number
opponentName?: string
opponentGranblueId?: string
victory?: boolean
}
export interface CreateIndividualScoreInput {
// Either crewMembershipId OR phantomPlayerId, not both
crewMembershipId?: string
phantomPlayerId?: string
round: GwRound
score: number
isCumulative?: boolean
}
// Batch score entry
export interface BatchScoreEntry {
// For member type, use crewMembershipId; for phantom type, use phantomPlayerId
crewMembershipId?: string
phantomPlayerId?: string
round: GwRound
score: number
isCumulative?: boolean
}
export interface BatchIndividualScoresInput {
scores: BatchScoreEntry[]
}
// Aggregated data for visualization
export interface GwLeaderboardEntry {
playerId: string
playerName: string
playerType: PlayerType
totalScore: number
roundScores: Record<GwRound, number>
}
export interface GwChartDataPoint {
round: GwRound
roundLabel: string
crewScore: number
opponentScore: number | null
memberContributions: Array<{
playerId: string
playerName: string
score: number
}>
}

View file

@ -0,0 +1,732 @@
<svelte:options runes={true} />
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { createQuery, createMutation, useQueryClient } from '@tanstack/svelte-query'
import { gwAdapter } from '$lib/api/adapters/gw.adapter'
import { crewStore } from '$lib/stores/crew.store.svelte'
import Button from '$lib/components/ui/Button.svelte'
import Dialog from '$lib/components/ui/Dialog.svelte'
import ModalHeader from '$lib/components/ui/ModalHeader.svelte'
import ModalBody from '$lib/components/ui/ModalBody.svelte'
import ModalFooter from '$lib/components/ui/ModalFooter.svelte'
import Input from '$lib/components/ui/Input.svelte'
import Select from '$lib/components/ui/Select.svelte'
import Checkbox from '$lib/components/ui/checkbox/Checkbox.svelte'
import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
import { GW_ROUND_LABELS, type GwRound } from '$lib/types/api/gw'
import type { PageData } from './$types'
interface Props {
data: PageData
}
let { data }: Props = $props()
const queryClient = useQueryClient()
// Get event number from URL
const eventNumber = $derived($page.params.eventNumber)
// Query for event and participation data
const eventQuery = createQuery(() => ({
queryKey: ['crew', 'gw', 'event', eventNumber],
queryFn: () => gwAdapter.getEventWithParticipation(eventNumber),
enabled: !!eventNumber && crewStore.isInCrew
}))
const gwEvent = $derived(eventQuery.data?.gwEvent)
const participation = $derived(eventQuery.data?.participation)
const membersDuringEvent = $derived(eventQuery.data?.membersDuringEvent ?? [])
const phantomPlayers = $derived(eventQuery.data?.phantomPlayers ?? [])
// Element labels (matches GranblueEnums::ELEMENTS)
const elementLabels: Record<number, string> = {
0: 'Null',
1: 'Wind',
2: 'Fire',
3: 'Water',
4: 'Earth',
5: 'Dark',
6: 'Light'
}
const elementColors: Record<number, string> = {
0: 'null',
1: 'wind',
2: 'fire',
3: 'water',
4: 'earth',
5: 'dark',
6: 'light'
}
// Aggregate scores by player, including members with 0 score
interface PlayerScore {
id: string
name: string
type: 'member' | 'phantom'
totalScore: number
isRetired?: boolean
}
const playerScores = $derived.by(() => {
const scoreMap = new Map<string, PlayerScore>()
// First, add all members who were active during the event (with 0 score)
for (const member of membersDuringEvent) {
if (member.user) {
scoreMap.set(member.id, {
id: member.id,
name: member.user.username,
type: 'member',
totalScore: 0,
isRetired: member.retired
})
}
}
// Then add/update with actual scores
if (participation?.individualScores) {
for (const score of participation.individualScores) {
const playerId = score.member?.id ?? score.phantom?.id ?? score.playerName
const existing = scoreMap.get(playerId)
if (existing) {
existing.totalScore += score.score
} else {
// Phantom player or member not in membersDuringEvent
scoreMap.set(playerId, {
id: playerId,
name: score.playerName,
type: score.playerType,
totalScore: score.score,
isRetired: score.member?.retired
})
}
}
}
// Sort by total score descending
return Array.from(scoreMap.values()).sort((a, b) => b.totalScore - a.totalScore)
})
// Format number with commas
function formatScore(score: number): string {
return score.toLocaleString()
}
// Parse score string, removing commas
function parseScore(value: string): number {
return parseInt(value.replace(/,/g, ''), 10)
}
// Format date
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
// Navigate back
function handleBack() {
goto('/crew')
}
// ==================== Add Score Modal ====================
let showScoreModal = $state(false)
let selectedPlayerId = $state<string | undefined>(undefined)
let isCumulative = $state(true)
let cumulativeScore = $state('')
let roundScores = $state<Record<GwRound, string>>({
0: '',
1: '',
2: '',
3: '',
4: '',
5: ''
})
let isSubmitting = $state(false)
// Player type tracking - value format is "member:id" or "phantom:id"
const selectedPlayerType = $derived.by(() => {
if (!selectedPlayerId) return null
const [type] = selectedPlayerId.split(':')
return type as 'member' | 'phantom'
})
const selectedPlayerActualId = $derived.by(() => {
if (!selectedPlayerId) return null
const [, id] = selectedPlayerId.split(':')
return id
})
// Track which players already have scores for this event
const playersWithScores = $derived.by(() => {
const ids = new Set<string>()
if (participation?.individualScores) {
for (const score of participation.individualScores) {
if (score.member?.id) {
ids.add(`member:${score.member.id}`)
} else if (score.phantom?.id) {
ids.add(`phantom:${score.phantom.id}`)
}
}
}
return ids
})
// Player options for dropdown - members first, then phantoms
const playerOptions = $derived.by(() => {
const options: Array<{ value: string; label: string; suffix?: string; muted?: boolean }> = []
// Add members
for (const m of membersDuringEvent) {
if (m.user) {
const hasScore = playersWithScores.has(`member:${m.id}`)
options.push({
value: `member:${m.id}`,
label: m.user.username + (m.retired ? ' (Retired)' : ''),
muted: hasScore
})
}
}
// Add phantoms
for (const p of phantomPlayers) {
const hasScore = playersWithScores.has(`phantom:${p.id}`)
options.push({
value: `phantom:${p.id}`,
label: p.name + (p.retired ? ' (Retired)' : ''),
suffix: 'Phantom',
muted: hasScore
})
}
return options
})
// Calculate cumulative from round scores
const calculatedCumulativeScore = $derived.by(() => {
if (isCumulative) return 0
let total = 0
for (const round of [0, 1, 2, 3, 4, 5] as GwRound[]) {
const value = parseScore(roundScores[round] || '0')
if (!isNaN(value)) total += value
}
return total
})
// Sync cumulative when round scores change
$effect(() => {
if (!isCumulative) {
cumulativeScore = formatScore(calculatedCumulativeScore)
}
})
// Add score mutation - uses by-event endpoint which auto-creates participation
const addScoreMutation = createMutation(() => ({
mutationFn: async () => {
if (!gwEvent?.id || !selectedPlayerActualId || !selectedPlayerType) {
throw new Error('Missing event or player')
}
if (isCumulative) {
// Single cumulative score
const score = parseScore(cumulativeScore)
if (isNaN(score)) throw new Error('Invalid score')
const input =
selectedPlayerType === 'member'
? { crewMembershipId: selectedPlayerActualId }
: { phantomPlayerId: selectedPlayerActualId }
return gwAdapter.addIndividualScoreByEvent(gwEvent.id, {
...input,
round: 0, // Cumulative scores go to preliminaries
score,
isCumulative: true
})
} else {
// Batch add per-round scores
const scores = []
for (const round of [0, 1, 2, 3, 4, 5] as GwRound[]) {
const value = parseScore(roundScores[round] || '0')
if (!isNaN(value) && value > 0) {
const entry =
selectedPlayerType === 'member'
? { crewMembershipId: selectedPlayerActualId }
: { phantomPlayerId: selectedPlayerActualId }
scores.push({
...entry,
round,
score: value,
isCumulative: false
})
}
}
if (scores.length === 0) {
throw new Error('No scores entered')
}
return gwAdapter.batchAddIndividualScoresByEvent(gwEvent.id, { scores })
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew', 'gw', 'event', eventNumber] })
closeScoreModal()
}
}))
function openScoreModal() {
showScoreModal = true
resetScoreForm()
}
function closeScoreModal() {
showScoreModal = false
resetScoreForm()
}
function resetScoreForm() {
selectedPlayerId = undefined
isCumulative = true
cumulativeScore = ''
roundScores = { 0: '', 1: '', 2: '', 3: '', 4: '', 5: '' }
isSubmitting = false
}
async function handleSubmitScore() {
isSubmitting = true
try {
await addScoreMutation.mutateAsync()
} finally {
isSubmitting = false
}
}
</script>
<svelte:head>
<title>GW #{eventNumber} | Crew | Hensei</title>
</svelte:head>
<div class="page">
<div class="card">
{#if eventQuery.isLoading}
<div class="loading-state">
<p>Loading...</p>
</div>
{:else if !gwEvent}
<div class="error-state">
<p>Event not found</p>
<Button variant="secondary" size="small" onclick={handleBack}>Back to Crew</Button>
</div>
{:else}
<CrewHeader title="GW #{gwEvent.eventNumber}">
{#snippet belowTitle()}
<div class="event-meta">
<span class="element-badge element-{elementColors[gwEvent.element]}">
{elementLabels[gwEvent.element] ?? 'Unknown'}
</span>
<span class="event-dates">
{formatDate(gwEvent.startDate)} {formatDate(gwEvent.endDate)}
</span>
</div>
{/snippet}
{#snippet actions()}
{#if crewStore.isOfficer && gwEvent.status !== 'upcoming'}
<Button variant="primary" size="small" onclick={openScoreModal}>Add Score</Button>
{/if}
{/snippet}
</CrewHeader>
{#if !participation}
<div class="not-participating">
<p>No scores recorded yet for this event.</p>
{#if crewStore.isOfficer && gwEvent.status !== 'upcoming'}
<p class="hint">Click "Add Score" above to start recording scores.</p>
{/if}
</div>
{:else}
<!-- Score summary -->
{#if participation.totalScore !== undefined}
<div class="stats-row">
<div class="stat">
<span class="stat-value">{formatScore(participation.totalScore)}</span>
<span class="stat-label">Total Honors</span>
</div>
<div class="stat">
<span class="stat-value">{participation.wins ?? 0}</span>
<span class="stat-label">Wins</span>
</div>
<div class="stat">
<span class="stat-value">{participation.losses ?? 0}</span>
<span class="stat-label">Losses</span>
</div>
</div>
{/if}
<!-- Player scores -->
<div class="section-header">
<span class="section-title">Individual Scores</span>
</div>
{#if playerScores.length > 0}
<ul class="player-list">
{#each playerScores as player, index}
<li class="player-item" class:retired={player.isRetired}>
<div class="player-info">
<span class="player-rank">{index + 1}</span>
<span class="player-name">{player.name}</span>
{#if player.isRetired}
<span class="player-badge retired">Retired</span>
{/if}
</div>
{#if player.type === 'phantom'}
<span class="player-type">Phantom</span>
{/if}
<span class="player-score">{formatScore(player.totalScore)}</span>
</li>
{/each}
</ul>
{:else}
<p class="empty-state">No scores recorded yet</p>
{/if}
{/if}
{/if}
</div>
</div>
<!-- Add Score Modal -->
<Dialog bind:open={showScoreModal} onOpenChange={(open) => !open && closeScoreModal()}>
<ModalHeader title="Add Score" onClose={closeScoreModal} />
<ModalBody>
<div class="score-form">
<Select
options={playerOptions}
bind:value={selectedPlayerId}
placeholder="Select player"
label="Player"
fullWidth
contained
portal
/>
<Input
label="Cumulative Score"
type="text"
inputmode="numeric"
bind:value={cumulativeScore}
placeholder="Enter total score"
disabled={!isCumulative}
fullWidth
contained
/>
<label class="checkbox-row">
<Checkbox bind:checked={isCumulative} size="small" />
<span>Cumulative score</span>
</label>
{#if !isCumulative}
<div class="round-scores">
{#each [0, 1, 2, 3, 4, 5] as round (round)}
<Input
label={GW_ROUND_LABELS[round as GwRound]}
type="text"
inputmode="numeric"
bind:value={roundScores[round as GwRound]}
placeholder="0"
fullWidth
/>
{/each}
</div>
{/if}
{#if addScoreMutation.isError}
<p class="error-message">
{addScoreMutation.error?.message ?? 'Failed to add score'}
</p>
{/if}
</div>
</ModalBody>
<ModalFooter>
<Button variant="secondary" size="small" onclick={closeScoreModal}>Cancel</Button>
<Button
variant="primary"
size="small"
onclick={handleSubmitScore}
disabled={isSubmitting ||
!selectedPlayerId ||
(!isCumulative && calculatedCumulativeScore === 0) ||
(isCumulative && !cumulativeScore)}
>
{isSubmitting ? 'Saving...' : 'Save'}
</Button>
</ModalFooter>
</Dialog>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/effects' as effects;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
.page {
padding: spacing.$unit-2x 0;
margin: 0 auto;
max-width: var(--main-max-width);
}
.card {
background: var(--card-bg);
border: 0.5px solid rgba(0, 0, 0, 0.18);
border-radius: layout.$page-corner;
box-shadow: effects.$page-elevation;
overflow: hidden;
}
.loading-state,
.error-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: spacing.$unit-4x;
gap: spacing.$unit-2x;
color: var(--text-secondary);
font-size: typography.$font-small;
}
.event-meta {
display: flex;
align-items: center;
gap: spacing.$unit;
}
.event-dates {
font-size: typography.$font-small;
color: var(--text-secondary);
}
.element-badge {
display: inline-block;
padding: 2px 8px;
border-radius: layout.$item-corner-small;
font-size: typography.$font-small;
font-weight: typography.$medium;
&.element-null {
background: rgba(0, 0, 0, 0.04);
color: var(--text-secondary);
}
&.element-fire {
background: #fee2e2;
color: #dc2626;
}
&.element-water {
background: #dbeafe;
color: #2563eb;
}
&.element-earth {
background: #fef3c7;
color: #d97706;
}
&.element-wind {
background: #d1fae5;
color: #059669;
}
&.element-light {
background: #fef9c3;
color: #ca8a04;
}
&.element-dark {
background: #ede9fe;
color: #7c3aed;
}
}
.not-participating {
display: flex;
flex-direction: column;
align-items: center;
gap: spacing.$unit;
padding: spacing.$unit-4x;
text-align: center;
color: var(--text-secondary);
font-size: typography.$font-small;
p {
margin: 0;
}
.hint {
color: var(--text-tertiary);
}
}
.stats-row {
display: flex;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: spacing.$unit-2x;
border-right: 1px solid rgba(0, 0, 0, 0.08);
&:last-child {
border-right: none;
}
}
.stat-value {
font-size: typography.$font-medium;
font-weight: typography.$medium;
margin-bottom: 2px;
}
.stat-label {
font-size: typography.$font-small;
color: var(--text-secondary);
}
.section-header {
display: flex;
align-items: center;
padding: spacing.$unit spacing.$unit-2x;
background: rgba(0, 0, 0, 0.02);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.section-title {
font-size: typography.$font-small;
font-weight: typography.$medium;
color: var(--text-secondary);
}
.player-list {
list-style: none;
margin: 0;
padding: spacing.$unit;
}
.player-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: spacing.$unit spacing.$unit-2x;
border-radius: layout.$item-corner;
transition: background-color 0.15s;
&:hover {
background: rgba(0, 0, 0, 0.03);
}
&.retired {
opacity: 0.6;
}
}
.player-info {
display: flex;
align-items: center;
gap: spacing.$unit;
}
.player-rank {
font-size: typography.$font-small;
font-weight: typography.$medium;
color: var(--text-secondary);
min-width: 24px;
}
.player-name {
font-size: typography.$font-small;
font-weight: typography.$medium;
}
.player-badge {
display: inline-block;
padding: 2px 6px;
border-radius: layout.$item-corner-small;
font-size: typography.$font-small;
&.phantom {
background: var(--color-purple-light, #ede9fe);
color: var(--color-purple-dark, #7c3aed);
}
&.retired {
background: rgba(0, 0, 0, 0.04);
color: var(--text-secondary);
}
}
.player-type {
font-size: typography.$font-small;
color: var(--text-tertiary);
margin-left: auto;
margin-right: spacing.$unit;
}
.player-score {
font-size: typography.$font-small;
font-weight: typography.$medium;
font-variant-numeric: tabular-nums;
min-width: 108px;
text-align: right;
}
.empty-state {
text-align: center;
color: var(--text-secondary);
padding: spacing.$unit-3x;
font-size: typography.$font-small;
}
// Score form styles
.score-form {
display: flex;
flex-direction: column;
gap: spacing.$unit-2x;
}
.checkbox-row {
display: flex;
align-items: center;
gap: spacing.$unit;
cursor: pointer;
font-size: typography.$font-small;
}
.round-scores {
display: flex;
flex-direction: column;
gap: spacing.$unit;
padding: spacing.$unit-2x;
background: var(--input-section-bg);
border-radius: layout.$card-corner;
}
.error-message {
color: colors.$error;
font-size: typography.$font-small;
margin: 0;
}
</style>