This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+113
View File
@@ -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>
);
}