initial
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
checkForUpdate,
|
||||
IDLE_UPDATE_STATE,
|
||||
installUpdate,
|
||||
type UpdateState,
|
||||
} from '../lib/appUpdates';
|
||||
import { SparklesIcon, SpinnerIcon, XIcon } from './icons';
|
||||
|
||||
// Light-weight update UX: check once on mount, then every hour while the
|
||||
// app is open. When an update exists show a toast with "Install & Restart"
|
||||
// and a dismiss button. Dismiss is session-scoped — on next launch we check
|
||||
// again.
|
||||
export function UpdateToast() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [state, setState] = useState<UpdateState>(IDLE_UPDATE_STATE);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [progressPct, setProgressPct] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
const next = await checkForUpdate();
|
||||
if (!cancelled) setState(next);
|
||||
};
|
||||
void run();
|
||||
const id = window.setInterval(run, 60 * 60 * 1000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!state.available || dismissed) return null;
|
||||
|
||||
const handleInstall = async () => {
|
||||
setInstalling(true);
|
||||
setProgressPct(0);
|
||||
try {
|
||||
await installUpdate((downloaded, total) => {
|
||||
if (total && total > 0) setProgressPct((downloaded / total) * 100);
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
error: err instanceof Error ? err.message : 'install failed',
|
||||
}));
|
||||
setInstalling(false);
|
||||
setProgressPct(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed bottom-6 left-6 z-50 w-80 overflow-hidden rounded-2xl border border-white/10 bg-ink-900/95 p-4 shadow-2xl backdrop-blur-xl"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/25 text-brand-200 ring-1 ring-brand-400/30">
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-white">
|
||||
{t('app:update.available', { defaultValue: 'Update verfügbar' })}
|
||||
{state.version ? ' · v' + state.version : ''}
|
||||
</p>
|
||||
{state.notes && (
|
||||
<p className="mt-0.5 line-clamp-3 text-xs text-neutral-400">{state.notes}</p>
|
||||
)}
|
||||
{state.error && (
|
||||
<p className="mt-1 text-xs text-rose-300">{state.error}</p>
|
||||
)}
|
||||
</div>
|
||||
{!installing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label={t('common:close', { defaultValue: 'Schließen' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleInstall()}
|
||||
disabled={installing}
|
||||
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-3 py-2 text-sm font-semibold text-white transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{installing ? (
|
||||
<>
|
||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{progressPct != null
|
||||
? t('app:update.downloading', { defaultValue: 'Lade…' }) +
|
||||
' ' +
|
||||
Math.round(progressPct) +
|
||||
'%'
|
||||
: t('app:update.installing', { defaultValue: 'Installiere…' })}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t('app:update.install', { defaultValue: 'Installieren & Neustarten' })}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user