Files
dns-autoresolver/web/src/components/DiffView.tsx
T

306 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ReactNode } from "react"
import { ArrowRight, BookmarkCheck, CircleCheck, Lock, Pencil, Trash2, Undo2 } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils"
import type { ChangesetResponse, RecordView } from "@/api/types"
type Tone = "update" | "delete" | "custom" | "readonly"
const TONE_META: Record<
Tone,
{ label: string; empty: string; icon: typeof Pencil; dot: string; ring: string }
> = {
update: {
label: "Updates",
empty: "Нет изменений — все значения совпадают.",
icon: Pencil,
dot: "var(--diff-update)",
ring: "ring-[color-mix(in_oklch,var(--diff-update),transparent_78%)]",
},
delete: {
label: "Prunes",
empty: "Нечего удалять.",
icon: Trash2,
dot: "var(--diff-delete)",
ring: "ring-[color-mix(in_oklch,var(--diff-delete),transparent_78%)]",
},
custom: {
label: "Customs",
empty: "Нет записей, помеченных как осознанные.",
icon: BookmarkCheck,
dot: "var(--diff-custom)",
ring: "ring-[color-mix(in_oklch,var(--diff-custom),transparent_80%)]",
},
readonly: {
label: "Read-only",
empty: "Нет read-only записей.",
icon: Lock,
dot: "var(--diff-readonly)",
ring: "ring-[color-mix(in_oklch,var(--diff-readonly),transparent_82%)]",
},
}
function Values({ values }: { values?: string[] }) {
if (!values || values.length === 0) {
return <span className="text-muted-foreground/50"></span>
}
return <>{values.join(", ")}</>
}
function RecordRow({
record,
tone,
checked,
onToggle,
action,
}: {
record: RecordView
tone: Tone
checked?: boolean
onToggle?: (key: string) => void
action?: ReactNode
}) {
const meta = TONE_META[tone]
const showArrow = tone !== "delete"
// Read-only records aren't selectable — onToggle is only passed for
// update/delete sections. Presence of onToggle is the selectability flag,
// not the tone, so this stays correct if a tone's selectability ever changes.
const selectable = onToggle !== undefined
return (
<div
className={cn(
"group/row flex flex-col gap-1.5 border-l-2 px-3 py-2.5 transition-colors",
"hover:bg-foreground/[0.025]",
)}
style={{ borderLeftColor: meta.dot }}
>
{/* Top line: (optional) checkbox, type badge, name, read-only flag —
always single-line, never affected by how long the record values are. */}
<div className="flex items-center gap-3">
{selectable && (
<Checkbox
checked={checked ?? false}
onCheckedChange={() => onToggle!(record.key)}
aria-label={`${tone === "delete" ? "Удалить" : "Применить"} ${record.type} ${record.name}`}
className="shrink-0"
/>
)}
<Badge
variant="outline"
className="font-dns w-11 shrink-0 justify-center border-border text-[10px] tracking-wide text-muted-foreground"
>
{record.type}
</Badge>
<span className="font-dns min-w-0 flex-1 truncate text-sm text-foreground">
{record.name}
</span>
{record.readOnly && (
<Badge
variant="secondary"
className="shrink-0 gap-1 bg-muted text-[10px] text-muted-foreground"
>
<Lock className="size-2.5" strokeWidth={2} />
read-only
</Badge>
)}
{action}
</div>
{/* Values line: plain block-level text (not flex) so a long
unbreakable value like a DKIM key wraps within the row's own
width instead of stretching it — a flex item's content can
otherwise refuse to shrink below its intrinsic width. Indented
to align under the name (badge width + gap, plus checkbox width +
gap when this row is selectable). */}
<div
className={cn(
"font-dns hidden text-xs leading-relaxed break-all text-muted-foreground sm:block",
selectable ? "pl-[5.25rem]" : "pl-14",
)}
>
<Values values={record.actual} />
{showArrow && (
<>
{" "}
<ArrowRight
className="mx-1 inline-block size-3 align-[-2px] text-muted-foreground/50"
strokeWidth={1.75}
/>{" "}
<span style={{ color: meta.dot }}>
<Values values={record.desired} />
</span>
</>
)}
</div>
</div>
)
}
function Section({
tone,
records,
selected,
onToggle,
onToggleAll,
renderAction,
}: {
tone: Tone
records: RecordView[]
selected?: Set<string>
onToggle?: (key: string) => void
onToggleAll?: (checked: boolean) => void
renderAction?: (record: RecordView) => ReactNode
}) {
const meta = TONE_META[tone]
const Icon = meta.icon
// Read-only (NS/SOA) and custom records are never selectable — only
// update/delete sections receive selection props from DiffView.
const selectable =
tone !== "readonly" && tone !== "custom" && !!selected && !!onToggle && !!onToggleAll
const allSelected = selectable && records.length > 0 && records.every((r) => selected!.has(r.key))
const someSelected = selectable && records.some((r) => selected!.has(r.key))
const indeterminate = someSelected && !allSelected
return (
<section aria-label={meta.label} className="flex flex-col gap-2">
<header className="flex items-center gap-2 px-0.5">
{selectable && records.length > 0 && (
<Checkbox
checked={allSelected}
indeterminate={indeterminate}
onCheckedChange={(v) => onToggleAll!(v === true)}
aria-label={`Выбрать все — ${meta.label}`}
/>
)}
<Icon className="size-3.5" strokeWidth={1.75} style={{ color: meta.dot }} />
<h2 className="text-xs font-semibold tracking-wide text-foreground uppercase">
{meta.label}
</h2>
<Badge variant="outline" className="font-dns h-4.5 px-1.5 text-[10px] text-muted-foreground">
{records.length}
</Badge>
</header>
{records.length === 0 ? (
<p className="rounded-lg border border-dashed border-border px-3 py-2.5 text-sm text-muted-foreground/70">
{meta.empty}
</p>
) : (
<div
className={cn(
"flex flex-col divide-y divide-border rounded-lg bg-card ring-1",
meta.ring,
)}
>
{records.map((record) => (
<RecordRow
key={record.key}
record={record}
tone={tone}
checked={selectable ? selected!.has(record.key) : undefined}
onToggle={selectable ? onToggle : undefined}
action={renderAction?.(record)}
/>
))}
</div>
)}
</section>
)
}
export function DiffView({
changeset,
selectedUpdates,
selectedPrunes,
onToggleUpdate,
onTogglePrune,
onToggleAllUpdates,
onToggleAllPrunes,
onMarkCustom,
onUnmarkCustom,
footerExtra,
}: {
changeset: ChangesetResponse
selectedUpdates: Set<string>
selectedPrunes: Set<string>
onToggleUpdate: (key: string) => void
onTogglePrune: (key: string) => void
onToggleAllUpdates: (checked: boolean) => void
onToggleAllPrunes: (checked: boolean) => void
onMarkCustom?: (key: string) => void
onUnmarkCustom?: (key: string) => void
footerExtra?: ReactNode
}) {
// Defensive: a field may arrive as null (e.g. a nil slice from an older
// backend) — normalise to [] so Section never calls .length/.map on null.
return (
<div className="flex flex-col gap-6">
<Section
tone="update"
records={changeset.updates ?? []}
selected={selectedUpdates}
onToggle={onToggleUpdate}
onToggleAll={onToggleAllUpdates}
/>
<Section
tone="delete"
records={changeset.prunes ?? []}
selected={selectedPrunes}
onToggle={onTogglePrune}
onToggleAll={onToggleAllPrunes}
renderAction={
onMarkCustom &&
((record) => (
<Button
variant="ghost"
size="icon-sm"
aria-label={`В customs ${record.type} ${record.name}`}
onClick={() => onMarkCustom(record.key)}
>
<BookmarkCheck className="size-3.5" strokeWidth={1.75} />
</Button>
))
}
/>
<Section
tone="custom"
records={changeset.customs ?? []}
renderAction={
onUnmarkCustom &&
((record) => (
<Button
variant="ghost"
size="icon-sm"
aria-label={`Вернуть в дифф ${record.type} ${record.name}`}
onClick={() => onUnmarkCustom(record.key)}
>
<Undo2 className="size-3.5" strokeWidth={1.75} />
</Button>
))
}
/>
<Section tone="readonly" records={changeset.readOnly ?? []} />
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<CircleCheck
className="size-3.5"
strokeWidth={1.75}
style={{ color: "var(--diff-insync)" }}
/>
<span className="font-dns">{changeset.inSyncCount}</span>
<span>record{changeset.inSyncCount === 1 ? "" : "s"} in sync</span>
</div>
{footerExtra}
</div>
</div>
)
}