diff --git a/README.md b/README.md index ea263d4..077e20c 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,10 @@ - 需先指定先攻,之後點擊分數即可直接加分。 - 第一分記錄後,右側 `設定隊伍` 會切成 `上一步`。 - 可交換上下隊伍位置,也可交換同隊左右球員位置。 - - `比賽結算` 為防誤觸設計,需長按 `1.5 秒` 才會觸發。 + - `比賽結算` 為防誤觸設計,需長按 `1 秒` 才會觸發。 + - 比分仍是 `0:0` 時,不會啟動比賽結算長按。 - 長按 `比賽結算` 時,按鈕下方會顯示進度條,按鈕本身也會有亮起與下壓的視覺回饋。 + - 手機長按 `比賽結算` 不會再觸發文字選取。 - 連勝會出現特效提示: - `3 連勝`:`大殺特殺` - `4 連勝`:`暴走` @@ -37,7 +39,9 @@ - PWA - 可加入手機主畫面,像 App 一樣開啟。 - 支援 `manifest`、`service worker`、主畫面 icon。 + - 網頁 favicon 與 PWA icon 已改用 `ICON.png` 產生的 PNG 圖示。 - 偵測到新版本時,畫面底部會顯示更新提示,可一鍵重新整理套用新版。 + - 前端會定期輪詢 `/api/version`,只要重新部署並重建 app container,就能偵測到新版本。 ## 執行環境 @@ -104,6 +108,8 @@ sudo docker compose up -d --build https://你的網域或 NAS IP:3501 ``` +每次執行 `sudo docker compose up -d --build` 重建 app container 時,後端都會帶新的容器啟動版號;前端輪詢發現版號不同後,就會跳出更新提示。 + ## SSL 憑證目錄 Docker Compose 會直接掛載 NAS 上的憑證目錄: diff --git a/index.html b/index.html index 328cda9..5dae752 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 865ef0d..429c146 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..690cc2a Binary files /dev/null and b/public/icon.png differ diff --git a/public/pwa-192.png b/public/pwa-192.png index c6f35d3..0d6a41d 100644 Binary files a/public/pwa-192.png and b/public/pwa-192.png differ diff --git a/public/pwa-512.png b/public/pwa-512.png index f5eec54..b47be7c 100644 Binary files a/public/pwa-512.png and b/public/pwa-512.png differ diff --git a/public/sw.js b/public/sw.js index 2703855..7eec742 100644 --- a/public/sw.js +++ b/public/sw.js @@ -3,7 +3,8 @@ const APP_SHELL = [ '/', '/index.html', '/manifest.webmanifest', - '/favicon.svg', + '/favicon.png', + '/icon.png', '/apple-touch-icon.png', '/pwa-192.png', '/pwa-512.png', diff --git a/server/server.mjs b/server/server.mjs index 5d220d9..6a0f161 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -9,6 +9,8 @@ const app = express() const port = Number(process.env.PORT ?? process.env.SERVER_PORT ?? 8788) const matchTableName = process.env.DB_TABLE ?? 'badminton' const historyTableName = process.env.DB_HISTORY_TABLE ?? 'history' +const appVersion = process.env.APP_VERSION ?? `${Date.now()}` +const appStartedAt = new Date().toISOString() const currentFilePath = fileURLToPath(import.meta.url) const currentDir = path.dirname(currentFilePath) @@ -37,6 +39,8 @@ app.use(express.json()) app.get('/api/health', (_request, response) => { response.json({ + appStartedAt, + appVersion, ok: true, dbReady: Boolean(pool), distReady, @@ -46,6 +50,21 @@ app.get('/api/health', (_request, response) => { }) }) +app.get('/api/version', (_request, response) => { + response.set({ + 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', + Expires: '0', + Pragma: 'no-cache', + 'Surrogate-Control': 'no-store', + }) + + response.json({ + ok: true, + startedAt: appStartedAt, + version: appVersion, + }) +}) + app.get('/api/match-results/:time', async (request, response) => { if (!pool) { response.status(500).json({ diff --git a/src/App.css b/src/App.css index e80b8bc..c5ce49f 100644 --- a/src/App.css +++ b/src/App.css @@ -948,6 +948,10 @@ border-radius: 999px; padding: 14px 14px; cursor: pointer; + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; + touch-action: manipulation; font: inherit; font-size: 1rem; color: #4a2e1d; @@ -964,6 +968,9 @@ .rail-pill-hold-wrap { display: grid; gap: 8px; + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; } .rail-pill-hold-wrap-active { diff --git a/src/App.tsx b/src/App.tsx index f2fa12d..5048c98 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { NavLink, Route, Routes, useLocation } from 'react-router-dom' import './App.css' import { loadMatchResults, saveMatchHistory } from './lib/api' @@ -80,6 +80,7 @@ const STREAK_TITLES: Record = { 8: '成為傳說', } const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready' +const APP_VERSION_POLL_MS = 30000 function App() { const location = useLocation() @@ -117,6 +118,7 @@ function App() { const [streakAnnouncement, setStreakAnnouncement] = useState(null) const [victoryAnnouncement, setVictoryAnnouncement] = useState(null) const [pwaUpdateReady, setPwaUpdateReady] = useState(false) + const currentAppVersionRef = useRef(null) const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput]) const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput]) @@ -188,6 +190,57 @@ function App() { } }, []) + useEffect(() => { + let active = true + + const checkAppVersion = async () => { + try { + const response = await fetch('/api/version', { + cache: 'no-store', + headers: { + 'cache-control': 'no-cache', + }, + }) + + if (!response.ok) { + return + } + + const payload = (await response.json()) as { + ok?: boolean + version?: string + } + const nextVersion = payload.version?.trim() + + if (!active || !nextVersion) { + return + } + + if (!currentAppVersionRef.current) { + currentAppVersionRef.current = nextVersion + return + } + + if (currentAppVersionRef.current !== nextVersion) { + currentAppVersionRef.current = nextVersion + setPwaUpdateReady(true) + } + } catch { + // Ignore transient version-check failures and retry on next poll. + } + } + + void checkAppVersion() + const timer = window.setInterval(() => { + void checkAppVersion() + }, APP_VERSION_POLL_MS) + + return () => { + active = false + window.clearInterval(timer) + } + }, []) + const resetScoring = (nextState: ScoreState = initialScoreState) => { setScoreState(nextState) setScoreHistory([]) diff --git a/src/pages/ScoreboardPage.tsx b/src/pages/ScoreboardPage.tsx index cb9c704..8124445 100644 --- a/src/pages/ScoreboardPage.tsx +++ b/src/pages/ScoreboardPage.tsx @@ -1,1197 +1,1226 @@ -import { useEffect, useMemo, useRef, useState } from 'react' import type { Dispatch, SetStateAction } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { - getCourtAssignments, - getReceivingPlayer, - getServiceCourt, - getServingPlayer, - getTeamDisplayName, + getCourtAssignments, + getReceivingPlayer, + getServiceCourt, + getServingPlayer, + getTeamDisplayName, } from '../lib/match' import type { - CourtSide, - GroupTeam, - PlayerSlot, - RoundGroup, - ScoreSide, - ScoreState, + CourtSide, + GroupTeam, + PlayerSlot, + RoundGroup, + ScoreSide, + ScoreState, } from '../types' type VoiceSettings = { - announceScore: boolean - announceServer: boolean - rate: number + announceScore: boolean + announceServer: boolean + rate: number } const VOICE_SETTINGS_STORAGE_KEY = 'badminton-scoreboard::voice-settings' const defaultVoiceSettings: VoiceSettings = { - announceScore: true, - announceServer: true, - rate: 1, + announceScore: true, + announceServer: true, + rate: 1, } const SPEECH_NAME_MAP: Record = { - ruru: '嚕嚕', + ruru: '嚕嚕', } type ScoreboardPageProps = { - currentSelectionOrder: string[] - finishDialogError: string - finishDialogOpen: boolean - finishDialogUploading: boolean - groupSource: 'idle' | 'db' | 'manual' - hasRecordedPoint: boolean - leftTeam: GroupTeam | null - rightTeam: GroupTeam | null - scoreState: ScoreState - selectedGroup: RoundGroup | null - streakAnnouncement: { - count: number - key: number - teamName: string - title: string - } | null - victoryAnnouncement: { - key: number - scoreLabel: string - teamName: string - title: string - } | null - targetDate: string - onApplyMatchup: ( - leftTeam: GroupTeam, - rightTeam: GroupTeam, - targetScore: number, - ) => void - onCloseFinishDialog: () => void - onConfirmUpload: () => void - onOpenFinishDialog: () => void - onRecordPoint: (side: ScoreSide) => void - onSetServing: (side: ScoreSide) => void - onSkipUpload: () => void - onSwapMatchup: () => void - onSwapTeamPlayers: (side: ScoreSide) => void - onUndoLastPoint: () => void + currentSelectionOrder: string[] + finishDialogError: string + finishDialogOpen: boolean + finishDialogUploading: boolean + groupSource: 'idle' | 'db' | 'manual' + hasRecordedPoint: boolean + leftTeam: GroupTeam | null + rightTeam: GroupTeam | null + scoreState: ScoreState + selectedGroup: RoundGroup | null + streakAnnouncement: { + count: number + key: number + teamName: string + title: string + } | null + victoryAnnouncement: { + key: number + scoreLabel: string + teamName: string + title: string + } | null + targetDate: string + onApplyMatchup: ( + leftTeam: GroupTeam, + rightTeam: GroupTeam, + targetScore: number, + ) => void + 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({ - currentSelectionOrder, - finishDialogError, - finishDialogOpen, - finishDialogUploading, - groupSource, - hasRecordedPoint, - leftTeam, - rightTeam, - scoreState, - selectedGroup, - streakAnnouncement, - victoryAnnouncement, - targetDate, - onApplyMatchup, - onCloseFinishDialog, - onConfirmUpload, - onOpenFinishDialog, - onRecordPoint, - onSetServing, - onSkipUpload, - onSwapMatchup, - onSwapTeamPlayers, - onUndoLastPoint, + currentSelectionOrder, + finishDialogError, + finishDialogOpen, + finishDialogUploading, + groupSource, + hasRecordedPoint, + leftTeam, + rightTeam, + scoreState, + selectedGroup, + streakAnnouncement, + victoryAnnouncement, + targetDate, + onApplyMatchup, + onCloseFinishDialog, + onConfirmUpload, + onOpenFinishDialog, + onRecordPoint, + onSetServing, + onSkipUpload, + onSwapMatchup, + onSwapTeamPlayers, + onUndoLastPoint, }: ScoreboardPageProps) { - const FINISH_HOLD_DURATION = 1500 - const [pickerOpen, setPickerOpen] = useState(false) - const [settingsOpen, setSettingsOpen] = useState(false) - const [draftPlayers, setDraftPlayers] = useState([]) - const [draftTargetScore, setDraftTargetScore] = useState(() => - String(scoreState.targetScore), - ) - const [clock, setClock] = useState(() => formatClock()) - const [finishHoldActive, setFinishHoldActive] = useState(false) - const [finishHoldProgress, setFinishHoldProgress] = useState(0) - const [voiceSettings, setVoiceSettings] = useState(() => - loadVoiceSettings(), - ) - const finishHoldFrameRef = useRef(null) - const finishHoldStartRef = useRef(0) - const finishTriggeredRef = useRef(false) - const lastAnnouncedPointRef = useRef(0) - const previousScoresRef = useRef({ left: 0, right: 0 }) + const FINISH_HOLD_DURATION = 1000 + const [pickerOpen, setPickerOpen] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) + const [draftPlayers, setDraftPlayers] = useState([]) + const [draftTargetScore, setDraftTargetScore] = useState(() => + String(scoreState.targetScore), + ) + const [clock, setClock] = useState(() => formatClock()) + const [finishHoldActive, setFinishHoldActive] = useState(false) + const [finishHoldProgress, setFinishHoldProgress] = useState(0) + const [voiceSettings, setVoiceSettings] = useState(() => + loadVoiceSettings(), + ) + const finishHoldFrameRef = useRef(null) + const finishHoldTimerRef = useRef(null) + const finishHoldStartRef = useRef(0) + const finishTriggeredRef = useRef(false) + const lastAnnouncedPointRef = useRef(0) + const previousScoresRef = useRef({ left: 0, right: 0 }) - useEffect(() => { - const timer = window.setInterval(() => { - setClock(formatClock()) - }, 1000) + useEffect(() => { + const timer = window.setInterval(() => { + setClock(formatClock()) + }, 1000) - return () => window.clearInterval(timer) - }, []) + return () => window.clearInterval(timer) + }, []) - useEffect(() => { - window.localStorage.setItem( - VOICE_SETTINGS_STORAGE_KEY, - JSON.stringify(voiceSettings), - ) - }, [voiceSettings]) + useEffect(() => { + window.localStorage.setItem( + VOICE_SETTINGS_STORAGE_KEY, + JSON.stringify(voiceSettings), + ) + }, [voiceSettings]) - useEffect(() => { - return () => { - if (finishHoldFrameRef.current !== null) { - window.cancelAnimationFrame(finishHoldFrameRef.current) - } + useEffect(() => { + return () => { + if (finishHoldFrameRef.current !== null) { + window.cancelAnimationFrame(finishHoldFrameRef.current) + } - if ('speechSynthesis' in window) { - window.speechSynthesis.cancel() - } - } - }, []) + if (finishHoldTimerRef.current !== null) { + window.clearTimeout(finishHoldTimerRef.current) + } - const selectablePlayers = useMemo(() => { - if (!selectedGroup) { - return [] - } + if ('speechSynthesis' in window) { + window.speechSynthesis.cancel() + } + } + }, []) - const seen = new Set() - const players: string[] = [] + const selectablePlayers = useMemo(() => { + if (!selectedGroup) { + return [] + } - selectedGroup.teams.forEach((team) => { - if (!team.isPlaceholderA && !seen.has(team.playerA)) { - seen.add(team.playerA) - players.push(team.playerA) - } + const seen = new Set() + const players: string[] = [] - if (!team.isPlaceholderB && !seen.has(team.playerB)) { - seen.add(team.playerB) - players.push(team.playerB) - } - }) + selectedGroup.teams.forEach((team) => { + if (!team.isPlaceholderA && !seen.has(team.playerA)) { + seen.add(team.playerA) + players.push(team.playerA) + } - return players - }, [selectedGroup]) + if (!team.isPlaceholderB && !seen.has(team.playerB)) { + seen.add(team.playerB) + players.push(team.playerB) + } + }) - const presetTeams = useMemo( - () => - selectedGroup?.teams.filter((team) => !team.isPlaceholderA && !team.isPlaceholderB) ?? [], - [selectedGroup], - ) + return players + }, [selectedGroup]) - const canArrangeMatch = !hasRecordedPoint - const canScore = scoreState.serving !== null + const presetTeams = useMemo( + () => + selectedGroup?.teams.filter((team) => !team.isPlaceholderA && !team.isPlaceholderB) ?? [], + [selectedGroup], + ) - const servingScore = - scoreState.serving === 'left' ? scoreState.scoreLeft : scoreState.scoreRight - const servingCourt = - scoreState.serving === null ? null : getServiceCourt(servingScore) + const canArrangeMatch = !hasRecordedPoint + const canScore = scoreState.serving !== null + const canFinishMatch = scoreState.scoreLeft > 0 || scoreState.scoreRight > 0 - const leftAssignments = useMemo( - () => - leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer) : [], - [leftTeam, scoreState.leftRightCourtPlayer], - ) - const rightAssignments = useMemo( - () => - rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer) : [], - [rightTeam, scoreState.rightRightCourtPlayer], - ) + const servingScore = + scoreState.serving === 'left' ? scoreState.scoreLeft : scoreState.scoreRight + const servingCourt = + scoreState.serving === null ? null : getServiceCourt(servingScore) - 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 leftAssignments = useMemo( + () => + leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer) : [], + [leftTeam, scoreState.leftRightCourtPlayer], + ) + const rightAssignments = useMemo( + () => + rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer) : [], + [rightTeam, scoreState.rightRightCourtPlayer], + ) - 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 + 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 - useEffect(() => { - const totalPoints = scoreState.scoreLeft + scoreState.scoreRight + 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 - if (scoreState.serving === null || !currentServer?.name || totalPoints === 0) { - lastAnnouncedPointRef.current = totalPoints - previousScoresRef.current = { - left: scoreState.scoreLeft, - right: scoreState.scoreRight, - } - return - } + useEffect(() => { + const totalPoints = scoreState.scoreLeft + scoreState.scoreRight - if (lastAnnouncedPointRef.current === totalPoints) { - return - } + if (scoreState.serving === null || !currentServer?.name || totalPoints === 0) { + lastAnnouncedPointRef.current = totalPoints + previousScoresRef.current = { + left: scoreState.scoreLeft, + right: scoreState.scoreRight, + } + return + } - lastAnnouncedPointRef.current = totalPoints + if (lastAnnouncedPointRef.current === totalPoints) { + return + } - const scorerSide = - scoreState.scoreLeft > previousScoresRef.current.left - ? 'left' - : scoreState.scoreRight > previousScoresRef.current.right - ? 'right' - : null + lastAnnouncedPointRef.current = totalPoints - previousScoresRef.current = { - left: scoreState.scoreLeft, - right: scoreState.scoreRight, - } + const scorerSide = + scoreState.scoreLeft > previousScoresRef.current.left + ? 'left' + : scoreState.scoreRight > previousScoresRef.current.right + ? 'right' + : null - const parts: string[] = [] + previousScoresRef.current = { + left: scoreState.scoreLeft, + right: scoreState.scoreRight, + } - if (voiceSettings.announceScore && scorerSide) { - parts.push( - `${getAnnouncementName(scorerSide === 'left' ? leftTeam : rightTeam)}得分`, - ) - } + const parts: string[] = [] - if (voiceSettings.announceServer) { - parts.push(`${getSpeechName(currentServer.name)}發球`) - } + if (voiceSettings.announceScore && scorerSide) { + parts.push( + `${getAnnouncementName(scorerSide === 'left' ? leftTeam : rightTeam)}得分`, + ) + } - 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, - ]) + if (voiceSettings.announceServer) { + parts.push(`${getSpeechName(currentServer.name)}發球`) + } - if (!selectedGroup) { - return ( -
-
-

