codex-proxy / web /src /components /UpdateModal.tsx
icebear0828
feat: Docker image auto-publish to GHCR + Watchtower support
7ff102d
raw
history blame
9.14 kB
import { useEffect, useRef } from "preact/hooks";
import { useI18n } from "../../../shared/i18n/context";
import type { UpdateStep } from "../../../shared/hooks/use-update-status";
import type { TranslationKey } from "../../../shared/i18n/translations";
const STEP_LABELS: Record<string, TranslationKey> = {
pull: "updatePulling",
install: "updateInstalling",
build: "updateBuilding",
restart: "updateRestarting",
};
interface UpdateModalProps {
open: boolean;
onClose: () => void;
mode: "git" | "docker" | "electron";
commits: { hash: string; message: string }[];
release: { version: string; body: string; url: string } | null;
onApply: () => void;
applying: boolean;
restarting: boolean;
restartFailed: boolean;
updateSteps?: UpdateStep[];
}
export function UpdateModal({
open,
onClose,
mode,
commits,
release,
onApply,
applying,
restarting,
restartFailed,
updateSteps = [],
}: UpdateModalProps) {
const { t } = useI18n();
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
} else if (!open && dialog.open) {
dialog.close();
}
}, [open]);
// Close on backdrop click
const handleBackdropClick = (e: MouseEvent) => {
if (e.target === dialogRef.current && !restarting && !applying) {
onClose();
}
};
// Close on Escape
const handleCancel = (e: Event) => {
if (restarting || applying) {
e.preventDefault();
}
};
if (!open) return null;
return (
<dialog
ref={dialogRef}
onClick={handleBackdropClick}
onCancel={handleCancel}
class="backdrop:bg-black/50 bg-transparent p-0 m-0 w-full h-full max-w-none max-h-none inset-0 open:flex open:items-center open:justify-center"
>
<div class="w-full max-w-lg bg-white dark:bg-card-dark rounded-xl shadow-2xl border border-gray-200 dark:border-border-dark overflow-hidden">
{/* Header */}
<div class="px-5 py-4 border-b border-gray-200 dark:border-border-dark flex items-center justify-between">
<h2 class="text-base font-bold text-slate-800 dark:text-text-main">
{t("updateTitle")}
</h2>
{!restarting && !applying && (
<button
onClick={onClose}
class="p-1 rounded-md text-slate-400 hover:text-slate-600 dark:text-text-dim dark:hover:text-text-main hover:bg-slate-100 dark:hover:bg-border-dark transition-colors"
>
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Body */}
<div class="px-5 py-4">
{(applying || restarting) && updateSteps.length > 0 ? (
<div class="space-y-2 py-2">
{updateSteps.map((s) => (
<div key={s.step} class="flex items-center gap-3 text-sm">
{s.status === "done" ? (
<svg class="size-5 text-primary shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
) : s.status === "error" ? (
<svg class="size-5 text-red-500 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg class="size-5 animate-spin text-primary shrink-0" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
<span class={s.status === "done" ? "text-slate-500 dark:text-text-dim" : "text-slate-700 dark:text-text-main font-medium"}>
{t(STEP_LABELS[s.step] ?? ("updateBuilding" as TranslationKey))}
</span>
</div>
))}
</div>
) : restarting ? (
<div class="flex flex-col items-center gap-3 py-6">
<svg class="size-8 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span class="text-sm font-medium text-slate-600 dark:text-text-dim">
{t("updateRestarting")}
</span>
</div>
) : restartFailed ? (
<div class="flex flex-col items-center gap-3 py-6">
<svg class="size-8 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<span class="text-sm font-medium text-red-600 dark:text-red-400">
{t("restartFailed")}
</span>
</div>
) : mode === "git" ? (
<ul class="space-y-1 text-sm text-slate-600 dark:text-text-dim max-h-64 overflow-y-auto">
{commits.map((c) => (
<li key={c.hash} class="flex gap-2 py-0.5">
<code class="text-primary/70 text-xs shrink-0 pt-0.5">{c.hash}</code>
<span class="text-xs">{c.message}</span>
</li>
))}
</ul>
) : (
<>
{release && (
<pre class="text-xs text-slate-600 dark:text-text-dim whitespace-pre-wrap max-h-64 overflow-y-auto leading-relaxed">
{release.body}
</pre>
)}
</>
)}
</div>
{/* Footer */}
{!restarting && !restartFailed && (
<div class="px-5 py-3 border-t border-gray-200 dark:border-border-dark flex items-center justify-end gap-2">
<button
onClick={onClose}
disabled={applying}
class="px-4 py-2 text-xs font-semibold text-slate-600 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark rounded-lg transition-colors disabled:opacity-50"
>
{t("cancelBtn")}
</button>
{mode === "git" ? (
<button
onClick={onApply}
disabled={applying}
class="px-4 py-2 text-xs font-semibold bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 transition-colors flex items-center gap-1.5"
>
{applying && (
<svg class="size-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{applying ? t("applyingUpdate") : t("updateNow")}
</button>
) : mode === "docker" ? (
<div class="flex flex-col items-end gap-1.5">
<button
onClick={() => { navigator.clipboard.writeText("docker compose pull && docker compose up -d"); }}
class="px-4 py-2 text-xs font-semibold bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
>
{t("copy")} docker compose pull && docker compose up -d
</button>
<span class="text-[10px] text-slate-400 dark:text-text-dim">
{t("dockerAutoUpdateHint")}
</span>
</div>
) : (
<span class="text-xs text-slate-500 dark:text-text-dim italic">
{t("electronUpdateHint")}
</span>
)}
</div>
)}
{/* Close button for restartFailed state */}
{restartFailed && (
<div class="px-5 py-3 border-t border-gray-200 dark:border-border-dark flex justify-end">
<button
onClick={onClose}
class="px-4 py-2 text-xs font-semibold text-slate-600 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark rounded-lg transition-colors"
>
{t("close")}
</button>
</div>
)}
</div>
</dialog>
);
}