diff --git a/CHANGELOG.md b/CHANGELOG.md index dd8bc87..8c6028b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/web/app/(app)/explore/page.tsx b/apps/web/app/(app)/explore/page.tsx index a1fbb11..a458b87 100644 --- a/apps/web/app/(app)/explore/page.tsx +++ b/apps/web/app/(app)/explore/page.tsx @@ -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 ( ); } diff --git a/apps/web/app/(app)/feed/page.tsx b/apps/web/app/(app)/feed/page.tsx index 62e54b9..7c457b0 100644 --- a/apps/web/app/(app)/feed/page.tsx +++ b/apps/web/app/(app)/feed/page.tsx @@ -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 ; +export default function FeedPage() { + redirect("/explore"); } diff --git a/apps/web/app/api/v1/feed/for-you/route.ts b/apps/web/app/api/v1/feed/for-you/route.ts index f29f080..8ceedff 100644 --- a/apps/web/app/api/v1/feed/for-you/route.ts +++ b/apps/web/app/api/v1/feed/for-you/route.ts @@ -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. diff --git a/apps/web/app/api/v1/feed/route.ts b/apps/web/app/api/v1/feed/route.ts index 557a25a..04634b3 100644 --- a/apps/web/app/api/v1/feed/route.ts +++ b/apps/web/app/api/v1/feed/route.ts @@ -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 }); } diff --git a/apps/web/app/api/v1/search/route.ts b/apps/web/app/api/v1/search/route.ts index 3975524..dc71425 100644 --- a/apps/web/app/api/v1/search/route.ts +++ b/apps/web/app/api/v1/search/route.ts @@ -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, diff --git a/apps/web/components/feed/feed-page-content.tsx b/apps/web/components/feed/feed-page-content.tsx deleted file mode 100644 index 803751e..0000000 --- a/apps/web/components/feed/feed-page-content.tsx +++ /dev/null @@ -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 ( -
-
- - - {recipe.authorName.slice(0, 2).toUpperCase()} - -
- - {recipe.authorName} - - - {new Date(recipe.createdAt).toLocaleDateString(locale, { month: "short", day: "numeric" })} - -
-
- {recipe.favoriteCount !== undefined && recipe.favoriteCount > 0 && ( - - - {recipe.favoriteCount} - - )} - {recipe.aiGenerated && AI} -
-
- - -

{recipe.title}

- {recipe.description && ( -

{recipe.description}

- )} -
- {recipe.difficulty && {recipe.difficulty}} - {recipe.baseServings} - {recipe.prepMins && {recipe.prepMins}m} - {recipe.cookMins && {recipe.cookMins}m} -
- -
- ); -} - -/** 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([]); - 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

{t("loading")}

; - - if (error && recipes.length === 0) { - return ( -
-

{t("loadFailed")}

- -
- ); - } - - if (recipes.length === 0) return ; - - const hasMore = recipes.length < total; - - return ( -
- {recipes.map((recipe) => ( - - ))} - {error && ( -

{t("loadFailed")}

- )} - {hasMore || error ? ( -
- -
- ) : null} -
- ); -} - -export function FeedPageContent({ followedCount }: Props) { - const t = useTranslations("feed"); - const [tab, setTab] = useState<"following" | "trending" | "forYou">("following"); - - return ( -
-

{t("title")}

- - {/* Tabs */} -
- - - -
- - {tab === "following" ? ( - followedCount === 0 ? ( - - ) : ( - - ) - ) : tab === "trending" ? ( - - ) : ( - - )} -
- ); -} diff --git a/apps/web/components/layout/nav.tsx b/apps/web/components/layout/nav.tsx index f085419..e920101 100644 --- a/apps/web/components/layout/nav.tsx +++ b/apps/web/components/layout/nav.tsx @@ -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 }, diff --git a/apps/web/components/recipe/recipe-grid-card.tsx b/apps/web/components/recipe/recipe-grid-card.tsx new file mode 100644 index 0000000..a0f4e60 --- /dev/null +++ b/apps/web/components/recipe/recipe-grid-card.tsx @@ -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 ( +
+ {cover ? ( + {recipe.title} + ) : ( +
+ {recipe.recipeType === "drink" ? "🍹" : "🍽️"} +
+ )} +
+ ); +} + +/** 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 ( +
+ +
+ {recipe.authorName && ( +

{recipe.authorName}

+ )} +

+ {recipe.title} +

+ {recipe.description && ( +

+ {recipe.isBatchCook ? stripMarkdown(recipe.description) : recipe.description} +

+ )} + {recipe.tags.length > 0 && ( +
+ {recipe.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {recipe.tags.length > 3 && ( + +{recipe.tags.length - 3} + )} +
+ )} +
+
+
+ {recipe.isBatchCook && recipe.dishCount ? ( + {tBatch("dishCount", { count: recipe.dishCount })} + ) : ( + {recipe.baseServings} + )} + {totalMins > 0 && {t("total", { mins: totalMins })}} + {recipe.difficulty && ( + {t(`difficulty.${recipe.difficulty}`)} + )} +
+
+ {recipe.sourceUrl && ( + + + } /> + {t("importedFrom", { host: hostnameOf(recipe.sourceUrl) })} + + + )} + {recipe.isBatchCook && ( + + + } /> + {t("batchCookBadge")} + + + )} + { e.preventDefault(); e.stopPropagation(); }}> + + + + + } /> + {t(`visibility.${recipe.visibility}`)} + + +
+
+
+ ); +} diff --git a/apps/web/components/search/explore-page-content.tsx b/apps/web/components/search/explore-page-content.tsx index cb188b4..9dcc543 100644 --- a/apps/web/components/search/explore-page-content.tsx +++ b/apps/web/components/search/explore-page-content.tsx @@ -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 = { easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", @@ -20,17 +22,7 @@ const DIFFICULTY_COLORS: Record = { 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 ( +
+ {recipes.map((recipe) => ( + + + + ))} +
+ ); +} + +/** 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([]); + 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 ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ ); + } + + if (error && recipes.length === 0) { + return ( +
+

{t("loadFailed")}

+ +
+ ); + } + + if (recipes.length === 0) return <>{emptyMessage}; + + const hasMore = recipes.length < total; + + return ( +
+ + {error &&

{t("loadFailed")}

} + {hasMore || error ? ( +
+ +
+ ) : null} +
+ ); +} + 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(null); + const [tab, setTab] = useState<"discover" | "following" | "forYou">("discover"); + const inputRef = useRef(null); const debounceRef = useRef | null>(null); @@ -379,11 +474,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags {hasQuery && loading && (
{Array.from({ length: 8 }).map((_, i) => ( -
-
-
-
-
+
))}
)} @@ -402,11 +493,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags

{t("recipesHeading")}

-
- {results.map((recipe) => ( - - ))} -
+ {hasMore && (
+ + +
+ + {tab === "discover" && ( + <> + {/* AI Recipe Ideas */} +
+
+ +

