更新圖示與部署更新機制並整理 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([])
|
||||||
|
|||||||