feat: add "tap to update" banner when new version is deployed
Polls /api/version every 60s and on tab focus. When the server's build ID differs from the one loaded at page init, a fixed purple banner appears at the top of the screen prompting the user to reload. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1bdb5e50ef
commit
b122d00be3
|
|
@ -0,0 +1,16 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
// BUILD_ID is set at build time by Next.js standalone output.
|
||||
// Falls back to a startup timestamp so every restart counts as a new version.
|
||||
const VERSION = process.env.BUILD_ID || process.env.NEXT_BUILD_ID || Date.now().toString()
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{ version: VERSION },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import type { Metadata } from "next"
|
|||
import { Geist, Geist_Mono, Fredoka, Permanent_Marker } from "next/font/google"
|
||||
import { MusicProvider } from "@/components/music/music-provider"
|
||||
import { MiniPlayer } from "@/components/music/mini-player"
|
||||
import { UpdateBanner } from "@/components/update-banner"
|
||||
import "./globals.css"
|
||||
|
||||
const _geist = Geist({ subsets: ["latin"] })
|
||||
|
|
@ -72,6 +73,7 @@ export default function RootLayout({
|
|||
return (
|
||||
<html lang="en" className={`${_fredoka.variable} ${_permanentMarker.variable}`}>
|
||||
<body className={`font-sans antialiased`}>
|
||||
<UpdateBanner />
|
||||
<MusicProvider>
|
||||
{children}
|
||||
<MiniPlayer />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
const CHECK_INTERVAL = 60_000 // check every 60s
|
||||
|
||||
export function UpdateBanner() {
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||||
const [initialVersion, setInitialVersion] = useState<string | null>(null)
|
||||
|
||||
const checkVersion = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/version', { cache: 'no-store' })
|
||||
const { version } = await res.json()
|
||||
if (!version) return
|
||||
|
||||
if (initialVersion === null) {
|
||||
setInitialVersion(version)
|
||||
} else if (version !== initialVersion) {
|
||||
setUpdateAvailable(true)
|
||||
}
|
||||
} catch {
|
||||
// network error, skip
|
||||
}
|
||||
}, [initialVersion])
|
||||
|
||||
useEffect(() => {
|
||||
checkVersion()
|
||||
const id = setInterval(checkVersion, CHECK_INTERVAL)
|
||||
return () => clearInterval(id)
|
||||
}, [checkVersion])
|
||||
|
||||
// Also check on visibility change (user returns to tab)
|
||||
useEffect(() => {
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') checkVersion()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => document.removeEventListener('visibilitychange', onVisible)
|
||||
}, [checkVersion])
|
||||
|
||||
if (!updateAvailable) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="fixed top-0 left-0 right-0 z-[100] bg-purple-600 text-white text-sm font-medium py-2.5 px-4 flex items-center justify-center gap-2 shadow-lg hover:bg-purple-700 transition-colors cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
A new version is available — tap to update
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,6 +7,9 @@ const nextConfig = {
|
|||
unoptimized: true,
|
||||
},
|
||||
output: 'standalone',
|
||||
env: {
|
||||
BUILD_ID: process.env.BUILD_ID || new Date().toISOString(),
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue