546ba98d2f
recipe-form/API routes were never sending/persisting photos on create+update. PhotoUploader accumulated uploads via a stale closure over the photos prop, so only the last file of a multi-select survived. Once both were fixed, thumbnails still 400'd because Next's image optimizer blocks upstream hosts resolving to private/loopback IPs (localhost:9000 MinIO) — skip optimization for storage-hosted photos since they're already web-sized user uploads.
197 lines
6.7 KiB
TypeScript
197 lines
6.7 KiB
TypeScript
"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)}
|
|
unoptimized
|
|
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>
|
|
);
|
|
}
|