Step 3

-

請先回到選隊伍頁面

-

- 目前還沒有帶入要上場的組別,先回去選擇分組,再進入記分板。 -

- - 回到選隊伍 - -
-
- ) - } + 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, + ]) - const matchupLabel = - leftTeam && rightTeam - ? `${getTeamDisplayName(leftTeam)} vs ${getTeamDisplayName(rightTeam)}` - : '尚未設定對戰隊伍' + if (!selectedGroup) { + return ( +
+
+

Step 3

+

請先回到選隊伍頁面

+

+ 目前還沒有帶入要上場的組別,先回去選擇分組,再進入記分板。 +

+ + 回到選隊伍 + +
+
+ ) + } - const openPicker = () => { - setDraftPlayers(currentSelectionOrder.filter((player) => selectablePlayers.includes(player))) - setDraftTargetScore(String(scoreState.targetScore)) - setPickerOpen(true) - } + const matchupLabel = + leftTeam && rightTeam + ? `${getTeamDisplayName(leftTeam)} vs ${getTeamDisplayName(rightTeam)}` + : '尚未設定對戰隊伍' - const stopFinishHold = () => { - if (finishHoldFrameRef.current !== null) { - window.cancelAnimationFrame(finishHoldFrameRef.current) - finishHoldFrameRef.current = null - } + const openPicker = () => { + setDraftPlayers(currentSelectionOrder.filter((player) => selectablePlayers.includes(player))) + setDraftTargetScore(String(scoreState.targetScore)) + setPickerOpen(true) + } - finishHoldStartRef.current = 0 - finishTriggeredRef.current = false - setFinishHoldActive(false) - setFinishHoldProgress(0) - } + const stopFinishHold = () => { + if (finishHoldFrameRef.current !== null) { + window.cancelAnimationFrame(finishHoldFrameRef.current) + finishHoldFrameRef.current = null + } - const startFinishHold = () => { - if (finishDialogOpen || finishDialogUploading || finishHoldActive) { - return - } + if (finishHoldTimerRef.current !== null) { + window.clearTimeout(finishHoldTimerRef.current) + finishHoldTimerRef.current = null + } - finishTriggeredRef.current = false - finishHoldStartRef.current = performance.now() - setFinishHoldActive(true) - setFinishHoldProgress(0) + finishHoldStartRef.current = 0 + finishTriggeredRef.current = false + setFinishHoldActive(false) + setFinishHoldProgress(0) + } - const tick = (now: number) => { - const elapsed = now - finishHoldStartRef.current - const progress = Math.min(elapsed / FINISH_HOLD_DURATION, 1) - setFinishHoldProgress(progress) + const startFinishHold = () => { + if ( + !canFinishMatch || + finishDialogOpen || + finishDialogUploading || + finishHoldActive + ) { + return + } - if (progress >= 1) { - finishTriggeredRef.current = true - setFinishHoldActive(false) - setFinishHoldProgress(0) - finishHoldFrameRef.current = null - onOpenFinishDialog() - return - } + finishTriggeredRef.current = false + finishHoldStartRef.current = performance.now() + setFinishHoldActive(true) + setFinishHoldProgress(0) - finishHoldFrameRef.current = window.requestAnimationFrame(tick) - } + finishHoldTimerRef.current = window.setTimeout(() => { + finishTriggeredRef.current = true + setFinishHoldActive(false) + setFinishHoldProgress(0) + finishHoldTimerRef.current = null - finishHoldFrameRef.current = window.requestAnimationFrame(tick) - } + if (finishHoldFrameRef.current !== null) { + window.cancelAnimationFrame(finishHoldFrameRef.current) + finishHoldFrameRef.current = null + } - const cancelFinishHold = () => { - if (finishTriggeredRef.current) { - finishTriggeredRef.current = false - return - } + onOpenFinishDialog() + }, FINISH_HOLD_DURATION) - stopFinishHold() - } + const tick = (now: number) => { + const elapsed = now - finishHoldStartRef.current + const progress = Math.min(elapsed / FINISH_HOLD_DURATION, 1) + setFinishHoldProgress(progress) - const toggleDraftPlayer = (playerName: string) => { - setDraftPlayers((current) => { - if (current.includes(playerName)) { - return current.filter((value) => value !== playerName) - } + if (!finishHoldStartRef.current || finishTriggeredRef.current) { + finishHoldFrameRef.current = null + return + } - if (current.length >= 4) { - return current - } + finishHoldFrameRef.current = window.requestAnimationFrame(tick) + } - return [...current, playerName] - }) - } + finishHoldFrameRef.current = window.requestAnimationFrame(tick) + } - const togglePresetTeam = (team: GroupTeam) => { - setDraftPlayers((current) => { - const removed = removePresetTeamFromDraft(current, team) + const cancelFinishHold = () => { + if (finishTriggeredRef.current) { + finishTriggeredRef.current = false + return + } - if (removed.length !== current.length) { - return removed - } + stopFinishHold() + } - if (current.length >= 4 || current.length % 2 !== 0) { - return current - } + const toggleDraftPlayer = (playerName: string) => { + setDraftPlayers((current) => { + if (current.includes(playerName)) { + return current.filter((value) => value !== playerName) + } - if (current.includes(team.playerA) || current.includes(team.playerB)) { - return current - } + if (current.length >= 4) { + return current + } - return [...current, team.playerA, team.playerB] - }) - } + return [...current, playerName] + }) + } - const confirmDraftTeams = () => { - if (draftPlayers.length !== 4) { - return - } + const togglePresetTeam = (team: GroupTeam) => { + setDraftPlayers((current) => { + const removed = removePresetTeamFromDraft(current, team) - 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), - ) - setPickerOpen(false) - } + if (removed.length !== current.length) { + return removed + } - const autoPickDraftPlayers = () => { - const shuffled = [...selectablePlayers] + if (current.length >= 4 || current.length % 2 !== 0) { + return current + } - 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]] - } + if (current.includes(team.playerA) || current.includes(team.playerB)) { + return current + } - setDraftPlayers(shuffled.slice(0, 4)) - } + return [...current, team.playerA, team.playerB] + }) + } - return ( - <> - {streakAnnouncement ? ( -
- {streakAnnouncement.count} 連勝 - {streakAnnouncement.title} - {streakAnnouncement.teamName} -
- ) : null} + const confirmDraftTeams = () => { + if (draftPlayers.length !== 4) { + return + } - {victoryAnnouncement ? ( -
- 目標分數達成 - {victoryAnnouncement.title} - {victoryAnnouncement.teamName} - {victoryAnnouncement.scoreLabel} -
- ) : null} + 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), + ) + setPickerOpen(false) + } -
-
- onRecordPoint('left')} - onSetServing={() => onSetServing('left')} - onSwapPlayers={() => onSwapTeamPlayers('left')} - onSwapTeams={onSwapMatchup} - score={scoreState.scoreLeft} - serviceCourt={scoreState.serving === 'left' ? servingCourt : null} - showServingPrompt={scoreState.serving === null} - team={leftTeam} - teamSlot="top" - /> + const autoPickDraftPlayers = () => { + const shuffled = [...selectablePlayers] -
-

{scoreState.serving === null ? '請先設定發球方' : '比賽進行中'}

- - {scoreState.serving === null - ? `本場 ${scoreState.targetScore} 分獲勝` - : `發球:${currentServer?.name ?? '-'}${ - currentReceiver ? ` / 接發:${currentReceiver.name}` : '' - } / 目標 ${scoreState.targetScore} 分`} - -
+ 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]] + } - onRecordPoint('right')} - onSetServing={() => onSetServing('right')} - onSwapPlayers={() => onSwapTeamPlayers('right')} - onSwapTeams={onSwapMatchup} - score={scoreState.scoreRight} - serviceCourt={scoreState.serving === 'right' ? servingCourt : null} - showServingPrompt={scoreState.serving === null} - team={rightTeam} - teamSlot="bottom" - /> -
+ setDraftPlayers(shuffled.slice(0, 4)) + } - -
+
+

