新增即時觀戰房間並整理 README

This commit is contained in:
2026-04-19 12:46:59 +08:00
parent c097ceb9ad
commit 896c24547b
10 changed files with 1283 additions and 61 deletions

View File

@@ -1,7 +1,13 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { NavLink, Route, Routes, useLocation } from 'react-router-dom'
import { NavLink, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import './App.css'
import { loadMatchResults, saveMatchHistory } from './lib/api'
import {
createLiveRoom,
loadMatchResults,
releaseLiveRoom,
saveMatchHistory,
updateLiveRoom,
} from './lib/api'
import {
buildManualGroups,
convertDateToKey,
@@ -14,12 +20,15 @@ import {
swapCourtPositions,
} from './lib/match'
import { HistoryPage } from './pages/HistoryPage'
import { RoomListPage } from './pages/RoomListPage'
import { RoomSpectatorPage } from './pages/RoomSpectatorPage'
import { ScoreboardPage } from './pages/ScoreboardPage'
import { TeamSelectionPage } from './pages/TeamSelectionPage'
import type {
ActiveMatchup,
GroupTeam,
HistoryUploadPayload,
LiveRoomSession,
LoadStatus,
MatchHistoryItem,
PointHistoryEntry,
@@ -84,6 +93,7 @@ const APP_VERSION_POLL_MS = 30000
function App() {
const location = useLocation()
const navigate = useNavigate()
const isScoreboardRoute = location.pathname === '/scoreboard'
const [targetDate, setTargetDate] = useState(() =>
@@ -118,13 +128,17 @@ function App() {
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
const currentAppVersionRef = useRef<string | null>(null)
const creatingRoomRef = useRef(false)
const lastSyncedRoomSignatureRef = useRef('')
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null
const leftTeam = activeMatchup.leftTeam
const rightTeam = activeMatchup.rightTeam
const liveRoomId = liveRoomSession?.roomId ?? null
useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate)
@@ -242,6 +256,10 @@ function App() {
}, [])
const resetScoring = (nextState: ScoreState = initialScoreState) => {
if (liveRoomSession?.status === 'live') {
void releaseLiveRoom(liveRoomSession.roomId, liveRoomSession.hostToken).catch(() => {})
}
setScoreState(nextState)
setScoreHistory([])
setPointLog([])
@@ -252,6 +270,9 @@ function App() {
open: false,
uploading: false,
})
creatingRoomRef.current = false
setLiveRoomSession(null)
lastSyncedRoomSignatureRef.current = ''
}
const selectGroup = (groupId: number, nextGroups = groups) => {
@@ -297,6 +318,173 @@ function App() {
})
}
useEffect(() => {
if (
!isScoreboardRoute ||
!leftTeam ||
!rightTeam ||
liveRoomSession ||
creatingRoomRef.current
) {
return
}
let cancelled = false
const createRoom = async () => {
try {
creatingRoomRef.current = true
const session = await createLiveRoom(
buildLiveRoomPayload({
groupId: selectedGroup?.id ?? null,
leftTeam,
pointLog,
rightTeam,
scoreState,
targetDate,
}),
)
if (!cancelled) {
setLiveRoomSession(session)
}
} catch (error) {
console.error('create live room error:', error)
} finally {
creatingRoomRef.current = false
}
}
void createRoom()
return () => {
cancelled = true
}
}, [
leftTeam,
liveRoomSession,
pointLog,
rightTeam,
scoreState,
selectedGroup?.id,
targetDate,
isScoreboardRoute,
])
useEffect(() => {
if (!isScoreboardRoute || !liveRoomSession || !leftTeam || !rightTeam) {
return
}
const winnerTeamName =
scoreState.scoreLeft >= scoreState.targetScore
? getTeamDisplayName(leftTeam)
: scoreState.scoreRight >= scoreState.targetScore
? getTeamDisplayName(rightTeam)
: null
const nextStatus = winnerTeamName ? 'finished' : 'live'
const payload = buildLiveRoomPayload({
groupId: selectedGroup?.id ?? null,
leftTeam,
pointLog,
rightTeam,
scoreState,
targetDate,
})
const signature = JSON.stringify({
payload,
roomId: liveRoomSession.roomId,
status: nextStatus,
winnerTeamName,
})
if (signature === lastSyncedRoomSignatureRef.current) {
return
}
lastSyncedRoomSignatureRef.current = signature
void updateLiveRoom(liveRoomSession.roomId, {
...payload,
hostToken: liveRoomSession.hostToken,
status: nextStatus,
winnerTeamName,
})
.then((room) => {
setLiveRoomSession((current) =>
current
? {
...current,
status: room.status,
}
: current,
)
})
.catch((error) => {
console.error('update live room error:', error)
})
}, [
leftTeam,
liveRoomSession,
pointLog,
rightTeam,
scoreState,
selectedGroup?.id,
targetDate,
isScoreboardRoute,
])
useEffect(() => {
if (!liveRoomSession || liveRoomSession.status !== 'live') {
return
}
const { hostToken, roomId } = liveRoomSession
let released = false
const release = () => {
if (released) {
return
}
released = true
void releaseLiveRoom(roomId, hostToken).catch(() => {})
}
const handleBeforeUnload = () => {
if (released) {
return
}
released = true
if (navigator.sendBeacon) {
const payload = new Blob([JSON.stringify({ hostToken })], {
type: 'application/json',
})
navigator.sendBeacon(`/api/rooms/${roomId}/release`, payload)
return
}
void fetch(`/api/rooms/${roomId}/release`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ hostToken }),
keepalive: true,
}).catch(() => {})
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
if (!isScoreboardRoute) {
release()
}
}
}, [isScoreboardRoute, liveRoomSession])
const loadGroupsFromDb = async () => {
if (!targetDate) {
setLoadStatus('error')
@@ -607,6 +795,9 @@ function App() {
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/history">
</NavLink>
<NavLink className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} to="/rooms">
</NavLink>
</nav>
</header>
@@ -662,6 +853,7 @@ function App() {
groupSource={groupSource}
hasRecordedPoint={pointLog.length > 0}
leftTeam={leftTeam}
liveRoomId={liveRoomId}
rightTeam={rightTeam}
scoreState={scoreState}
selectedGroup={selectedGroup}
@@ -682,6 +874,11 @@ function App() {
}
/>
<Route path="/history" element={<HistoryPage />} />
<Route path="/rooms" element={<RoomListPage />} />
<Route
path="/rooms/:roomId"
element={<RoomSpectatorPage onConfirmFinished={() => navigate('/rooms')} />}
/>
</Routes>
{pwaUpdateReady ? (
@@ -802,4 +999,30 @@ function loadStoredHistory(storageKey: string) {
}
}
function buildLiveRoomPayload({
groupId,
leftTeam,
pointLog,
rightTeam,
scoreState,
targetDate,
}: {
groupId: number | null
leftTeam: GroupTeam
pointLog: PointHistoryEntry[]
rightTeam: GroupTeam
scoreState: ScoreState
targetDate: string
}) {
return {
groupId,
leftTeamName: getTeamDisplayName(leftTeam),
matchupLabel: `${getTeamDisplayName(leftTeam)} vs ${getTeamDisplayName(rightTeam)}`,
pointLog,
rightTeamName: getTeamDisplayName(rightTeam),
scoreState,
targetDate,
}
}
export default App