feat(web): DiffView + DomainDiffPage с prune-guard по умолчанию выключенным
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import { useId, useState } from "react"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { AlertTriangle, Loader2, Play, RefreshCw, TriangleAlert } from "lucide-react"
|
||||
import { DiffView } from "@/components/DiffView"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useApplyDomain, useCheckDomain } from "@/hooks/useApi"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function DomainDiffPage() {
|
||||
const { id = "" } = useParams()
|
||||
const check = useCheckDomain(id)
|
||||
const apply = useApplyDomain(id)
|
||||
const [applyPrunes, setApplyPrunes] = useState(false)
|
||||
const pruneCheckboxId = useId()
|
||||
|
||||
const changeset = check.data
|
||||
const hasPrunes = (changeset?.prunes.length ?? 0) > 0
|
||||
const hasUpdates = (changeset?.updates.length ?? 0) > 0
|
||||
const pruneWarning = applyPrunes && hasPrunes
|
||||
|
||||
function onApply() {
|
||||
apply.mutate({ applyUpdates: true, applyPrunes })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-dns text-[11px] tracking-wider text-muted-foreground uppercase">
|
||||
domain / check
|
||||
</span>
|
||||
<h1 className="font-dns text-xl font-semibold tracking-tight text-foreground">
|
||||
{id}
|
||||
</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => check.refetch()}
|
||||
disabled={check.isFetching}
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", check.isFetching && "animate-spin")} strokeWidth={1.75} />
|
||||
Recheck
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{check.isPending && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-card px-4 py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||
Вычисляю дифф…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{check.isError && (
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Не удалось получить дифф</span>
|
||||
<span className="font-dns text-xs opacity-90">{check.error.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changeset && (
|
||||
<>
|
||||
<DiffView changeset={changeset} />
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4">
|
||||
<Label
|
||||
htmlFor={pruneCheckboxId}
|
||||
className="flex items-start gap-2.5 text-sm font-normal"
|
||||
>
|
||||
<Checkbox
|
||||
id={pruneCheckboxId}
|
||||
aria-label="prune — удалить лишние записи"
|
||||
checked={applyPrunes}
|
||||
onCheckedChange={(v) => setApplyPrunes(v === true)}
|
||||
className="mt-0.5"
|
||||
style={
|
||||
applyPrunes
|
||||
? ({ borderColor: "var(--diff-delete)", background: "var(--diff-delete)" } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="font-medium text-foreground">
|
||||
Prune — удалить записи, которых нет в шаблоне
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
По умолчанию выключено. Apply меняет только записи из шаблона.
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
{pruneWarning && (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs"
|
||||
style={{
|
||||
color: "var(--diff-delete)",
|
||||
background: "color-mix(in oklch, var(--diff-delete), transparent 90%)",
|
||||
}}
|
||||
role="alert"
|
||||
>
|
||||
<TriangleAlert className="mt-px size-3.5 shrink-0" strokeWidth={2} />
|
||||
<span>
|
||||
Будет безвозвратно удалено записей:{" "}
|
||||
<span className="font-dns font-semibold">{changeset.prunes.length}</span>. Действие необратимо.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
|
||||
{apply.isError ? (
|
||||
<span className="font-dns text-xs text-destructive">{apply.error.message}</span>
|
||||
) : apply.isSuccess ? (
|
||||
<span className="font-dns text-xs" style={{ color: "var(--diff-add)" }}>
|
||||
Применено ✓
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{hasUpdates || (applyPrunes && hasPrunes)
|
||||
? "Готово к применению"
|
||||
: "Изменений для применения нет"}
|
||||
</span>
|
||||
)}
|
||||
<Button onClick={onApply} disabled={apply.isPending}>
|
||||
{apply.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||
) : (
|
||||
<Play className="size-4" strokeWidth={1.75} />
|
||||
)}
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user