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 { Geist, Geist_Mono, Fredoka, Permanent_Marker } from "next/font/google"
|
||||||
import { MusicProvider } from "@/components/music/music-provider"
|
import { MusicProvider } from "@/components/music/music-provider"
|
||||||
import { MiniPlayer } from "@/components/music/mini-player"
|
import { MiniPlayer } from "@/components/music/mini-player"
|
||||||
|
import { UpdateBanner } from "@/components/update-banner"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
|
|
||||||
const _geist = Geist({ subsets: ["latin"] })
|
const _geist = Geist({ subsets: ["latin"] })
|
||||||
|
|
@ -72,6 +73,7 @@ export default function RootLayout({
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${_fredoka.variable} ${_permanentMarker.variable}`}>
|
<html lang="en" className={`${_fredoka.variable} ${_permanentMarker.variable}`}>
|
||||||
<body className={`font-sans antialiased`}>
|
<body className={`font-sans antialiased`}>
|
||||||
|
<UpdateBanner />
|
||||||
<MusicProvider>
|
<MusicProvider>
|
||||||
{children}
|
{children}
|
||||||
<MiniPlayer />
|
<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,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
env: {
|
||||||
|
BUILD_ID: process.env.BUILD_ID || new Date().toISOString(),
|
||||||
|
},
|
||||||
async redirects() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue