Files
Arnaud 1614da38cd 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>
2026-07-09 04:19:33 +02:00

138 lines
4.6 KiB
TypeScript
Raw Permalink 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.
"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>
);
}