LeLab / src /components /UpdateNotice.tsx
GitHub CI
Sync from leLab @ d1a1feb2485436934aca9c812f4fd196a9afc726
05ffe31
Raw
History Blame Contribute Delete
6.46 kB
import { useState } from "react";
import { Loader2, Copy, Sparkles, ChevronRight } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useApi } from "@/contexts/ApiContext";
import { useToast } from "@/hooks/use-toast";
import { useUpdateCheck } from "@/hooks/useUpdateCheck";
/**
* App-level popup that notifies the user when a newer LeLab is available on
* GitHub. Offers a copy-able upgrade command, a best-effort "Update now" button
* (runs the pip upgrade on the backend), and a "don't ask again" opt-out.
*/
const UpdateNotice = () => {
const { status, open, dismiss } = useUpdateCheck();
const { baseUrl, fetchWithHeaders } = useApi();
const { toast } = useToast();
const [dontAsk, setDontAsk] = useState(false);
const [updating, setUpdating] = useState(false);
const [output, setOutput] = useState<string | null>(null);
if (!status) return null;
const behind =
typeof status.commits_behind === "number" && status.commits_behind > 0
? `${status.commits_behind} commit${status.commits_behind === 1 ? "" : "s"} behind`
: "A new version is available";
const copyCommand = async () => {
if (!status.update_command) return;
try {
await navigator.clipboard.writeText(status.update_command);
toast({
title: "Copied",
description: "Update command copied to clipboard.",
});
} catch {
toast({
title: "Copy failed",
description: "Select and copy the command manually.",
variant: "destructive",
});
}
};
const runUpdate = async () => {
setUpdating(true);
setOutput(null);
try {
const r = await fetchWithHeaders(`${baseUrl}/system/update`, {
method: "POST",
});
const body: { success: boolean; message: string; output: string } =
await r.json();
if (body.success) {
toast({ title: "Updated", description: body.message });
dismiss(false);
} else {
setOutput(body.output || body.message);
toast({
title: "Update failed",
description: body.message,
variant: "destructive",
});
}
} catch (e) {
toast({
title: "Update failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
} finally {
setUpdating(false);
}
};
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o && !updating) dismiss(dontAsk);
}}
>
<DialogContent
className="bg-slate-800 border-slate-700 text-white max-w-lg"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-white">
<Sparkles className="w-5 h-5 text-amber-400" />
LeLab update available
</DialogTitle>
<DialogDescription className="text-slate-300">
You're {behind} 😱.
<br />
Update to get the latest fixes and features 🤗.
{status.compare_url && (
<>
{" "}
<a
href={status.compare_url}
target="_blank"
rel="noreferrer"
className="text-sky-300 underline hover:text-sky-200"
>
See what changed
</a>
.
</>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Collapsible>
<CollapsibleTrigger className="group flex items-center gap-1.5 text-xs font-medium text-slate-300 hover:text-white transition-colors">
<ChevronRight className="w-3.5 h-3.5 transition-transform group-data-[state=open]:rotate-90" />
Or update manually
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<div className="flex items-start gap-2">
<code className="min-w-0 flex-1 px-2 py-1.5 rounded bg-slate-900 text-sky-300 text-xs break-all whitespace-pre-wrap">
{status.update_command}
</code>
<Button
variant="outline"
size="icon"
onClick={copyCommand}
title="Copy command"
className="shrink-0 bg-slate-900 border-slate-600 text-white hover:bg-slate-700"
>
<Copy className="w-4 h-4" />
</Button>
</div>
</CollapsibleContent>
</Collapsible>
{output && (
<pre className="max-h-40 overflow-auto rounded bg-slate-900 p-2 text-xs text-slate-300 whitespace-pre-wrap">
{output}
</pre>
)}
<div className="flex items-center justify-between gap-3 pt-1">
<label className="flex items-center gap-2 text-sm text-slate-300">
<Checkbox
checked={dontAsk}
onCheckedChange={(v) => setDontAsk(v === true)}
className="border-slate-300 data-[state=checked]:bg-slate-300 data-[state=checked]:text-slate-900"
/>
Don't ask me again
</label>
<div className="flex items-center gap-2">
<Button
variant="ghost"
onClick={() => dismiss(dontAsk)}
disabled={updating}
className="text-slate-300 hover:bg-slate-700 hover:text-white"
>
Later
</Button>
{status.can_auto_update && (
<Button onClick={runUpdate} disabled={updating}>
{updating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Updating…
</>
) : (
"Update now"
)}
</Button>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default UpdateNotice;