feat: version banner UI + GET /api/v1/version backend endpoint

This commit is contained in:
Александр Зимин
2026-05-10 16:50:50 +00:00
parent 17386ba0b4
commit e8f98230f2
9 changed files with 355 additions and 0 deletions
@@ -0,0 +1,74 @@
import { useTranslation } from 'react-i18next'
import { Button } from '@nstart/ui'
import { ArrowClockwise, BookOpen, X } from '@phosphor-icons/react'
import { useState } from 'react'
import { useAppVersion } from './useAppVersion'
const DOCS_URL = '/docs/' // ordinis-docs deployment, mirrors prod /docs path
/**
* Banner — показывается top-of-page когда client bundle отстаёт от server.
*
* <p>Pattern такой же как Altum VS / Geoportal: persistent strip, contextual
* CTAs, dismissable (но re-appears после next refetch если still mismatched).
*
* <p>Two CTAs:
* <ul>
* <li><b>Обновить</b> — {@code window.location.reload()}. Browser fetches new
* bundle (cache-busting через Vite hashed filenames).</li>
* <li><b>Что нового →</b> — opens user руководство (changelog / release notes).</li>
* </ul>
*
* <p>Dismiss button (×) — temporarily скрывает banner до следующего
* {@code useServerVersion} refetch (5 min). Не persists в localStorage —
* критичный update должен progressively становиться более insistent.
*/
export function UpdateBanner() {
const { t } = useTranslation()
const { updateAvailable, serverVersion, serverCommit, serverTag } = useAppVersion()
const [dismissed, setDismissed] = useState(false)
if (!updateAvailable || dismissed) return null
const versionLabel = serverTag || serverVersion || serverCommit?.substring(0, 7) || ''
return (
<div
role="status"
aria-live="polite"
className="bg-ultramarain text-white"
>
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-2 flex items-center gap-3 flex-wrap text-sm">
<ArrowClockwise size={18} weight="bold" className="shrink-0" aria-hidden />
<span className="flex-1 min-w-0">
{t('updateBanner.message', { version: versionLabel })}
</span>
<div className="flex items-center gap-2 ml-auto">
<Button
size="sm"
variant="secondary"
onClick={() => window.open(DOCS_URL, '_blank', 'noopener,noreferrer')}
>
<BookOpen size={16} weight="regular" className="mr-1" aria-hidden />
{t('updateBanner.docs')}
</Button>
<Button
size="sm"
variant="primary"
onClick={() => window.location.reload()}
>
{t('updateBanner.reload')}
</Button>
<button
type="button"
aria-label={t('updateBanner.dismiss') as string}
onClick={() => setDismissed(true)}
className="ml-1 p-1 rounded hover:bg-white/10"
>
<X size={16} weight="bold" aria-hidden />
</button>
</div>
</div>
</div>
)
}