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",