{t("recipeIdeas")}

+
+

{t("recipeIdeasHint")}

+ { + e.preventDefault(); + fetchIdeas(ideasPrompt); + }} + className="flex gap-2" + > + setIdeasPrompt(e.target.value)} + placeholder={t("aiSearchPlaceholder")} + className="bg-background flex-1 min-w-0" + /> + + + + + {ideasLoading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+ ))} +
)} - - - - {ideasLoading && ( -
- {Array.from({ length: 6 }).map((_, i) => ( -
-
-
-
+ {!ideasLoading && ideas.length > 0 && ( +
+ {ideas.map((idea) => ( +
+
+

{idea.title}

+ + {tRecipe(`difficulty.${idea.difficulty}`)} + +
+

{idea.description}

+
+ {idea.tags.map((tag) => ( + {tag} + ))} + {idea.totalMins && ( + + {tRecipe("total", { mins: idea.totalMins })} + + )} +
+ +
+ ))}
- ))} -
- )} + )} +
- {!ideasLoading && ideas.length > 0 && ( -
- {ideas.map((idea) => ( -
-
-

{idea.title}

- - {tRecipe(`difficulty.${idea.difficulty}`)} - -
-

{idea.description}

-
- {idea.tags.map((tag) => ( - {tag} - ))} - {idea.totalMins && ( - - {tRecipe("total", { mins: idea.totalMins })} - - )} -
- -
- ))} -
- )} - +
+
+ +

{t("trendingThisWeek")}

+
+ {trending.length === 0 ? ( +

+ {t("noTrending")} +

+ ) : ( + + {trending.map((recipe) => ( +
+ + + +
+ ))} +
+ )} +
-
-
- -

{t("trendingThisWeek")}

-
- {trending.length === 0 ? ( -

- {t("noTrending")} -

+
+
+ +

{t("recentlyAdded")}

+
+ {recent.length === 0 ? ( +

+ {t("noRecent")} +

+ ) : ( + + )} +
+ + )} + + {tab === "following" && ( + followedCount === 0 ? ( + ) : ( - - {trending.map((recipe) => ( -
- -
- ))} -
- )} -
+ } + /> + ) + )} -
-
- -

{t("recentlyAdded")}

-
- {recent.length === 0 ? ( -

- {t("noRecent")} -

- ) : ( -
- {recent.map((recipe) => ( - - ))} -
- )} -
+ {tab === "forYou" && ( + } + /> + )} )}
diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 3eb9c0c..19b08bf 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -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", diff --git a/apps/web/lib/openapi.ts b/apps/web/lib/openapi.ts index 3daa454..9c4133f 100644 --- a/apps/web/lib/openapi.ts +++ b/apps/web/lib/openapi.ts @@ -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 } } } } }); diff --git a/apps/web/lib/recipe-card-extras.ts b/apps/web/lib/recipe-card-extras.ts new file mode 100644 index 0000000..6a30d6d --- /dev/null +++ b/apps/web/lib/recipe-card-extras.ts @@ -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( + 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(); + 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(); + 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), + })); +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index c806e01..ae09682 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 32d08bf..e76277a 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -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", diff --git a/apps/web/package.json b/apps/web/package.json index 169e242..476e35a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.35.0", + "version": "0.36.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 20642fb..7551d5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.35.0", + "version": "0.36.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",