新增 PWA 更新提示並整理 README
This commit is contained in:
74
src/App.css
74
src/App.css
@@ -75,6 +75,59 @@
|
||||
background: linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(10, 96, 84, 0.92));
|
||||
}
|
||||
|
||||
.pwa-update-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 18px;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: min(560px, calc(100vw - 24px));
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(10, 51, 45, 0.16);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 249, 236, 0.96);
|
||||
box-shadow:
|
||||
0 22px 46px rgba(10, 51, 45, 0.22),
|
||||
0 8px 18px rgba(10, 51, 45, 0.12);
|
||||
transform: translateX(-50%);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.pwa-update-copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pwa-update-copy strong {
|
||||
font-size: 1rem;
|
||||
color: var(--panel-strong);
|
||||
}
|
||||
|
||||
.pwa-update-copy span {
|
||||
font-size: 0.88rem;
|
||||
color: var(--panel-soft);
|
||||
}
|
||||
|
||||
.pwa-update-button {
|
||||
flex: 0 0 auto;
|
||||
min-width: 96px;
|
||||
padding: 10px 14px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #0d5d53, #123f49);
|
||||
color: #f8fff8;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pwa-update-button:hover {
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
|
||||
.page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
@@ -1644,6 +1697,27 @@
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.pwa-update-toast {
|
||||
gap: 10px;
|
||||
bottom: 12px;
|
||||
padding: 12px 12px 12px 14px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.pwa-update-copy strong {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.pwa-update-copy span {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.pwa-update-button {
|
||||
min-width: 84px;
|
||||
padding: 9px 12px;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(100% - 14px, 1240px);
|
||||
padding: 14px 0 24px;
|
||||
|
||||
41
src/App.tsx
41
src/App.tsx
@@ -79,6 +79,7 @@ const STREAK_TITLES: Record<number, string> = {
|
||||
7: '像神一般的',
|
||||
8: '成為傳說',
|
||||
}
|
||||
const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready'
|
||||
|
||||
function App() {
|
||||
const location = useLocation()
|
||||
@@ -115,6 +116,7 @@ function App() {
|
||||
})
|
||||
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
|
||||
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
|
||||
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
|
||||
|
||||
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
|
||||
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
|
||||
@@ -174,6 +176,18 @@ function App() {
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [victoryAnnouncement])
|
||||
|
||||
useEffect(() => {
|
||||
const handlePwaUpdateReady = () => {
|
||||
setPwaUpdateReady(true)
|
||||
}
|
||||
|
||||
window.addEventListener(PWA_UPDATE_EVENT, handlePwaUpdateReady)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(PWA_UPDATE_EVENT, handlePwaUpdateReady)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetScoring = (nextState: ScoreState = initialScoreState) => {
|
||||
setScoreState(nextState)
|
||||
setScoreHistory([])
|
||||
@@ -215,6 +229,21 @@ function App() {
|
||||
})
|
||||
}
|
||||
|
||||
const refreshForPwaUpdate = () => {
|
||||
const registrationPromise = navigator.serviceWorker?.getRegistration
|
||||
? navigator.serviceWorker.getRegistration()
|
||||
: Promise.resolve(undefined)
|
||||
|
||||
void registrationPromise.then((registration) => {
|
||||
if (registration?.waiting) {
|
||||
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||
return
|
||||
}
|
||||
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
|
||||
const loadGroupsFromDb = async () => {
|
||||
if (!targetDate) {
|
||||
setLoadStatus('error')
|
||||
@@ -601,6 +630,18 @@ function App() {
|
||||
/>
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
</Routes>
|
||||
|
||||
{pwaUpdateReady ? (
|
||||
<div className="pwa-update-toast" role="status" aria-live="polite">
|
||||
<div className="pwa-update-copy">
|
||||
<strong>有新版本可更新</strong>
|
||||
<span>點重新整理後套用最新版本。</span>
|
||||
</div>
|
||||
<button className="pwa-update-button" onClick={refreshForPwaUpdate} type="button">
|
||||
重新整理
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
44
src/main.tsx
44
src/main.tsx
@@ -4,6 +4,8 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
@@ -11,3 +13,45 @@ createRoot(document.getElementById('root')!).render(
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
let refreshing = false
|
||||
|
||||
const notifyUpdateReady = () => {
|
||||
window.dispatchEvent(new CustomEvent(PWA_UPDATE_EVENT))
|
||||
}
|
||||
|
||||
const trackWorker = (worker: ServiceWorker | null) => {
|
||||
if (!worker) {
|
||||
return
|
||||
}
|
||||
|
||||
worker.addEventListener('statechange', () => {
|
||||
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
notifyUpdateReady()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (refreshing) {
|
||||
return
|
||||
}
|
||||
|
||||
refreshing = true
|
||||
window.location.reload()
|
||||
})
|
||||
|
||||
void navigator.serviceWorker.register('/sw.js').then((registration) => {
|
||||
if (registration.waiting) {
|
||||
notifyUpdateReady()
|
||||
}
|
||||
|
||||
trackWorker(registration.installing)
|
||||
registration.addEventListener('updatefound', () => {
|
||||
trackWorker(registration.installing)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ const defaultVoiceSettings: VoiceSettings = {
|
||||
announceServer: true,
|
||||
rate: 1,
|
||||
}
|
||||
const SPEECH_NAME_MAP: Record<string, string> = {
|
||||
ruru: '嚕嚕',
|
||||
}
|
||||
|
||||
type ScoreboardPageProps = {
|
||||
currentSelectionOrder: string[]
|
||||
@@ -252,7 +255,7 @@ export function ScoreboardPage({
|
||||
}
|
||||
|
||||
if (voiceSettings.announceServer) {
|
||||
parts.push(`${currentServer.name}發球`)
|
||||
parts.push(`${getSpeechName(currentServer.name)}發球`)
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
@@ -1061,7 +1064,11 @@ function loadVoiceSettings(): VoiceSettings {
|
||||
}
|
||||
|
||||
function getAnnouncementName(team: GroupTeam | null) {
|
||||
return team?.playerA ?? '本隊'
|
||||
return getSpeechName(team?.playerA ?? '本隊')
|
||||
}
|
||||
|
||||
function getSpeechName(name: string) {
|
||||
return SPEECH_NAME_MAP[name.trim().toLowerCase()] ?? name
|
||||
}
|
||||
|
||||
function speakAnnouncement(message: string, rate: number) {
|
||||
|
||||
Reference in New Issue
Block a user