extract AddScoreModal and PlayerScoreRow from events page
This commit is contained in:
parent
00813ddd58
commit
d23e1db7d1
3 changed files with 586 additions and 551 deletions
382
src/lib/components/crew/AddScoreModal.svelte
Normal file
382
src/lib/components/crew/AddScoreModal.svelte
Normal 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>
|
||||||
181
src/lib/components/crew/PlayerScoreRow.svelte
Normal file
181
src/lib/components/crew/PlayerScoreRow.svelte
Normal 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>
|
||||||
|
|
@ -3,33 +3,23 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto, replaceState } from '$app/navigation'
|
import { goto, replaceState } from '$app/navigation'
|
||||||
import { page } from '$app/stores'
|
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 { gwAdapter } from '$lib/api/adapters/gw.adapter'
|
||||||
import { crewStore } from '$lib/stores/crew.store.svelte'
|
import { crewStore } from '$lib/stores/crew.store.svelte'
|
||||||
import Button from '$lib/components/ui/Button.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 from '$lib/components/ui/DropdownMenu.svelte'
|
||||||
import { DropdownMenu as DropdownMenuBase } from 'bits-ui'
|
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 CrewHeader from '$lib/components/crew/CrewHeader.svelte'
|
||||||
import EditScoreModal from '$lib/components/crew/EditScoreModal.svelte'
|
import EditScoreModal from '$lib/components/crew/EditScoreModal.svelte'
|
||||||
import EditCrewScoreModal from '$lib/components/crew/EditCrewScoreModal.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 SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
|
||||||
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
|
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
|
||||||
import { formatDateJST } from '$lib/utils/date'
|
import { formatDateJST } from '$lib/utils/date'
|
||||||
import {
|
import { formatScore } from '$lib/utils/gw'
|
||||||
GW_ROUND_LABELS,
|
import { GW_ROUND_LABELS, type GwRound, type GwCrewScore } from '$lib/types/api/gw'
|
||||||
type GwRound,
|
|
||||||
type GwIndividualScore,
|
|
||||||
type GwCrewScore,
|
|
||||||
type CreateCrewScoreInput,
|
|
||||||
type UpdateCrewScoreInput
|
|
||||||
} from '$lib/types/api/gw'
|
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -38,8 +28,6 @@
|
||||||
|
|
||||||
let { data }: Props = $props()
|
let { data }: Props = $props()
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
// Get event number from URL
|
// Get event number from URL
|
||||||
const eventNumber = $derived($page.params.eventNumber)
|
const eventNumber = $derived($page.params.eventNumber)
|
||||||
|
|
||||||
|
|
@ -75,37 +63,6 @@
|
||||||
// Crew scores from participation (Finals Day 1-4 only: rounds 2-5)
|
// Crew scores from participation (Finals Day 1-4 only: rounds 2-5)
|
||||||
const crewScores = $derived(participation?.crewScores ?? [])
|
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 playerScores = $derived.by(() => {
|
||||||
const scoreMap = new Map<string, PlayerScore>()
|
const scoreMap = new Map<string, PlayerScore>()
|
||||||
|
|
||||||
|
|
@ -150,16 +107,6 @@
|
||||||
return Array.from(scoreMap.values()).sort((a, b) => b.totalScore - a.totalScore)
|
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
|
// Navigate back
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
goto('/crew')
|
goto('/crew')
|
||||||
|
|
@ -167,186 +114,6 @@
|
||||||
|
|
||||||
// ==================== Add Score Modal ====================
|
// ==================== Add Score Modal ====================
|
||||||
let showScoreModal = $state(false)
|
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 ====================
|
// ==================== Edit Score Modal ====================
|
||||||
let showEditScoreModal = $state(false)
|
let showEditScoreModal = $state(false)
|
||||||
|
|
@ -394,9 +161,7 @@
|
||||||
<CrewHeader title="GW #{gwEvent.eventNumber}" backHref="/crew">
|
<CrewHeader title="GW #{gwEvent.eventNumber}" backHref="/crew">
|
||||||
{#snippet belowTitle()}
|
{#snippet belowTitle()}
|
||||||
<div class="event-meta">
|
<div class="event-meta">
|
||||||
<span class="element-badge element-{elementColors[gwEvent.element]}">
|
<ElementBadge element={gwEvent.element} />
|
||||||
{elementLabels[gwEvent.element] ?? 'Unknown'}
|
|
||||||
</span>
|
|
||||||
<span class="event-dates">
|
<span class="event-dates">
|
||||||
{formatDateJST(gwEvent.startDate)} – {formatDateJST(gwEvent.endDate)}
|
{formatDateJST(gwEvent.startDate)} – {formatDateJST(gwEvent.endDate)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -410,7 +175,7 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{#if crewStore.isOfficer && gwEvent.status !== 'upcoming' && activeTab === 'individual'}
|
{#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}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CrewHeader>
|
</CrewHeader>
|
||||||
|
|
@ -450,53 +215,12 @@
|
||||||
{#if playerScores.length > 0}
|
{#if playerScores.length > 0}
|
||||||
<ul class="player-list">
|
<ul class="player-list">
|
||||||
{#each playerScores as player, index}
|
{#each playerScores as player, index}
|
||||||
<li class="player-item" class:retired={player.isRetired}>
|
<PlayerScoreRow
|
||||||
<div class="player-info">
|
{player}
|
||||||
<span class="player-rank">{index + 1}</span>
|
rank={index + 1}
|
||||||
<span class="player-name">{player.name}</span>
|
isOfficer={crewStore.isOfficer}
|
||||||
{#if player.isRetired}
|
onEditScore={() => openEditScoreModal(player)}
|
||||||
<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>
|
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -584,85 +308,16 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Score Modal -->
|
<!-- Add Score Modal -->
|
||||||
<Dialog bind:open={showScoreModal} onOpenChange={(open) => !open && closeScoreModal()}>
|
{#if gwEvent}
|
||||||
<ModalHeader title="Add Score" />
|
<AddScoreModal
|
||||||
<ModalBody>
|
bind:open={showScoreModal}
|
||||||
<div class="score-form">
|
eventId={gwEvent.id}
|
||||||
<Select
|
eventNumber={eventNumber ?? ''}
|
||||||
options={playerOptions}
|
{membersDuringEvent}
|
||||||
bind:value={selectedPlayerId}
|
{phantomPlayers}
|
||||||
placeholder="Select player"
|
existingScores={participation?.individualScores ?? []}
|
||||||
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)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
{/if}
|
||||||
|
|
||||||
<!-- Edit Score Modal -->
|
<!-- Edit Score Modal -->
|
||||||
{#if participation?.id && editingPlayer}
|
{#if participation?.id && editingPlayer}
|
||||||
|
|
@ -730,49 +385,6 @@
|
||||||
color: var(--text-secondary);
|
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 {
|
.not-participating {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -841,84 +453,6 @@
|
||||||
padding: spacing.$unit;
|
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 {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|
@ -926,68 +460,6 @@
|
||||||
font-size: typography.$font-small;
|
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
|
||||||
.tab-control {
|
.tab-control {
|
||||||
margin-top: spacing.$unit;
|
margin-top: spacing.$unit;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue