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.
## 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
### Added
+47 -31
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import {
db,
recipes,
users,
favorites,
userFollows,
eq,
desc,
sql,
@@ -11,20 +13,29 @@ import {
gte,
count,
} from "@epicure/db";
import { auth } from "@/lib/auth/server";
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 type RecipeResult = {
id: string;
title: string;
description: string | null;
authorName: string | null;
difficulty: string | null;
difficulty: "easy" | "medium" | "hard" | null;
baseServings: number;
prepMins: number | null;
cookMins: number | null;
avgRating: number | null;
ratingCount: number;
visibility: "private" | "unlisted" | "public";
tags: string[];
isBatchCook: boolean;
sourceUrl: string | null;
recipeType: "dish" | "drink";
photos: { storageKey: string; isCover: boolean }[];
dishCount: number;
isFavorited: boolean;
};
export default async function ExplorePage({
@@ -33,19 +44,28 @@ export default async function ExplorePage({
searchParams: Promise<{ q?: string }>;
}) {
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 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
const trendingRows = await db
.select({
id: recipes.id,
title: recipes.title,
authorName: users.name,
difficulty: recipes.difficulty,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
favoriteCount: count(favorites.recipeId),
})
.select({ ...columns, favoriteCount: count(favorites.recipeId) })
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.leftJoin(
@@ -62,31 +82,17 @@ export default async function ExplorePage({
// Recent: public recipes ordered by createdAt desc
const recentRows = await db
.select({
id: recipes.id,
title: recipes.title,
authorName: users.name,
difficulty: recipes.difficulty,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
})
.select(columns)
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.where(and(eq(recipes.visibility, "public"), eq(users.isPrivate, false)))
.orderBy(desc(recipes.createdAt))
.limit(12);
const ratingByRecipe = await getAvgRatingsByRecipeId([
...trendingRows.map((r) => r.id),
...recentRows.map((r) => r.id),
const [trending, recent] = await Promise.all([
attachCardExtras(trendingRows.map(({ favoriteCount: _fc, ...r }) => r), session?.user.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
// 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 followedCount = session
? (
await db
.select({ followingId: userFollows.followingId })
.from(userFollows)
.where(eq(userFollows.followerId, session.user.id))
).length
: 0;
return (
<ExplorePageContent
trending={trending}
recent={recent}
initialQuery={q ?? ""}
popularTags={popularTags}
followedCount={followedCount}
/>
);
}
+3 -17
View File
@@ -1,19 +1,5 @@
import type { Metadata } from "next";
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";
import { redirect } from "next/navigation";
export const metadata: Metadata = {};
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} />;
export default function FeedPage() {
redirect("/explore");
}
+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 { requireSessionOrApiKey } from "@/lib/api-auth";
import { buildPreferenceMap, rankForYou } from "@/lib/for-you-ranking";
import { attachCardExtras } from "@/lib/recipe-card-extras";
export async function GET(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
@@ -47,6 +48,9 @@ export async function GET(req: NextRequest) {
authorAvatarUrl: users.avatarUrl,
tags: recipes.tags,
dietaryTags: recipes.dietaryTags,
isBatchCook: recipes.isBatchCook,
sourceUrl: recipes.sourceUrl,
recipeType: recipes.recipeType,
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
@@ -68,10 +72,11 @@ export async function GET(req: NextRequest) {
? rankForYou(candidates, preferences)
: [...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,
createdAt: r.createdAt.toISOString(),
}));
const data = await attachCardExtras(page, userId);
// `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.
+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 { requireSessionOrApiKey } from "@/lib/api-auth";
import { desc, inArray } from "@epicure/db";
import { attachCardExtras } from "@/lib/recipe-card-extras";
export async function GET(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
@@ -36,6 +37,10 @@ export async function GET(req: NextRequest) {
cookMins: recipes.cookMins,
difficulty: recipes.difficulty,
visibility: recipes.visibility,
tags: recipes.tags,
isBatchCook: recipes.isBatchCook,
sourceUrl: recipes.sourceUrl,
recipeType: recipes.recipeType,
createdAt: recipes.createdAt,
updatedAt: recipes.updatedAt,
authorId: recipes.authorId,
@@ -57,6 +62,7 @@ export async function GET(req: NextRequest) {
]);
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,
} from "@epicure/db";
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;
type DietaryTag = (typeof VALID_DIETARY)[number];
@@ -130,6 +132,11 @@ export async function GET(req: NextRequest) {
baseServings: recipes.baseServings,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
visibility: recipes.visibility,
tags: recipes.tags,
isBatchCook: recipes.isBatchCook,
sourceUrl: recipes.sourceUrl,
recipeType: recipes.recipeType,
authorId: recipes.authorId,
authorName: users.name,
createdAt: recipes.createdAt,
@@ -150,8 +157,12 @@ export async function GET(req: NextRequest) {
const total = countResult[0]?.total ?? 0;
const ratingByRecipe = await getAvgRatingsByRecipeId(rows.map((r) => r.id));
const data = rows.map((r) => ({
const session = await getOptionalSession();
const [ratingByRecipe, rowsWithExtras] = await Promise.all([
getAvgRatingsByRecipeId(rows.map((r) => r.id)),
attachCardExtras(rows, session?.user.id),
]);
const data = rowsWithExtras.map((r) => ({
...r,
avgRating: ratingByRecipe.get(r.id)?.avgRating ?? null,
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 { usePathname, useRouter } from "next/navigation";
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 { Button, buttonVariants } from "@/components/ui/button";
import {
@@ -30,7 +30,6 @@ import { useTranslations } from "next-intl";
const NAV_ITEMS = [
{ href: "/recipes", key: "recipes", icon: BookOpen },
{ href: "/explore", key: "explore", icon: Search },
{ href: "/feed", key: "feed", icon: Rss },
{ href: "/collections", key: "collections", icon: FolderOpen },
{ href: "/meal-plan", key: "mealPlan", icon: Calendar },
{ 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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
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 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 { EmptyState } from "@/components/shared/empty-state";
import { cn } from "@/lib/utils";
const DIFFICULTY_COLORS: Record<string, string> = {
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",
};
type SearchRecipeResult = {
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 SearchRecipeResult = GridCardRecipe & { authorId: string; createdAt: string };
type SearchResponse = {
data: SearchRecipeResult[];
@@ -39,6 +31,15 @@ type SearchResponse = {
offset: number;
};
type FeedRecipeResult = GridCardRecipe & { authorId: string; createdAt: string };
type FeedResponse = {
data: FeedRecipeResult[];
total: number;
limit: number;
offset: number;
};
type PersonResult = {
id: string;
name: string;
@@ -68,6 +69,7 @@ const SURPRISE_IDEA_PROMPTS = [
"cozy soup for a rainy day",
];
const PAGE_SIZE = 20;
function HorizontalScroll({ children }: { children: React.ReactNode }) {
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 = {
trending: ExploreRecipeResult[];
recent: ExploreRecipeResult[];
initialQuery: 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 searchParams = useSearchParams();
const t = useTranslations("explore");
const tFeed = useTranslations("feed");
const tCommon = useTranslations("common");
const tRecipe = useTranslations("recipe");
const tPeople = useTranslations("people");
@@ -112,6 +205,8 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
const [ideasLoading, setIdeasLoading] = useState(false);
const [generatingId, setGeneratingId] = useState<string | null>(null);
const [tab, setTab] = useState<"discover" | "following" | "forYou">("discover");
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -379,11 +474,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
{hasQuery && loading && (
<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-lg border bg-card p-4 space-y-3">
<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 key={i} className="rounded-xl border bg-card aspect-video animate-pulse" />
))}
</div>
)}
@@ -402,11 +493,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
<ChefHat className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{t("recipesHeading")}</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{results.map((recipe) => (
<SearchResultCard key={recipe.id} recipe={recipe} />
))}
</div>
<RecipeGrid recipes={results} />
{hasMore && (
<div className="flex justify-center pt-4">
<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 && (
<>
{/* 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"
<div className="flex gap-1 border-b">
<button
onClick={() => setTab("discover")}
className={cn(
"pb-2 px-1 text-sm font-medium border-b-2 transition-colors",
tab === "discover" ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<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>
{t("tabDiscover")}
</button>
<button
onClick={() => setTab("following")}
className={cn(
"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"
)}
>
{tFeed("following")}
</button>
<button
onClick={() => setTab("forYou")}
className={cn(
"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 && (
<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" />
{!ideasLoading && ideas.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
{ideas.map((idea) => (
<div key={idea.title} className="rounded-lg border bg-background p-4 space-y-2 flex flex-col">
<div className="flex items-start justify-between gap-2">
<h3 className="font-medium text-sm leading-snug flex-1">{idea.title}</h3>
<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>
)}
)}
</section>
{!ideasLoading && ideas.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
{ideas.map((idea) => (
<div key={idea.title} className="rounded-lg border bg-background p-4 space-y-2 flex flex-col">
<div className="flex items-start justify-between gap-2">
<h3 className="font-medium text-sm leading-snug flex-1">{idea.title}</h3>
<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>
)}
</section>
<section>
<div className="flex items-center gap-2 mb-4">
<Flame className="h-5 w-5 text-orange-500" />
<h2 className="text-xl font-semibold">{t("trendingThisWeek")}</h2>
</div>
{trending.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center">
{t("noTrending")}
</p>
) : (
<HorizontalScroll>
{trending.map((recipe) => (
<div key={recipe.id} className="snap-start shrink-0 w-64">
<Link href={`/recipes/${recipe.id}`}>
<RecipeGridCard recipe={recipe} />
</Link>
</div>
))}
</HorizontalScroll>
)}
</section>
<section>
<div className="flex items-center gap-2 mb-4">
<Flame className="h-5 w-5 text-orange-500" />
<h2 className="text-xl font-semibold">{t("trendingThisWeek")}</h2>
</div>
{trending.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center">
{t("noTrending")}
</p>
<section>
<div className="flex items-center gap-2 mb-4">
<Clock className="h-5 w-5 text-blue-500" />
<h2 className="text-xl font-semibold">{t("recentlyAdded")}</h2>
</div>
{recent.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center">
{t("noRecent")}
</p>
) : (
<RecipeGrid recipes={recent} />
)}
</section>
</>
)}
{tab === "following" && (
followedCount === 0 ? (
<EmptyState
icon={UserPlus}
title={tFeed("followEmpty")}
description={tFeed("followEmptyDescriptionExplore")}
/>
) : (
<HorizontalScroll>
{trending.map((recipe) => (
<div key={recipe.id} className="snap-start shrink-0 w-64">
<SearchResultCard recipe={recipe} />
</div>
))}
</HorizontalScroll>
)}
</section>
<PaginatedFeedTab
endpoint="/api/v1/feed"
emptyMessage={<EmptyState icon={Rss} title={tFeed("noNew")} compact />}
/>
)
)}
<section>
<div className="flex items-center gap-2 mb-4">
<Clock className="h-5 w-5 text-blue-500" />
<h2 className="text-xl font-semibold">{t("recentlyAdded")}</h2>
</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>
{tab === "forYou" && (
<PaginatedFeedTab
endpoint="/api/v1/feed/for-you"
emptyMessage={<EmptyState icon={Sparkles} title={tFeed("forYouEmpty")} compact />}
/>
)}
</>
)}
</div>
+9 -1
View File
@@ -1,5 +1,5 @@
// 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 = {
version: string;
@@ -11,6 +11,14 @@ export type 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",
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(),
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"]),
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(),
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/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"]),
aiGenerated: z.boolean(), createdAt: z.string().datetime(), authorId: z.string(),
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() }) } } } } });
@@ -614,8 +620,12 @@ export function generateOpenApiSpec(): 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(),
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(),
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 } } } } });
+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": {
"title": "Explore",
"tabDiscover": "Discover",
"searchPlaceholder": "Search recipes and people…",
"peopleHeading": "People",
"recipesHeading": "Recipes",
@@ -934,6 +935,7 @@
"title": "Feed",
"followEmpty": "Follow chefs to see their recipes here.",
"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",
"noNew": "No new recipes from people you follow.",
"following": "Following",
+2
View File
@@ -504,6 +504,7 @@
},
"explore": {
"title": "Explorer",
"tabDiscover": "Découvrir",
"searchPlaceholder": "Rechercher des recettes et des personnes…",
"peopleHeading": "Personnes",
"recipesHeading": "Recettes",
@@ -925,6 +926,7 @@
"title": "Fil d'actualité",
"followEmpty": "Suivez des chefs pour voir leurs recettes ici.",
"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",
"noNew": "Aucune nouvelle recette de vos abonnements.",
"following": "Abonnements",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.35.0",
"version": "0.36.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.35.0",
"version": "0.36.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",