feat: implement remaining TODO.md feature ideas + fix mobile headers

Implements the six previously-unscoped feature ideas plus a mobile
layout fix reported via screenshot:

- Mobile: Recipes/Collections/Pantry/Meal Plan/Shopping Lists headers
  now stack and wrap instead of clipping buttons on narrow viewports.
- Recipe diff/compare view: word/list diff against any past version,
  next to Restore in version history.
- Shared meal plans & shopping lists: new shoppingListMembers/
  mealPlanMembers tables (viewer/editor roles, mirrors
  collectionMembers), share dialogs, membership-checked routes.
- PDF cookbook export: /print/collection/[id] renders a whole
  collection with page breaks, using the existing print-CSS pattern
  instead of adding a PDF rendering dependency.
- Grocery delivery handoff: shopping lists can copy-as-text (works
  today) or send to Instacart once INSTACART_API_KEY is configured
  (stub adapter — real API needs a partner agreement).
- Personalized "For You" feed tab: ranks public recipes by tag/
  dietary overlap with the user's favorited/highly-rated history.
- PWA: added manifest.json + icons on top of the existing service
  worker so the app is installable; cook-mode pages were already
  cached for offline use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-02 12:13:00 +02:00
parent d2faf98ac1
commit e5d1080fb9
56 changed files with 6096 additions and 74 deletions
@@ -3,7 +3,7 @@
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { History, RotateCcw, ChevronDown } from "lucide-react";
import { History, RotateCcw, ChevronDown, GitCompare } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -12,7 +12,14 @@ import {
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;
@@ -21,9 +28,13 @@ type VersionSummary = {
createdAt: string;
};
type SnapshotData = {
ingredients: unknown[];
steps: unknown[];
type SnapshotData = DiffSnapshot & {
description?: string | null;
baseServings?: number;
difficulty?: string | null;
prepMins?: number | null;
cookMins?: number | null;
dietaryTags?: Record<string, boolean>;
};
type VersionDetail = {
@@ -39,7 +50,13 @@ function formatDate(dateStr: string): string {
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
export function VersionHistoryButton({
recipeId,
currentSnapshot,
}: {
recipeId: string;
currentSnapshot: DiffSnapshot;
}) {
const t = useTranslations("recipe");
const tForm = useTranslations("recipeForm");
const router = useRouter();
@@ -49,6 +66,7 @@ export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
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);
@@ -66,19 +84,27 @@ export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
}
}
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);
if (!expandedData[version.id]) {
const res = await fetch(`/api/v1/recipes/${recipeId}/versions/${version.id}`);
if (res.ok) {
const data = await res.json() as VersionDetail;
setExpandedData((prev) => ({ ...prev, [version.id]: data }));
}
}
await ensureDetail(version.id);
}
async function handleCompare(version: VersionSummary) {
await ensureDetail(version.id);
setComparingId(version.id);
}
async function handleRestore(version: VersionSummary) {
@@ -151,6 +177,15 @@ export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
>
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
</Button>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => void handleCompare(v)}
>
<GitCompare className="h-3 w-3" />
Compare
</Button>
<Button
variant="outline"
size="sm"
@@ -169,8 +204,8 @@ export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
<p>Loading...</p>
) : (
<p>
{(detail.snapshotData.ingredients as unknown[]).length} ingredient{(detail.snapshotData.ingredients as unknown[]).length !== 1 ? "s" : ""},{" "}
{(detail.snapshotData.steps as unknown[]).length} step{(detail.snapshotData.steps as unknown[]).length !== 1 ? "s" : ""}
{detail.snapshotData.ingredients.length} ingredient{detail.snapshotData.ingredients.length !== 1 ? "s" : ""},{" "}
{detail.snapshotData.steps.length} step{detail.snapshotData.steps.length !== 1 ? "s" : ""}
</p>
)}
</div>
@@ -181,6 +216,19 @@ export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
</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>
</>
);
}