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:
Jeff Emmett 2026-03-30 23:35:02 -07:00
parent 1bdb5e50ef
commit b122d00be3
4 changed files with 75 additions and 0 deletions

16
app/api/version/route.ts Normal file
View File

@ -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',
},
}
)
}

View File

@ -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 />

View File

@ -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>
)
}

View File

@ -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 [
{ {