306 lines
9.6 KiB
TypeScript
306 lines
9.6 KiB
TypeScript
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>
|
||
)
|
||
}
|