import { Box, Button, DialogActions, LinearProgress, Link, Typography } from "@mui/material"; import { getLocalizedString } from "@olenbetong/appframe-core"; import { ResponsiveDialog } from "@olenbetong/appframe-mui"; import { useEffect, useRef, useState } from "react"; export type MergeProgressDialogProps = { open: boolean; onClose: () => void; /** Called once when the dialog opens; should return the merger ID for the start route */ getMergerId: () => Promise; mergeType?: "pdf" | "zip"; }; type MergePhase = | { kind: "preparing" } | { kind: "processing"; completed: number; total: number; currentFile: string; startedAt: number } | { kind: "completed"; jobId: string; fileName: string } | { kind: "failed"; error: string }; function formatRemaining(ms: number) { if (ms < 60_000) return `~${Math.ceil(ms / 1000)}s`; let mins = Math.floor(ms / 60_000); let secs = Math.ceil((ms % 60_000) / 1000); return secs > 0 ? `~${mins}m ${secs}s` : `~${mins}m`; } export function MergeProgressDialog({ open, onClose, getMergerId, mergeType = "pdf" }: MergeProgressDialogProps) { let [phase, setPhase] = useState({ kind: "preparing" }); let [now, setNow] = useState(Date.now); let esRef = useRef(null); let doneRef = useRef(false); // Tick every second while processing to recompute the time estimate useEffect(() => { if (phase.kind !== "processing") return; let timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, [phase.kind]); // Start the merge job each time the dialog opens // oxlint-disable-next-line react-hooks/exhaustive-deps -- intentionally only re-runs on open change useEffect(() => { if (!open) return; setPhase({ kind: "preparing" }); doneRef.current = false; let cancelled = false; let es: EventSource | null = null; async function start() { try { let mergerId = await getMergerId(); if (cancelled) return; let response = await fetch(`/api/ob/merger/start/${mergerId}/${mergeType}`, { method: "POST" }); if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); let { jobId } = await response.json(); if (cancelled) return; es = new EventSource(`/api/ob/merger/events/${jobId}`); esRef.current = es; es.addEventListener("progress", (e) => { let d = JSON.parse((e as MessageEvent).data); setPhase((prev) => ({ kind: "processing", completed: d.completed, total: d.total, currentFile: d.currentFile ?? "", startedAt: prev.kind === "processing" ? prev.startedAt : Date.now(), })); setNow(Date.now()); }); es.addEventListener("done", (e) => { doneRef.current = true; es?.close(); let d = JSON.parse((e as MessageEvent).data); setPhase({ kind: "completed", jobId, fileName: d.fileName ?? "" }); window.open(`/api/ob/merger/download/${jobId}`); }); // Catches both server-sent "event: error" and connection errors. // After a "done" event the server closes the stream, which also fires error — // doneRef guards against treating that as a failure. es.addEventListener("error", (e) => { if (doneRef.current) return; es?.close(); let message = getLocalizedString("Unknown error"); let data = (e as MessageEvent).data; if (data) { try { message = JSON.parse(data).message ?? message; } catch {} } setPhase({ kind: "failed", error: message }); }); } catch (err: unknown) { if (!cancelled) { setPhase({ kind: "failed", error: (err as Error)?.message ?? String(err) }); } } } start(); return () => { cancelled = true; es?.close(); esRef.current = null; }; }, [open]); function handleClose() { esRef.current?.close(); esRef.current = null; onClose(); } let pct = 0; let remaining: string | null = null; if (phase.kind === "processing" && phase.total > 0) { pct = Math.round((phase.completed / phase.total) * 100); if (phase.completed > 0) { let elapsed = now - phase.startedAt; let msPerFile = elapsed / phase.completed; let remainingMs = msPerFile * (phase.total - phase.completed); remaining = formatRemaining(remainingMs); } } let title = phase.kind === "completed" ? getLocalizedString("Merge complete") : phase.kind === "failed" ? getLocalizedString("Merge failed") : getLocalizedString("Merging drawings"); let canClose = phase.kind === "completed" || phase.kind === "failed"; return ( {(phase.kind === "preparing" || phase.kind === "processing") && ( )} {phase.kind === "preparing" && ( {getLocalizedString("Starting merge job…")} )} {phase.kind === "processing" && ( <> {phase.completed} / {phase.total} {getLocalizedString("files")} {remaining && ( {" "} — {remaining} )} {phase.currentFile && ( {phase.currentFile} )} )} {phase.kind === "completed" && ( <> {getLocalizedString("Download started. You can close this dialog.")} {getLocalizedString("If the file did not open automatically, ")}{" "} {getLocalizedString("click here to open it")} . )} {phase.kind === "failed" && ( {phase.error} )} ); }