feat: merge Explore and Activity Feed, unify recipe cards

Explore and Feed covered overlapping ground (recommendations, following,
trending) in two separate places with three different card designs.
Explore now hosts Discover/Following/For You tabs, and every recipe
card everywhere on the page matches the Recipes page's cover-photo
card instead of the old text-only search result.

v0.36.0
This commit is contained in:
Arnaud
2026-07-17 16:06:17 +02:00
parent 1577b8de01
commit 5763bd3318
17 changed files with 601 additions and 445 deletions
+6
View File
@@ -2,6 +2,12 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.36.0 — 2026-07-17 12:00
### Added
- Explore and the Activity Feed are now one page — Explore has Discover/Following/For You tabs, so browsing and following recipes no longer live in two separate places.
- Explore's recipe cards (search results, trending, recently added, following, for you) now use the same cover-photo card as the Recipes page, instead of the old text-only search card.
## 0.35.0 — 2026-07-14 18:20 ## 0.35.0 — 2026-07-14 18:20
### Added ### Added
+47 -31
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { headers } from "next/headers";
import { import {
db, db,
recipes, recipes,
users, users,
favorites, favorites,
userFollows,
eq, eq,
desc, desc,
sql, sql,
@@ -11,20 +13,29 @@ import {
gte, gte,
count, count,
} from "@epicure/db"; } from "@epicure/db";
import { auth } from "@/lib/auth/server";
import { ExplorePageContent } from "@/components/search/explore-page-content"; import { ExplorePageContent } from "@/components/search/explore-page-content";
import { getAvgRatingsByRecipeId } from "@/lib/recipe-ratings"; import { attachCardExtras } from "@/lib/recipe-card-extras";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
export type RecipeResult = { export type RecipeResult = {
id: string; id: string;
title: string; title: string;
description: string | null;
authorName: string | null; authorName: string | null;
difficulty: string | null; difficulty: "easy" | "medium" | "hard" | null;
baseServings: number;
prepMins: number | null; prepMins: number | null;
cookMins: number | null; cookMins: number | null;
avgRating: number | null; visibility: "private" | "unlisted" | "public";
ratingCount: number; tags: string[];
isBatchCook: boolean;
sourceUrl: string | null;
recipeType: "dish" | "drink";
photos: { storageKey: string; isCover: boolean }[];
dishCount: number;
isFavorited: boolean;
}; };
export default async function ExplorePage({ export default async function ExplorePage({
@@ -33,19 +44,28 @@ export default async function ExplorePage({
searchParams: Promise<{ q?: string }>; searchParams: Promise<{ q?: string }>;
}) { }) {
const { q } = await searchParams; const { q } = await searchParams;
const session = await auth.api.getSession({ headers: await headers() });
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const columns = {
id: recipes.id,
title: recipes.title,
description: recipes.description,
authorName: users.name,
difficulty: recipes.difficulty,
baseServings: recipes.baseServings,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
visibility: recipes.visibility,
tags: recipes.tags,
isBatchCook: recipes.isBatchCook,
sourceUrl: recipes.sourceUrl,
recipeType: recipes.recipeType,
};
// Trending: public recipes ordered by favorite count in last 7 days // Trending: public recipes ordered by favorite count in last 7 days
const trendingRows = await db const trendingRows = await db
.select({ .select({ ...columns, favoriteCount: count(favorites.recipeId) })
id: recipes.id,
title: recipes.title,
authorName: users.name,
difficulty: recipes.difficulty,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
favoriteCount: count(favorites.recipeId),
})
.from(recipes) .from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id)) .innerJoin(users, eq(recipes.authorId, users.id))
.leftJoin( .leftJoin(
@@ -62,31 +82,17 @@ export default async function ExplorePage({
// Recent: public recipes ordered by createdAt desc // Recent: public recipes ordered by createdAt desc
const recentRows = await db const recentRows = await db
.select({ .select(columns)
id: recipes.id,
title: recipes.title,
authorName: users.name,
difficulty: recipes.difficulty,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
})
.from(recipes) .from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id)) .innerJoin(users, eq(recipes.authorId, users.id))
.where(and(eq(recipes.visibility, "public"), eq(users.isPrivate, false))) .where(and(eq(recipes.visibility, "public"), eq(users.isPrivate, false)))
.orderBy(desc(recipes.createdAt)) .orderBy(desc(recipes.createdAt))
.limit(12); .limit(12);
const ratingByRecipe = await getAvgRatingsByRecipeId([ const [trending, recent] = await Promise.all([
...trendingRows.map((r) => r.id), attachCardExtras(trendingRows.map(({ favoriteCount: _fc, ...r }) => r), session?.user.id),
...recentRows.map((r) => r.id), attachCardExtras(recentRows, session?.user.id),
]); ]);
const withRating = (r: { id: string }) => ({
avgRating: ratingByRecipe.get(r.id)?.avgRating ?? null,
ratingCount: ratingByRecipe.get(r.id)?.ratingCount ?? 0,
});
const trending: RecipeResult[] = trendingRows.map(({ favoriteCount: _fc, ...r }) => ({ ...r, ...withRating(r) }));
const recent: RecipeResult[] = recentRows.map((r) => ({ ...r, ...withRating(r) }));
// Most-used tags across public recipes — shown as filter chips on the // Most-used tags across public recipes — shown as filter chips on the
// search bar so browsing by tag doesn't require typing the exact word. // search bar so browsing by tag doesn't require typing the exact word.
@@ -102,12 +108,22 @@ export default async function ExplorePage({
`); `);
const popularTags = popularTagsResult.map((r) => r.tag); const popularTags = popularTagsResult.map((r) => r.tag);
const followedCount = session
? (
await db
.select({ followingId: userFollows.followingId })
.from(userFollows)
.where(eq(userFollows.followerId, session.user.id))
).length
: 0;
return ( return (
<ExplorePageContent <ExplorePageContent
trending={trending} trending={trending}
recent={recent} recent={recent}
initialQuery={q ?? ""} initialQuery={q ?? ""}
popularTags={popularTags} popularTags={popularTags}
followedCount={followedCount}
/> />
); );
} }
+3 -17
View File
@@ -1,19 +1,5 @@
import type { Metadata } from "next"; import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, userFollows, eq } from "@epicure/db";
import { FeedPageContent } from "@/components/feed/feed-page-content";
export const metadata: Metadata = {}; export default function FeedPage() {
redirect("/explore");
export default async function FeedPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const followedRows = await db
.select({ followingId: userFollows.followingId })
.from(userFollows)
.where(eq(userFollows.followerId, session.user.id));
return <FeedPageContent followedCount={followedRows.length} />;
} }
+6 -1
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { db, recipes, users, favorites, ratings, userFollows, eq, and, or, ne, gte, notInArray, inArray, desc, isNotNull } from "@epicure/db"; import { db, recipes, users, favorites, ratings, userFollows, eq, and, or, ne, gte, notInArray, inArray, desc, isNotNull } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth"; import { requireSessionOrApiKey } from "@/lib/api-auth";
import { buildPreferenceMap, rankForYou } from "@/lib/for-you-ranking"; import { buildPreferenceMap, rankForYou } from "@/lib/for-you-ranking";
import { attachCardExtras } from "@/lib/recipe-card-extras";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req); const { session, response } = await requireSessionOrApiKey(req);
@@ -47,6 +48,9 @@ export async function GET(req: NextRequest) {
authorAvatarUrl: users.avatarUrl, authorAvatarUrl: users.avatarUrl,
tags: recipes.tags, tags: recipes.tags,
dietaryTags: recipes.dietaryTags, dietaryTags: recipes.dietaryTags,
isBatchCook: recipes.isBatchCook,
sourceUrl: recipes.sourceUrl,
recipeType: recipes.recipeType,
}) })
.from(recipes) .from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id)) .innerJoin(users, eq(recipes.authorId, users.id))
@@ -68,10 +72,11 @@ export async function GET(req: NextRequest) {
? rankForYou(candidates, preferences) ? rankForYou(candidates, preferences)
: [...candidates].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); : [...candidates].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
const data = ranked.slice(offset, offset + limit).map(({ tags: _tags, dietaryTags: _dietaryTags, ...r }) => ({ const page = ranked.slice(offset, offset + limit).map(({ dietaryTags: _dietaryTags, ...r }) => ({
...r, ...r,
createdAt: r.createdAt.toISOString(), createdAt: r.createdAt.toISOString(),
})); }));
const data = await attachCardExtras(page, userId);
// `ranked` is capped to a bounded recent window (see `candidates` query above), so this // `ranked` is capped to a bounded recent window (see `candidates` query above), so this
// total reflects the size of that ranked window rather than every eligible recipe. // total reflects the size of that ranked window rather than every eligible recipe.
+7 -1
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { db, recipes, users, userFollows, eq, and, ne, sql } from "@epicure/db"; import { db, recipes, users, userFollows, eq, and, ne, sql } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth"; import { requireSessionOrApiKey } from "@/lib/api-auth";
import { desc, inArray } from "@epicure/db"; import { desc, inArray } from "@epicure/db";
import { attachCardExtras } from "@/lib/recipe-card-extras";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req); const { session, response } = await requireSessionOrApiKey(req);
@@ -36,6 +37,10 @@ export async function GET(req: NextRequest) {
cookMins: recipes.cookMins, cookMins: recipes.cookMins,
difficulty: recipes.difficulty, difficulty: recipes.difficulty,
visibility: recipes.visibility, visibility: recipes.visibility,
tags: recipes.tags,
isBatchCook: recipes.isBatchCook,
sourceUrl: recipes.sourceUrl,
recipeType: recipes.recipeType,
createdAt: recipes.createdAt, createdAt: recipes.createdAt,
updatedAt: recipes.updatedAt, updatedAt: recipes.updatedAt,
authorId: recipes.authorId, authorId: recipes.authorId,
@@ -57,6 +62,7 @@ export async function GET(req: NextRequest) {
]); ]);
const total = totalRow[0]?.total ?? 0; const total = totalRow[0]?.total ?? 0;
const data = await attachCardExtras(feedRecipes, session!.user.id);
return NextResponse.json({ data: feedRecipes, total, limit, offset }); return NextResponse.json({ data, total, limit, offset });
} }
+13 -2
View File
@@ -12,6 +12,8 @@ import {
desc, desc,
} from "@epicure/db"; } from "@epicure/db";
import { getAvgRatingsByRecipeId } from "@/lib/recipe-ratings"; import { getAvgRatingsByRecipeId } from "@/lib/recipe-ratings";
import { attachCardExtras } from "@/lib/recipe-card-extras";
import { getOptionalSession } from "@/lib/api-auth";
const VALID_DIETARY = ["vegan", "vegetarian", "glutenFree", "dairyFree"] as const; const VALID_DIETARY = ["vegan", "vegetarian", "glutenFree", "dairyFree"] as const;
type DietaryTag = (typeof VALID_DIETARY)[number]; type DietaryTag = (typeof VALID_DIETARY)[number];
@@ -130,6 +132,11 @@ export async function GET(req: NextRequest) {
baseServings: recipes.baseServings, baseServings: recipes.baseServings,
prepMins: recipes.prepMins, prepMins: recipes.prepMins,
cookMins: recipes.cookMins, cookMins: recipes.cookMins,
visibility: recipes.visibility,
tags: recipes.tags,
isBatchCook: recipes.isBatchCook,
sourceUrl: recipes.sourceUrl,
recipeType: recipes.recipeType,
authorId: recipes.authorId, authorId: recipes.authorId,
authorName: users.name, authorName: users.name,
createdAt: recipes.createdAt, createdAt: recipes.createdAt,
@@ -150,8 +157,12 @@ export async function GET(req: NextRequest) {
const total = countResult[0]?.total ?? 0; const total = countResult[0]?.total ?? 0;
const ratingByRecipe = await getAvgRatingsByRecipeId(rows.map((r) => r.id)); const session = await getOptionalSession();
const data = rows.map((r) => ({ const [ratingByRecipe, rowsWithExtras] = await Promise.all([
getAvgRatingsByRecipeId(rows.map((r) => r.id)),
attachCardExtras(rows, session?.user.id),
]);
const data = rowsWithExtras.map((r) => ({
...r, ...r,
avgRating: ratingByRecipe.get(r.id)?.avgRating ?? null, avgRating: ratingByRecipe.get(r.id)?.avgRating ?? null,
ratingCount: ratingByRecipe.get(r.id)?.ratingCount ?? 0, ratingCount: ratingByRecipe.get(r.id)?.ratingCount ?? 0,
@@ -1,233 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Clock, Users, ChefHat, Flame, Heart, Sparkles, Rss, UserPlus } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/shared/empty-state";
import { useLocale } from "@/lib/i18n/provider";
const PAGE_SIZE = 20;
type FeedRecipe = {
id: string;
title: string;
description: string | null;
baseServings: number;
prepMins: number | null;
cookMins: number | null;
difficulty: string | null;
aiGenerated: boolean;
createdAt: string;
authorId: string;
authorName: string;
authorUsername: string | null;
authorAvatarUrl: string | null;
visibility: string;
favoriteCount?: number;
};
type FeedResponse = {
data: FeedRecipe[];
total: number;
limit: number;
offset: number;
};
type Props = {
followedCount: number;
};
function RecipeCard({ recipe, locale }: { recipe: FeedRecipe; locale: string }) {
return (
<article className="rounded-xl border p-4 hover:shadow-sm transition-shadow">
<div className="flex items-center gap-3 mb-3">
<Avatar className="h-7 w-7">
<AvatarImage src={recipe.authorAvatarUrl ?? ""} alt={recipe.authorName} />
<AvatarFallback className="text-xs">{recipe.authorName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex items-baseline gap-2 text-sm">
<Link href={`/u/${recipe.authorUsername ?? recipe.authorId}`} className="font-medium hover:underline">
{recipe.authorName}
</Link>
<span className="text-muted-foreground text-xs">
{new Date(recipe.createdAt).toLocaleDateString(locale, { month: "short", day: "numeric" })}
</span>
</div>
<div className="ml-auto flex items-center gap-2">
{recipe.favoriteCount !== undefined && recipe.favoriteCount > 0 && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Heart className="h-3 w-3 fill-rose-500 text-rose-500" />
{recipe.favoriteCount}
</span>
)}
{recipe.aiGenerated && <Badge variant="secondary" className="text-xs">AI</Badge>}
</div>
</div>
<Link href={`/recipes/${recipe.id}`} className="group block space-y-2">
<h2 className="font-semibold text-lg group-hover:text-primary transition-colors">{recipe.title}</h2>
{recipe.description && (
<p className="text-sm text-muted-foreground line-clamp-2">{recipe.description}</p>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{recipe.difficulty && <Badge variant="outline" className="text-xs">{recipe.difficulty}</Badge>}
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{recipe.baseServings}</span>
{recipe.prepMins && <span className="flex items-center gap-1"><Clock className="h-3 w-3" />{recipe.prepMins}m</span>}
{recipe.cookMins && <span className="flex items-center gap-1"><ChefHat className="h-3 w-3" />{recipe.cookMins}m</span>}
</div>
</Link>
</article>
);
}
/** Shared paginated tab: fetches `endpoint`, supports "load more", and surfaces network
* failures as a real error state (with retry) instead of silently rendering an empty list. */
function PaginatedFeedTab({
endpoint,
emptyMessage,
}: {
endpoint: string;
emptyMessage: string;
}) {
const { locale } = useLocale();
const t = useTranslations("feed");
const [recipes, setRecipes] = useState<FeedRecipe[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState(false);
const fetchPage = useCallback(
async (off: number, append: boolean) => {
if (append) setLoadingMore(true);
else setLoading(true);
setError(false);
try {
const res = await fetch(`${endpoint}?limit=${PAGE_SIZE}&offset=${off}`);
if (!res.ok) throw new Error("Request failed");
const json = (await res.json()) as FeedResponse;
setRecipes((prev) => (append ? [...prev, ...json.data] : json.data));
setTotal(json.total);
setOffset(off + json.data.length);
} catch {
setError(true);
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[endpoint]
);
useEffect(() => {
void fetchPage(0, false);
}, [fetchPage]);
if (loading) return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
if (error && recipes.length === 0) {
return (
<div className="flex flex-col items-start gap-2">
<p className="text-sm text-destructive">{t("loadFailed")}</p>
<Button variant="outline" size="sm" onClick={() => void fetchPage(0, false)}>
{t("retry")}
</Button>
</div>
);
}
if (recipes.length === 0) return <EmptyState icon={Rss} title={emptyMessage} compact />;
const hasMore = recipes.length < total;
return (
<div className="space-y-4">
{recipes.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} locale={locale} />
))}
{error && (
<p className="text-sm text-destructive">{t("loadFailed")}</p>
)}
{hasMore || error ? (
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() => void fetchPage(offset, true)}
disabled={loadingMore}
>
{loadingMore ? t("loading") : error ? t("retry") : t("loadMore")}
</Button>
</div>
) : null}
</div>
);
}
export function FeedPageContent({ followedCount }: Props) {
const t = useTranslations("feed");
const [tab, setTab] = useState<"following" | "trending" | "forYou">("following");
return (
<div className="space-y-6 max-w-2xl">
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
{/* Tabs */}
<div className="flex gap-1 border-b">
<button
onClick={() => setTab("following")}
className={`pb-2 px-1 text-sm font-medium border-b-2 transition-colors ${
tab === "following"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{t("following")}
</button>
<button
onClick={() => setTab("trending")}
className={`pb-2 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-1.5 ${
tab === "trending"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Flame className="h-3.5 w-3.5" />
{t("trending")}
</button>
<button
onClick={() => setTab("forYou")}
className={`pb-2 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-1.5 ${
tab === "forYou"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Sparkles className="h-3.5 w-3.5" />
{t("forYou")}
</button>
</div>
{tab === "following" ? (
followedCount === 0 ? (
<EmptyState
icon={UserPlus}
title={t("followEmpty")}
description={t("followEmptyDescription")}
action={{ label: t("findPeople"), href: "/explore?tab=people" }}
/>
) : (
<PaginatedFeedTab endpoint="/api/v1/feed" emptyMessage={t("noNew")} />
)
) : tab === "trending" ? (
<PaginatedFeedTab endpoint="/api/v1/feed/trending" emptyMessage={t("trendingEmpty")} />
) : (
<PaginatedFeedTab endpoint="/api/v1/feed/for-you" emptyMessage={t("forYouEmpty")} />
)}
</div>
);
}
+1 -2
View File
@@ -3,7 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { BookOpen, Calendar, Package, ChefHat, User, Rss, FolderOpen, ShoppingCart, Shield, Search, Compass, Menu, Sun, Moon, Monitor, Apple } from "lucide-react"; import { BookOpen, Calendar, Package, ChefHat, User, FolderOpen, ShoppingCart, Shield, Search, Compass, Menu, Sun, Moon, Monitor, Apple } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { import {
@@ -30,7 +30,6 @@ import { useTranslations } from "next-intl";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: "/recipes", key: "recipes", icon: BookOpen }, { href: "/recipes", key: "recipes", icon: BookOpen },
{ href: "/explore", key: "explore", icon: Search }, { href: "/explore", key: "explore", icon: Search },
{ href: "/feed", key: "feed", icon: Rss },
{ href: "/collections", key: "collections", icon: FolderOpen }, { href: "/collections", key: "collections", icon: FolderOpen },
{ href: "/meal-plan", key: "mealPlan", icon: Calendar }, { href: "/meal-plan", key: "mealPlan", icon: Calendar },
{ href: "/nutrition", key: "nutrition", icon: Apple }, { href: "/nutrition", key: "nutrition", icon: Apple },
@@ -0,0 +1,142 @@
"use client";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Globe, Lock, Link2, ExternalLink, ChefHat, Clock, Users, Utensils } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { FavoriteButton } from "@/components/social/favorite-button";
import { getPublicUrl } from "@/lib/storage";
import { cn, stripMarkdown } from "@/lib/utils";
export type GridCardRecipe = {
id: string;
title: string;
description: string | null;
baseServings: number;
prepMins: number | null;
cookMins: number | null;
difficulty: "easy" | "medium" | "hard" | null;
visibility: "private" | "unlisted" | "public";
tags: string[];
photos?: Array<{ storageKey: string; isCover: boolean }>;
isFavorited?: boolean;
isBatchCook?: boolean;
dishCount?: number;
sourceUrl?: string | null;
recipeType?: "dish" | "drink";
authorName?: string | null;
};
const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const;
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe };
function hostnameOf(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}
/** Shared cover-photo/emoji-fallback thumbnail used by the recipe grid card. */
export function RecipeThumb({ recipe, className }: { recipe: GridCardRecipe; className?: string }) {
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
return (
<div className={cn("relative overflow-hidden bg-muted shrink-0", className)}>
{cover ? (
<Image
src={getPublicUrl(cover.storageKey)}
unoptimized
alt={recipe.title}
fill
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-2xl">
{recipe.recipeType === "drink" ? "🍹" : "🍽️"}
</div>
)}
</div>
);
}
/** The recipe grid card shown on Recipes, Explore, and the feed tabs — one visual identity everywhere a recipe appears in a grid. */
export function RecipeGridCard({ recipe }: { recipe: GridCardRecipe }) {
const t = useTranslations("recipe");
const tBatch = useTranslations("batchCooking");
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
return (
<div className="group relative overflow-hidden rounded-xl border bg-card flex flex-col h-full transition-all duration-200 hover:shadow-md hover:border-muted-foreground/30">
<RecipeThumb recipe={recipe} className="aspect-video" />
<div className="flex flex-col flex-1 p-3 gap-1">
{recipe.authorName && (
<p className="text-xs text-muted-foreground truncate">{recipe.authorName}</p>
)}
<h3 className="font-semibold leading-tight line-clamp-2 text-sm transition-colors group-hover:text-primary">
{recipe.title}
</h3>
{recipe.description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
{recipe.isBatchCook ? stripMarkdown(recipe.description) : recipe.description}
</p>
)}
{recipe.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{recipe.tags.slice(0, 3).map((tag) => (
<span key={tag} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-muted text-muted-foreground">
{tag}
</span>
))}
{recipe.tags.length > 3 && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs text-muted-foreground">+{recipe.tags.length - 3}</span>
)}
</div>
)}
</div>
<div className="px-3 pb-3 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2.5">
{recipe.isBatchCook && recipe.dishCount ? (
<span className="flex items-center gap-1"><Utensils className="h-3 w-3" />{tBatch("dishCount", { count: recipe.dishCount })}</span>
) : (
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{recipe.baseServings}</span>
)}
{totalMins > 0 && <span className="flex items-center gap-1"><Clock className="h-3 w-3" />{t("total", { mins: totalMins })}</span>}
{recipe.difficulty && (
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5">{t(`difficulty.${recipe.difficulty}`)}</Badge>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{recipe.sourceUrl && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span className="p-1.5 inline-flex"><ExternalLink className="h-3.5 w-3.5 text-muted-foreground" /></span>} />
<TooltipContent>{t("importedFrom", { host: hostnameOf(recipe.sourceUrl) })}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{recipe.isBatchCook && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span className="p-1.5 inline-flex"><ChefHat className="h-3.5 w-3.5 text-muted-foreground" /></span>} />
<TooltipContent>{t("batchCookBadge")}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<FavoriteButton recipeId={recipe.id} initialFavorited={recipe.isFavorited} />
</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span className="p-1.5 inline-flex"><VisibilityIcon className="h-3 w-3" /></span>} />
<TooltipContent>{t(`visibility.${recipe.visibility}`)}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
);
}
@@ -8,11 +8,13 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Flame, Clock, ChefHat, Search, Sparkles, Wand2, ArrowRight, Shuffle, Users } from "lucide-react"; import { Flame, Clock, ChefHat, Search, Sparkles, Wand2, ArrowRight, Shuffle, Users, Rss, UserPlus } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { RecipeResult as ExploreRecipeResult } from "@/app/(app)/explore/page"; import type { RecipeResult as ExploreRecipeResult } from "@/app/(app)/explore/page";
import { SearchResultCard } from "@/components/recipe/search-result-card"; import { RecipeGridCard, type GridCardRecipe } from "@/components/recipe/recipe-grid-card";
import { FollowButton } from "@/components/social/follow-button"; import { FollowButton } from "@/components/social/follow-button";
import { EmptyState } from "@/components/shared/empty-state";
import { cn } from "@/lib/utils";
const DIFFICULTY_COLORS: Record<string, string> = { const DIFFICULTY_COLORS: Record<string, string> = {
easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
@@ -20,17 +22,7 @@ const DIFFICULTY_COLORS: Record<string, string> = {
hard: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", hard: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
}; };
type SearchRecipeResult = { type SearchRecipeResult = GridCardRecipe & { authorId: string; createdAt: string };
id: string;
title: string;
description: string | null;
difficulty: string | null;
prepMins: number | null;
cookMins: number | null;
authorName: string | null;
avgRating: number | null;
ratingCount: number;
};
type SearchResponse = { type SearchResponse = {
data: SearchRecipeResult[]; data: SearchRecipeResult[];
@@ -39,6 +31,15 @@ type SearchResponse = {
offset: number; offset: number;
}; };
type FeedRecipeResult = GridCardRecipe & { authorId: string; createdAt: string };
type FeedResponse = {
data: FeedRecipeResult[];
total: number;
limit: number;
offset: number;
};
type PersonResult = { type PersonResult = {
id: string; id: string;
name: string; name: string;
@@ -68,6 +69,7 @@ const SURPRISE_IDEA_PROMPTS = [
"cozy soup for a rainy day", "cozy soup for a rainy day",
]; ];
const PAGE_SIZE = 20;
function HorizontalScroll({ children }: { children: React.ReactNode }) { function HorizontalScroll({ children }: { children: React.ReactNode }) {
return ( return (
@@ -77,17 +79,108 @@ function HorizontalScroll({ children }: { children: React.ReactNode }) {
); );
} }
function RecipeGrid({ recipes }: { recipes: GridCardRecipe[] }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{recipes.map((recipe) => (
<Link key={recipe.id} href={`/recipes/${recipe.id}`} className="h-full block">
<RecipeGridCard recipe={recipe} />
</Link>
))}
</div>
);
}
/** Shared paginated tab for Following/For You — fetches `endpoint`, supports "load
* more", and surfaces network failures as a real error state (with retry). */
function PaginatedFeedTab({ endpoint, emptyMessage }: { endpoint: string; emptyMessage: React.ReactNode }) {
const t = useTranslations("feed");
const [recipes, setRecipes] = useState<FeedRecipeResult[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState(false);
const fetchPage = useCallback(
async (off: number, append: boolean) => {
if (append) setLoadingMore(true);
else setLoading(true);
setError(false);
try {
const res = await fetch(`${endpoint}?limit=${PAGE_SIZE}&offset=${off}`);
if (!res.ok) throw new Error("Request failed");
const json = (await res.json()) as FeedResponse;
setRecipes((prev) => (append ? [...prev, ...json.data] : json.data));
setTotal(json.total);
setOffset(off + json.data.length);
} catch {
setError(true);
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[endpoint]
);
useEffect(() => {
void fetchPage(0, false);
}, [fetchPage]);
if (loading) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-xl border bg-card aspect-video animate-pulse" />
))}
</div>
);
}
if (error && recipes.length === 0) {
return (
<div className="flex flex-col items-start gap-2">
<p className="text-sm text-destructive">{t("loadFailed")}</p>
<Button variant="outline" size="sm" onClick={() => void fetchPage(0, false)}>
{t("retry")}
</Button>
</div>
);
}
if (recipes.length === 0) return <>{emptyMessage}</>;
const hasMore = recipes.length < total;
return (
<div className="space-y-4">
<RecipeGrid recipes={recipes} />
{error && <p className="text-sm text-destructive">{t("loadFailed")}</p>}
{hasMore || error ? (
<div className="flex justify-center pt-2">
<Button variant="outline" size="sm" onClick={() => void fetchPage(offset, true)} disabled={loadingMore}>
{loadingMore ? t("loading") : error ? t("retry") : t("loadMore")}
</Button>
</div>
) : null}
</div>
);
}
type Props = { type Props = {
trending: ExploreRecipeResult[]; trending: ExploreRecipeResult[];
recent: ExploreRecipeResult[]; recent: ExploreRecipeResult[];
initialQuery: string; initialQuery: string;
popularTags: string[]; popularTags: string[];
followedCount: number;
}; };
export function ExplorePageContent({ trending, recent, initialQuery, popularTags }: Props) { export function ExplorePageContent({ trending, recent, initialQuery, popularTags, followedCount }: Props) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const t = useTranslations("explore"); const t = useTranslations("explore");
const tFeed = useTranslations("feed");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const tRecipe = useTranslations("recipe"); const tRecipe = useTranslations("recipe");
const tPeople = useTranslations("people"); const tPeople = useTranslations("people");
@@ -112,6 +205,8 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
const [ideasLoading, setIdeasLoading] = useState(false); const [ideasLoading, setIdeasLoading] = useState(false);
const [generatingId, setGeneratingId] = useState<string | null>(null); const [generatingId, setGeneratingId] = useState<string | null>(null);
const [tab, setTab] = useState<"discover" | "following" | "forYou">("discover");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -379,11 +474,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
{hasQuery && loading && ( {hasQuery && loading && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-lg border bg-card p-4 space-y-3"> <div key={i} className="rounded-xl border bg-card aspect-video animate-pulse" />
<div className="h-5 bg-muted rounded animate-pulse" />
<div className="h-3 bg-muted rounded animate-pulse w-3/4" />
<div className="h-3 bg-muted rounded animate-pulse w-1/2" />
</div>
))} ))}
</div> </div>
)} )}
@@ -402,11 +493,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
<ChefHat className="h-4 w-4 text-muted-foreground" /> <ChefHat className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{t("recipesHeading")}</h2> <h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{t("recipesHeading")}</h2>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> <RecipeGrid recipes={results} />
{results.map((recipe) => (
<SearchResultCard key={recipe.id} recipe={recipe} />
))}
</div>
{hasMore && ( {hasMore && (
<div className="flex justify-center pt-4"> <div className="flex justify-center pt-4">
<Button <Button
@@ -422,147 +509,202 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
</> </>
)} )}
{/* Explore sections — shown when no query */} {/* Explore tabs — shown when no query */}
{!hasQuery && ( {!hasQuery && (
<> <>
{/* AI Recipe Ideas */} <div className="flex gap-1 border-b">
<section className="rounded-xl border bg-gradient-to-br from-violet-50 to-indigo-50 dark:from-violet-950/30 dark:to-indigo-950/30 p-6 space-y-4"> <button
<div className="flex items-center gap-2"> onClick={() => setTab("discover")}
<Sparkles className="h-5 w-5 text-violet-500" /> className={cn(
<h2 className="text-xl font-semibold">{t("recipeIdeas")}</h2> "pb-2 px-1 text-sm font-medium border-b-2 transition-colors",
</div> tab === "discover" ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"
<p className="text-sm text-muted-foreground">{t("recipeIdeasHint")}</p> )}
<form
onSubmit={(e) => {
e.preventDefault();
fetchIdeas(ideasPrompt);
}}
className="flex gap-2"
> >
<Input {t("tabDiscover")}
value={ideasPrompt} </button>
onChange={(e) => setIdeasPrompt(e.target.value)} <button
placeholder={t("aiSearchPlaceholder")} onClick={() => setTab("following")}
className="bg-background flex-1 min-w-0" className={cn(
/> "pb-2 px-1 text-sm font-medium border-b-2 transition-colors",
<Button tab === "following" ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"
type="submit" )}
disabled={ideasLoading} >
className="shrink-0 bg-violet-600 text-white hover:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-400" {tFeed("following")}
> </button>
{ideasLoading ? ( <button
<span className="flex items-center gap-2"><Wand2 className="h-4 w-4 animate-pulse" /> <span className="hidden sm:inline">{t("generating")}</span></span> onClick={() => setTab("forYou")}
) : ( className={cn(
<span className="flex items-center gap-2"><Wand2 className="h-4 w-4" /> <span className="hidden sm:inline">{t("getIdeas")}</span></span> "pb-2 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-1.5",
tab === "forYou" ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<Sparkles className="h-3.5 w-3.5" />
{tFeed("forYou")}
</button>
</div>
{tab === "discover" && (
<>
{/* AI Recipe Ideas */}
<section className="rounded-xl border bg-gradient-to-br from-violet-50 to-indigo-50 dark:from-violet-950/30 dark:to-indigo-950/30 p-6 space-y-4">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-violet-500" />
<h2 className="text-xl font-semibold">{t("recipeIdeas")}</h2>
</div>
<p className="text-sm text-muted-foreground">{t("recipeIdeasHint")}</p>
<form
onSubmit={(e) => {
e.preventDefault();
fetchIdeas(ideasPrompt);
}}
className="flex gap-2"
>
<Input
value={ideasPrompt}
onChange={(e) => setIdeasPrompt(e.target.value)}
placeholder={t("aiSearchPlaceholder")}
className="bg-background flex-1 min-w-0"
/>
<Button
type="submit"
disabled={ideasLoading}
className="shrink-0 bg-violet-600 text-white hover:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-400"
>
{ideasLoading ? (
<span className="flex items-center gap-2"><Wand2 className="h-4 w-4 animate-pulse" /> <span className="hidden sm:inline">{t("generating")}</span></span>
) : (
<span className="flex items-center gap-2"><Wand2 className="h-4 w-4" /> <span className="hidden sm:inline">{t("getIdeas")}</span></span>
)}
</Button>
<Button
type="button"
variant="outline"
disabled={ideasLoading}
onClick={() => {
const idx = Math.floor(Math.random() * SURPRISE_IDEA_PROMPTS.length);
const surprisePrompt = SURPRISE_IDEA_PROMPTS[idx]!;
setIdeasPrompt(surprisePrompt);
fetchIdeas(surprisePrompt);
}}
className="shrink-0 bg-background/80 border-violet-300 text-violet-700 hover:bg-background hover:text-violet-800 dark:border-violet-700 dark:text-violet-300 dark:hover:text-violet-200"
>
<span className="flex items-center gap-2"><Shuffle className="h-4 w-4" /> <span className="hidden sm:inline">{t("surpriseMe")}</span></span>
</Button>
</form>
{ideasLoading && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-lg border bg-background p-4 space-y-2">
<div className="h-4 bg-muted rounded animate-pulse" />
<div className="h-3 bg-muted rounded animate-pulse w-5/6" />
<div className="h-3 bg-muted rounded animate-pulse w-2/3" />
</div>
))}
</div>
)} )}
</Button>
<Button
type="button"
variant="outline"
disabled={ideasLoading}
onClick={() => {
const idx = Math.floor(Math.random() * SURPRISE_IDEA_PROMPTS.length);
const surprisePrompt = SURPRISE_IDEA_PROMPTS[idx]!;
setIdeasPrompt(surprisePrompt);
fetchIdeas(surprisePrompt);
}}
className="shrink-0 bg-background/80 border-violet-300 text-violet-700 hover:bg-background hover:text-violet-800 dark:border-violet-700 dark:text-violet-300 dark:hover:text-violet-200"
>
<span className="flex items-center gap-2"><Shuffle className="h-4 w-4" /> <span className="hidden sm:inline">{t("surpriseMe")}</span></span>
</Button>
</form>
{ideasLoading && ( {!ideasLoading && ideas.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
{Array.from({ length: 6 }).map((_, i) => ( {ideas.map((idea) => (
<div key={i} className="rounded-lg border bg-background p-4 space-y-2"> <div key={idea.title} className="rounded-lg border bg-background p-4 space-y-2 flex flex-col">
<div className="h-4 bg-muted rounded animate-pulse" /> <div className="flex items-start justify-between gap-2">
<div className="h-3 bg-muted rounded animate-pulse w-5/6" /> <h3 className="font-medium text-sm leading-snug flex-1">{idea.title}</h3>
<div className="h-3 bg-muted rounded animate-pulse w-2/3" /> <Badge
variant="secondary"
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[idea.difficulty] ?? ""}`}
>
{tRecipe(`difficulty.${idea.difficulty}`)}
</Badge>
</div>
<p className="text-xs text-muted-foreground line-clamp-2 flex-1">{idea.description}</p>
<div className="flex flex-wrap gap-1">
{idea.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
))}
{idea.totalMins && (
<Badge variant="outline" className="text-xs flex items-center gap-1">
<Clock className="h-3 w-3" />{tRecipe("total", { mins: idea.totalMins })}
</Badge>
)}
</div>
<Button
size="sm"
className="w-full mt-auto"
disabled={generatingId !== null}
onClick={() => generateFromIdea(idea)}
>
{generatingId === idea.title ? (
<span className="flex items-center gap-2"><Wand2 className="h-3.5 w-3.5 animate-pulse" /> {t("generating")}</span>
) : (
<span className="flex items-center gap-2"><ArrowRight className="h-3.5 w-3.5" /> {t("generateFullRecipe")}</span>
)}
</Button>
</div>
))}
</div> </div>
))} )}
</div> </section>
)}
{!ideasLoading && ideas.length > 0 && ( <section>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2"> <div className="flex items-center gap-2 mb-4">
{ideas.map((idea) => ( <Flame className="h-5 w-5 text-orange-500" />
<div key={idea.title} className="rounded-lg border bg-background p-4 space-y-2 flex flex-col"> <h2 className="text-xl font-semibold">{t("trendingThisWeek")}</h2>
<div className="flex items-start justify-between gap-2"> </div>
<h3 className="font-medium text-sm leading-snug flex-1">{idea.title}</h3> {trending.length === 0 ? (
<Badge <p className="text-muted-foreground text-sm py-8 text-center">
variant="secondary" {t("noTrending")}
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[idea.difficulty] ?? ""}`} </p>
> ) : (
{tRecipe(`difficulty.${idea.difficulty}`)} <HorizontalScroll>
</Badge> {trending.map((recipe) => (
</div> <div key={recipe.id} className="snap-start shrink-0 w-64">
<p className="text-xs text-muted-foreground line-clamp-2 flex-1">{idea.description}</p> <Link href={`/recipes/${recipe.id}`}>
<div className="flex flex-wrap gap-1"> <RecipeGridCard recipe={recipe} />
{idea.tags.map((tag) => ( </Link>
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge> </div>
))} ))}
{idea.totalMins && ( </HorizontalScroll>
<Badge variant="outline" className="text-xs flex items-center gap-1"> )}
<Clock className="h-3 w-3" />{tRecipe("total", { mins: idea.totalMins })} </section>
</Badge>
)}
</div>
<Button
size="sm"
className="w-full mt-auto"
disabled={generatingId !== null}
onClick={() => generateFromIdea(idea)}
>
{generatingId === idea.title ? (
<span className="flex items-center gap-2"><Wand2 className="h-3.5 w-3.5 animate-pulse" /> {t("generating")}</span>
) : (
<span className="flex items-center gap-2"><ArrowRight className="h-3.5 w-3.5" /> {t("generateFullRecipe")}</span>
)}
</Button>
</div>
))}
</div>
)}
</section>
<section> <section>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Flame className="h-5 w-5 text-orange-500" /> <Clock className="h-5 w-5 text-blue-500" />
<h2 className="text-xl font-semibold">{t("trendingThisWeek")}</h2> <h2 className="text-xl font-semibold">{t("recentlyAdded")}</h2>
</div> </div>
{trending.length === 0 ? ( {recent.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center"> <p className="text-muted-foreground text-sm py-8 text-center">
{t("noTrending")} {t("noRecent")}
</p> </p>
) : (
<RecipeGrid recipes={recent} />
)}
</section>
</>
)}
{tab === "following" && (
followedCount === 0 ? (
<EmptyState
icon={UserPlus}
title={tFeed("followEmpty")}
description={tFeed("followEmptyDescriptionExplore")}
/>
) : ( ) : (
<HorizontalScroll> <PaginatedFeedTab
{trending.map((recipe) => ( endpoint="/api/v1/feed"
<div key={recipe.id} className="snap-start shrink-0 w-64"> emptyMessage={<EmptyState icon={Rss} title={tFeed("noNew")} compact />}
<SearchResultCard recipe={recipe} /> />
</div> )
))} )}
</HorizontalScroll>
)}
</section>
<section> {tab === "forYou" && (
<div className="flex items-center gap-2 mb-4"> <PaginatedFeedTab
<Clock className="h-5 w-5 text-blue-500" /> endpoint="/api/v1/feed/for-you"
<h2 className="text-xl font-semibold">{t("recentlyAdded")}</h2> emptyMessage={<EmptyState icon={Sparkles} title={tFeed("forYouEmpty")} compact />}
</div> />
{recent.length === 0 ? ( )}
<p className="text-muted-foreground text-sm py-8 text-center">
{t("noRecent")}
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{recent.map((recipe) => (
<SearchResultCard key={recipe.id} recipe={recipe} />
))}
</div>
)}
</section>
</> </>
)} )}
</div> </div>
+9 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together. // Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.35.0"; export const APP_VERSION = "0.36.0";
export type ChangelogEntry = { export type ChangelogEntry = {
version: string; version: string;
@@ -11,6 +11,14 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.36.0",
date: "2026-07-17 12:00",
added: [
"Explore and the Activity Feed are now one page — Explore has Discover/Following/For You tabs, so browsing and following recipes no longer live in two separate places.",
"Explore's recipe cards (search results, trending, recently added, following, for you) now use the same cover-photo card as the Recipes page, instead of the old text-only search card.",
],
},
{ {
version: "0.35.0", version: "0.35.0",
date: "2026-07-14 18:20", date: "2026-07-14 18:20",
+10
View File
@@ -320,8 +320,11 @@ export function generateOpenApiSpec(): object {
id: z.string(), title: z.string(), description: z.string().nullable(), id: z.string(), title: z.string(), description: z.string().nullable(),
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(), baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public"]), difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public"]),
tags: z.array(z.string()), isBatchCook: z.boolean(), sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
createdAt: z.string().datetime(), updatedAt: z.string().datetime(), authorId: z.string(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), authorId: z.string(),
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(), authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
photos: z.array(z.object({ storageKey: z.string(), isCover: z.boolean() })),
dishCount: z.number().int(), isFavorited: z.boolean(),
})); }));
registry.registerPath({ method: "get", path: "/api/v1/feed", summary: "Activity feed (pull-based) — recent recipes from users you follow", security, request: { query: z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: z.object({ data: z.array(FeedRecipeRef), total: z.number(), limit: z.number(), offset: z.number(), message: z.string().optional() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/feed", summary: "Activity feed (pull-based) — recent recipes from users you follow", security, request: { query: z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: z.object({ data: z.array(FeedRecipeRef), total: z.number(), limit: z.number(), offset: z.number(), message: z.string().optional() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: LimitOffset }, responses: { 200: { description: "Paginated collections", content: { "application/json": { schema: PaginatedCollections } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: LimitOffset }, responses: { 200: { description: "Paginated collections", content: { "application/json": { schema: PaginatedCollections } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
@@ -605,6 +608,9 @@ export function generateOpenApiSpec(): object {
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public"]), difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public"]),
aiGenerated: z.boolean(), createdAt: z.string().datetime(), authorId: z.string(), aiGenerated: z.boolean(), createdAt: z.string().datetime(), authorId: z.string(),
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(), authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
tags: z.array(z.string()), isBatchCook: z.boolean(), sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
photos: z.array(z.object({ storageKey: z.string(), isCover: z.boolean() })),
dishCount: z.number().int(), isFavorited: z.boolean(),
})); }));
registry.registerPath({ method: "get", path: "/api/v1/feed/trending", summary: "Trending public recipes (by favorites in the last 7 days)", description: "Public — no auth required. Excludes private authors.", security: [], request: { query: z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: z.object({ data: z.array(TrendingRecipeRef), total: z.number(), limit: z.number(), offset: z.number() }) } } } } }); registry.registerPath({ method: "get", path: "/api/v1/feed/trending", summary: "Trending public recipes (by favorites in the last 7 days)", description: "Public — no auth required. Excludes private authors.", security: [], request: { query: z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: z.object({ data: z.array(TrendingRecipeRef), total: z.number(), limit: z.number(), offset: z.number() }) } } } } });
@@ -614,8 +620,12 @@ export function generateOpenApiSpec(): object {
const SearchResultRef = registry.register("SearchResult", z.object({ const SearchResultRef = registry.register("SearchResult", z.object({
id: z.string(), title: z.string(), description: z.string().nullable(), difficulty: z.enum(["easy", "medium", "hard"]).nullable(), id: z.string(), title: z.string(), description: z.string().nullable(), difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(), baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
visibility: z.enum(["private", "unlisted", "public"]), tags: z.array(z.string()), isBatchCook: z.boolean(),
sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
authorId: z.string(), authorName: z.string(), createdAt: z.string().datetime(), authorId: z.string(), authorName: z.string(), createdAt: z.string().datetime(),
avgRating: z.number().nullable(), ratingCount: z.number().int(), avgRating: z.number().nullable(), ratingCount: z.number().int(),
photos: z.array(z.object({ storageKey: z.string(), isCover: z.boolean() })),
dishCount: z.number().int(), isFavorited: z.boolean(),
})); }));
registry.registerPath({ method: "get", path: "/api/v1/search", summary: "Full-text search across public recipes — title, description, ingredient names, and tags", description: "Public — no auth required. Matches only public recipes by non-private authors.", security: [], request: { query: z.object({ q: z.string().min(1).max(200), difficulty: z.enum(["easy", "medium", "hard"]).optional(), recipeType: z.enum(["dish", "drink"]).optional(), maxMins: z.coerce.number().optional().describe("Max prepMins + cookMins combined"), dietary: z.string().optional().describe("Comma-separated: vegan,vegetarian,glutenFree,dairyFree"), tags: z.string().optional().describe("Comma-separated exact-tag filter chips, up to 5"), limit: z.coerce.number().min(1).max(50).default(20), offset: z.coerce.number().min(0).default(0) }) }, responses: { 200: { description: "Paginated results", content: { "application/json": { schema: z.object({ data: z.array(SearchResultRef), total: z.number(), limit: z.number(), offset: z.number() }) } } }, 400: { description: "Missing or empty 'q'", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/search", summary: "Full-text search across public recipes — title, description, ingredient names, and tags", description: "Public — no auth required. Matches only public recipes by non-private authors.", security: [], request: { query: z.object({ q: z.string().min(1).max(200), difficulty: z.enum(["easy", "medium", "hard"]).optional(), recipeType: z.enum(["dish", "drink"]).optional(), maxMins: z.coerce.number().optional().describe("Max prepMins + cookMins combined"), dietary: z.string().optional().describe("Comma-separated: vegan,vegetarian,glutenFree,dairyFree"), tags: z.string().optional().describe("Comma-separated exact-tag filter chips, up to 5"), limit: z.coerce.number().min(1).max(50).default(20), offset: z.coerce.number().min(0).default(0) }) }, responses: { 200: { description: "Paginated results", content: { "application/json": { schema: z.object({ data: z.array(SearchResultRef), total: z.number(), limit: z.number(), offset: z.number() }) } } }, 400: { description: "Missing or empty 'q'", content: { "application/json": { schema: ApiErrorRef } } } } });
+54
View File
@@ -0,0 +1,54 @@
import { db, recipePhotos, recipeBatchDishes, favorites, inArray, eq, and } from "@epicure/db";
export type CardExtras = {
photos: { storageKey: string; isCover: boolean }[];
dishCount: number;
isFavorited: boolean;
};
/** Batches the photo/batch-dish/favorite lookups a recipe grid card needs, for
* list endpoints whose main query selects individual recipe columns rather
* than going through `db.query.recipes.findMany`'s relational loader. */
export async function attachCardExtras<T extends { id: string }>(
recipeList: T[],
viewerId?: string
): Promise<(T & CardExtras)[]> {
const ids = recipeList.map((r) => r.id);
if (ids.length === 0) return [];
const [photoRows, dishRows, favoritedRows] = await Promise.all([
db
.select({ recipeId: recipePhotos.recipeId, storageKey: recipePhotos.storageKey, isCover: recipePhotos.isCover })
.from(recipePhotos)
.where(inArray(recipePhotos.recipeId, ids)),
db
.select({ recipeId: recipeBatchDishes.recipeId })
.from(recipeBatchDishes)
.where(inArray(recipeBatchDishes.recipeId, ids)),
viewerId
? db
.select({ recipeId: favorites.recipeId })
.from(favorites)
.where(and(eq(favorites.userId, viewerId), inArray(favorites.recipeId, ids)))
: Promise.resolve([]),
]);
const photosByRecipe = new Map<string, { storageKey: string; isCover: boolean }[]>();
for (const p of photoRows) {
const arr = photosByRecipe.get(p.recipeId) ?? [];
arr.push({ storageKey: p.storageKey, isCover: p.isCover });
photosByRecipe.set(p.recipeId, arr);
}
const dishCountByRecipe = new Map<string, number>();
for (const d of dishRows) dishCountByRecipe.set(d.recipeId, (dishCountByRecipe.get(d.recipeId) ?? 0) + 1);
const favoritedSet = new Set(favoritedRows.map((f) => f.recipeId));
return recipeList.map((r) => ({
...r,
photos: photosByRecipe.get(r.id) ?? [],
dishCount: dishCountByRecipe.get(r.id) ?? 0,
isFavorited: favoritedSet.has(r.id),
}));
}
+2
View File
@@ -504,6 +504,7 @@
}, },
"explore": { "explore": {
"title": "Explore", "title": "Explore",
"tabDiscover": "Discover",
"searchPlaceholder": "Search recipes and people…", "searchPlaceholder": "Search recipes and people…",
"peopleHeading": "People", "peopleHeading": "People",
"recipesHeading": "Recipes", "recipesHeading": "Recipes",
@@ -934,6 +935,7 @@
"title": "Feed", "title": "Feed",
"followEmpty": "Follow chefs to see their recipes here.", "followEmpty": "Follow chefs to see their recipes here.",
"followEmptyDescription": "Your following feed fills up as soon as you follow a few people.", "followEmptyDescription": "Your following feed fills up as soon as you follow a few people.",
"followEmptyDescriptionExplore": "Search for people above and follow a few chefs to see their recipes here.",
"findPeople": "Find people to follow", "findPeople": "Find people to follow",
"noNew": "No new recipes from people you follow.", "noNew": "No new recipes from people you follow.",
"following": "Following", "following": "Following",
+2
View File
@@ -504,6 +504,7 @@
}, },
"explore": { "explore": {
"title": "Explorer", "title": "Explorer",
"tabDiscover": "Découvrir",
"searchPlaceholder": "Rechercher des recettes et des personnes…", "searchPlaceholder": "Rechercher des recettes et des personnes…",
"peopleHeading": "Personnes", "peopleHeading": "Personnes",
"recipesHeading": "Recettes", "recipesHeading": "Recettes",
@@ -925,6 +926,7 @@
"title": "Fil d'actualité", "title": "Fil d'actualité",
"followEmpty": "Suivez des chefs pour voir leurs recettes ici.", "followEmpty": "Suivez des chefs pour voir leurs recettes ici.",
"followEmptyDescription": "Votre fil d'abonnements se remplit dès que vous suivez quelques personnes.", "followEmptyDescription": "Votre fil d'abonnements se remplit dès que vous suivez quelques personnes.",
"followEmptyDescriptionExplore": "Recherchez des personnes ci-dessus et suivez quelques chefs pour voir leurs recettes ici.",
"findPeople": "Trouver des personnes à suivre", "findPeople": "Trouver des personnes à suivre",
"noNew": "Aucune nouvelle recette de vos abonnements.", "noNew": "Aucune nouvelle recette de vos abonnements.",
"following": "Abonnements", "following": "Abonnements",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@epicure/web", "name": "@epicure/web",
"version": "0.35.0", "version": "0.36.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "epicure", "name": "epicure",
"version": "0.35.0", "version": "0.36.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm --filter web dev", "dev": "pnpm --filter web dev",