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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } } } } });
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.35.0",
|
||||
"version": "0.36.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.35.0",
|
||||
"version": "0.36.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
Reference in New Issue
Block a user