{scoreState.serving === null ? '請先設定發球方' : '比賽進行中'}

+ + {scoreState.serving === null + ? `本場 ${scoreState.targetScore} 分獲勝` + : `發球:${currentServer?.name ?? '-'}${currentReceiver ? ` / 接發:${currentReceiver.name}` : '' + } / 目標 ${scoreState.targetScore} 分`} + +
- {pickerOpen ? ( - setDraftPlayers([])} - onClose={() => setPickerOpen(false)} - onConfirm={confirmDraftTeams} - onDraftTargetScoreChange={setDraftTargetScore} - onTogglePlayer={toggleDraftPlayer} - onTogglePresetTeam={togglePresetTeam} - /> - ) : null} + onRecordPoint('right')} + onSetServing={() => onSetServing('right')} + onSwapPlayers={() => onSwapTeamPlayers('right')} + onSwapTeams={onSwapMatchup} + score={scoreState.scoreRight} + serviceCourt={scoreState.serving === 'right' ? servingCourt : null} + showServingPrompt={scoreState.serving === null} + team={rightTeam} + teamSlot="bottom" + /> + - {settingsOpen ? ( - setSettingsOpen(false)} - onUpdateSettings={setVoiceSettings} - /> - ) : null} + + + + {pickerOpen ? ( + setDraftPlayers([])} + onClose={() => setPickerOpen(false)} + onConfirm={confirmDraftTeams} + onDraftTargetScoreChange={setDraftTargetScore} + onTogglePlayer={toggleDraftPlayer} + onTogglePresetTeam={togglePresetTeam} + /> + ) : null} + + {settingsOpen ? ( + setSettingsOpen(false)} + onUpdateSettings={setVoiceSettings} + /> + ) : null} + + {finishDialogOpen ? ( + + ) : 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 - showServingPrompt: boolean - team: GroupTeam | null - teamSlot: 'top' | 'bottom' + 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 + showServingPrompt: boolean + team: GroupTeam | null + teamSlot: 'top' | 'bottom' } function ScoreboardTeamPanel({ - assignments, - canArrangeMatch, - canScore, - currentReceiver, - currentServer, - onRecordPoint, - onSetServing, - onSwapPlayers, - onSwapTeams, - score, - serviceCourt, - showServingPrompt, - team, - teamSlot, + assignments, + canArrangeMatch, + canScore, + currentReceiver, + currentServer, + onRecordPoint, + onSetServing, + onSwapPlayers, + onSwapTeams, + score, + serviceCourt, + showServingPrompt, + team, + teamSlot, }: ScoreboardTeamPanelProps) { - const orderedAssignments = [...assignments].sort((left, right) => { - if (left.court === right.court) { - return 0 - } + const orderedAssignments = [...assignments].sort((left, right) => { + if (left.court === right.court) { + return 0 + } - return left.court === 'left' ? -1 : 1 - }) + return left.court === 'left' ? -1 : 1 + }) - const header = ( -
-
- {orderedAssignments.map((assignment) => ( -
- {getPlayerNumber(teamSlot, assignment.slot)} - {assignment.name} -
- ))} -
+ const header = ( +
+
+ {orderedAssignments.map((assignment) => ( +
+ {getPlayerNumber(teamSlot, assignment.slot)} + {assignment.name} +
+ ))} +
-
- - -
-
- ) +
+ + +
+
+ ) - const serveBar = ( - - ) + const serveBar = ( + + ) - const scoreBoard = ( - - ) + const scoreBoard = ( + + ) - return ( -
- {teamSlot === 'top' ? ( - <> - {header} - {serveBar} - {scoreBoard} - - ) : ( - <> - {scoreBoard} - {serveBar} - {header} - - )} -
- ) + return ( +
+ {teamSlot === 'top' ? ( + <> + {header} + {serveBar} + {scoreBoard} + + ) : ( + <> + {scoreBoard} + {serveBar} + {header} + + )} +
+ ) } type TeamPickerModalProps = { - draftPlayers: string[] - draftTargetScore: string - group: RoundGroup - presetTeams: GroupTeam[] - selectablePlayers: string[] - selectionCount: number - sourceLabel: string - targetDate: string - onAutoPick: () => void - onClear: () => void - onClose: () => void - onConfirm: () => void - onDraftTargetScoreChange: (value: string) => void - onTogglePlayer: (playerName: string) => void - onTogglePresetTeam: (team: GroupTeam) => void + draftPlayers: string[] + draftTargetScore: string + group: RoundGroup + presetTeams: GroupTeam[] + selectablePlayers: string[] + selectionCount: number + sourceLabel: string + targetDate: string + onAutoPick: () => void + onClear: () => void + onClose: () => void + onConfirm: () => void + onDraftTargetScoreChange: (value: string) => void + onTogglePlayer: (playerName: string) => void + onTogglePresetTeam: (team: GroupTeam) => void } function TeamPickerModal({ - draftPlayers, - draftTargetScore, - group, - presetTeams, - selectablePlayers, - selectionCount, - sourceLabel, - targetDate, - onAutoPick, - onClear, - onClose, - onConfirm, - onDraftTargetScoreChange, - onTogglePlayer, - onTogglePresetTeam, + draftPlayers, + draftTargetScore, + group, + presetTeams, + selectablePlayers, + selectionCount, + sourceLabel, + targetDate, + onAutoPick, + onClear, + onClose, + onConfirm, + onDraftTargetScoreChange, + onTogglePlayer, + onTogglePresetTeam, }: TeamPickerModalProps) { - return ( -
-
event.stopPropagation()} - role="dialog" - > - + return ( +
+
event.stopPropagation()} + role="dialog" + > + -
- {selectionCount >= 4 ? '已完成選擇' : '請選滿 4 位球員'} -
+
+ {selectionCount >= 4 ? '已完成選擇' : '請選滿 4 位球員'} +
-
-
-
- {selectionCount}/4 -
- 依序選擇球員 -

- 第 {group.id} 組 / {sourceLabel} / {targetDate || '-'} -

-
-
+
+
+
+ {selectionCount}/4 +
+ 依序選擇球員 +

+ 第 {group.id} 組 / {sourceLabel} / {targetDate || '-'} +

+
+
-
- -
+
+ +
-
- {selectablePlayers.map((playerName) => { - const checked = draftPlayers.includes(playerName) - const selectedOrder = checked ? draftPlayers.indexOf(playerName) + 1 : null +
+ {selectablePlayers.map((playerName) => { + const checked = draftPlayers.includes(playerName) + const selectedOrder = checked ? draftPlayers.indexOf(playerName) + 1 : null - return ( - - ) - })} -
+ return ( + + ) + })} +
-
- - -
-
+
+ + +
+
- -
-
-
- ) +

