Files
ProxMenux/AppImage/components/lxc-update-detection.tsx
T
MacRimi 99ad69e8cf Event-only modal cache + Settings edit gating + ES i18n polish
Modal caches now refresh on events only — the periodic prewarmer runs one-shot at startup, mount points split into static/runtime endpoints, and backups get a client 6-hour gate. Updates tab shows post-apply feedback and the script terminal no longer closes the parent modal. Settings adds edit gating on 3 cards with the 3-level contrast rule applied consistently. ES translation batch (~25 fixes) and What's New for 1.2.4.1-beta refreshed.
2026-08-14 20:17:45 +02:00

234 lines
8.6 KiB
TypeScript

"use client"
import { useEffect, useState } from "react"
import { Boxes, Info, Loader2, Settings2, CheckCircle2 } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface DetectionResponse {
success: boolean
enabled?: boolean
message?: string
purged?: number
}
export function LxcUpdateDetection() {
const t = useT()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [enabled, setEnabled] = useState<boolean>(true)
const [pending, setPending] = useState<boolean>(true)
const [editMode, setEditMode] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [lastPurged, setLastPurged] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
fetchApi<DetectionResponse>("/api/lxc-updates/detection")
.then(data => {
if (cancelled) return
if (data.success && typeof data.enabled === "boolean") {
setEnabled(data.enabled)
setPending(data.enabled)
} else {
setError(data.message || t("settings.lxcUpdateDetection.loadFailed"))
}
})
.catch(e => {
if (!cancelled) setError(t("settings.lxcUpdateDetection.loadFailed"))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
const hasChanges = pending !== enabled
function handleEdit() {
setEditMode(true)
setError(null)
setSaved(false)
setLastPurged(null)
}
function handleCancel() {
setPending(enabled)
setEditMode(false)
setError(null)
setLastPurged(null)
}
async function handleSave() {
if (!hasChanges) {
setEditMode(false)
return
}
setSaving(true)
setError(null)
setSaved(false)
setLastPurged(null)
try {
const data = await fetchApi<DetectionResponse>("/api/lxc-updates/detection", {
method: "POST",
body: JSON.stringify({ enabled: pending }),
})
if (!data.success) {
setError(data.message || t("settings.lxcUpdateDetection.saveFailed"))
return
}
setEnabled(pending)
setEditMode(false)
setSaved(true)
setTimeout(() => setSaved(false), 3000)
if (!pending && typeof data.purged === "number" && data.purged > 0) {
setLastPurged(data.purged)
}
// Notify the Notifications section so it hides/shows the
// lxc_updates_available toggle in real time.
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("proxmenux:lxc-detection-changed", { detail: { enabled: pending } }),
)
}
} catch (e) {
setError(t("settings.lxcUpdateDetection.saveFailed"))
} finally {
setSaving(false)
}
}
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
{/* Title row — flex-wrap so on narrow screens the badge can drop
under the title without dragging the icon along with it. The
icon stays on the same baseline as the title text on every
breakpoint thanks to `items-center` + leading-tight title. */}
<div className="flex items-center gap-2 flex-wrap min-w-0">
<Boxes className="h-5 w-5 text-purple-500 shrink-0" />
<CardTitle className="leading-tight">{t("settings.lxcUpdateDetection.title")}</CardTitle>
{enabled ? (
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
{t("status.active")}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] border-muted-foreground/30 text-muted-foreground">
{t("status.disabled")}
</Badge>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{saved && (
<span className="flex items-center gap-1 text-xs text-green-500">
<CheckCircle2 className="h-3.5 w-3.5" />
{t("status.saved")}
</span>
)}
{error && !editMode && (
<span
className="flex items-center gap-1 text-xs text-red-500 max-w-[40ch] truncate"
title={error}
>
{t("status.saveFailed")}: {error}
</span>
)}
{editMode ? (
<>
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
onClick={handleCancel}
disabled={saving}
>
{t("actions.cancel")}
</button>
<button
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
onClick={handleSave}
disabled={saving || !hasChanges}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
{t("actions.save")}
</button>
</>
) : (
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
onClick={handleEdit}
disabled={loading}
>
<Settings2 className="h-3 w-3" />
{t("actions.edit")}
</button>
)}
</div>
</div>
<CardDescription>
{t("settings.lxcUpdateDetection.descriptionStart")}{" "}
(<code>apt list --upgradable</code> / <code>apk list -u</code>)
{t("settings.lxcUpdateDetection.descriptionEnd")}
</CardDescription>
</CardHeader>
<CardContent
className={`space-y-5${
editMode
? " bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
: ""
}`}
>
{/* ── Enable/Disable ── single-line label + toggle. The description
paragraph was removed because the CardDescription above already
covers the behaviour; on mobile that second paragraph forced
the icon to top-align and made the toggle wrap awkwardly. */}
<div className="flex items-center justify-between gap-3 py-2 px-1">
<div className="flex items-center gap-2 min-w-0">
<Boxes
className={`h-4 w-4 shrink-0 ${pending ? "text-purple-500" : "text-muted-foreground"}`}
/>
<span className="text-sm font-medium truncate">{t("settings.lxcUpdateDetection.enableLabel")}</span>
</div>
<button
className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${
pending ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => editMode && setPending(p => !p)}
disabled={!editMode || saving}
role="switch"
aria-checked={pending}
aria-label={t("settings.lxcUpdateDetection.enableLabel")}
>
<span
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${
pending ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
{lastPurged !== null && lastPurged > 0 && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 border border-border">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("settings.lxcUpdateDetection.purgedMessage", { count: lastPurged })}
</p>
</div>
)}
{error && editMode && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<Info className="h-3.5 w-3.5 text-amber-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-amber-500 leading-relaxed break-all">{error}</p>
</div>
)}
</CardContent>
</Card>
)
}