2026-04-16 17:11:11 +08:00
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
|
|
import type { Dispatch, SetStateAction } from 'react'
|
2026-04-15 22:56:50 +08:00
|
|
|
|
import { Link } from 'react-router-dom'
|
|
|
|
|
|
import {
|
|
|
|
|
|
getCourtAssignments,
|
|
|
|
|
|
getReceivingPlayer,
|
|
|
|
|
|
getServiceCourt,
|
|
|
|
|
|
getServingPlayer,
|
|
|
|
|
|
getTeamDisplayName,
|
|
|
|
|
|
} from '../lib/match'
|
|
|
|
|
|
import type {
|
|
|
|
|
|
CourtSide,
|
|
|
|
|
|
GroupTeam,
|
|
|
|
|
|
PlayerSlot,
|
|
|
|
|
|
RoundGroup,
|
|
|
|
|
|
ScoreSide,
|
|
|
|
|
|
ScoreState,
|
|
|
|
|
|
} from '../types'
|
|
|
|
|
|
|
2026-04-16 17:11:11 +08:00
|
|
|
|
type VoiceSettings = {
|
|
|
|
|
|
announceScore: boolean
|
|
|
|
|
|
announceServer: boolean
|
|
|
|
|
|
rate: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const VOICE_SETTINGS_STORAGE_KEY = 'badminton-scoreboard::voice-settings'
|
|
|
|
|
|
const defaultVoiceSettings: VoiceSettings = {
|
|
|
|
|
|
announceScore: true,
|
|
|
|
|
|
announceServer: true,
|
|
|
|
|
|
rate: 1,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 22:56:50 +08:00
|
|
|
|
type ScoreboardPageProps = {
|
2026-04-16 08:53:05 +08:00
|
|
|
|
currentSelectionOrder: string[]
|
2026-04-15 22:56:50 +08:00
|
|
|
|
finishDialogError: string
|
|
|
|
|
|
finishDialogOpen: boolean
|
|
|
|
|
|
finishDialogUploading: boolean
|
|
|
|
|
|
groupSource: 'idle' | 'db' | 'manual'
|
|
|
|
|
|
hasRecordedPoint: boolean
|
|
|
|
|
|
leftTeam: GroupTeam | null
|
|
|
|
|
|
rightTeam: GroupTeam | null
|
|
|
|
|
|
scoreState: ScoreState
|
|
|
|
|
|
selectedGroup: RoundGroup | null
|
2026-04-16 16:54:59 +08:00
|
|
|
|
streakAnnouncement: {
|
|
|
|
|
|
count: number
|
|
|
|
|
|
key: number
|
|
|
|
|
|
teamName: string
|
|
|
|
|
|
title: string
|
|
|
|
|
|
} | null
|
|
|
|
|
|
victoryAnnouncement: {
|
|
|
|
|
|
key: number
|
|
|
|
|
|
scoreLabel: string
|
|
|
|
|
|
teamName: string
|
|
|
|
|
|
title: string
|
|
|
|
|
|
} | null
|
2026-04-15 22:56:50 +08:00
|
|
|
|
targetDate: string
|
2026-04-16 08:53:05 +08:00
|
|
|
|
onApplyMatchup: (
|
|
|
|
|
|
leftTeam: GroupTeam,
|
|
|
|
|
|
rightTeam: GroupTeam,
|
|
|
|
|
|
targetScore: number,
|
|
|
|
|
|
) => void
|
2026-04-15 22:56:50 +08:00
|
|
|
|
onCloseFinishDialog: () => void
|
|
|
|
|
|
onConfirmUpload: () => void
|
|
|
|
|
|
onOpenFinishDialog: () => void
|
|
|
|
|
|
onRecordPoint: (side: ScoreSide) => void
|
|
|
|
|
|
onSetServing: (side: ScoreSide) => void
|
|
|
|
|
|
onSkipUpload: () => void
|
|
|
|
|
|
onSwapMatchup: () => void
|
|
|
|
|
|
onSwapTeamPlayers: (side: ScoreSide) => void
|
|
|
|
|
|
onUndoLastPoint: () => void
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function ScoreboardPage({
|
2026-04-16 08:53:05 +08:00
|
|
|
|
currentSelectionOrder,
|
2026-04-15 22:56:50 +08:00
|
|
|
|
finishDialogError,
|
|
|
|
|
|
finishDialogOpen,
|
|
|
|
|
|
finishDialogUploading,
|
|
|
|
|
|
groupSource,
|
|
|
|
|
|
hasRecordedPoint,
|
|
|
|
|
|
leftTeam,
|
|
|
|
|
|
rightTeam,
|
|
|
|
|
|
scoreState,
|
|
|
|
|
|
selectedGroup,
|
2026-04-16 16:54:59 +08:00
|
|
|
|
streakAnnouncement,
|
|
|
|
|
|
victoryAnnouncement,
|
2026-04-15 22:56:50 +08:00
|
|
|
|
targetDate,
|
|
|
|
|
|
onApplyMatchup,
|
|
|
|
|
|
onCloseFinishDialog,
|
|
|
|
|
|
onConfirmUpload,
|
|
|
|
|
|
onOpenFinishDialog,
|
|
|
|
|
|
onRecordPoint,
|
|
|
|
|
|
onSetServing,
|
|
|
|
|
|
onSkipUpload,
|
|
|
|
|
|
onSwapMatchup,
|
|
|
|
|
|
onSwapTeamPlayers,
|
|
|
|
|
|
onUndoLastPoint,
|
|
|
|
|
|
}: ScoreboardPageProps) {
|
|
|
|
|
|
const [pickerOpen, setPickerOpen] = useState(false)
|
2026-04-16 17:11:11 +08:00
|
|
|
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
2026-04-16 08:53:05 +08:00
|
|
|
|
const [draftPlayers, setDraftPlayers] = useState<string[]>([])
|
2026-04-16 17:11:11 +08:00
|
|
|
|
const [draftTargetScore, setDraftTargetScore] = useState(() =>
|
|
|
|
|
|
String(scoreState.targetScore),
|
|
|
|
|
|
)
|
2026-04-15 22:56:50 +08:00
|
|
|
|
const [clock, setClock] = useState(() => formatClock())
|
2026-04-16 17:11:11 +08:00
|
|
|
|
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>(() =>
|
|
|
|
|
|
loadVoiceSettings(),
|
|
|
|
|
|
)
|
|
|
|
|
|
const lastAnnouncedPointRef = useRef(0)
|
|
|
|
|
|
const previousScoresRef = useRef({ left: 0, right: 0 })
|
2026-04-15 22:56:50 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const timer = window.setInterval(() => {
|
|
|
|
|
|
setClock(formatClock())
|
|
|
|
|
|
}, 1000)
|
|
|
|
|
|
|
|
|
|
|
|
return () => window.clearInterval(timer)
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
2026-04-16 17:11:11 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
window.localStorage.setItem(
|
|
|
|
|
|
VOICE_SETTINGS_STORAGE_KEY,
|
|
|
|
|
|
JSON.stringify(voiceSettings),
|
|
|
|
|
|
)
|
|
|
|
|
|
}, [voiceSettings])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if ('speechSynthesis' in window) {
|
|
|
|
|
|
window.speechSynthesis.cancel()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
2026-04-16 08:53:05 +08:00
|
|
|
|
const selectablePlayers = useMemo(() => {
|
|
|
|
|
|
if (!selectedGroup) {
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const seen = new Set<string>()
|
|
|
|
|
|
const players: string[] = []
|
|
|
|
|
|
|
|
|
|
|
|
selectedGroup.teams.forEach((team) => {
|
|
|
|
|
|
if (!team.isPlaceholderA && !seen.has(team.playerA)) {
|
|
|
|
|
|
seen.add(team.playerA)
|
|
|
|
|
|
players.push(team.playerA)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!team.isPlaceholderB && !seen.has(team.playerB)) {
|
|
|
|
|
|
seen.add(team.playerB)
|
|
|
|
|
|
players.push(team.playerB)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return players
|
|
|
|
|
|
}, [selectedGroup])
|
|
|
|
|
|
|
2026-04-16 10:19:23 +08:00
|
|
|
|
const presetTeams = useMemo(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
selectedGroup?.teams.filter((team) => !team.isPlaceholderA && !team.isPlaceholderB) ?? [],
|
|
|
|
|
|
[selectedGroup],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-15 22:56:50 +08:00
|
|
|
|
const canArrangeMatch = !hasRecordedPoint
|
|
|
|
|
|
const canScore = scoreState.serving !== null
|
|
|
|
|
|
|
|
|
|
|
|
const servingScore =
|
|
|
|
|
|
scoreState.serving === 'left' ? scoreState.scoreLeft : scoreState.scoreRight
|
|
|
|
|
|
const servingCourt =
|
|
|
|
|
|
scoreState.serving === null ? null : getServiceCourt(servingScore)
|
|
|
|
|
|
|
|
|
|
|
|
const leftAssignments = useMemo(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer) : [],
|
|
|
|
|
|
[leftTeam, scoreState.leftRightCourtPlayer],
|
|
|
|
|
|
)
|
|
|
|
|
|
const rightAssignments = useMemo(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer) : [],
|
|
|
|
|
|
[rightTeam, scoreState.rightRightCourtPlayer],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const currentServer =
|
|
|
|
|
|
scoreState.serving === 'left'
|
|
|
|
|
|
? leftTeam
|
|
|
|
|
|
? getServingPlayer(leftTeam, scoreState.leftRightCourtPlayer, scoreState.scoreLeft)
|
|
|
|
|
|
: null
|
|
|
|
|
|
: scoreState.serving === 'right'
|
|
|
|
|
|
? rightTeam
|
|
|
|
|
|
? getServingPlayer(
|
|
|
|
|
|
rightTeam,
|
|
|
|
|
|
scoreState.rightRightCourtPlayer,
|
|
|
|
|
|
scoreState.scoreRight,
|
|
|
|
|
|
)
|
|
|
|
|
|
: null
|
|
|
|
|
|
: null
|
|
|
|
|
|
|
|
|
|
|
|
const currentReceiver =
|
|
|
|
|
|
scoreState.serving === 'left'
|
|
|
|
|
|
? rightTeam
|
|
|
|
|
|
? getReceivingPlayer(
|
|
|
|
|
|
rightTeam,
|
|
|
|
|
|
scoreState.rightRightCourtPlayer,
|
|
|
|
|
|
scoreState.scoreLeft,
|
|
|
|
|
|
)
|
|
|
|
|
|
: null
|
|
|
|
|
|
: scoreState.serving === 'right'
|
|
|
|
|
|
? leftTeam
|
|
|
|
|
|
? getReceivingPlayer(
|
|
|
|
|
|
leftTeam,
|
|
|
|
|
|
scoreState.leftRightCourtPlayer,
|
|
|
|
|
|
scoreState.scoreRight,
|
|
|
|
|
|
)
|
|
|
|
|
|
: null
|
|
|
|
|
|
: null
|
|
|
|
|
|
|
2026-04-16 17:11:11 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const totalPoints = scoreState.scoreLeft + scoreState.scoreRight
|
|
|
|
|
|
|
|
|
|
|
|
if (scoreState.serving === null || !currentServer?.name || totalPoints === 0) {
|
|
|
|
|
|
lastAnnouncedPointRef.current = totalPoints
|
|
|
|
|
|
previousScoresRef.current = {
|
|
|
|
|
|
left: scoreState.scoreLeft,
|
|
|
|
|
|
right: scoreState.scoreRight,
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (lastAnnouncedPointRef.current === totalPoints) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
lastAnnouncedPointRef.current = totalPoints
|
|
|
|
|
|
|
|
|
|
|
|
const scorerSide =
|
|
|
|
|
|
scoreState.scoreLeft > previousScoresRef.current.left
|
|
|
|
|
|
? 'left'
|
|
|
|
|
|
: scoreState.scoreRight > previousScoresRef.current.right
|
|
|
|
|
|
? 'right'
|
|
|
|
|
|
: null
|
|
|
|
|
|
|
|
|
|
|
|
previousScoresRef.current = {
|
|
|
|
|
|
left: scoreState.scoreLeft,
|
|
|
|
|
|
right: scoreState.scoreRight,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const parts: string[] = []
|
|
|
|
|
|
|
|
|
|
|
|
if (voiceSettings.announceScore && scorerSide) {
|
|
|
|
|
|
parts.push(
|
|
|
|
|
|
`${getAnnouncementName(scorerSide === 'left' ? leftTeam : rightTeam)}得分`,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (voiceSettings.announceServer) {
|
|
|
|
|
|
parts.push(`${currentServer.name}發球`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (parts.length > 0) {
|
|
|
|
|
|
speakAnnouncement(parts.join(','), voiceSettings.rate)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [
|
|
|
|
|
|
currentServer?.name,
|
|
|
|
|
|
leftTeam,
|
|
|
|
|
|
rightTeam,
|
|
|
|
|
|
scoreState.scoreLeft,
|
|
|
|
|
|
scoreState.scoreRight,
|
|
|
|
|
|
scoreState.serving,
|
|
|
|
|
|
voiceSettings.announceScore,
|
|
|
|
|
|
voiceSettings.announceServer,
|
|
|
|
|
|
voiceSettings.rate,
|
|
|
|
|
|
])
|
|
|
|
|
|
|
2026-04-15 22:56:50 +08:00
|
|
|
|
if (!selectedGroup) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section className="page-grid">
|
|
|
|
|
|
<article className="panel panel-hero">
|
|
|
|
|
|
<p className="panel-kicker">Step 3</p>
|
2026-04-16 17:11:11 +08:00
|
|
|
|
<h2>請先回到選隊伍頁面</h2>
|
2026-04-15 22:56:50 +08:00
|
|
|
|
<p className="panel-copy">
|
2026-04-16 17:11:11 +08:00
|
|
|
|
目前還沒有帶入要上場的組別,先回去選擇分組,再進入記分板。
|
2026-04-15 22:56:50 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
<Link className="primary-button inline-link" to="/teams">
|
2026-04-16 17:11:11 +08:00
|
|
|
|
回到選隊伍
|
2026-04-15 22:56:50 +08:00
|
|
|
|
</Link>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const matchupLabel =
|
|
|
|
|
|
leftTeam && rightTeam
|
|
|
|
|
|
? `${getTeamDisplayName(leftTeam)} vs ${getTeamDisplayName(rightTeam)}`
|
|
|
|
|
|
: '尚未設定對戰隊伍'
|
|
|
|
|
|
|
|
|
|
|
|
const openPicker = () => {
|
2026-04-16 08:53:05 +08:00
|
|
|
|
setDraftPlayers(currentSelectionOrder.filter((player) => selectablePlayers.includes(player)))
|
|
|
|
|
|
setDraftTargetScore(String(scoreState.targetScore))
|
2026-04-15 22:56:50 +08:00
|
|
|
|
setPickerOpen(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:53:05 +08:00
|
|
|
|
const toggleDraftPlayer = (playerName: string) => {
|
|
|
|
|
|
setDraftPlayers((current) => {
|
|
|
|
|
|
if (current.includes(playerName)) {
|
|
|
|
|
|
return current.filter((value) => value !== playerName)
|
2026-04-15 22:56:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:53:05 +08:00
|
|
|
|
if (current.length >= 4) {
|
|
|
|
|
|
return current
|
2026-04-15 22:56:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:53:05 +08:00
|
|
|
|
return [...current, playerName]
|
2026-04-15 22:56:50 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 10:19:23 +08:00
|
|
|
|
const togglePresetTeam = (team: GroupTeam) => {
|
|
|
|
|
|
setDraftPlayers((current) => {
|
|
|
|
|
|
const removed = removePresetTeamFromDraft(current, team)
|
|
|
|
|
|
|
|
|
|
|
|
if (removed.length !== current.length) {
|
|
|
|
|
|
return removed
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (current.length >= 4 || current.length % 2 !== 0) {
|
|
|
|
|
|
return current
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (current.includes(team.playerA) || current.includes(team.playerB)) {
|
|
|
|
|
|
return current
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [...current, team.playerA, team.playerB]
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 22:56:50 +08:00
|
|
|
|
const confirmDraftTeams = () => {
|
2026-04-16 08:53:05 +08:00
|
|
|
|
if (draftPlayers.length !== 4) {
|
2026-04-15 22:56:50 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:53:05 +08:00
|
|
|
|
onApplyMatchup(
|
|
|
|
|
|
{
|
|
|
|
|
|
id: -1,
|
|
|
|
|
|
playerA: draftPlayers[0],
|
|
|
|
|
|
playerB: draftPlayers[1],
|
|
|
|
|
|
isPlaceholderA: false,
|
|
|
|
|
|
isPlaceholderB: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: -2,
|
|
|
|
|
|
playerA: draftPlayers[3],
|
|
|
|
|
|
playerB: draftPlayers[2],
|
|
|
|
|
|
isPlaceholderA: false,
|
|
|
|
|
|
isPlaceholderB: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
sanitizeTargetScore(draftTargetScore),
|
|
|
|
|
|
)
|
2026-04-15 22:56:50 +08:00
|
|
|
|
setPickerOpen(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:53:05 +08:00
|
|
|
|
const autoPickDraftPlayers = () => {
|
|
|
|
|
|
const shuffled = [...selectablePlayers]
|
2026-04-15 22:56:50 +08:00
|
|
|
|
|
|
|
|
|
|
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
|
|
|
|
|
const swapIndex = Math.floor(Math.random() * (index + 1))
|
|
|
|
|
|
;[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:53:05 +08:00
|
|
|
|
setDraftPlayers(shuffled.slice(0, 4))
|
2026-04-15 22:56:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
2026-04-16 16:54:59 +08:00
|
|
|
|
{streakAnnouncement ? (
|
|
|
|
|
|
<div className="streak-banner" key={streakAnnouncement.key}>
|
|
|
|
|
|
<span className="streak-banner-count">{streakAnnouncement.count} 連勝</span>
|
|
|
|
|
|
<strong>{streakAnnouncement.title}</strong>
|
|
|
|
|
|
<small>{streakAnnouncement.teamName}</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{victoryAnnouncement ? (
|
|
|
|
|
|
<div className="victory-banner" key={victoryAnnouncement.key}>
|
|
|
|
|
|
<span className="victory-banner-kicker">目標分數達成</span>
|
|
|
|
|
|
<strong>{victoryAnnouncement.title}</strong>
|
|
|
|
|
|
<small>{victoryAnnouncement.teamName}</small>
|
|
|
|
|
|
<em>{victoryAnnouncement.scoreLabel}</em>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
2026-04-15 22:56:50 +08:00
|
|
|
|
<section className="scoreboard-screen">
|
|
|
|
|
|
<div className="scoreboard-court">
|
|
|
|
|
|
<ScoreboardTeamPanel
|
|
|
|
|
|
assignments={leftAssignments}
|
|
|
|
|
|
canArrangeMatch={canArrangeMatch}
|
|
|
|
|
|
canScore={canScore}
|
|
|
|
|
|
currentReceiver={scoreState.serving === 'right' ? currentReceiver?.name ?? null : null}
|
|
|
|
|
|
currentServer={scoreState.serving === 'left' ? currentServer?.name ?? null : null}
|
|
|
|
|
|
onRecordPoint={() => onRecordPoint('left')}
|
|
|
|
|
|
onSetServing={() => onSetServing('left')}
|
|
|
|
|
|
onSwapPlayers={() => onSwapTeamPlayers('left')}
|
|
|
|
|
|
onSwapTeams={onSwapMatchup}
|
|
|
|
|
|
score={scoreState.scoreLeft}
|
|
|
|
|
|
serviceCourt={scoreState.serving === 'left' ? servingCourt : null}
|
2026-04-16 16:49:02 +08:00
|
|
|
|
showServingPrompt={scoreState.serving === null}
|
2026-04-15 22:56:50 +08:00
|
|
|
|
team={leftTeam}
|
|
|
|
|
|
teamSlot="top"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="scoreboard-center-banner">
|
2026-04-16 17:11:11 +08:00
|
|
|
|
<p>{scoreState.serving === null ? '請先設定發球方' : '比賽進行中'}</p>
|
2026-04-15 22:56:50 +08:00
|
|
|
|
<small>
|
|
|
|
|
|
{scoreState.serving === null
|
2026-04-16 08:53:05 +08:00
|
|
|
|
? `本場 ${scoreState.targetScore} 分獲勝`
|
|
|
|
|
|
: `發球:${currentServer?.name ?? '-'}${
|
2026-04-15 22:56:50 +08:00
|
|
|
|
currentReceiver ? ` / 接發:${currentReceiver.name}` : ''
|
2026-04-16 08:53:05 +08:00
|
|
|
|
} / 目標 ${scoreState.targetScore} 分`}
|
2026-04-15 22:56:50 +08:00
|
|
|
|
</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<ScoreboardTeamPanel
|
|
|
|
|
|
assignments={rightAssignments}
|
|
|
|
|
|
canArrangeMatch={canArrangeMatch}
|
|
|
|
|
|
canScore={canScore}
|
|
|
|
|
|
currentReceiver={scoreState.serving === 'left' ? currentReceiver?.name ?? null : null}
|
|
|
|
|
|
currentServer={scoreState.serving === 'right' ? currentServer?.name ?? null : null}
|
|
|
|
|
|
onRecordPoint={() => onRecordPoint('right')}
|
|
|
|
|
|
onSetServing={() => onSetServing('right')}
|
|
|
|
|
|
onSwapPlayers={() => onSwapTeamPlayers('right')}
|
|
|
|
|
|
onSwapTeams={onSwapMatchup}
|
|
|
|
|
|
score={scoreState.scoreRight}
|
|
|
|
|
|
serviceCourt={scoreState.serving === 'right' ? servingCourt : null}
|
2026-04-16 16:49:02 +08:00
|
|
|
|
showServingPrompt={scoreState.serving === null}
|
2026-04-15 22:56:50 +08:00
|
|
|
|
team={rightTeam}
|
|
|
|
|
|
teamSlot="bottom"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<aside className="scoreboard-rail">
|
|
|
|
|
|
<div className="rail-icon-grid">
|
|
|
|
|
|
{hasRecordedPoint ? (
|
|
|
|
|
|
<button className="rail-square-button" type="button" onClick={onUndoLastPoint}>
|
|
|
|
|
|
上一步
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<button className="rail-square-button" type="button" onClick={openPicker}>
|
|
|
|
|
|
設定隊伍
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-04-16 17:11:11 +08:00
|
|
|
|
<button
|
|
|
|
|
|
aria-label="語音設定"
|
|
|
|
|
|
className="rail-square-button"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setSettingsOpen(true)}
|
|
|
|
|
|
>
|
|
|
|
|
|
設定
|
|
|
|
|
|
</button>
|
2026-04-15 22:56:50 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="rail-clock">{clock}</div>
|
|
|
|
|
|
|
|
|
|
|
|
<button className="rail-pill rail-pill-danger" type="button" onClick={onOpenFinishDialog}>
|
|
|
|
|
|
比賽結算
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
{pickerOpen ? (
|
|
|
|
|
|
<TeamPickerModal
|
2026-04-16 08:53:05 +08:00
|
|
|
|
draftPlayers={draftPlayers}
|
|
|
|
|
|
draftTargetScore={draftTargetScore}
|
2026-04-15 22:56:50 +08:00
|
|
|
|
group={selectedGroup}
|
2026-04-16 10:19:23 +08:00
|
|
|
|
presetTeams={presetTeams}
|
2026-04-16 08:53:05 +08:00
|
|
|
|
selectablePlayers={selectablePlayers}
|
|
|
|
|
|
selectionCount={draftPlayers.length}
|
2026-04-15 22:56:50 +08:00
|
|
|
|
sourceLabel={groupSource === 'db' ? 'DB' : groupSource === 'manual' ? '手動' : '-'}
|
|
|
|
|
|
targetDate={targetDate}
|
2026-04-16 08:53:05 +08:00
|
|
|
|
onAutoPick={autoPickDraftPlayers}
|
|
|
|
|
|
onClear={() => setDraftPlayers([])}
|
2026-04-15 22:56:50 +08:00
|
|
|
|
onClose={() => setPickerOpen(false)}
|
|
|
|
|
|
onConfirm={confirmDraftTeams}
|
2026-04-16 08:53:05 +08:00
|
|
|
|
onDraftTargetScoreChange={setDraftTargetScore}
|
|
|
|
|
|
onTogglePlayer={toggleDraftPlayer}
|
2026-04-16 10:19:23 +08:00
|
|
|
|
onTogglePresetTeam={togglePresetTeam}
|
2026-04-15 22:56:50 +08:00
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
2026-04-16 17:11:11 +08:00
|
|
|
|
{settingsOpen ? (
|
|
|
|
|
|
<VoiceSettingsModal
|
|
|
|
|
|
settings={voiceSettings}
|
|
|
|
|
|
onClose={() => setSettingsOpen(false)}
|
|
|
|
|
|
onUpdateSettings={setVoiceSettings}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
2026-04-15 22:56:50 +08:00
|
|
|
|
{finishDialogOpen ? (
|
|
|
|
|
|
<FinishDialog
|
|
|
|
|
|
error={finishDialogError}
|
|
|
|
|
|
leftScore={scoreState.scoreLeft}
|
2026-04-16 17:11:11 +08:00
|
|
|
|
leftTeamName={leftTeam ? getTeamDisplayName(leftTeam) : '未設定'}
|
2026-04-15 22:56:50 +08:00
|
|
|
|
matchupLabel={matchupLabel}
|
|
|
|
|
|
rightScore={scoreState.scoreRight}
|
2026-04-16 17:11:11 +08:00
|
|
|
|
rightTeamName={rightTeam ? getTeamDisplayName(rightTeam) : '未設定'}
|
2026-04-15 22:56:50 +08:00
|
|
|
|
uploading={finishDialogUploading}
|
|
|
|
|
|
onClose={onCloseFinishDialog}
|
|
|
|
|
|
onConfirm={onConfirmUpload}
|
|
|
|
|
|
onSkip={onSkipUpload}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ScoreboardTeamPanelProps = {
|
|
|
|
|
|
assignments: Array<{ slot: PlayerSlot; name: string; court: CourtSide }>
|
|
|
|
|
|
canArrangeMatch: boolean
|
|
|
|
|
|
canScore: boolean
|
|
|
|
|
|
currentReceiver: string | null
|
|
|
|
|
|
currentServer: string | null
|
|
|
|
|
|
onRecordPoint: () => void
|
|
|
|
|
|
onSetServing: () => void
|
|
|
|
|
|
onSwapPlayers: () => void
|
|
|
|
|
|
onSwapTeams: () => void
|
|
|
|
|
|
score: number
|
|
|
|
|
|
serviceCourt: CourtSide | null
|
2026-04-16 16:49:02 +08:00
|
|
|
|
showServingPrompt: boolean
|
2026-04-15 22:56:50 +08:00
|
|
|
|
team: GroupTeam | null
|
|
|
|
|
|
teamSlot: 'top' | 'bottom'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ScoreboardTeamPanel({
|
|
|
|
|
|
assignments,
|
|
|
|
|
|
canArrangeMatch,
|
|
|
|
|
|
canScore,
|
|
|
|
|
|
currentReceiver,
|
|
|
|
|
|
currentServer,
|
|
|
|
|
|
onRecordPoint,
|
|
|
|
|
|
onSetServing,
|
|
|
|
|
|
onSwapPlayers,
|
|
|
|
|
|
onSwapTeams,
|
|
|
|
|
|
score,
|
|
|
|
|
|
serviceCourt,
|
2026-04-16 16:49:02 +08:00
|
|
|
|
showServingPrompt,
|
2026-04-15 22:56:50 +08:00
|
|
|
|
team,
|
|
|
|
|
|
teamSlot,
|
|
|
|
|
|
}: ScoreboardTeamPanelProps) {
|
|
|
|
|
|
const orderedAssignments = [...assignments].sort((left, right) => {
|
|
|
|
|
|
if (left.court === right.court) {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return left.court === 'left' ? -1 : 1
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const header = (
|
|
|
|
|
|
<div className="scoreboard-team-head">
|
|
|
|
|
|
<div className="team-head-main">
|
|
|
|
|
|
{orderedAssignments.map((assignment) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={
|
|
|
|
|
|
assignment.name === currentServer
|
|
|
|
|
|
? 'scoreboard-name-chip scoreboard-name-chip-serving'
|
|
|
|
|
|
: 'scoreboard-name-chip'
|
|
|
|
|
|
}
|
|
|
|
|
|
key={assignment.slot}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="team-number">{getPlayerNumber(teamSlot, assignment.slot)}</span>
|
|
|
|
|
|
<strong>{assignment.name}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="team-head-buttons">
|
|
|
|
|
|
<button
|
2026-04-16 17:11:11 +08:00
|
|
|
|
aria-label="交換上下隊伍"
|
2026-04-15 22:56:50 +08:00
|
|
|
|
className="team-icon-button"
|
|
|
|
|
|
disabled={!canArrangeMatch}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onSwapTeams}
|
|
|
|
|
|
>
|
|
|
|
|
|
↕
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
2026-04-16 17:11:11 +08:00
|
|
|
|
aria-label="交換左右位置"
|
2026-04-15 22:56:50 +08:00
|
|
|
|
className="team-icon-button"
|
|
|
|
|
|
disabled={!canArrangeMatch}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onSwapPlayers}
|
|
|
|
|
|
>
|
|
|
|
|
|
↔
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const serveBar = (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={
|
2026-04-16 16:49:02 +08:00
|
|
|
|
currentServer && !canArrangeMatch
|
|
|
|
|
|
? 'serve-lane serve-lane-locked'
|
|
|
|
|
|
: showServingPrompt
|
|
|
|
|
|
? 'serve-lane serve-lane-prompt'
|
|
|
|
|
|
: 'serve-lane'
|
2026-04-15 22:56:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
disabled={!canArrangeMatch || !team}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onSetServing}
|
|
|
|
|
|
>
|
2026-04-16 16:49:02 +08:00
|
|
|
|
<span className={currentServer ? 'serve-lane-box serve-lane-box-checked' : 'serve-lane-box'} />
|
2026-04-15 22:56:50 +08:00
|
|
|
|
<span>先攻</span>
|
|
|
|
|
|
{currentServer ? (
|
|
|
|
|
|
<small>
|
2026-04-16 08:53:05 +08:00
|
|
|
|
發球區:{serviceCourt === 'left' ? '左' : serviceCourt === 'right' ? '右' : '-'}
|
|
|
|
|
|
{currentReceiver ? ` / 接發:${currentReceiver}` : ''}
|
2026-04-15 22:56:50 +08:00
|
|
|
|
</small>
|
|
|
|
|
|
) : (
|
2026-04-16 08:53:05 +08:00
|
|
|
|
<small>點擊設定這一隊先攻</small>
|
2026-04-15 22:56:50 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const scoreBoard = (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={canScore ? 'score-panel-surface score-panel-surface-live' : 'score-panel-surface'}
|
|
|
|
|
|
disabled={!canScore || !team}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onRecordPoint}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="score-panel-value">{score}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section className="scoreboard-team-section">
|
|
|
|
|
|
{teamSlot === 'top' ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{header}
|
|
|
|
|
|
{serveBar}
|
|
|
|
|
|
{scoreBoard}
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{scoreBoard}
|
|
|
|
|
|
{serveBar}
|
|
|
|
|
|
{header}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type TeamPickerModalProps = {
|
2026-04-16 08:53:05 +08:00
|
|
|
|
draftPlayers: string[]
|
|
|
|
|
|
draftTargetScore: string
|
2026-04-15 22:56:50 +08:00
|
|
|
|
group: RoundGroup
|
2026-04-16 10:19:23 +08:00
|
|
|
|
presetTeams: GroupTeam[]
|
2026-04-16 08:53:05 +08:00
|
|
|
|
selectablePlayers: string[]
|
2026-04-15 22:56:50 +08:00
|
|
|
|
selectionCount: number
|
|
|
|
|
|
sourceLabel: string
|
|
|
|
|
|
targetDate: string
|
|
|
|
|
|
onAutoPick: () => void
|
|
|
|
|
|
onClear: () => void
|
|
|
|
|
|
onClose: () => void
|
|
|
|
|
|
onConfirm: () => void
|
2026-04-16 08:53:05 +08:00
|
|
|
|
onDraftTargetScoreChange: (value: string) => void
|
|
|
|
|
|
onTogglePlayer: (playerName: string) => void
|
2026-04-16 10:19:23 +08:00
|
|
|
|
onTogglePresetTeam: (team: GroupTeam) => void
|
2026-04-15 22:56:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function TeamPickerModal({
|
2026-04-16 08:53:05 +08:00
|
|
|
|
draftPlayers,
|
|
|
|
|
|
draftTargetScore,
|
2026-04-15 22:56:50 +08:00
|
|
|
|
group,
|
2026-04-16 10:19:23 +08:00
|
|
|
|
presetTeams,
|
2026-04-16 08:53:05 +08:00
|
|
|
|
selectablePlayers,
|
2026-04-15 22:56:50 +08:00
|
|
|
|
selectionCount,
|
|
|
|
|
|
sourceLabel,
|
|
|
|
|
|
targetDate,
|
|
|
|
|
|
onAutoPick,
|
|
|
|
|
|
onClear,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
onConfirm,
|
2026-04-16 08:53:05 +08:00
|
|
|
|
onDraftTargetScoreChange,
|
|
|
|
|
|
onTogglePlayer,
|
2026-04-16 10:19:23 +08:00
|
|
|
|
onTogglePresetTeam,
|
2026-04-15 22:56:50 +08:00
|
|
|
|
}: TeamPickerModalProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="team-picker-overlay" role="presentation" onClick={onClose}>
|
|
|
|
|
|
<div
|
2026-04-16 08:53:05 +08:00
|
|
|
|
aria-label="設定隊伍"
|
2026-04-15 22:56:50 +08:00
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
className="team-picker-shell"
|
|
|
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
|
|
role="dialog"
|
|
|
|
|
|
>
|
2026-04-16 08:53:05 +08:00
|
|
|
|
<button aria-label="關閉設定隊伍" className="team-picker-close" type="button" onClick={onClose}>
|
2026-04-15 22:56:50 +08:00
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="team-picker-ribbon">
|
2026-04-16 17:11:11 +08:00
|
|
|
|
<span>{selectionCount >= 4 ? '已完成選擇' : '請選滿 4 位球員'}</span>
|
2026-04-15 22:56:50 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="team-picker-layout">
|
|
|
|
|
|
<section className="team-picker-panel team-picker-list-panel">
|
|
|
|
|
|
<div className="team-picker-title">
|
2026-04-16 08:53:05 +08:00
|
|
|
|
<span className="team-picker-count">{selectionCount}/4</span>
|
2026-04-15 22:56:50 +08:00
|
|
|
|
<div>
|
2026-04-16 17:11:11 +08:00
|
|
|
|
<strong>依序選擇球員</strong>
|
2026-04-15 22:56:50 +08:00
|
|
|
|
<p>
|
|
|
|
|
|
第 {group.id} 組 / {sourceLabel} / {targetDate || '-'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-16 10:19:23 +08:00
|
|
|
|
<div className="team-picker-config-row">
|
|
|
|
|
|
<label className="team-picker-config team-picker-config-compact">
|
2026-04-16 17:11:11 +08:00
|
|
|
|
<span>獲勝分數</span>
|
2026-04-16 10:19:23 +08:00
|
|
|
|
<input
|
|
|
|
|
|
className="team-picker-score-input team-picker-score-input-compact"
|
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
|
maxLength={2}
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={draftTargetScore}
|
|
|
|
|
|
onChange={(event) => onDraftTargetScoreChange(event.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
2026-04-16 08:53:05 +08:00
|
|
|
|
|
2026-04-15 22:56:50 +08:00
|
|
|
|
<div className="team-picker-list">
|
2026-04-16 08:53:05 +08:00
|
|
|
|
{selectablePlayers.map((playerName) => {
|
|
|
|
|
|
const checked = draftPlayers.includes(playerName)
|
|
|
|
|
|
const selectedOrder = checked ? draftPlayers.indexOf(playerName) + 1 : null
|
2026-04-15 22:56:50 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={
|
|
|
|
|
|
checked
|
|
|
|
|
|
? 'team-picker-option team-picker-option-active'
|
|
|
|
|
|
: 'team-picker-option'
|
|
|
|
|
|
}
|
2026-04-16 08:53:05 +08:00
|
|
|
|
key={`player-option-${playerName}`}
|
2026-04-15 22:56:50 +08:00
|
|
|
|
type="button"
|
2026-04-16 08:53:05 +08:00
|
|
|
|
onClick={() => onTogglePlayer(playerName)}
|
2026-04-15 22:56:50 +08:00
|
|
|
|
>
|
|
|
|
|
|
<span className="team-picker-checkbox">
|
|
|
|
|
|
{checked ? String(selectedOrder) : ''}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div className="team-picker-option-text">
|
2026-04-16 08:53:05 +08:00
|
|
|
|
<strong>{playerName}</strong>
|
|
|
|
|
|
<small>
|
2026-04-16 17:11:11 +08:00
|
|
|
|
{selectedOrder ? `已選為第 ${selectedOrder} 位` : '尚未加入上場名單'}
|
2026-04-16 08:53:05 +08:00
|
|
|
|
</small>
|
2026-04-15 22:56:50 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="team-picker-actions">
|
|
|
|
|
|
<button className="team-picker-ghost" type="button" onClick={onAutoPick}>
|
|
|
|
|
|
自動選擇
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="team-picker-confirm"
|
2026-04-16 08:53:05 +08:00
|
|
|
|
disabled={draftPlayers.length !== 4}
|
2026-04-15 22:56:50 +08:00
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onConfirm}
|
|
|
|
|
|
>
|
|
|
|
|
|
確認
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<aside className="team-picker-panel team-picker-side-panel">
|
2026-04-16 10:19:23 +08:00
|
|
|
|
<div className="preset-team-block">
|
|
|
|
|
|
<div className="preset-team-head">
|
2026-04-16 17:11:11 +08:00
|
|
|
|
<strong>快速選預設隊伍</strong>
|
|
|
|
|
|
<small>可直接點一整隊,或和左側逐一選人混用。</small>
|
2026-04-16 10:19:23 +08:00
|
|
|
|
</div>
|
2026-04-15 22:56:50 +08:00
|
|
|
|
|
2026-04-16 10:19:23 +08:00
|
|
|
|
<div className="preset-team-list">
|
|
|
|
|
|
{presetTeams.map((team) => {
|
|
|
|
|
|
const selectedSlot = getPresetTeamSelectionSlot(draftPlayers, team)
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={
|
|
|
|
|
|
selectedSlot === null
|
|
|
|
|
|
? 'preset-team-card'
|
|
|
|
|
|
: 'preset-team-card preset-team-card-active'
|
|
|
|
|
|
}
|
|
|
|
|
|
key={`preset-team-${team.id}`}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => onTogglePresetTeam(team)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="preset-team-index">{team.id}</span>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>{getTeamDisplayName(team)}</strong>
|
|
|
|
|
|
<small>
|
|
|
|
|
|
{selectedSlot === null
|
2026-04-16 17:11:11 +08:00
|
|
|
|
? '點擊套用這一隊'
|
|
|
|
|
|
: `已放入第 ${selectedSlot === 0 ? '1' : '2'} 隊`}
|
2026-04-16 10:19:23 +08:00
|
|
|
|
</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
2026-04-15 22:56:50 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<button className="team-picker-clear" type="button" onClick={onClear}>
|
|
|
|
|
|
清空
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<p className="picker-side-hint">
|
2026-04-16 17:11:11 +08:00
|
|
|
|
第 1、2 位會自動成為上方隊伍,第 3、4 位會成為下方隊伍。
|
2026-04-15 22:56:50 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 17:11:11 +08:00
|
|
|
|
type VoiceSettingsModalProps = {
|
|
|
|
|
|
settings: VoiceSettings
|
|
|
|
|
|
onClose: () => void
|
|
|
|
|
|
onUpdateSettings: Dispatch<SetStateAction<VoiceSettings>>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function VoiceSettingsModal({
|
|
|
|
|
|
settings,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
onUpdateSettings,
|
|
|
|
|
|
}: VoiceSettingsModalProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="voice-settings-overlay" role="presentation" onClick={onClose}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
aria-label="語音設定"
|
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
className="voice-settings-panel"
|
|
|
|
|
|
role="dialog"
|
|
|
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
aria-label="關閉語音設定"
|
|
|
|
|
|
className="voice-settings-close"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<p className="panel-kicker">語音設定</p>
|
|
|
|
|
|
<h3>播報內容</h3>
|
|
|
|
|
|
|
|
|
|
|
|
<label className="voice-setting-row">
|
|
|
|
|
|
<span>播報誰得分</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
checked={settings.announceScore}
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
|
onUpdateSettings((current) => ({
|
|
|
|
|
|
...current,
|
|
|
|
|
|
announceScore: event.target.checked,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
<label className="voice-setting-row">
|
|
|
|
|
|
<span>播報誰發球</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
checked={settings.announceServer}
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
|
onUpdateSettings((current) => ({
|
|
|
|
|
|
...current,
|
|
|
|
|
|
announceServer: event.target.checked,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
<label className="voice-setting-slider">
|
|
|
|
|
|
<span>語速</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
max="10"
|
|
|
|
|
|
min="0.7"
|
|
|
|
|
|
step="0.1"
|
|
|
|
|
|
type="range"
|
|
|
|
|
|
value={settings.rate}
|
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
|
onUpdateSettings((current) => ({
|
|
|
|
|
|
...current,
|
|
|
|
|
|
rate: Number(event.target.value),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<strong>{settings.rate.toFixed(1)}x</strong>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 22:56:50 +08:00
|
|
|
|
type FinishDialogProps = {
|
|
|
|
|
|
error: string
|
|
|
|
|
|
leftScore: number
|
|
|
|
|
|
leftTeamName: string
|
|
|
|
|
|
matchupLabel: string
|
|
|
|
|
|
rightScore: number
|
|
|
|
|
|
rightTeamName: string
|
|
|
|
|
|
uploading: boolean
|
|
|
|
|
|
onClose: () => void
|
|
|
|
|
|
onConfirm: () => void
|
|
|
|
|
|
onSkip: () => void
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function FinishDialog({
|
|
|
|
|
|
error,
|
|
|
|
|
|
leftScore,
|
|
|
|
|
|
leftTeamName,
|
|
|
|
|
|
matchupLabel,
|
|
|
|
|
|
rightScore,
|
|
|
|
|
|
rightTeamName,
|
|
|
|
|
|
uploading,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
onConfirm,
|
|
|
|
|
|
onSkip,
|
|
|
|
|
|
}: FinishDialogProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="finish-dialog-overlay" role="presentation">
|
|
|
|
|
|
<div aria-modal="true" className="finish-dialog" role="dialog">
|
|
|
|
|
|
<button
|
2026-04-16 08:53:05 +08:00
|
|
|
|
aria-label="關閉比賽結算"
|
2026-04-15 22:56:50 +08:00
|
|
|
|
className="finish-dialog-close"
|
|
|
|
|
|
disabled={uploading}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<p className="panel-kicker">比賽結算</p>
|
|
|
|
|
|
<h3>{matchupLabel}</h3>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="finish-score">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>{leftScore}</strong>
|
|
|
|
|
|
<span>{leftTeamName}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="finish-score-divider">:</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>{rightScore}</strong>
|
|
|
|
|
|
<span>{rightTeamName}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-16 17:11:11 +08:00
|
|
|
|
<p className="finish-dialog-copy">要不要把這場比賽戰績上傳到資料庫?</p>
|
2026-04-15 22:56:50 +08:00
|
|
|
|
|
|
|
|
|
|
{error ? <p className="finish-dialog-error">{error}</p> : null}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="finish-dialog-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="team-picker-ghost"
|
|
|
|
|
|
disabled={uploading}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onSkip}
|
|
|
|
|
|
>
|
|
|
|
|
|
不上傳
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="team-picker-confirm"
|
|
|
|
|
|
disabled={uploading}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onConfirm}
|
|
|
|
|
|
>
|
2026-04-16 08:53:05 +08:00
|
|
|
|
{uploading ? '上傳中...' : '確認上傳'}
|
2026-04-15 22:56:50 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getPlayerNumber(teamSlot: 'top' | 'bottom', slot: PlayerSlot) {
|
|
|
|
|
|
if (teamSlot === 'top') {
|
|
|
|
|
|
return slot === 'playerA' ? 1 : 2
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return slot === 'playerA' ? 4 : 3
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 08:53:05 +08:00
|
|
|
|
function sanitizeTargetScore(value: string) {
|
|
|
|
|
|
const numeric = Number.parseInt(value.replace(/[^\d]/g, ''), 10)
|
|
|
|
|
|
|
|
|
|
|
|
if (Number.isNaN(numeric)) {
|
|
|
|
|
|
return 21
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Math.min(99, Math.max(1, numeric))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 10:19:23 +08:00
|
|
|
|
function removePresetTeamFromDraft(players: string[], team: GroupTeam) {
|
|
|
|
|
|
const firstPairSelected = players[0] === team.playerA && players[1] === team.playerB
|
|
|
|
|
|
const secondPairSelected = players[2] === team.playerA && players[3] === team.playerB
|
|
|
|
|
|
|
|
|
|
|
|
if (firstPairSelected) {
|
|
|
|
|
|
return players.slice(2)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (secondPairSelected) {
|
|
|
|
|
|
return players.slice(0, 2)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return players
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getPresetTeamSelectionSlot(players: string[], team: GroupTeam) {
|
|
|
|
|
|
if (players[0] === team.playerA && players[1] === team.playerB) {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (players[2] === team.playerA && players[3] === team.playerB) {
|
|
|
|
|
|
return 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 22:56:50 +08:00
|
|
|
|
function formatClock() {
|
|
|
|
|
|
return new Date().toLocaleTimeString('zh-TW', {
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
|
hour12: false,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-04-16 17:11:11 +08:00
|
|
|
|
|
|
|
|
|
|
function loadVoiceSettings(): VoiceSettings {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const raw = window.localStorage.getItem(VOICE_SETTINGS_STORAGE_KEY)
|
|
|
|
|
|
|
|
|
|
|
|
if (!raw) {
|
|
|
|
|
|
return defaultVoiceSettings
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const parsed = JSON.parse(raw) as Partial<VoiceSettings>
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
announceScore: parsed.announceScore ?? defaultVoiceSettings.announceScore,
|
|
|
|
|
|
announceServer: parsed.announceServer ?? defaultVoiceSettings.announceServer,
|
|
|
|
|
|
rate:
|
|
|
|
|
|
typeof parsed.rate === 'number'
|
|
|
|
|
|
? Math.min(10, Math.max(0.7, parsed.rate))
|
|
|
|
|
|
: defaultVoiceSettings.rate,
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return defaultVoiceSettings
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getAnnouncementName(team: GroupTeam | null) {
|
|
|
|
|
|
return team?.playerA ?? '本隊'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function speakAnnouncement(message: string, rate: number) {
|
|
|
|
|
|
if (!('speechSynthesis' in window)) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const synthesis = window.speechSynthesis
|
|
|
|
|
|
const utterance = new SpeechSynthesisUtterance(message)
|
|
|
|
|
|
const voices = synthesis.getVoices()
|
|
|
|
|
|
const zhVoice =
|
|
|
|
|
|
voices.find((voice) => voice.lang.toLowerCase().startsWith('zh-tw')) ??
|
|
|
|
|
|
voices.find((voice) => voice.lang.toLowerCase().startsWith('zh'))
|
|
|
|
|
|
|
|
|
|
|
|
utterance.lang = zhVoice?.lang ?? 'zh-TW'
|
|
|
|
|
|
utterance.rate = rate
|
|
|
|
|
|
utterance.pitch = 1
|
|
|
|
|
|
utterance.volume = 1
|
|
|
|
|
|
|
|
|
|
|
|
if (zhVoice) {
|
|
|
|
|
|
utterance.voice = zhVoice
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
synthesis.cancel()
|
|
|
|
|
|
synthesis.speak(utterance)
|
|
|
|
|
|
}
|