新增即時觀戰房間並整理 README
This commit is contained in:
+126
@@ -943,6 +943,15 @@
|
||||
linear-gradient(145deg, rgba(8, 47, 73, 0.97), rgba(10, 96, 84, 0.93));
|
||||
}
|
||||
|
||||
.rail-room-id {
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
text-align: center;
|
||||
color: #f7fff8;
|
||||
background: rgba(8, 47, 73, 0.72);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.rail-pill {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
@@ -1964,6 +1973,11 @@
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.rail-room-id {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.rail-pill {
|
||||
padding: 10px 8px;
|
||||
font-size: 0.92rem;
|
||||
@@ -2030,6 +2044,16 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.room-card {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.room-card-score strong,
|
||||
.room-watch-team strong {
|
||||
font-size: clamp(1.7rem, 12vw, 2.6rem);
|
||||
}
|
||||
|
||||
.team-picker-ribbon {
|
||||
left: 18px;
|
||||
right: 90px;
|
||||
@@ -2146,3 +2170,105 @@
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
}
|
||||
|
||||
.room-list-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.room-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
box-shadow: 0 18px 28px rgba(8, 47, 73, 0.08);
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.room-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 22px 34px rgba(8, 47, 73, 0.12);
|
||||
}
|
||||
|
||||
.room-card-head,
|
||||
.room-watch-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.room-card-head span,
|
||||
.room-watch-meta span,
|
||||
.room-card-updated {
|
||||
color: var(--panel-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.room-card-score,
|
||||
.room-watch-scoreboard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.room-card-matchup {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.room-card-score div,
|
||||
.room-watch-team {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
padding: 16px 12px;
|
||||
border-radius: 18px;
|
||||
background: rgba(244, 236, 216, 0.8);
|
||||
}
|
||||
|
||||
.room-card-matchup strong {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 16px 12px;
|
||||
border-radius: 18px;
|
||||
text-align: center;
|
||||
background: rgba(244, 236, 216, 0.8);
|
||||
color: var(--panel-strong);
|
||||
}
|
||||
|
||||
.room-card-matchup span {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--panel-soft);
|
||||
}
|
||||
|
||||
.room-card-score small,
|
||||
.room-watch-team small {
|
||||
color: var(--panel-soft);
|
||||
}
|
||||
|
||||
.room-card-score strong,
|
||||
.room-watch-team strong {
|
||||
font-size: clamp(2rem, 8vw, 3.4rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.room-card-score span,
|
||||
.room-watch-divider {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--panel-strong);
|
||||
}
|
||||
|
||||
.room-watch-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
+225
-2
@@ -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
|
||||
|
||||
+159
-4
@@ -3,12 +3,17 @@ import type {
|
||||
HistoryRecord,
|
||||
HistoryUploadPayload,
|
||||
HistoryUploadResponse,
|
||||
LiveRoomDetail,
|
||||
LiveRoomPayload,
|
||||
LiveRoomSession,
|
||||
LiveRoomSummary,
|
||||
LiveRoomUpdatePayload,
|
||||
MatchResultsRecord,
|
||||
} from '../types'
|
||||
|
||||
export async function loadMatchResults(time: string) {
|
||||
const response = await fetch(`/api/match-results/${time}`)
|
||||
const payload = (await response.json()) as {
|
||||
const payload = (await readJsonSafely(response)) as {
|
||||
ok?: boolean
|
||||
message?: string
|
||||
data?: MatchResultsRecord
|
||||
@@ -34,7 +39,7 @@ export async function saveMatchHistory(payload: HistoryUploadPayload) {
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const result = (await response.json()) as {
|
||||
const result = (await readJsonSafely(response)) as {
|
||||
ok?: boolean
|
||||
message?: string
|
||||
data?: HistoryUploadResponse
|
||||
@@ -49,7 +54,7 @@ export async function saveMatchHistory(payload: HistoryUploadPayload) {
|
||||
|
||||
export async function loadHistoryList() {
|
||||
const response = await fetch('/api/history')
|
||||
const payload = (await response.json()) as {
|
||||
const payload = (await readJsonSafely(response)) as {
|
||||
ok?: boolean
|
||||
message?: string
|
||||
data?: HistoryRecord[]
|
||||
@@ -67,7 +72,7 @@ export async function deleteHistoryItem(id: number) {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
const payload = (await readJsonSafely(response)) as {
|
||||
ok?: boolean
|
||||
message?: string
|
||||
}
|
||||
@@ -77,6 +82,142 @@ export async function deleteHistoryItem(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLiveRoom(payload: LiveRoomPayload) {
|
||||
const response = await fetch('/api/rooms', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const result = (await readJsonSafely(response)) as {
|
||||
ok?: boolean
|
||||
message?: string
|
||||
data?: LiveRoomSession
|
||||
}
|
||||
|
||||
if (!response.ok || !result.ok || !result.data) {
|
||||
throw new Error(result.message ?? '建立觀戰房間失敗。')
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
export async function updateLiveRoom(roomId: string, payload: LiveRoomUpdatePayload) {
|
||||
const response = await fetch(`/api/rooms/${roomId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const result = (await readJsonSafely(response)) as {
|
||||
ok?: boolean
|
||||
message?: string
|
||||
data?: LiveRoomDetail
|
||||
}
|
||||
|
||||
if (!response.ok || !result.ok || !result.data) {
|
||||
throw new Error(result.message ?? '同步房間比分失敗。')
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
export async function releaseLiveRoom(roomId: string, hostToken: string) {
|
||||
const response = await fetch(`/api/rooms/${roomId}/release`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ hostToken }),
|
||||
keepalive: true,
|
||||
})
|
||||
|
||||
const result = (await readJsonSafely(response)) as {
|
||||
ok?: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
if (!response.ok || !result.ok) {
|
||||
throw new Error(result.message ?? '清除舊房間失敗。')
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadLiveRoomList() {
|
||||
const response = await fetch('/api/rooms')
|
||||
const result = (await readJsonSafely(response)) as {
|
||||
ok?: boolean
|
||||
message?: string
|
||||
data?: LiveRoomSummary[]
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error('後端還沒更新到房間功能,請重新部署最新版。')
|
||||
}
|
||||
|
||||
if (!response.ok || !result.ok) {
|
||||
throw new Error(result.message ?? '載入房間列表失敗。')
|
||||
}
|
||||
|
||||
return result.data ?? []
|
||||
}
|
||||
|
||||
export async function loadLiveRoom(roomId: string) {
|
||||
const response = await fetch(`/api/rooms/${roomId}`)
|
||||
const result = (await readJsonSafely(response)) as {
|
||||
ok?: boolean
|
||||
message?: string
|
||||
data?: LiveRoomDetail
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error('後端還沒更新到房間功能,或這個房間已不存在。')
|
||||
}
|
||||
|
||||
if (!response.ok || !result.ok || !result.data) {
|
||||
throw new Error(result.message ?? '載入房間內容失敗。')
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
export function subscribeRoomList(onMessage: (rooms: LiveRoomSummary[]) => void) {
|
||||
const source = new EventSource('/api/rooms/stream')
|
||||
|
||||
source.addEventListener('rooms', (event) => {
|
||||
const payload = JSON.parse((event as MessageEvent).data) as LiveRoomSummary[]
|
||||
onMessage(payload)
|
||||
})
|
||||
|
||||
return () => {
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeLiveRoom(
|
||||
roomId: string,
|
||||
onMessage: (room: LiveRoomDetail) => void,
|
||||
onError?: () => void,
|
||||
) {
|
||||
const source = new EventSource(`/api/rooms/${roomId}/stream`)
|
||||
|
||||
source.addEventListener('room', (event) => {
|
||||
const payload = JSON.parse((event as MessageEvent).data) as LiveRoomDetail
|
||||
onMessage(payload)
|
||||
})
|
||||
|
||||
source.onerror = () => {
|
||||
onError?.()
|
||||
}
|
||||
|
||||
return () => {
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHistoryRecord(record: HistoryRecord): HistoryListItem {
|
||||
const score = parseJson<[number, number]>(record.score, [0, 0])
|
||||
const players = parseJson<string[]>(record.players, [])
|
||||
@@ -124,3 +265,17 @@ function getDayLabel(dayOfWeek: number) {
|
||||
const labels = ['週日', '週一', '週二', '週三', '週四', '週五', '週六']
|
||||
return labels[dayOfWeek] ?? '-'
|
||||
}
|
||||
|
||||
async function readJsonSafely(response: Response) {
|
||||
const raw = await response.text()
|
||||
|
||||
if (!raw.trim()) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as unknown
|
||||
} catch {
|
||||
throw new Error('伺服器回傳內容不是有效的 JSON。')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { loadLiveRoomList, subscribeRoomList } from '../lib/api'
|
||||
import type { LiveRoomSummary } from '../types'
|
||||
|
||||
export function RoomListPage() {
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [rooms, setRooms] = useState<LiveRoomSummary[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const nextRooms = await loadLiveRoomList()
|
||||
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
setRooms(nextRooms)
|
||||
setError('')
|
||||
} catch (loadError) {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
setError(loadError instanceof Error ? loadError.message : '載入房間列表失敗。')
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
const unsubscribe = subscribeRoomList((nextRooms) => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
setRooms(nextRooms)
|
||||
setError('')
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="page-grid">
|
||||
<article className="panel panel-hero full-span">
|
||||
<p className="panel-kicker">Live Rooms</p>
|
||||
<h2>房間列表</h2>
|
||||
<p className="panel-copy">可查看目前正在進行中的房間,點進去就能即時觀戰。</p>
|
||||
</article>
|
||||
|
||||
<article className="panel full-span">
|
||||
{loading ? <p>正在載入房間列表...</p> : null}
|
||||
{!loading && error ? <p className="history-empty">{error}</p> : null}
|
||||
{!loading && !error && rooms.length === 0 ? (
|
||||
<p className="history-empty">目前沒有正在進行中的房間。</p>
|
||||
) : null}
|
||||
|
||||
{!loading && !error && rooms.length > 0 ? (
|
||||
<div className="room-list-grid">
|
||||
{rooms.map((room) => (
|
||||
<Link className="room-card" key={room.roomId} to={`/rooms/${room.roomId}`}>
|
||||
<div className="room-card-head">
|
||||
<strong>房號 {room.roomId}</strong>
|
||||
<span>目標 {room.targetScore} 分</span>
|
||||
</div>
|
||||
|
||||
<div className="room-card-matchup">
|
||||
<strong>{room.leftTeamName}</strong>
|
||||
<span>VS</span>
|
||||
<strong>{room.rightTeamName}</strong>
|
||||
</div>
|
||||
|
||||
<p className="room-card-updated">
|
||||
最後更新 {new Date(room.updatedAt).toLocaleTimeString('zh-TW', { hour12: false })}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { loadLiveRoom, subscribeLiveRoom } from '../lib/api'
|
||||
import type { LiveRoomDetail } from '../types'
|
||||
|
||||
type RoomSpectatorPageProps = {
|
||||
onConfirmFinished: () => void
|
||||
}
|
||||
|
||||
const ROOM_POLL_MS = 1500
|
||||
|
||||
export function RoomSpectatorPage({ onConfirmFinished }: RoomSpectatorPageProps) {
|
||||
const { roomId = '' } = useParams()
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [room, setRoom] = useState<LiveRoomDetail | null>(null)
|
||||
const [showFinishedDialog, setShowFinishedDialog] = useState(false)
|
||||
const previousStatusRef = useRef<string | null>(null)
|
||||
const hasRoomRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId) {
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
hasRoomRef.current = false
|
||||
previousStatusRef.current = null
|
||||
|
||||
const applyRoomUpdate = (nextRoom: LiveRoomDetail) => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextRoom.status === 'finished' && previousStatusRef.current !== 'finished') {
|
||||
setShowFinishedDialog(true)
|
||||
}
|
||||
|
||||
previousStatusRef.current = nextRoom.status
|
||||
hasRoomRef.current = true
|
||||
setRoom(nextRoom)
|
||||
setError('')
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const load = async (showLoadError = true) => {
|
||||
try {
|
||||
const nextRoom = await loadLiveRoom(roomId)
|
||||
applyRoomUpdate(nextRoom)
|
||||
} catch (loadError) {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
if (showLoadError || !hasRoomRef.current) {
|
||||
setError(loadError instanceof Error ? loadError.message : '載入觀戰房間失敗。')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
const unsubscribe = subscribeLiveRoom(
|
||||
roomId,
|
||||
(nextRoom) => {
|
||||
applyRoomUpdate(nextRoom)
|
||||
},
|
||||
() => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasRoomRef.current) {
|
||||
setError('觀戰連線中斷,請稍後重試。')
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void load(false)
|
||||
}, ROOM_POLL_MS)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
window.clearInterval(timer)
|
||||
unsubscribe()
|
||||
}
|
||||
}, [roomId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="page-grid">
|
||||
<article className="panel full-span">
|
||||
<p>正在載入觀戰房間...</p>
|
||||
</article>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (!room || error) {
|
||||
return (
|
||||
<section className="page-grid">
|
||||
<article className="panel panel-hero full-span">
|
||||
<p className="panel-kicker">Spectator</p>
|
||||
<h2>無法進入房間</h2>
|
||||
<p className="panel-copy">{error || '這個房間不存在或已關閉。'}</p>
|
||||
<Link className="primary-button inline-link" to="/rooms">
|
||||
返回房間列表
|
||||
</Link>
|
||||
</article>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-grid">
|
||||
<article className="panel panel-hero full-span">
|
||||
<p className="panel-kicker">Spectator</p>
|
||||
<h2>房號 {room.roomId}</h2>
|
||||
<p className="panel-copy">這是觀戰模式,只會即時同步比分,不提供任何操作。</p>
|
||||
</article>
|
||||
|
||||
<article className="panel full-span room-watch-panel">
|
||||
<div className="room-watch-scoreboard">
|
||||
<div className="room-watch-team">
|
||||
<small>{room.leftTeamName}</small>
|
||||
<strong>{room.scoreState.scoreLeft}</strong>
|
||||
</div>
|
||||
<div className="room-watch-divider">:</div>
|
||||
<div className="room-watch-team">
|
||||
<small>{room.rightTeamName}</small>
|
||||
<strong>{room.scoreState.scoreRight}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="room-watch-meta">
|
||||
<span>目標分數 {room.scoreState.targetScore}</span>
|
||||
<span>房間狀態 {room.status === 'finished' ? '已結束' : '進行中'}</span>
|
||||
<span>
|
||||
最後更新 {new Date(room.updatedAt).toLocaleTimeString('zh-TW', { hour12: false })}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{showFinishedDialog ? (
|
||||
<div className="finish-dialog-overlay" role="presentation">
|
||||
<div aria-modal="true" className="finish-dialog" role="dialog">
|
||||
<p className="panel-kicker">比賽結束</p>
|
||||
<h3>{room.winnerTeamName ?? '已有獲勝隊伍'}</h3>
|
||||
<p className="finish-dialog-copy">
|
||||
{room.winnerTeamName
|
||||
? `${room.winnerTeamName} 已獲勝,本場觀戰會返回列表。`
|
||||
: '比賽已結束,本場觀戰會返回列表。'}
|
||||
</p>
|
||||
<div className="finish-dialog-actions">
|
||||
<button
|
||||
className="team-picker-confirm"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowFinishedDialog(false)
|
||||
onConfirmFinished()
|
||||
}}
|
||||
>
|
||||
確定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -41,6 +41,7 @@ type ScoreboardPageProps = {
|
||||
groupSource: 'idle' | 'db' | 'manual'
|
||||
hasRecordedPoint: boolean
|
||||
leftTeam: GroupTeam | null
|
||||
liveRoomId: string | null
|
||||
rightTeam: GroupTeam | null
|
||||
scoreState: ScoreState
|
||||
selectedGroup: RoundGroup | null
|
||||
@@ -81,6 +82,7 @@ export function ScoreboardPage({
|
||||
groupSource,
|
||||
hasRecordedPoint,
|
||||
leftTeam,
|
||||
liveRoomId,
|
||||
rightTeam,
|
||||
scoreState,
|
||||
selectedGroup,
|
||||
@@ -547,6 +549,8 @@ export function ScoreboardPage({
|
||||
|
||||
<div className="rail-clock">{clock}</div>
|
||||
|
||||
{liveRoomId ? <div className="rail-room-id">房號 {liveRoomId}</div> : null}
|
||||
|
||||
<div
|
||||
className={
|
||||
finishHoldActive ? 'rail-pill-hold-wrap rail-pill-hold-wrap-active' : 'rail-pill-hold-wrap'
|
||||
|
||||
@@ -116,3 +116,47 @@ export type HistoryListItem = {
|
||||
rightTeamName: string
|
||||
winnerTeamName: string
|
||||
}
|
||||
|
||||
export type LiveRoomStatus = 'live' | 'finished'
|
||||
|
||||
export type LiveRoomSession = {
|
||||
hostToken: string
|
||||
roomId: string
|
||||
status: LiveRoomStatus
|
||||
}
|
||||
|
||||
export type LiveRoomSummary = {
|
||||
roomId: string
|
||||
createdAt: string
|
||||
leftTeamName: string
|
||||
rightTeamName: string
|
||||
scoreLeft: number
|
||||
scoreRight: number
|
||||
status: LiveRoomStatus
|
||||
targetScore: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type LiveRoomPayload = {
|
||||
groupId: number | null
|
||||
leftTeamName: string
|
||||
matchupLabel: string
|
||||
pointLog: PointHistoryEntry[]
|
||||
rightTeamName: string
|
||||
scoreState: ScoreState
|
||||
targetDate: string
|
||||
}
|
||||
|
||||
export type LiveRoomDetail = LiveRoomPayload & {
|
||||
createdAt: string
|
||||
roomId: string
|
||||
status: LiveRoomStatus
|
||||
updatedAt: string
|
||||
winnerTeamName: string | null
|
||||
}
|
||||
|
||||
export type LiveRoomUpdatePayload = LiveRoomPayload & {
|
||||
hostToken: string
|
||||
status: LiveRoomStatus
|
||||
winnerTeamName: string | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user