更新圖示與部署更新機制並整理 README
@@ -16,8 +16,10 @@
|
|||||||
- 需先指定先攻,之後點擊分數即可直接加分。
|
- 需先指定先攻,之後點擊分數即可直接加分。
|
||||||
- 第一分記錄後,右側 `設定隊伍` 會切成 `上一步`。
|
- 第一分記錄後,右側 `設定隊伍` 會切成 `上一步`。
|
||||||
- 可交換上下隊伍位置,也可交換同隊左右球員位置。
|
- 可交換上下隊伍位置,也可交換同隊左右球員位置。
|
||||||
- `比賽結算` 為防誤觸設計,需長按 `1.5 秒` 才會觸發。
|
- `比賽結算` 為防誤觸設計,需長按 `1 秒` 才會觸發。
|
||||||
|
- 比分仍是 `0:0` 時,不會啟動比賽結算長按。
|
||||||
- 長按 `比賽結算` 時,按鈕下方會顯示進度條,按鈕本身也會有亮起與下壓的視覺回饋。
|
- 長按 `比賽結算` 時,按鈕下方會顯示進度條,按鈕本身也會有亮起與下壓的視覺回饋。
|
||||||
|
- 手機長按 `比賽結算` 不會再觸發文字選取。
|
||||||
- 連勝會出現特效提示:
|
- 連勝會出現特效提示:
|
||||||
- `3 連勝`:`大殺特殺`
|
- `3 連勝`:`大殺特殺`
|
||||||
- `4 連勝`:`暴走`
|
- `4 連勝`:`暴走`
|
||||||
@@ -37,7 +39,9 @@
|
|||||||
- PWA
|
- PWA
|
||||||
- 可加入手機主畫面,像 App 一樣開啟。
|
- 可加入手機主畫面,像 App 一樣開啟。
|
||||||
- 支援 `manifest`、`service worker`、主畫面 icon。
|
- 支援 `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
|
https://你的網域或 NAS IP:3501
|
||||||
```
|
```
|
||||||
|
|
||||||
|
每次執行 `sudo docker compose up -d --build` 重建 app container 時,後端都會帶新的容器啟動版號;前端輪詢發現版號不同後,就會跳出更新提示。
|
||||||
|
|
||||||
## SSL 憑證目錄
|
## SSL 憑證目錄
|
||||||
|
|
||||||
Docker Compose 會直接掛載 NAS 上的憑證目錄:
|
Docker Compose 會直接掛載 NAS 上的憑證目錄:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-Hant">
|
<html lang="zh-Hant">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/png" sizes="64x64" href="/favicon.png" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 1008 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 355 KiB |
@@ -3,7 +3,8 @@ const APP_SHELL = [
|
|||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
'/manifest.webmanifest',
|
'/manifest.webmanifest',
|
||||||
'/favicon.svg',
|
'/favicon.png',
|
||||||
|
'/icon.png',
|
||||||
'/apple-touch-icon.png',
|
'/apple-touch-icon.png',
|
||||||
'/pwa-192.png',
|
'/pwa-192.png',
|
||||||
'/pwa-512.png',
|
'/pwa-512.png',
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const app = express()
|
|||||||
const port = Number(process.env.PORT ?? process.env.SERVER_PORT ?? 8788)
|
const port = Number(process.env.PORT ?? process.env.SERVER_PORT ?? 8788)
|
||||||
const matchTableName = process.env.DB_TABLE ?? 'badminton'
|
const matchTableName = process.env.DB_TABLE ?? 'badminton'
|
||||||
const historyTableName = process.env.DB_HISTORY_TABLE ?? 'history'
|
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 currentFilePath = fileURLToPath(import.meta.url)
|
||||||
const currentDir = path.dirname(currentFilePath)
|
const currentDir = path.dirname(currentFilePath)
|
||||||
@@ -37,6 +39,8 @@ app.use(express.json())
|
|||||||
|
|
||||||
app.get('/api/health', (_request, response) => {
|
app.get('/api/health', (_request, response) => {
|
||||||
response.json({
|
response.json({
|
||||||
|
appStartedAt,
|
||||||
|
appVersion,
|
||||||
ok: true,
|
ok: true,
|
||||||
dbReady: Boolean(pool),
|
dbReady: Boolean(pool),
|
||||||
distReady,
|
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) => {
|
app.get('/api/match-results/:time', async (request, response) => {
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
response.status(500).json({
|
response.status(500).json({
|
||||||
|
|||||||
@@ -948,6 +948,10 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 14px 14px;
|
padding: 14px 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
touch-action: manipulation;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #4a2e1d;
|
color: #4a2e1d;
|
||||||
@@ -964,6 +968,9 @@
|
|||||||
.rail-pill-hold-wrap {
|
.rail-pill-hold-wrap {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-pill-hold-wrap-active {
|
.rail-pill-hold-wrap-active {
|
||||||
|
|||||||
@@ -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 { NavLink, Route, Routes, useLocation } from 'react-router-dom'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import { loadMatchResults, saveMatchHistory } from './lib/api'
|
import { loadMatchResults, saveMatchHistory } from './lib/api'
|
||||||
@@ -80,6 +80,7 @@ const STREAK_TITLES: Record<number, string> = {
|
|||||||
8: '成為傳說',
|
8: '成為傳說',
|
||||||
}
|
}
|
||||||
const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready'
|
const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready'
|
||||||
|
const APP_VERSION_POLL_MS = 30000
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -117,6 +118,7 @@ function App() {
|
|||||||
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
|
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
|
||||||
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
|
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
|
||||||
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
|
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
|
||||||
|
const currentAppVersionRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
|
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
|
||||||
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
|
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) => {
|
const resetScoring = (nextState: ScoreState = initialScoreState) => {
|
||||||
setScoreState(nextState)
|
setScoreState(nextState)
|
||||||
setScoreHistory([])
|
setScoreHistory([])
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import type { Dispatch, SetStateAction } from 'react'
|
import type { Dispatch, SetStateAction } from 'react'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
getCourtAssignments,
|
getCourtAssignments,
|
||||||
@@ -98,7 +98,7 @@ export function ScoreboardPage({
|
|||||||
onSwapTeamPlayers,
|
onSwapTeamPlayers,
|
||||||
onUndoLastPoint,
|
onUndoLastPoint,
|
||||||
}: ScoreboardPageProps) {
|
}: ScoreboardPageProps) {
|
||||||
const FINISH_HOLD_DURATION = 1500
|
const FINISH_HOLD_DURATION = 1000
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const [draftPlayers, setDraftPlayers] = useState<string[]>([])
|
const [draftPlayers, setDraftPlayers] = useState<string[]>([])
|
||||||
@@ -112,6 +112,7 @@ export function ScoreboardPage({
|
|||||||
loadVoiceSettings(),
|
loadVoiceSettings(),
|
||||||
)
|
)
|
||||||
const finishHoldFrameRef = useRef<number | null>(null)
|
const finishHoldFrameRef = useRef<number | null>(null)
|
||||||
|
const finishHoldTimerRef = useRef<number | null>(null)
|
||||||
const finishHoldStartRef = useRef(0)
|
const finishHoldStartRef = useRef(0)
|
||||||
const finishTriggeredRef = useRef(false)
|
const finishTriggeredRef = useRef(false)
|
||||||
const lastAnnouncedPointRef = useRef(0)
|
const lastAnnouncedPointRef = useRef(0)
|
||||||
@@ -138,6 +139,10 @@ export function ScoreboardPage({
|
|||||||
window.cancelAnimationFrame(finishHoldFrameRef.current)
|
window.cancelAnimationFrame(finishHoldFrameRef.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (finishHoldTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(finishHoldTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
if ('speechSynthesis' in window) {
|
if ('speechSynthesis' in window) {
|
||||||
window.speechSynthesis.cancel()
|
window.speechSynthesis.cancel()
|
||||||
}
|
}
|
||||||
@@ -175,6 +180,7 @@ export function ScoreboardPage({
|
|||||||
|
|
||||||
const canArrangeMatch = !hasRecordedPoint
|
const canArrangeMatch = !hasRecordedPoint
|
||||||
const canScore = scoreState.serving !== null
|
const canScore = scoreState.serving !== null
|
||||||
|
const canFinishMatch = scoreState.scoreLeft > 0 || scoreState.scoreRight > 0
|
||||||
|
|
||||||
const servingScore =
|
const servingScore =
|
||||||
scoreState.serving === 'left' ? scoreState.scoreLeft : scoreState.scoreRight
|
scoreState.serving === 'left' ? scoreState.scoreLeft : scoreState.scoreRight
|
||||||
@@ -317,6 +323,11 @@ export function ScoreboardPage({
|
|||||||
finishHoldFrameRef.current = null
|
finishHoldFrameRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (finishHoldTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(finishHoldTimerRef.current)
|
||||||
|
finishHoldTimerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
finishHoldStartRef.current = 0
|
finishHoldStartRef.current = 0
|
||||||
finishTriggeredRef.current = false
|
finishTriggeredRef.current = false
|
||||||
setFinishHoldActive(false)
|
setFinishHoldActive(false)
|
||||||
@@ -324,7 +335,12 @@ export function ScoreboardPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startFinishHold = () => {
|
const startFinishHold = () => {
|
||||||
if (finishDialogOpen || finishDialogUploading || finishHoldActive) {
|
if (
|
||||||
|
!canFinishMatch ||
|
||||||
|
finishDialogOpen ||
|
||||||
|
finishDialogUploading ||
|
||||||
|
finishHoldActive
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,17 +349,27 @@ export function ScoreboardPage({
|
|||||||
setFinishHoldActive(true)
|
setFinishHoldActive(true)
|
||||||
setFinishHoldProgress(0)
|
setFinishHoldProgress(0)
|
||||||
|
|
||||||
|
finishHoldTimerRef.current = window.setTimeout(() => {
|
||||||
|
finishTriggeredRef.current = true
|
||||||
|
setFinishHoldActive(false)
|
||||||
|
setFinishHoldProgress(0)
|
||||||
|
finishHoldTimerRef.current = null
|
||||||
|
|
||||||
|
if (finishHoldFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(finishHoldFrameRef.current)
|
||||||
|
finishHoldFrameRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenFinishDialog()
|
||||||
|
}, FINISH_HOLD_DURATION)
|
||||||
|
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
const elapsed = now - finishHoldStartRef.current
|
const elapsed = now - finishHoldStartRef.current
|
||||||
const progress = Math.min(elapsed / FINISH_HOLD_DURATION, 1)
|
const progress = Math.min(elapsed / FINISH_HOLD_DURATION, 1)
|
||||||
setFinishHoldProgress(progress)
|
setFinishHoldProgress(progress)
|
||||||
|
|
||||||
if (progress >= 1) {
|
if (!finishHoldStartRef.current || finishTriggeredRef.current) {
|
||||||
finishTriggeredRef.current = true
|
|
||||||
setFinishHoldActive(false)
|
|
||||||
setFinishHoldProgress(0)
|
|
||||||
finishHoldFrameRef.current = null
|
finishHoldFrameRef.current = null
|
||||||
onOpenFinishDialog()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,8 +501,7 @@ export function ScoreboardPage({
|
|||||||
<small>
|
<small>
|
||||||
{scoreState.serving === null
|
{scoreState.serving === null
|
||||||
? `本場 ${scoreState.targetScore} 分獲勝`
|
? `本場 ${scoreState.targetScore} 分獲勝`
|
||||||
: `發球:${currentServer?.name ?? '-'}${
|
: `發球:${currentServer?.name ?? '-'}${currentReceiver ? ` / 接發:${currentReceiver.name}` : ''
|
||||||
currentReceiver ? ` / 接發:${currentReceiver.name}` : ''
|
|
||||||
} / 目標 ${scoreState.targetScore} 分`}
|
} / 目標 ${scoreState.targetScore} 分`}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -531,9 +556,13 @@ export function ScoreboardPage({
|
|||||||
className={
|
className={
|
||||||
finishHoldActive
|
finishHoldActive
|
||||||
? 'rail-pill rail-pill-danger rail-pill-active-hold'
|
? 'rail-pill rail-pill-danger rail-pill-active-hold'
|
||||||
: 'rail-pill rail-pill-danger'
|
: canFinishMatch
|
||||||
|
? 'rail-pill rail-pill-danger'
|
||||||
|
: 'rail-pill rail-pill-muted'
|
||||||
}
|
}
|
||||||
|
aria-disabled={!canFinishMatch}
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={(event) => event.preventDefault()}
|
||||||
onPointerDown={startFinishHold}
|
onPointerDown={startFinishHold}
|
||||||
onPointerUp={cancelFinishHold}
|
onPointerUp={cancelFinishHold}
|
||||||
onPointerLeave={cancelFinishHold}
|
onPointerLeave={cancelFinishHold}
|
||||||
|
|||||||