1412da9a31
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
161 lines
4.7 KiB
TypeScript
161 lines
4.7 KiB
TypeScript
import type { ReactNode } from "react"
|
|
import { ArrowRight, CircleCheck, Lock, Pencil, Trash2 } from "lucide-react"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { cn } from "@/lib/utils"
|
|
import type { ChangesetResponse, RecordView } from "@/api/types"
|
|
|
|
type Tone = "update" | "delete" | "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%)]",
|
|
},
|
|
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 }: { record: RecordView; tone: Tone }) {
|
|
const meta = TONE_META[tone]
|
|
const showArrow = tone !== "delete"
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"group/row flex items-center gap-3 border-l-2 px-3 py-2.5 transition-colors",
|
|
"hover:bg-foreground/[0.025]",
|
|
)}
|
|
style={{ borderLeftColor: meta.dot }}
|
|
>
|
|
<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>
|
|
|
|
<span className="font-dns hidden shrink-0 items-center gap-1.5 text-xs text-muted-foreground sm:flex">
|
|
<Values values={record.actual} />
|
|
{showArrow && (
|
|
<>
|
|
<ArrowRight className="size-3 text-muted-foreground/50" strokeWidth={1.75} />
|
|
<span style={{ color: meta.dot }}>
|
|
<Values values={record.desired} />
|
|
</span>
|
|
</>
|
|
)}
|
|
</span>
|
|
|
|
{record.readOnly && (
|
|
<Badge
|
|
variant="secondary"
|
|
className="ml-1 shrink-0 gap-1 bg-muted text-[10px] text-muted-foreground"
|
|
>
|
|
<Lock className="size-2.5" strokeWidth={2} />
|
|
read-only
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Section({
|
|
tone,
|
|
records,
|
|
}: {
|
|
tone: Tone
|
|
records: RecordView[]
|
|
}) {
|
|
const meta = TONE_META[tone]
|
|
const Icon = meta.icon
|
|
|
|
return (
|
|
<section aria-label={meta.label} className="flex flex-col gap-2">
|
|
<header className="flex items-center gap-2 px-0.5">
|
|
<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, i) => (
|
|
<RecordRow key={`${record.type}-${record.name}-${i}`} record={record} tone={tone} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
export function DiffView({
|
|
changeset,
|
|
footerExtra,
|
|
}: {
|
|
changeset: ChangesetResponse
|
|
footerExtra?: ReactNode
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<Section tone="update" records={changeset.updates} />
|
|
<Section tone="delete" records={changeset.prunes} />
|
|
<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>
|
|
)
|
|
}
|