From 1614da38cdf16c96a8573dfb0d5ff7189b9a95b7 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Thu, 9 Jul 2026 04:19:33 +0200 Subject: [PATCH] fix: recipe version diff readability + i18n, tooltip on markdown export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../components/recipe/version-diff-view.tsx | 102 ++++++++++++------ .../recipe/version-history-button.tsx | 33 +++--- .../shared/export-markdown-button.tsx | 18 +++- apps/web/messages/en.json | 13 +++ apps/web/messages/fr.json | 13 +++ 5 files changed, 125 insertions(+), 54 deletions(-) diff --git a/apps/web/components/recipe/version-diff-view.tsx b/apps/web/components/recipe/version-diff-view.tsx index fee1e1b..9d39b4b 100644 --- a/apps/web/components/recipe/version-diff-view.tsx +++ b/apps/web/components/recipe/version-diff-view.tsx @@ -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( -
- + {a} -
- ); - } else if (a === undefined) { - rows.push( -
- − {b} -
- ); - } else if (b === a) { - rows.push( -
- {b} -
- ); - } else { - rows.push( -
- -
- ); + 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( +
+ +
+ ); + } + for (let j = pairCount; j < removedItems.length; j++) { + rows.push( +
+ − {removedItems[j]} +
+ ); + } + for (let j = pairCount; j < addedItems.length; j++) { + rows.push( +
+ + {addedItems[j]} +
+ ); + } + i++; // consumed the paired "added" chunk too + continue; + } + + for (const item of change.value) { + if (change.added) { + rows.push( +
+ + {item} +
+ ); + } else if (change.removed) { + rows.push( +
+ − {item} +
+ ); + } else { + rows.push( +
+ {item} +
+ ); + } } } + return
{rows}
; } export function VersionDiffView({ before, after }: { before: DiffSnapshot; after: DiffSnapshot }) { + const t = useTranslations("recipe"); return (
-

Title

+

{t("diffTitleLabel")}

{(before.description || after.description) && (
-

Description

+

{t("diffDescriptionLabel")}

)}
-

Ingredients

+

{t("diffIngredientsLabel")}

-

Steps

+

{t("diffStepsLabel")}

diff --git a/apps/web/components/recipe/version-history-button.tsx b/apps/web/components/recipe/version-history-button.tsx index 5d66c86..c649443 100644 --- a/apps/web/components/recipe/version-history-button.tsx +++ b/apps/web/components/recipe/version-history-button.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; +import { useLocale } from "@/lib/i18n/provider"; import { History, RotateCcw, ChevronDown, GitCompare } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -45,11 +46,6 @@ type VersionDetail = { snapshotData: SnapshotData; }; -function formatDate(dateStr: string): string { - const date = new Date(dateStr); - return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); -} - export function VersionHistoryButton({ recipeId, currentSnapshot, @@ -59,7 +55,12 @@ export function VersionHistoryButton({ }) { const t = useTranslations("recipe"); const tForm = useTranslations("recipeForm"); + const { locale } = useLocale(); const router = useRouter(); + + function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString(locale, { month: "short", day: "numeric", year: "numeric" }); + } const [open, setOpen] = useState(false); const [versions, setVersions] = useState([]); const [loading, setLoading] = useState(false); @@ -116,7 +117,7 @@ export function VersionHistoryButton({ body: JSON.stringify({ action: "restore" }), }); if (res.ok) { - toast.success(`Recipe restored to version ${version.version}`); + toast.success(t("versionRestored", { version: version.version })); setOpen(false); router.refresh(); } else { @@ -142,16 +143,16 @@ export function VersionHistoryButton({ - Version History + {t("versionHistoryTitle")}
{loading && ( -

Loading versions...

+

{t("versionsLoading")}

)} {!loading && versions.length === 0 && ( -

No versions yet. Versions are saved automatically when you edit this recipe.

+

{t("versionsEmpty")}

)} {versions.map((v) => { @@ -196,7 +197,7 @@ export function VersionHistoryButton({ onClick={() => void handleCompare(v)} > - Compare + {t("versionCompare")}
@@ -214,11 +215,13 @@ export function VersionHistoryButton({ {isExpanded && (
{!detail ? ( -

Loading...

+

{t("versionDetailLoading")}

) : (

- {detail.snapshotData.ingredients.length} ingredient{detail.snapshotData.ingredients.length !== 1 ? "s" : ""},{" "} - {detail.snapshotData.steps.length} step{detail.snapshotData.steps.length !== 1 ? "s" : ""} + {t("versionDetailSummary", { + ingredients: detail.snapshotData.ingredients.length, + steps: detail.snapshotData.steps.length, + })}

)}
@@ -234,7 +237,7 @@ export function VersionHistoryButton({ - {comparingId ? `Compare v${expandedData[comparingId]?.version} with current` : "Compare"} + {comparingId ? t("versionCompareWith", { version: expandedData[comparingId]?.version ?? "" }) : t("versionCompare")} {comparingId && expandedData[comparingId] && ( diff --git a/apps/web/components/shared/export-markdown-button.tsx b/apps/web/components/shared/export-markdown-button.tsx index 6009ffc..76126af 100644 --- a/apps/web/components/shared/export-markdown-button.tsx +++ b/apps/web/components/shared/export-markdown-button.tsx @@ -10,6 +10,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; export function ExportMarkdownButton({ markdown, @@ -43,11 +44,18 @@ export function ExportMarkdownButton({ return ( - - - - } /> + + + + + + } /> + } /> + {t("exportMarkdown")} + + { void handleCopy(); }}> diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 749a834..635efc0 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -113,6 +113,19 @@ "urlImportFetchFailed": "Failed to import recipe", "urlImportSaveFailed": "Failed to save imported recipe", "urlImportSuccess": "Recipe imported! Review before publishing.", + "diffTitleLabel": "Title", + "diffDescriptionLabel": "Description", + "diffIngredientsLabel": "Ingredients", + "diffStepsLabel": "Steps", + "versionHistoryTitle": "Version History", + "versionsLoading": "Loading versions...", + "versionsEmpty": "No versions yet. Versions are saved automatically when you edit this recipe.", + "versionCompare": "Compare", + "versionCompareWith": "Compare v{version} with current", + "versionRestore": "Restore", + "versionRestored": "Recipe restored to version {version}", + "versionDetailLoading": "Loading...", + "versionDetailSummary": "{ingredients, plural, one {1 ingredient} other {{ingredients} ingredients}}, {steps, plural, one {1 step} other {{steps} steps}}", "analyzingPhoto": "Analyzing photo…", "pairMealTooltip": "Pair meal", "historyTooltip": "History", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 53b8ede..59bceee 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -113,6 +113,19 @@ "urlImportFetchFailed": "Échec de l'importation de la recette", "urlImportSaveFailed": "Échec de l'enregistrement de la recette importée", "urlImportSuccess": "Recette importée ! Vérifiez-la avant de la publier.", + "diffTitleLabel": "Titre", + "diffDescriptionLabel": "Description", + "diffIngredientsLabel": "Ingrédients", + "diffStepsLabel": "Étapes", + "versionHistoryTitle": "Historique des versions", + "versionsLoading": "Chargement des versions...", + "versionsEmpty": "Aucune version pour l'instant. Les versions sont enregistrées automatiquement lorsque vous modifiez cette recette.", + "versionCompare": "Comparer", + "versionCompareWith": "Comparer v{version} avec la version actuelle", + "versionRestore": "Restaurer", + "versionRestored": "Recette restaurée à la version {version}", + "versionDetailLoading": "Chargement...", + "versionDetailSummary": "{ingredients, plural, one {1 ingrédient} other {{ingredients} ingrédients}}, {steps, plural, one {1 étape} other {{steps} étapes}}", "analyzingPhoto": "Analyse de la photo…", "pairMealTooltip": "Accorder un plat", "historyTooltip": "Historique",