import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { X, ExternalLink, Copy, Check, Container, GitBranch, Github, Star } from "lucide-react"; import type { UpdateInfo, DeployMode } from "../../shared/types"; import { useT } from "../i18n"; interface UpdateModalProps { info: UpdateInfo; onClose: () => void; } type TabKey = Exclude; const COMMANDS: Record = { ghcr: "docker compose pull && docker compose up -d", source: "git pull && docker compose up -d --build", }; export function UpdateModal({ info, onClose }: UpdateModalProps) { const { t } = useT(); // Default tab: detected mode if it's ghcr or source, otherwise ghcr. const initialTab: TabKey = info.deployMode === "source" ? "source" : "ghcr"; const [tab, setTab] = useState(initialTab); const [copied, setCopied] = useState(false); // Lock body scroll + block wheel events on canvas (React Flow zooms on wheel) // while the modal is open. Restore on close. useEffect(() => { const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; const blockWheel = (e: WheelEvent) => { const target = e.target as HTMLElement; if (!target.closest("[data-update-modal]")) { e.preventDefault(); e.stopPropagation(); } }; document.addEventListener("wheel", blockWheel, { passive: false, capture: true }); return () => { document.body.style.overflow = prevOverflow; document.removeEventListener("wheel", blockWheel, { capture: true } as any); }; }, []); const copy = async () => { const text = COMMANDS[tab]; let ok = false; try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); ok = true; } } catch {} if (!ok) { // Fallback for non-secure contexts (HTTP from LAN IP, older browsers). try { const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.left = "-9999px"; ta.style.top = "0"; ta.setAttribute("readonly", ""); document.body.appendChild(ta); ta.focus(); ta.select(); ta.setSelectionRange(0, text.length); ok = document.execCommand("copy"); document.body.removeChild(ta); } catch {} } if (ok) { setCopied(true); setTimeout(() => setCopied(false), 1500); } else { console.warn("ContainerFlow: clipboard copy failed"); } }; const releaseLines = info.releaseNotes ? info.releaseNotes.split("\n").slice(0, 15) : []; return createPortal(
e.stopPropagation()} > {/* Header — 3-row grid so version aligns with "X versions behind" badge, and release-notes link aligns with the repo link. */}
{/* Close button — absolutely positioned top-right so it doesn't affect grid row heights. */} {/* Row 1: label / (empty, X lives absolute) */}
{t("update.available")}
{/* spacer matching X width */} {/* Row 2: version / releases-behind badge */}
v{info.current}{" "} {" "} v{info.latest}
{info.releasesAhead > 1 ? ( {t("update.releasesBehind").replace("{n}", String(info.releasesAhead))} ) : (
)} {/* Row 3: release notes link / repo link */} {info.releaseUrl ? ( {t("update.fullNotes")} ) : ( {/* Release notes preview — caps height + scrolls when there are many changes */} {releaseLines.length > 0 && (
{t("update.whatsNew")}
    {releaseLines.map((line, i) => (
  • {line}
  • ))}
)} {/* How to update — tabs */}
{t("update.howToUpdate")}
{/* Tab buttons */}
{/* Tab description */}
{tab === "ghcr" ? t("update.ghcrHint") : t("update.sourceHint")} {info.deployMode === tab && ( · {t("update.detectedMode")} )}
{/* Command block */}
{COMMANDS[tab]}
, document.body ); }