extract AddScoreModal and PlayerScoreRow from events page

This commit is contained in:
Justin Edmund 2025-12-18 11:03:57 -08:00
parent 00813ddd58
commit d23e1db7d1
3 changed files with 586 additions and 551 deletions

View file

@ -0,0 +1,382 @@
<svelte:options runes={true} />
<script lang="ts">
import { createMutation, useQueryClient } from '@tanstack/svelte-query'
import { gwAdapter } from '$lib/api/adapters/gw.adapter'
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 { formatScore, parseScore } from '$lib/utils/gw'
import { GW_ROUND_LABELS, type GwRound, type GwIndividualScore } from '$lib/types/api/gw'
// Simplified types matching the API response
interface MemberDuringEvent {
id: string
user?: { id: string; username: string }
retired: boolean
}
interface PhantomPlayerMinimal {
id: string
name: string
retired: boolean
}
interface Props {
open: boolean
eventId: string
eventNumber: string
membersDuringEvent: MemberDuringEvent[]
phantomPlayers: PhantomPlayerMinimal[]
existingScores: GwIndividualScore[]
}
let {
open = $bindable(false),
eventId,
eventNumber,
membersDuringEvent,
phantomPlayers,
existingScores
}: Props = $props()
const queryClient = useQueryClient()
// Form state
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 isExcused = $state(false)
let excuseReason = $state('')
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>()
for (const score of existingScores) {
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
// Excludes players who already have scores for this event
const playerOptions = $derived.by(() => {
const options: Array<{ value: string; label: string; suffix?: string }> = []
// Add members (skip those with scores)
for (const m of membersDuringEvent) {
if (m.user) {
const hasScore = playersWithScores.has(`member:${m.id}`)
if (hasScore) continue
options.push({
value: `member:${m.id}`,
label: m.user.username + (m.retired ? ' (Retired)' : '')
})
}
}
// Add phantoms (skip those with scores)
for (const p of phantomPlayers) {
const hasScore = playersWithScores.has(`phantom:${p.id}`)
if (hasScore) continue
options.push({
value: `phantom:${p.id}`,
label: p.name + (p.retired ? ' (Retired)' : ''),
suffix: 'Phantom'
})
}
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
const addScoreMutation = createMutation(() => ({
mutationFn: async () => {
if (!eventId || !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(eventId, {
...input,
round: 0, // Cumulative scores go to preliminaries
score,
isCumulative: true,
excused: isExcused,
excuseReason: isExcused ? excuseReason : undefined
})
} 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,
excused: isExcused,
excuseReason: isExcused ? excuseReason : undefined
})
}
}
if (scores.length === 0) {
throw new Error('No scores entered')
}
return gwAdapter.batchAddIndividualScoresByEvent(eventId, { scores })
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew', 'gw', 'event', eventNumber] })
handleClose()
}
}))
function resetForm() {
selectedPlayerId = undefined
isCumulative = true
cumulativeScore = ''
roundScores = { 0: '', 1: '', 2: '', 3: '', 4: '', 5: '' }
isExcused = false
excuseReason = ''
isSubmitting = false
}
function handleClose() {
open = false
resetForm()
}
async function handleSubmit() {
isSubmitting = true
try {
await addScoreMutation.mutateAsync()
} finally {
isSubmitting = false
}
}
// Initialize form when modal opens
$effect(() => {
if (open) {
resetForm()
}
})
// Validation
const canSubmit = $derived(
!isSubmitting &&
selectedPlayerId &&
((isCumulative && cumulativeScore) || (!isCumulative && calculatedCumulativeScore > 0))
)
</script>
<Dialog bind:open onOpenChange={(isOpen) => !isOpen && handleClose()}>
<ModalHeader title="Add Score" />
<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}
<div class="excused-section">
<label class="checkbox-row">
<Checkbox bind:checked={isExcused} size="small" contained />
<span>Excused?</span>
</label>
{#if isExcused}
<textarea
class="excuse-textarea"
bind:value={excuseReason}
placeholder="Excusal reason (optional)"
rows="2"
></textarea>
{/if}
</div>
{#if addScoreMutation.isError}
<p class="error-message">
{addScoreMutation.error?.message ?? 'Failed to add score'}
</p>
{/if}
</div>
</ModalBody>
<ModalFooter
onCancel={handleClose}
primaryAction={{
label: isSubmitting ? 'Saving...' : 'Save',
onclick: handleSubmit,
disabled: !canSubmit
}}
/>
</Dialog>
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
.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;
}
.excused-section {
display: flex;
flex-direction: column;
gap: spacing.$unit;
}
.excuse-textarea {
width: 100%;
padding: spacing.$unit;
border: none;
border-radius: layout.$input-corner;
font-size: typography.$font-small;
font-family: inherit;
background: var(--input-bound-bg);
color: var(--text-primary);
resize: vertical;
min-height: 60px;
&::placeholder {
color: var(--text-tertiary);
}
&:hover {
background: var(--input-bound-bg-hover);
}
&:focus {
outline: none;
background: var(--input-bound-bg-hover);
}
}
.error-message {
color: colors.$error;
font-size: typography.$font-small;
margin: 0;
}
</style>

View file

@ -0,0 +1,181 @@
<svelte:options runes={true} />
<script lang="ts">
import { goto } from '$app/navigation'
import Button from '$lib/components/ui/Button.svelte'
import DropdownMenu from '$lib/components/ui/DropdownMenu.svelte'
import { DropdownMenu as DropdownMenuBase } from 'bits-ui'
import { formatScore } from '$lib/utils/gw'
import type { GwIndividualScore } from '$lib/types/api/gw'
export interface PlayerScore {
id: string
name: string
type: 'member' | 'phantom'
totalScore: number
isRetired?: boolean
scores: GwIndividualScore[]
}
interface Props {
player: PlayerScore
rank: number
isOfficer: boolean
onEditScore: () => void
}
let { player, rank, isOfficer, onEditScore }: Props = $props()
const hasScores = $derived(player.scores.length > 0)
const isExcused = $derived(player.scores.some((s) => s.excused))
const isTopFive = $derived(rank <= 5)
function handleRowClick() {
const path =
player.type === 'member'
? `/crew/members/${player.id}`
: `/crew/phantoms/${player.id}`
goto(path)
}
function handleDropdownClick(event: MouseEvent) {
event.stopPropagation()
}
</script>
<li
class="player-item"
class:retired={player.isRetired}
onclick={handleRowClick}
onkeydown={(e) => e.key === 'Enter' && handleRowClick()}
role="button"
tabindex="0"
>
<div class="player-info">
<span class="player-rank">{rank}</span>
<span class="player-name">{player.name}{#if isTopFive}<span class="star"></span>{/if}</span>
{#if player.isRetired}
<span class="player-badge retired">Retired</span>
{/if}
{#if isExcused}
<span class="player-badge excused">Excused</span>
{/if}
</div>
{#if player.type === 'phantom'}
<span class="player-type">Phantom</span>
{/if}
<div class="player-actions">
<span class="player-score">{formatScore(player.totalScore)}</span>
{#if isOfficer && hasScores}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={handleDropdownClick}>
<DropdownMenu>
{#snippet trigger({ props })}
<Button variant="ghost" size="small" iconOnly icon="ellipsis" {...props} />
{/snippet}
{#snippet menu()}
<DropdownMenuBase.Item class="dropdown-menu-item" onclick={onEditScore}>
Edit score...
</DropdownMenuBase.Item>
{/snippet}
</DropdownMenu>
</div>
{/if}
</div>
</li>
<style lang="scss">
@use '$src/themes/layout' as layout;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.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;
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, 0.03);
}
&:focus-visible {
outline: 2px solid var(--focus-ring-color, #3b82f6);
outline-offset: 2px;
}
&.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;
.star {
color: #f5a623;
margin-left: 4px;
}
}
.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);
}
&.excused {
background: var(--color-yellow-light, #fef9c3);
color: var(--color-yellow-dark, #854d0e);
}
}
.player-type {
font-size: typography.$font-small;
color: var(--text-tertiary);
margin-left: auto;
margin-right: spacing.$unit;
}
.player-actions {
display: flex;
align-items: center;
gap: spacing.$unit;
}
.player-score {
font-size: typography.$font-small;
font-weight: typography.$medium;
font-variant-numeric: tabular-nums;
min-width: 108px;
text-align: right;
}
</style>

View file

@ -3,33 +3,23 @@
<script lang="ts">
import { goto, replaceState } from '$app/navigation'
import { page } from '$app/stores'
import { createQuery, createMutation, useQueryClient } from '@tanstack/svelte-query'
import { createQuery } 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 DropdownMenu from '$lib/components/ui/DropdownMenu.svelte'
import { DropdownMenu as DropdownMenuBase } from 'bits-ui'
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 EditScoreModal from '$lib/components/crew/EditScoreModal.svelte'
import EditCrewScoreModal from '$lib/components/crew/EditCrewScoreModal.svelte'
import AddScoreModal from '$lib/components/crew/AddScoreModal.svelte'
import PlayerScoreRow, { type PlayerScore } from '$lib/components/crew/PlayerScoreRow.svelte'
import ElementBadge from '$lib/components/ui/ElementBadge.svelte'
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
import { formatDateJST } from '$lib/utils/date'
import {
GW_ROUND_LABELS,
type GwRound,
type GwIndividualScore,
type GwCrewScore,
type CreateCrewScoreInput,
type UpdateCrewScoreInput
} from '$lib/types/api/gw'
import { formatScore } from '$lib/utils/gw'
import { GW_ROUND_LABELS, type GwRound, type GwCrewScore } from '$lib/types/api/gw'
import type { PageData } from './$types'
interface Props {
@ -38,8 +28,6 @@
let { data }: Props = $props()
const queryClient = useQueryClient()
// Get event number from URL
const eventNumber = $derived($page.params.eventNumber)
@ -75,37 +63,6 @@
// Crew scores from participation (Finals Day 1-4 only: rounds 2-5)
const crewScores = $derived(participation?.crewScores ?? [])
// 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
scores: GwIndividualScore[] // Individual score records for this player
}
const playerScores = $derived.by(() => {
const scoreMap = new Map<string, PlayerScore>()
@ -150,16 +107,6 @@
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)
}
// Navigate back
function handleBack() {
goto('/crew')
@ -167,186 +114,6 @@
// ==================== 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 isExcused = $state(false)
let excuseReason = $state('')
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
// Excludes players who already have scores for this event
const playerOptions = $derived.by(() => {
const options: Array<{ value: string; label: string; suffix?: string }> = []
// Add members (skip those with scores)
for (const m of membersDuringEvent) {
if (m.user) {
const hasScore = playersWithScores.has(`member:${m.id}`)
if (hasScore) continue
options.push({
value: `member:${m.id}`,
label: m.user.username + (m.retired ? ' (Retired)' : '')
})
}
}
// Add phantoms (skip those with scores)
for (const p of phantomPlayers) {
const hasScore = playersWithScores.has(`phantom:${p.id}`)
if (hasScore) continue
options.push({
value: `phantom:${p.id}`,
label: p.name + (p.retired ? ' (Retired)' : ''),
suffix: 'Phantom'
})
}
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,
excused: isExcused,
excuseReason: isExcused ? excuseReason : undefined
})
} 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,
excused: isExcused,
excuseReason: isExcused ? excuseReason : undefined
})
}
}
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: '' }
isExcused = false
excuseReason = ''
isSubmitting = false
}
async function handleSubmitScore() {
isSubmitting = true
try {
await addScoreMutation.mutateAsync()
} finally {
isSubmitting = false
}
}
// ==================== Edit Score Modal ====================
let showEditScoreModal = $state(false)
@ -394,9 +161,7 @@
<CrewHeader title="GW #{gwEvent.eventNumber}" backHref="/crew">
{#snippet belowTitle()}
<div class="event-meta">
<span class="element-badge element-{elementColors[gwEvent.element]}">
{elementLabels[gwEvent.element] ?? 'Unknown'}
</span>
<ElementBadge element={gwEvent.element} />
<span class="event-dates">
{formatDateJST(gwEvent.startDate)} {formatDateJST(gwEvent.endDate)}
</span>
@ -410,7 +175,7 @@
{/snippet}
{#snippet actions()}
{#if crewStore.isOfficer && gwEvent.status !== 'upcoming' && activeTab === 'individual'}
<Button variant="primary" size="small" onclick={openScoreModal}>Add Score</Button>
<Button variant="primary" size="small" onclick={() => (showScoreModal = true)}>Add Score</Button>
{/if}
{/snippet}
</CrewHeader>
@ -450,53 +215,12 @@
{#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}
{#if player.scores.some((s) => s.excused)}
<span class="player-badge excused">Excused</span>
{/if}
</div>
{#if player.type === 'phantom'}
<span class="player-type">Phantom</span>
{/if}
<div class="player-actions">
<span class="player-score">{formatScore(player.totalScore)}</span>
{#if crewStore.isOfficer && player.scores.length > 0}
<DropdownMenu>
{#snippet trigger({ props })}
<Button
variant="ghost"
size="small"
iconOnly
icon="ellipsis"
{...props}
/>
{/snippet}
{#snippet menu()}
{#if player.type === 'member'}
<DropdownMenuBase.Item
class="dropdown-menu-item"
onclick={() => goto(`/${player.name}`)}
>
View profile
</DropdownMenuBase.Item>
{/if}
<DropdownMenuBase.Item
class="dropdown-menu-item"
onclick={() => openEditScoreModal(player)}
>
Edit score...
</DropdownMenuBase.Item>
{/snippet}
</DropdownMenu>
{/if}
</div>
</li>
<PlayerScoreRow
{player}
rank={index + 1}
isOfficer={crewStore.isOfficer}
onEditScore={() => openEditScoreModal(player)}
/>
{/each}
</ul>
{:else}
@ -584,85 +308,16 @@
</div>
<!-- Add Score Modal -->
<Dialog bind:open={showScoreModal} onOpenChange={(open) => !open && closeScoreModal()}>
<ModalHeader title="Add Score" />
<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}
<div class="excused-section">
<label class="checkbox-row">
<Checkbox bind:checked={isExcused} size="small" contained />
<span>Excused?</span>
</label>
{#if isExcused}
<textarea
class="excuse-textarea"
bind:value={excuseReason}
placeholder="Excusal reason (optional)"
rows="2"
></textarea>
{/if}
</div>
{#if addScoreMutation.isError}
<p class="error-message">
{addScoreMutation.error?.message ?? 'Failed to add score'}
</p>
{/if}
</div>
</ModalBody>
<ModalFooter
onCancel={closeScoreModal}
primaryAction={{
label: isSubmitting ? 'Saving...' : 'Save',
onclick: handleSubmitScore,
disabled: isSubmitting ||
!selectedPlayerId ||
(!isCumulative && calculatedCumulativeScore === 0) ||
(isCumulative && !cumulativeScore)
}}
{#if gwEvent}
<AddScoreModal
bind:open={showScoreModal}
eventId={gwEvent.id}
eventNumber={eventNumber ?? ''}
{membersDuringEvent}
{phantomPlayers}
existingScores={participation?.individualScores ?? []}
/>
</Dialog>
{/if}
<!-- Edit Score Modal -->
{#if participation?.id && editingPlayer}
@ -730,49 +385,6 @@
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;
@ -841,84 +453,6 @@
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);
}
&.excused {
background: var(--color-yellow-light, #fef9c3);
color: var(--color-yellow-dark, #854d0e);
}
}
.player-type {
font-size: typography.$font-small;
color: var(--text-tertiary);
margin-left: auto;
margin-right: spacing.$unit;
}
.player-actions {
display: flex;
align-items: center;
gap: 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);
@ -926,68 +460,6 @@
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;
}
.excused-section {
display: flex;
flex-direction: column;
gap: spacing.$unit;
}
.excuse-textarea {
width: 100%;
padding: spacing.$unit;
border: none;
border-radius: layout.$input-corner;
font-size: typography.$font-small;
font-family: inherit;
background: var(--input-bound-bg);
color: var(--text-primary);
resize: vertical;
min-height: 60px;
&::placeholder {
color: var(--text-tertiary);
}
&:hover {
background: var(--input-bound-bg-hover);
}
&:focus {
outline: none;
background: var(--input-bound-bg-hover);
}
}
.error-message {
color: colors.$error;
font-size: typography.$font-small;
margin: 0;
}
// Tab control
.tab-control {
margin-top: spacing.$unit;