+ 第 1、2 位會自動成為上方隊伍,第 3、4 位會成為下方隊伍。 +

+ +
+
+ + ) } type VoiceSettingsModalProps = { - settings: VoiceSettings - onClose: () => void - onUpdateSettings: Dispatch> + settings: VoiceSettings + onClose: () => void + onUpdateSettings: Dispatch> } function VoiceSettingsModal({ - settings, - onClose, - onUpdateSettings, + settings, + onClose, + onUpdateSettings, }: VoiceSettingsModalProps) { - return ( -
-
event.stopPropagation()} - > - + return ( +
+
event.stopPropagation()} + > + -

語音設定

-

播報內容

+

語音設定

+

播報內容

- + - + - -
-
- ) + +
+
+ ) } type FinishDialogProps = { - error: string - leftScore: number - leftTeamName: string - matchupLabel: string - rightScore: number - rightTeamName: string - uploading: boolean - onClose: () => void - onConfirm: () => void - onSkip: () => void + 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, + error, + leftScore, + leftTeamName, + matchupLabel, + rightScore, + rightTeamName, + uploading, + onClose, + onConfirm, + onSkip, }: FinishDialogProps) { - return ( -
-
- + return ( +
+
+ -

比賽結算

-

{matchupLabel}

+

比賽結算

+

{matchupLabel}

-
-
- {leftScore} - {leftTeamName} -
-
:
-
- {rightScore} - {rightTeamName} -
-
+
+
+ {leftScore} + {leftTeamName} +
+
:
+
+ {rightScore} + {rightTeamName} +
+
-

要不要把這場比賽戰績上傳到資料庫?

+

要不要把這場比賽戰績上傳到資料庫?

- {error ?

{error}

: null} + {error ?

{error}

: null} -
- - -
-
-
- ) +
+ + +
+
+
+ ) } function getPlayerNumber(teamSlot: 'top' | 'bottom', slot: PlayerSlot) { - if (teamSlot === 'top') { - return slot === 'playerA' ? 1 : 2 - } + if (teamSlot === 'top') { + return slot === 'playerA' ? 1 : 2 + } - return slot === 'playerA' ? 4 : 3 + return slot === 'playerA' ? 4 : 3 } function sanitizeTargetScore(value: string) { - const numeric = Number.parseInt(value.replace(/[^\d]/g, ''), 10) + const numeric = Number.parseInt(value.replace(/[^\d]/g, ''), 10) - if (Number.isNaN(numeric)) { - return 21 - } + if (Number.isNaN(numeric)) { + return 21 + } - return Math.min(99, Math.max(1, numeric)) + return Math.min(99, Math.max(1, numeric)) } 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 + 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 (firstPairSelected) { + return players.slice(2) + } - if (secondPairSelected) { - return players.slice(0, 2) - } + if (secondPairSelected) { + return players.slice(0, 2) + } - return players + return players } function getPresetTeamSelectionSlot(players: string[], team: GroupTeam) { - if (players[0] === team.playerA && players[1] === team.playerB) { - return 0 - } + if (players[0] === team.playerA && players[1] === team.playerB) { + return 0 + } - if (players[2] === team.playerA && players[3] === team.playerB) { - return 1 - } + if (players[2] === team.playerA && players[3] === team.playerB) { + return 1 + } - return null + return null } function formatClock() { - return new Date().toLocaleTimeString('zh-TW', { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }) + return new Date().toLocaleTimeString('zh-TW', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) } function loadVoiceSettings(): VoiceSettings { - try { - const raw = window.localStorage.getItem(VOICE_SETTINGS_STORAGE_KEY) + try { + const raw = window.localStorage.getItem(VOICE_SETTINGS_STORAGE_KEY) - if (!raw) { - return defaultVoiceSettings - } + if (!raw) { + return defaultVoiceSettings + } - const parsed = JSON.parse(raw) as Partial + const parsed = JSON.parse(raw) as Partial - 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 - } + 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 getSpeechName(team?.playerA ?? '本隊') + return getSpeechName(team?.playerA ?? '本隊') } function getSpeechName(name: string) { - return SPEECH_NAME_MAP[name.trim().toLowerCase()] ?? name + return SPEECH_NAME_MAP[name.trim().toLowerCase()] ?? name } function speakAnnouncement(message: string, rate: number) { - if (!('speechSynthesis' in window)) { - return - } + 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')) + 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 + utterance.lang = zhVoice?.lang ?? 'zh-TW' + utterance.rate = rate + utterance.pitch = 1 + utterance.volume = 1 - if (zhVoice) { - utterance.voice = zhVoice - } + if (zhVoice) { + utterance.voice = zhVoice + } - synthesis.cancel() - synthesis.speak(utterance) + synthesis.cancel() + synthesis.speak(utterance) }