Files
Epicure/apps/web/components/recipe/version-history-button.tsx
T
Arnaud eb424d8c04 fix: mobile layout fixes, i18n coverage, and recipe share link
Mobile:
- Recipes search bar full-width on mobile instead of capped narrow
- Cook mode ingredients panel stacks above the step instead of
  squeezing it into a narrow column
- Version history Compare/Restore buttons wrap onto their own row
- Recipe edit ingredient fields wrap instead of forcing horizontal
  scroll on narrow viewports

i18n: translates remaining hardcoded strings across recipes
filter/sort, adapt-recipe and AI variations dialogs, the full
settings section (sidebar + 6 sub-pages + BYOK/model-prefs/
API-keys/webhooks managers), explore tab, collections (new/fork/
share dialogs), meal planning (planner, AI generation phases, new
shopping list, shared-plan view), photo import, recipe bulk-select
toolbar, and recipe action-button tooltips. Also fixes the recipes
page subtitle, which wasn't just unworded but missing its {count}
interpolation entirely — it always rendered as the bare word
"results" regardless of how many recipes existed.

Feature: adds a ShareRecipeButton that copies the public /r/{id}
link to the clipboard, with a notice when the recipe isn't Public
yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 15:13:51 +02:00

248 lines
8.3 KiB
TypeScript

"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
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;
};
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,
}: {
recipeId: string;
currentSnapshot: DiffSnapshot;
}) {
const t = useTranslations("recipe");
const tForm = useTranslations("recipeForm");
const router = useRouter();
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(`Recipe restored to 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>Version History</SheetTitle>
</SheetHeader>
<div className="p-2 mt-6 space-y-2">
{loading && (
<p className="text-sm text-muted-foreground">Loading versions...</p>
)}
{!loading && versions.length === 0 && (
<p className="text-sm text-muted-foreground">No versions yet. Versions are saved automatically when you edit this recipe.</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" />
Compare
</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" />
Restore
</Button>
</div>
</div>
{isExpanded && (
<div className="px-3 pb-3 text-sm text-muted-foreground border-t pt-2">
{!detail ? (
<p>Loading...</p>
) : (
<p>
{detail.snapshotData.ingredients.length} ingredient{detail.snapshotData.ingredients.length !== 1 ? "s" : ""},{" "}
{detail.snapshotData.steps.length} step{detail.snapshotData.steps.length !== 1 ? "s" : ""}
</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 ? `Compare v${expandedData[comparingId]?.version} with current` : "Compare"}
</DialogTitle>
</DialogHeader>
{comparingId && expandedData[comparingId] && (
<VersionDiffView before={expandedData[comparingId]!.snapshotData} after={currentSnapshot} />
)}
</DialogContent>
</Dialog>
</>
);
}