fix: recipe version diff readability + i18n, tooltip on markdown export
- 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>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { diffWords } from "diff";
|
||||
import { diffWords, diffArrays } from "diff";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type DiffSnapshot = {
|
||||
title: string;
|
||||
@@ -40,62 +41,95 @@ function TextDiff({ before, after }: { before: string; after: string }) {
|
||||
}
|
||||
|
||||
function ListDiff({ before, after }: { before: string[]; after: string[] }) {
|
||||
const max = Math.max(before.length, after.length);
|
||||
const rows = [];
|
||||
for (let i = 0; i < max; i++) {
|
||||
const b = before[i];
|
||||
const a = after[i];
|
||||
if (b === undefined) {
|
||||
rows.push(
|
||||
<div key={i} className="bg-green-500/10 rounded px-2 py-1 text-sm text-green-700 dark:text-green-400">
|
||||
+ {a}
|
||||
</div>
|
||||
);
|
||||
} else if (a === undefined) {
|
||||
rows.push(
|
||||
<div key={i} className="bg-red-500/10 rounded px-2 py-1 text-sm text-red-700 dark:text-red-400 line-through">
|
||||
− {b}
|
||||
</div>
|
||||
);
|
||||
} else if (b === a) {
|
||||
rows.push(
|
||||
<div key={i} className="px-2 py-1 text-sm text-muted-foreground">
|
||||
{b}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
rows.push(
|
||||
<div key={i} className="rounded px-2 py-1">
|
||||
<TextDiff before={b} after={a} />
|
||||
</div>
|
||||
);
|
||||
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">Title</h3>
|
||||
<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">Description</h3>
|
||||
<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">Ingredients</h3>
|
||||
<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">Steps</h3>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user