1614da38cd
- Version compare used positional (index-by-index) list alignment — inserting one ingredient in the middle shifted every subsequent line out of alignment, making the whole rest of the list look changed. Switch to diffArrays (LCS-based) so insertions/deletions are detected properly; adjacent removed+added chunks still get paired for word-level diffing so a single edited item doesn't render as a full delete+insert. - Translated "Title"/"Description"/"Ingredients"/"Steps" diff section headers, "Version History" sheet title, compare/restore buttons, loading/empty states, and the version-detail ingredient/step count summary (with proper plural forms). - formatDate() in version history was hardcoded to "en-US" regardless of the app's locale — now uses the session locale. - ExportMarkdownButton had no hover tooltip (bare title attribute) — wrap it in the same Tooltip pattern every other icon button in the toolbar uses. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
138 lines
4.6 KiB
TypeScript
138 lines
4.6 KiB
TypeScript
"use client";
|
||
|
||
import { diffWords, diffArrays } from "diff";
|
||
import { useTranslations } from "next-intl";
|
||
|
||
export type DiffSnapshot = {
|
||
title: string;
|
||
description?: string | null;
|
||
ingredients: Array<{ rawName: string; quantity?: string | number | null; unit?: string | null; note?: string | null }>;
|
||
steps: Array<{ instruction: string; timerSeconds?: number | null }>;
|
||
};
|
||
|
||
function ingredientLine(i: DiffSnapshot["ingredients"][number]): string {
|
||
return [i.quantity, i.unit, i.rawName, i.note && `(${i.note})`].filter(Boolean).join(" ");
|
||
}
|
||
|
||
function stepLine(s: DiffSnapshot["steps"][number]): string {
|
||
return s.instruction;
|
||
}
|
||
|
||
function TextDiff({ before, after }: { before: string; after: string }) {
|
||
const parts = diffWords(before, after);
|
||
return (
|
||
<p className="text-sm leading-relaxed">
|
||
{parts.map((part, i) => (
|
||
<span
|
||
key={i}
|
||
className={
|
||
part.added
|
||
? "bg-green-500/20 text-green-700 dark:text-green-400"
|
||
: part.removed
|
||
? "bg-red-500/20 text-red-700 dark:text-red-400 line-through"
|
||
: undefined
|
||
}
|
||
>
|
||
{part.value}
|
||
</span>
|
||
))}
|
||
</p>
|
||
);
|
||
}
|
||
|
||
function ListDiff({ before, after }: { before: string[]; after: string[] }) {
|
||
const changes = diffArrays(before, after);
|
||
const rows: React.ReactNode[] = [];
|
||
let key = 0;
|
||
|
||
for (let i = 0; i < changes.length; i++) {
|
||
const change = changes[i]!;
|
||
|
||
// A removed chunk immediately followed by an added chunk is usually an
|
||
// edit, not a delete+insert — word-diff matching pairs so a one-word
|
||
// tweak doesn't render as an unreadable full remove + full add.
|
||
if (change.removed && changes[i + 1]?.added) {
|
||
const removedItems = change.value;
|
||
const addedItems = changes[i + 1]!.value;
|
||
const pairCount = Math.min(removedItems.length, addedItems.length);
|
||
|
||
for (let j = 0; j < pairCount; j++) {
|
||
rows.push(
|
||
<div key={key++} className="rounded px-2 py-1">
|
||
<TextDiff before={removedItems[j]!} after={addedItems[j]!} />
|
||
</div>
|
||
);
|
||
}
|
||
for (let j = pairCount; j < removedItems.length; j++) {
|
||
rows.push(
|
||
<div key={key++} className="bg-red-500/10 rounded px-2 py-1 text-sm text-red-700 dark:text-red-400 line-through">
|
||
− {removedItems[j]}
|
||
</div>
|
||
);
|
||
}
|
||
for (let j = pairCount; j < addedItems.length; j++) {
|
||
rows.push(
|
||
<div key={key++} className="bg-green-500/10 rounded px-2 py-1 text-sm text-green-700 dark:text-green-400">
|
||
+ {addedItems[j]}
|
||
</div>
|
||
);
|
||
}
|
||
i++; // consumed the paired "added" chunk too
|
||
continue;
|
||
}
|
||
|
||
for (const item of change.value) {
|
||
if (change.added) {
|
||
rows.push(
|
||
<div key={key++} className="bg-green-500/10 rounded px-2 py-1 text-sm text-green-700 dark:text-green-400">
|
||
+ {item}
|
||
</div>
|
||
);
|
||
} else if (change.removed) {
|
||
rows.push(
|
||
<div key={key++} className="bg-red-500/10 rounded px-2 py-1 text-sm text-red-700 dark:text-red-400 line-through">
|
||
− {item}
|
||
</div>
|
||
);
|
||
} else {
|
||
rows.push(
|
||
<div key={key++} className="px-2 py-1 text-sm text-muted-foreground">
|
||
{item}
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
return <div className="space-y-1">{rows}</div>;
|
||
}
|
||
|
||
export function VersionDiffView({ before, after }: { before: DiffSnapshot; after: DiffSnapshot }) {
|
||
const t = useTranslations("recipe");
|
||
return (
|
||
<div className="space-y-6">
|
||
<section className="space-y-2">
|
||
<h3 className="text-sm font-semibold text-muted-foreground">{t("diffTitleLabel")}</h3>
|
||
<TextDiff before={before.title} after={after.title} />
|
||
</section>
|
||
|
||
{(before.description || after.description) && (
|
||
<section className="space-y-2">
|
||
<h3 className="text-sm font-semibold text-muted-foreground">{t("diffDescriptionLabel")}</h3>
|
||
<TextDiff before={before.description ?? ""} after={after.description ?? ""} />
|
||
</section>
|
||
)}
|
||
|
||
<section className="space-y-2">
|
||
<h3 className="text-sm font-semibold text-muted-foreground">{t("diffIngredientsLabel")}</h3>
|
||
<ListDiff before={before.ingredients.map(ingredientLine)} after={after.ingredients.map(ingredientLine)} />
|
||
</section>
|
||
|
||
<section className="space-y-2">
|
||
<h3 className="text-sm font-semibold text-muted-foreground">{t("diffStepsLabel")}</h3>
|
||
<ListDiff before={before.steps.map(stepLine)} after={after.steps.map(stepLine)} />
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|