Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 | 2x 58x 17x 2x 17x 17x 17x 17x 17x 17x | // SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.
import { useEffect, useState } from "react";
import { Download, X, RefreshCw } from "lucide-react";
import type { UpdateState } from "@/hooks/useUpdateChecker";
interface UpdateBannerProps {
state: UpdateState;
hidden?: boolean;
}
export const UpdateBanner = ({ state, hidden }: UpdateBannerProps) => {
if (!state.update || hidden) return null;
return <UpdateCard state={state} update={state.update} />;
};
/** The banner itself. It mounts each time it shows, so it can fade in. */
const UpdateCard = ({
state,
update,
}: {
state: UpdateState;
update: NonNullable<UpdateState["update"]>;
}) => {
const { installing, progress, error, packageInstall, dismiss, install } = state;
const [visible, setVisible] = useState(false);
// Start hidden for one frame so the transition has a state to run from.
useEffect(() => {
const id = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(id);
}, []);
return (
<div
className={`fixed bottom-4 right-4 z-50 flex items-center gap-3 bg-card border border-border rounded-xl px-4 py-3 shadow-2xl max-w-sm transition-all duration-300 ease-out ${
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
}`}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold leading-tight">
Update available — v{update.version}
</p>
{installing ? (
<p className="text-xs text-muted-foreground mt-0.5">
{progress !== null ? `Downloading… ${progress}%` : "Installing…"}
{packageInstall && progress === 100 && " (password required)"}
</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{update.body ?? "A new version is ready to install."}
</p>
)}
{installing && progress !== null && (
<div className="mt-1.5 h-1 w-full rounded-full bg-secondary overflow-hidden">
<div
className="h-full bg-primary transition-all duration-200"
style={{ width: `${progress}%` }}
/>
</div>
)}
</div>
{error && <p className="text-xs text-destructive mt-1">{error}</p>}
{installing ? (
<RefreshCw className="h-4 w-4 text-muted-foreground animate-spin shrink-0" />
) : (
<>
<button
onClick={install}
className="flex items-center gap-1.5 text-xs font-semibold bg-primary text-primary-foreground px-3 py-1.5 rounded-lg hover:bg-primary/90 transition-colors shrink-0"
>
<Download className="h-3 w-3" />
Install
</button>
<button
onClick={dismiss}
aria-label="Dismiss update"
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<X className="h-4 w-4" />
</button>
</>
)}
</div>
);
};
|