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:
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Package } from "lucide-react";
|
||||
import { Package, Clock } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
@@ -19,6 +19,7 @@ type ScoredItem = {
|
||||
total: number;
|
||||
pct: number;
|
||||
missing: string[];
|
||||
usesExpiring: string[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -40,7 +41,15 @@ function RecipeRow({ s }: { s: ScoredItem }) {
|
||||
<div className="h-14 w-14 rounded-lg bg-muted shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="font-medium truncate">{s.recipe.title}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium truncate">{s.recipe.title}</p>
|
||||
{s.usesExpiring.length > 0 && (
|
||||
<Badge variant="outline" className="shrink-0 text-orange-500 border-orange-500 gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t("useItUp")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={s.pct} className="h-1.5 flex-1 max-w-[120px]" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -111,7 +111,7 @@ export function RecipesHeader({
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
@@ -120,7 +120,7 @@ export function RecipesHeader({
|
||||
: t(count !== 1 ? "resultPlural" : "resultSingular", { count })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setUrlOpen(true)}>
|
||||
<Link2 className="h-4 w-4" />
|
||||
{t("importUrl")}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { diffWords } from "diff";
|
||||
|
||||
export type DiffSnapshot = {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
ingredients: Array<{ rawName: string; quantity?: string | number | null; unit?: string | null; note?: string | null }>;
|
||||
steps: Array<{ instruction: string; timerSeconds?: number | null }>;
|
||||
};
|
||||
|
||||
function ingredientLine(i: DiffSnapshot["ingredients"][number]): string {
|
||||
return [i.quantity, i.unit, i.rawName, i.note && `(${i.note})`].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function stepLine(s: DiffSnapshot["steps"][number]): string {
|
||||
return s.instruction;
|
||||
}
|
||||
|
||||
function TextDiff({ before, after }: { before: string; after: string }) {
|
||||
const parts = diffWords(before, after);
|
||||
return (
|
||||
<p className="text-sm leading-relaxed">
|
||||
{parts.map((part, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={
|
||||
part.added
|
||||
? "bg-green-500/20 text-green-700 dark:text-green-400"
|
||||
: part.removed
|
||||
? "bg-red-500/20 text-red-700 dark:text-red-400 line-through"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{part.value}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<div key={i} className="bg-green-500/10 rounded px-2 py-1 text-sm text-green-700 dark:text-green-400">
|
||||
+ {a}
|
||||
</div>
|
||||
);
|
||||
} else if (a === undefined) {
|
||||
rows.push(
|
||||
<div key={i} className="bg-red-500/10 rounded px-2 py-1 text-sm text-red-700 dark:text-red-400 line-through">
|
||||
− {b}
|
||||
</div>
|
||||
);
|
||||
} else if (b === a) {
|
||||
rows.push(
|
||||
<div key={i} className="px-2 py-1 text-sm text-muted-foreground">
|
||||
{b}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
rows.push(
|
||||
<div key={i} className="rounded px-2 py-1">
|
||||
<TextDiff before={b} after={a} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return <div className="space-y-1">{rows}</div>;
|
||||
}
|
||||
|
||||
export function VersionDiffView({ before, after }: { before: DiffSnapshot; after: DiffSnapshot }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Title</h3>
|
||||
<TextDiff before={before.title} after={after.title} />
|
||||
</section>
|
||||
|
||||
{(before.description || after.description) && (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Description</h3>
|
||||
<TextDiff before={before.description ?? ""} after={after.description ?? ""} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Ingredients</h3>
|
||||
<ListDiff before={before.ingredients.map(ingredientLine)} after={after.ingredients.map(ingredientLine)} />
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Steps</h3>
|
||||
<ListDiff before={before.steps.map(stepLine)} after={after.steps.map(stepLine)} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user