Files
tolaria/src/components/UpdateBanner.tsx
2026-04-30 06:46:45 +02:00

181 lines
5.1 KiB
TypeScript

import type { CSSProperties } from 'react'
import { Download, ExternalLink, RefreshCw, X } from 'lucide-react'
import type { UpdateStatus, UpdateActions } from '../hooks/useUpdater'
import { restartApp } from '../hooks/useUpdater'
import { Button } from './ui/button'
import { translate, type AppLocale } from '../lib/i18n'
interface UpdateBannerProps {
status: UpdateStatus
actions: UpdateActions
locale?: AppLocale
}
type VisibleUpdateStatus = Exclude<UpdateStatus, { state: 'idle' } | { state: 'error' }>
const bannerStyle = {
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '6px 12px',
background: 'var(--accent-blue)',
borderBottom: 'none',
fontSize: 13,
color: 'var(--text-inverse)',
flexShrink: 0,
} satisfies CSSProperties
const iconStyle = {
color: 'var(--text-inverse)',
flexShrink: 0,
} satisfies CSSProperties
const primaryActionStyle = {
marginLeft: 'auto',
padding: '3px 10px',
background: 'var(--text-inverse)',
color: 'var(--accent-blue)',
fontSize: 12,
fontWeight: 500,
} satisfies CSSProperties
const dismissButtonStyle = {
color: 'var(--text-inverse)',
display: 'flex',
padding: 2,
} satisfies CSSProperties
const progressTrackStyle = {
flex: 1,
maxWidth: 200,
height: 4,
background: 'color-mix(in srgb, var(--text-inverse) 30%, transparent)',
borderRadius: 2,
overflow: 'hidden',
} satisfies CSSProperties
const progressTextStyle = {
fontSize: 11,
color: 'color-mix(in srgb, var(--text-inverse) 85%, transparent)',
} satisfies CSSProperties
const readyIconStyle = {
color: 'var(--accent-green)',
flexShrink: 0,
} satisfies CSSProperties
function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'available' }>, actions: UpdateActions, locale: AppLocale) {
return (
<>
<Download size={14} style={iconStyle} />
<span>
<strong>Tolaria {status.displayVersion}</strong> {translate(locale, 'update.available')}
</span>
<Button
type="button"
variant="link"
size="xs"
data-testid="update-release-notes"
onClick={actions.openReleaseNotes}
style={{ color: 'var(--text-inverse)', padding: 0, height: 'auto' }}
>
{translate(locale, 'update.releaseNotes')} <ExternalLink size={11} />
</Button>
<Button
type="button"
size="xs"
data-testid="update-now-btn"
onClick={actions.startDownload}
style={primaryActionStyle}
>
{translate(locale, 'update.updateNow')}
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
data-testid="update-dismiss"
onClick={actions.dismiss}
style={dismissButtonStyle}
aria-label={translate(locale, 'update.dismiss')}
>
<X size={14} />
</Button>
</>
)
}
function renderCheckingContent(locale: AppLocale) {
return (
<>
<RefreshCw size={14} style={{ ...iconStyle, animation: 'spin 1s linear infinite' }} />
<span>{translate(locale, 'update.checking')}</span>
</>
)
}
function renderDownloadingContent(status: Extract<VisibleUpdateStatus, { state: 'downloading' }>, locale: AppLocale) {
return (
<>
<RefreshCw size={14} style={{ ...iconStyle, animation: 'spin 1s linear infinite' }} />
<span>{translate(locale, 'update.downloading', { version: status.displayVersion })}</span>
<div style={progressTrackStyle}>
<div
data-testid="update-progress"
style={{
width: `${Math.round(status.progress * 100)}%`,
height: '100%',
background: 'var(--text-inverse)',
borderRadius: 2,
transition: 'width 0.2s ease',
}}
/>
</div>
<span style={progressTextStyle}>{Math.round(status.progress * 100)}%</span>
</>
)
}
function renderReadyContent(status: Extract<VisibleUpdateStatus, { state: 'ready' }>, locale: AppLocale) {
return (
<>
<RefreshCw size={14} style={readyIconStyle} />
<span>
<strong>Tolaria {status.displayVersion}</strong> {translate(locale, 'update.readyRestart')}
</span>
<Button
type="button"
size="xs"
data-testid="update-restart-btn"
onClick={restartApp}
style={{
...primaryActionStyle,
background: 'var(--accent-green)',
color: 'var(--text-inverse)',
}}
>
{translate(locale, 'update.restartNow')}
</Button>
</>
)
}
function renderBannerContent(status: VisibleUpdateStatus, actions: UpdateActions, locale: AppLocale) {
switch (status.state) {
case 'checking':
return renderCheckingContent(locale)
case 'available':
return renderAvailableContent(status, actions, locale)
case 'downloading':
return renderDownloadingContent(status, locale)
case 'ready':
return renderReadyContent(status, locale)
}
}
export function UpdateBanner({ status, actions, locale = 'en' }: UpdateBannerProps) {
if (status.state === 'idle' || status.state === 'error') return null
return <div data-testid="update-banner" style={bannerStyle}>{renderBannerContent(status, actions, locale)}</div>
}