新增即時觀戰房間並整理 README
This commit is contained in:
227
src/App.tsx
227
src/App.tsx
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user