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>
251 lines
8.4 KiB
TypeScript
251 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
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";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { VersionDiffView, type DiffSnapshot } from "@/components/recipe/version-diff-view";
|
|
|
|
type VersionSummary = {
|
|
id: string;
|
|
version: number;
|
|
title: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
type SnapshotData = DiffSnapshot & {
|
|
description?: string | null;
|
|
baseServings?: number;
|
|
difficulty?: string | null;
|
|
prepMins?: number | null;
|
|
cookMins?: number | null;
|
|
dietaryTags?: Record<string, boolean>;
|
|
};
|
|
|
|
type VersionDetail = {
|
|
id: string;
|
|
version: number;
|
|
title: string;
|
|
createdAt: string;
|
|
snapshotData: SnapshotData;
|
|
};
|
|
|
|
export function VersionHistoryButton({
|
|
recipeId,
|
|
currentSnapshot,
|
|
}: {
|
|
recipeId: string;
|
|
currentSnapshot: DiffSnapshot;
|
|
}) {
|
|
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<VersionSummary[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
const [expandedData, setExpandedData] = useState<Record<string, VersionDetail>>({});
|
|
const [restoringId, setRestoringId] = useState<string | null>(null);
|
|
const [comparingId, setComparingId] = useState<string | null>(null);
|
|
|
|
async function handleOpen(isOpen: boolean) {
|
|
setOpen(isOpen);
|
|
if (isOpen) {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(`/api/v1/recipes/${recipeId}/versions`);
|
|
if (res.ok) {
|
|
const data = await res.json() as VersionSummary[];
|
|
setVersions(data);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function ensureDetail(versionId: string): Promise<VersionDetail | null> {
|
|
if (expandedData[versionId]) return expandedData[versionId]!;
|
|
const res = await fetch(`/api/v1/recipes/${recipeId}/versions/${versionId}`);
|
|
if (!res.ok) return null;
|
|
const data = await res.json() as VersionDetail;
|
|
setExpandedData((prev) => ({ ...prev, [versionId]: data }));
|
|
return data;
|
|
}
|
|
|
|
async function handleExpand(version: VersionSummary) {
|
|
if (expandedId === version.id) {
|
|
setExpandedId(null);
|
|
return;
|
|
}
|
|
setExpandedId(version.id);
|
|
await ensureDetail(version.id);
|
|
}
|
|
|
|
async function handleCompare(version: VersionSummary) {
|
|
await ensureDetail(version.id);
|
|
setComparingId(version.id);
|
|
}
|
|
|
|
async function handleRestore(version: VersionSummary) {
|
|
setRestoringId(version.id);
|
|
try {
|
|
const res = await fetch(`/api/v1/recipes/${recipeId}/versions/${version.id}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ action: "restore" }),
|
|
});
|
|
if (res.ok) {
|
|
toast.success(t("versionRestored", { version: version.version }));
|
|
setOpen(false);
|
|
router.refresh();
|
|
} else {
|
|
toast.error(t("historyRestoreFailed"));
|
|
}
|
|
} finally {
|
|
setRestoringId(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger render={
|
|
<Button variant="ghost" size="icon" onClick={() => handleOpen(true)}>
|
|
<History className="h-4 w-4" />
|
|
</Button>
|
|
} />
|
|
<TooltipContent>{t("historyTooltip")}</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<Sheet open={open} onOpenChange={handleOpen}>
|
|
<SheetContent>
|
|
<SheetHeader>
|
|
<SheetTitle>{t("versionHistoryTitle")}</SheetTitle>
|
|
</SheetHeader>
|
|
|
|
<div className="p-2 mt-6 space-y-2">
|
|
{loading && (
|
|
<p className="text-sm text-muted-foreground">{t("versionsLoading")}</p>
|
|
)}
|
|
|
|
{!loading && versions.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">{t("versionsEmpty")}</p>
|
|
)}
|
|
|
|
{versions.map((v) => {
|
|
const isExpanded = expandedId === v.id;
|
|
const detail = expandedData[v.id];
|
|
|
|
return (
|
|
<div key={v.id} className="border rounded-lg overflow-hidden">
|
|
<div className="flex flex-col gap-2 p-3 sm:flex-row sm:items-center">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
className="flex-1 text-left text-sm"
|
|
onClick={() => void handleExpand(v)}
|
|
>
|
|
<span className="font-medium">v{v.version}</span>
|
|
<span className="text-muted-foreground"> — {v.title} — {formatDate(v.createdAt)}</span>
|
|
</button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 shrink-0 sm:hidden"
|
|
title={tForm("expand")}
|
|
onClick={() => void handleExpand(v)}
|
|
>
|
|
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
|
|
</Button>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 shrink-0 hidden sm:inline-flex"
|
|
title={tForm("expand")}
|
|
onClick={() => void handleExpand(v)}
|
|
>
|
|
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex-1 sm:flex-initial shrink-0"
|
|
onClick={() => void handleCompare(v)}
|
|
>
|
|
<GitCompare className="h-3 w-3" />
|
|
{t("versionCompare")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex-1 sm:flex-initial shrink-0"
|
|
disabled={restoringId === v.id}
|
|
onClick={() => void handleRestore(v)}
|
|
>
|
|
<RotateCcw className="h-3 w-3" />
|
|
{t("versionRestore")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="px-3 pb-3 text-sm text-muted-foreground border-t pt-2">
|
|
{!detail ? (
|
|
<p>{t("versionDetailLoading")}</p>
|
|
) : (
|
|
<p>
|
|
{t("versionDetailSummary", {
|
|
ingredients: detail.snapshotData.ingredients.length,
|
|
steps: detail.snapshotData.steps.length,
|
|
})}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
<Dialog open={comparingId !== null} onOpenChange={(isOpen) => !isOpen && setComparingId(null)}>
|
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{comparingId ? t("versionCompareWith", { version: expandedData[comparingId]?.version ?? "" }) : t("versionCompare")}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
{comparingId && expandedData[comparingId] && (
|
|
<VersionDiffView before={expandedData[comparingId]!.snapshotData} after={currentSnapshot} />
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|