diff --git a/next.config.ts b/next.config.ts index 3475016..228aacf 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,9 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { output: 'standalone', + env: { + NEXT_PUBLIC_BUILD_ID: Date.now().toString(), + }, serverExternalPackages: ['@prisma/client', 'minio'], typescript: { // We run tsc --noEmit separately before each push diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts new file mode 100644 index 0000000..53c4c87 --- /dev/null +++ b/src/app/api/version/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server' + +export const dynamic = 'force-static' + +export function GET() { + return NextResponse.json({ buildId: process.env.NEXT_PUBLIC_BUILD_ID }) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5bdfdad..6cdf80f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import './globals.css' import { Providers } from './providers' import { Toaster } from 'sonner' import { ImpersonationBanner } from '@/components/shared/impersonation-banner' +import { VersionGuard } from '@/components/shared/version-guard' export const metadata: Metadata = { title: { @@ -24,6 +25,7 @@ export default function RootLayout({ + {children} diff --git a/src/components/shared/version-guard.tsx b/src/components/shared/version-guard.tsx new file mode 100644 index 0000000..a5ca618 --- /dev/null +++ b/src/components/shared/version-guard.tsx @@ -0,0 +1,47 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { toast } from 'sonner' + +const CLIENT_BUILD_ID = process.env.NEXT_PUBLIC_BUILD_ID + +export function VersionGuard() { + const notified = useRef(false) + + useEffect(() => { + async function checkVersion() { + if (notified.current) return + try { + const res = await fetch('/api/version', { cache: 'no-store' }) + if (!res.ok) return + const { buildId } = await res.json() + if (buildId && CLIENT_BUILD_ID && buildId !== CLIENT_BUILD_ID) { + notified.current = true + toast('A new version is available', { + description: 'Refresh to get the latest updates.', + duration: Infinity, + action: { + label: 'Refresh', + onClick: () => window.location.reload(), + }, + }) + } + } catch { + // Network error — ignore + } + } + + // Check on tab focus (covers users returning to stale tabs) + window.addEventListener('focus', checkVersion) + + // Also check every 5 minutes for long-lived tabs + const interval = setInterval(checkVersion, 5 * 60 * 1000) + + return () => { + window.removeEventListener('focus', checkVersion) + clearInterval(interval) + } + }, []) + + return null +}