新增即時觀戰房間並整理 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

@@ -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>
)
}

View File

@@ -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}
</>
)
}

View File

@@ -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'