| 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"; |
|
|
| |
| |
| |
| |
| |
| 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; |
|
|