feat: cooking history/gallery, unit conversion, nutrition diary, pantry scan, digest cron, nutrition-targeted meal plans
Six M-sized items from HANDOFF.md's new-features backlog: - Profile tabs: cooking-history stats (total cooked, last-cooked, streak) and a "cooked it" photo gallery, both owner-only - Display-time unit conversion (metric<->imperial) for recipe ingredients, respecting each user's unitPref; original value always shown alongside the conversion - Nutrition daily diary: per-day macro totals computed from cooking history x recipe nutritionData, compared against user goals - Pantry scan: real barcode lookup (zxing + Open Food Facts, no API key) with an AI-vision fallback for unbarcoded items, always confirm-before- insert, both paths tier/rate-limited like other AI features - Weekly digest email: new followers/comments/ratings + trending recipes, sent via a new `cron` Docker stage (alpine+crond+curl) and `digest-cron` compose service hitting a bearer-token-protected internal route - Meal-plan generation can now target a user's nutrition goals as a prompt-level nudge (recipes are AI-invented, not DB-sourced, so this can't be a hard macro constraint) Caught a real deploy-breaking issue while adding the cron stage: appending it after `runner` silently changed the Dockerfile's default build target, and `web`'s compose config didn't pin one — fixed by pinning `target: runner` explicitly. Verified with typecheck, lint, and three separate `docker build --target` runs (runner/cron/migrator) plus `docker compose config` validation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Flame, ChefHat, History } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
|
||||
export type HistoryEntry = {
|
||||
id: string;
|
||||
recipeId: string;
|
||||
recipeTitle: string;
|
||||
cookedAt: string;
|
||||
servings: number | null;
|
||||
};
|
||||
|
||||
export type HistoryData = {
|
||||
totalCooked: number;
|
||||
streak: number;
|
||||
lastCooked: { recipeId: string; recipeTitle: string; cookedAt: string } | null;
|
||||
recentEntries: HistoryEntry[];
|
||||
};
|
||||
|
||||
export type PhotoItem = {
|
||||
id: string;
|
||||
recipeId: string;
|
||||
recipeTitle: string;
|
||||
photoKey: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type PhotosData = {
|
||||
items: PhotoItem[];
|
||||
page: number;
|
||||
totalPages: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-4 space-y-1">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="text-2xl font-semibold">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileTabs({
|
||||
username,
|
||||
defaultTab,
|
||||
recipesContent,
|
||||
history,
|
||||
photos,
|
||||
}: {
|
||||
username: string;
|
||||
defaultTab: string;
|
||||
recipesContent: React.ReactNode;
|
||||
history: HistoryData;
|
||||
photos: PhotosData;
|
||||
}) {
|
||||
const t = useTranslations("profilePage");
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={defaultTab} className="gap-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="recipes">{t("tabRecipes")}</TabsTrigger>
|
||||
<TabsTrigger value="history">{t("tabHistory")}</TabsTrigger>
|
||||
<TabsTrigger value="photos">{t("tabPhotos")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="recipes">{recipesContent}</TabsContent>
|
||||
|
||||
<TabsContent value="history">
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
label={t("statTotalCooked")}
|
||||
value={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ChefHat className="h-5 w-5 text-primary" />
|
||||
{history.totalCooked}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("statStreak")}
|
||||
value={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Flame className="h-5 w-5 text-orange-500" />
|
||||
{t("streakDays", { count: history.streak })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("statLastCooked")}
|
||||
value={
|
||||
history.lastCooked ? (
|
||||
<Link href={`/recipes/${history.lastCooked.recipeId}`} className="text-base font-medium hover:underline line-clamp-1">
|
||||
{history.lastCooked.recipeTitle}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-base font-normal text-muted-foreground">{t("noneYet")}</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{history.recentEntries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{t("recentActivity")}</h3>
|
||||
<div className="rounded-xl border divide-y overflow-hidden">
|
||||
{history.recentEntries.map((entry) => (
|
||||
<Link
|
||||
key={entry.id}
|
||||
href={`/recipes/${entry.recipeId}`}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
|
||||
>
|
||||
<History className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 text-sm font-medium truncate">{entry.recipeTitle}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{new Date(entry.cookedAt).toLocaleDateString(locale)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<p className="text-lg">{t("noHistoryYet")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="photos">
|
||||
{photos.items.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{photos.items.map((photo) => (
|
||||
<Link
|
||||
key={photo.id}
|
||||
href={`/recipes/${photo.recipeId}`}
|
||||
className="group block rounded-xl overflow-hidden border bg-card hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="relative aspect-square bg-muted overflow-hidden">
|
||||
<Image
|
||||
src={getPublicUrl(photo.photoKey)}
|
||||
alt={photo.recipeTitle}
|
||||
fill
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, 25vw"
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<p className="text-sm font-medium leading-tight line-clamp-2">{photo.recipeTitle}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{photos.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
{photos.page > 1 && (
|
||||
<Link
|
||||
href={`/u/${username}?tab=photos&ppage=${photos.page - 1}`}
|
||||
className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
{t("previous")}
|
||||
</Link>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground px-2">
|
||||
{t("pageOf", { page: photos.page, total: photos.totalPages })}
|
||||
</span>
|
||||
{photos.page < photos.totalPages && (
|
||||
<Link
|
||||
href={`/u/${username}?tab=photos&ppage=${photos.page + 1}`}
|
||||
className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
{t("next")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<p className="text-lg">{t("noPhotosYet")}</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user