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:
parent
e7dfca992a
commit
f4d04a7073
3 changed files with 1210 additions and 0 deletions
322
src/lib/api/adapters/gw.adapter.ts
Normal file
322
src/lib/api/adapters/gw.adapter.ts
Normal 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
156
src/lib/types/api/gw.ts
Normal 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
|
||||||
|
}>
|
||||||
|
}
|
||||||
732
src/routes/(app)/crew/events/[eventNumber]/+page.svelte
Normal file
732
src/routes/(app)/crew/events/[eventNumber]/+page.svelte
Normal 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>
|
||||||
Loading…
Reference in a new issue