8b749f432e
Every "nothing here" page had its own copy-pasted dashed-border box — icon + one muted line, inconsistent (some had a CTA, some didn't, no description text anywhere). Replaced with one shared EmptyState component: icon in a soft tinted circle, a real heading plus optional description, and primary/secondary actions (either a Link or an arbitrary action slot for things like "New Collection" that open a dialog rather than navigate). Applied to: recipes (no recipes / no search match), favorites, feed (no one followed / no new posts / trending / for-you), collections (index + detail), shopping lists, pantry, notifications, can-cook. Left the small inline "no trending"/"no recent" lines inside Explore's already-labeled sections and the notification-bell dropdown alone — different context, a full empty-state box would be heavier than the space warrants. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
122 lines
4.3 KiB
TypeScript
122 lines
4.3 KiB
TypeScript
import type { Metadata } from "next";
|
|
import Link from "next/link";
|
|
import { headers } from "next/headers";
|
|
import { auth } from "@/lib/auth/server";
|
|
import { db, favorites, recipes, recipePhotos, users, eq, and, or, ne, desc, inArray, count } from "@epicure/db";
|
|
import { FavoritesGrid } from "@/components/recipe/favorites-grid";
|
|
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
|
|
|
export const metadata: Metadata = {};
|
|
|
|
const PAGE_SIZE = 24;
|
|
|
|
export default async function FavoriteRecipesPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ page?: string }>;
|
|
}) {
|
|
const session = await auth.api.getSession({ headers: await headers() });
|
|
if (!session) return null;
|
|
const m = getMessages((session.user as { locale?: string }).locale);
|
|
|
|
const { page: pageParam } = await searchParams;
|
|
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
|
|
const offset = (page - 1) * PAGE_SIZE;
|
|
|
|
// A previously-favorited recipe stays visible to the person who favorited it even if
|
|
// the author later makes their account/recipe private — same "existing access isn't
|
|
// revoked, only new discovery is" rule as the private-accounts feature.
|
|
const where = and(
|
|
eq(favorites.userId, session.user.id),
|
|
or(ne(recipes.visibility, "private"), eq(recipes.authorId, session.user.id))
|
|
);
|
|
|
|
const [rows, totalRow] = await Promise.all([
|
|
db
|
|
.select({
|
|
id: recipes.id,
|
|
title: recipes.title,
|
|
difficulty: recipes.difficulty,
|
|
prepMins: recipes.prepMins,
|
|
cookMins: recipes.cookMins,
|
|
authorId: recipes.authorId,
|
|
authorName: users.name,
|
|
})
|
|
.from(favorites)
|
|
.innerJoin(recipes, eq(favorites.recipeId, recipes.id))
|
|
.innerJoin(users, eq(recipes.authorId, users.id))
|
|
.where(where)
|
|
.orderBy(desc(favorites.createdAt))
|
|
.limit(PAGE_SIZE)
|
|
.offset(offset),
|
|
db
|
|
.select({ total: count() })
|
|
.from(favorites)
|
|
.innerJoin(recipes, eq(favorites.recipeId, recipes.id))
|
|
.where(where),
|
|
]);
|
|
|
|
const recipeIds = rows.map((r) => r.id);
|
|
const photos = recipeIds.length > 0
|
|
? await db
|
|
.select({ recipeId: recipePhotos.recipeId, storageKey: recipePhotos.storageKey, isCover: recipePhotos.isCover })
|
|
.from(recipePhotos)
|
|
.where(inArray(recipePhotos.recipeId, recipeIds))
|
|
: [];
|
|
const coverByRecipe = new Map<string, string>();
|
|
for (const p of photos) {
|
|
if (!coverByRecipe.has(p.recipeId) || p.isCover) coverByRecipe.set(p.recipeId, p.storageKey);
|
|
}
|
|
|
|
const total = totalRow[0]?.total ?? 0;
|
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
|
|
const favoriteRecipes = rows.map((r) => ({
|
|
id: r.id,
|
|
title: r.title,
|
|
difficulty: r.difficulty,
|
|
prepMins: r.prepMins,
|
|
cookMins: r.cookMins,
|
|
authorName: r.authorId === session.user.id ? null : r.authorName,
|
|
coverPhotoKey: coverByRecipe.get(r.id) ?? null,
|
|
}));
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Link href="/recipes" className="rounded-full px-3 py-1 text-muted-foreground hover:bg-accent">
|
|
{m.recipes.tabMine}
|
|
</Link>
|
|
<span className="rounded-full bg-accent px-3 py-1 font-medium">{m.recipes.tabFavorites}</span>
|
|
</div>
|
|
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">{m.recipes.tabFavorites}</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
{formatMessage(total === 1 ? m.recipes.favoritesCount : m.recipes.favoritesCountPlural, { count: total })}
|
|
</p>
|
|
</div>
|
|
|
|
<FavoritesGrid initialRecipes={favoriteRecipes} emptyTitle={m.recipes.favoritesEmptyTitle} emptyDescription={m.recipes.favoritesEmpty} />
|
|
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-center gap-2 pt-2">
|
|
{page > 1 && (
|
|
<Link href={`/recipes/favorites?page=${page - 1}`} className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent">
|
|
Previous
|
|
</Link>
|
|
)}
|
|
<span className="text-sm text-muted-foreground px-2">
|
|
Page {page} of {totalPages}
|
|
</span>
|
|
{page < totalPages && (
|
|
<Link href={`/recipes/favorites?page=${page + 1}`} className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent">
|
|
Next
|
|
</Link>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|