143 lines
4.9 KiB
TypeScript
143 lines
4.9 KiB
TypeScript
// =====================================================================
|
||
// Bandeau « une mise à jour est disponible ».
|
||
//
|
||
// Discret : une bande fine en haut de l'application, jamais une modale
|
||
// qui interrompt le travail. On peut reporter — la proposition revient
|
||
// au prochain lancement.
|
||
// =====================================================================
|
||
|
||
import { useCallback, useEffect, useState } from 'react'
|
||
import { AnimatePresence, motion } from 'framer-motion'
|
||
import { Download, RefreshCw, Sparkles, X } from 'lucide-react'
|
||
import { AvailableUpdate, checkForUpdate } from '../lib/updater'
|
||
import { useFeedback } from '../lib/feedback'
|
||
import { Button, IconButton } from './ui'
|
||
|
||
/** Nouvelle vérification toutes les 2 heures pour les sessions qui durent. */
|
||
const RECHECK_MS = 2 * 60 * 60 * 1000
|
||
|
||
export default function UpdateBanner(): JSX.Element | null {
|
||
const { toast } = useFeedback()
|
||
const [update, setUpdate] = useState<AvailableUpdate | null>(null)
|
||
const [dismissed, setDismissed] = useState(false)
|
||
const [installing, setInstalling] = useState(false)
|
||
const [progress, setProgress] = useState<number | null>(null)
|
||
|
||
const look = useCallback(async () => {
|
||
const found = await checkForUpdate()
|
||
if (found) setUpdate(found)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
// Au démarrage, mais sans retarder l'affichage de l'application.
|
||
const first = setTimeout(look, 4000)
|
||
const timer = setInterval(look, RECHECK_MS)
|
||
return () => {
|
||
clearTimeout(first)
|
||
clearInterval(timer)
|
||
}
|
||
}, [look])
|
||
|
||
if (!update || dismissed) return null
|
||
|
||
const install = async (): Promise<void> => {
|
||
setInstalling(true)
|
||
setProgress(0)
|
||
try {
|
||
// Au succès l'application redémarre : rien à faire de plus ici.
|
||
await update.install((pct) => setProgress(pct))
|
||
} catch (e) {
|
||
setInstalling(false)
|
||
setProgress(null)
|
||
toast(
|
||
`La mise à jour a échoué : ${(e as Error).message}. Réessaie plus tard ou préviens l'administrateur.`,
|
||
'error'
|
||
)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<AnimatePresence>
|
||
<motion.div
|
||
initial={{ height: 0, opacity: 0 }}
|
||
animate={{ height: 'auto', opacity: 1 }}
|
||
exit={{ height: 0, opacity: 0 }}
|
||
className="shrink-0 overflow-hidden border-b border-accent/25 bg-accent/10"
|
||
>
|
||
<div className="flex items-center gap-3 px-4 py-2">
|
||
<Sparkles size={15} className="shrink-0 text-accent" />
|
||
|
||
<span className="min-w-0 flex-1 text-xs text-ink">
|
||
<span className="font-medium">Version {update.version} disponible.</span>{' '}
|
||
{installing ? (
|
||
<span className="text-muted">
|
||
{progress === null || progress === 0
|
||
? 'Téléchargement…'
|
||
: progress < 100
|
||
? `Téléchargement ${progress} %…`
|
||
: 'Installation, l’application va redémarrer…'}
|
||
</span>
|
||
) : (
|
||
<span className="text-muted">
|
||
{update.notes?.split('\n')[0] || "L'application se relancera toute seule."}
|
||
</span>
|
||
)}
|
||
</span>
|
||
|
||
{installing ? (
|
||
<div className="h-1 w-32 shrink-0 overflow-hidden rounded-full bg-accent/20">
|
||
<div
|
||
className={`h-full rounded-full bg-accent transition-[width] duration-300 ${
|
||
progress === null ? 'w-1/3 animate-pulse' : ''
|
||
}`}
|
||
style={progress === null ? undefined : { width: `${progress}%` }}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<Button size="sm" variant="primary" icon={<Download size={13} />} onClick={install}>
|
||
Installer et redémarrer
|
||
</Button>
|
||
<IconButton
|
||
size="sm"
|
||
label="Plus tard"
|
||
icon={<X size={15} />}
|
||
onClick={() => setDismissed(true)}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
)
|
||
}
|
||
|
||
/** Ligne « Version X · Rechercher une mise à jour », pour l'écran d'aide. */
|
||
export function UpdateCheckRow({ version }: { version: string | null }): JSX.Element {
|
||
const { toast } = useFeedback()
|
||
const [busy, setBusy] = useState(false)
|
||
|
||
const look = async (): Promise<void> => {
|
||
setBusy(true)
|
||
const found = await checkForUpdate()
|
||
setBusy(false)
|
||
toast(
|
||
found
|
||
? `Version ${found.version} disponible — le bandeau apparaît en haut de l'écran.`
|
||
: 'Tu es déjà à jour.',
|
||
found ? 'info' : 'success'
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-center justify-between gap-4 border-t border-border pt-3 text-sm">
|
||
<span className="text-muted">
|
||
Version installée <span className="font-medium text-ink">{version ?? '—'}</span>
|
||
</span>
|
||
<Button size="sm" icon={<RefreshCw size={13} />} loading={busy} onClick={look}>
|
||
Rechercher une mise à jour
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|