From e179692adf60fa4d9ef718497e0caa77f92d763a Mon Sep 17 00:00:00 2001 From: Arnaud Date: Wed, 1 Jul 2026 08:10:44 +0200 Subject: [PATCH] feat(search): full-text recipe search and explore page Postgres full-text search with dietary/difficulty/time filters. Search page with real-time results. Explore page with trending and recent recipes. --- apps/web/app/(app)/explore/page.tsx | 75 +++++ apps/web/app/(app)/search/page.tsx | 8 + apps/web/app/api/v1/search/route.ts | 124 +++++++ .../search/explore-page-content.tsx | 135 ++++++++ .../components/search/search-page-content.tsx | 305 ++++++++++++++++++ 5 files changed, 647 insertions(+) create mode 100644 apps/web/app/(app)/explore/page.tsx create mode 100644 apps/web/app/(app)/search/page.tsx create mode 100644 apps/web/app/api/v1/search/route.ts create mode 100644 apps/web/components/search/explore-page-content.tsx create mode 100644 apps/web/components/search/search-page-content.tsx diff --git a/apps/web/app/(app)/explore/page.tsx b/apps/web/app/(app)/explore/page.tsx new file mode 100644 index 0000000..e33f741 --- /dev/null +++ b/apps/web/app/(app)/explore/page.tsx @@ -0,0 +1,75 @@ +import type { Metadata } from "next"; +import { + db, + recipes, + users, + favorites, + eq, + desc, + sql, + and, + gte, + count, +} from "@epicure/db"; +import { ExplorePageContent } from "@/components/search/explore-page-content"; + +export const metadata: Metadata = { title: "Explore — Epicure" }; + +export type RecipeResult = { + id: string; + title: string; + authorName: string | null; + difficulty: string | null; + prepMins: number | null; + cookMins: number | null; +}; + +export default async function ExplorePage() { + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + // 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), + }) + .from(recipes) + .innerJoin(users, eq(recipes.authorId, users.id)) + .leftJoin( + favorites, + and( + eq(favorites.recipeId, recipes.id), + gte(favorites.createdAt, sevenDaysAgo) + ) + ) + .where(eq(recipes.visibility, "public")) + .groupBy(recipes.id, users.id) + .orderBy(desc(sql`count(${favorites.recipeId})`)) + .limit(12); + + // 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, + }) + .from(recipes) + .innerJoin(users, eq(recipes.authorId, users.id)) + .where(eq(recipes.visibility, "public")) + .orderBy(desc(recipes.createdAt)) + .limit(12); + + const trending: RecipeResult[] = trendingRows.map(({ favoriteCount: _fc, ...r }) => r); + const recent: RecipeResult[] = recentRows; + + return ; +} diff --git a/apps/web/app/(app)/search/page.tsx b/apps/web/app/(app)/search/page.tsx new file mode 100644 index 0000000..e5b9d12 --- /dev/null +++ b/apps/web/app/(app)/search/page.tsx @@ -0,0 +1,8 @@ +import { SearchPageContent } from "@/components/search/search-page-content"; + +type Params = { searchParams: Promise<{ q?: string; difficulty?: string; dietary?: string }> }; + +export default async function SearchPage({ searchParams }: Params) { + const { q } = await searchParams; + return ; +} diff --git a/apps/web/app/api/v1/search/route.ts b/apps/web/app/api/v1/search/route.ts new file mode 100644 index 0000000..1c76686 --- /dev/null +++ b/apps/web/app/api/v1/search/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + db, + recipes, + users, + eq, + and, + or, + ilike, + sql, + desc, +} from "@epicure/db"; + +const VALID_DIETARY = ["vegan", "vegetarian", "glutenFree", "dairyFree"] as const; +type DietaryTag = (typeof VALID_DIETARY)[number]; + +export async function GET(req: NextRequest) { + const { searchParams } = req.nextUrl; + + // --- Parse & validate required param --- + const q = searchParams.get("q")?.trim() ?? ""; + if (!q) { + return NextResponse.json( + { error: "Query parameter 'q' is required and must not be empty." }, + { status: 400 } + ); + } + + // --- Optional params --- + const difficultyParam = searchParams.get("difficulty"); + const difficulty = + difficultyParam === "easy" || + difficultyParam === "medium" || + difficultyParam === "hard" + ? (difficultyParam as "easy" | "medium" | "hard") + : undefined; + + const maxMinsRaw = searchParams.get("maxMins"); + const maxMins = + maxMinsRaw !== null && !Number.isNaN(Number(maxMinsRaw)) + ? Number(maxMinsRaw) + : undefined; + + const limitRaw = searchParams.get("limit"); + const limit = Math.min( + limitRaw !== null && !Number.isNaN(Number(limitRaw)) + ? Math.max(1, Number(limitRaw)) + : 20, + 50 + ); + + const offsetRaw = searchParams.get("offset"); + const offset = + offsetRaw !== null && !Number.isNaN(Number(offsetRaw)) + ? Math.max(0, Number(offsetRaw)) + : 0; + + const dietaryRaw = searchParams.get("dietary"); + const dietaryTags: DietaryTag[] = dietaryRaw + ? (dietaryRaw + .split(",") + .map((s) => s.trim()) + .filter((s): s is DietaryTag => + (VALID_DIETARY as readonly string[]).includes(s) + )) + : []; + + // --- Build WHERE conditions --- + const conditions = [ + eq(recipes.visibility, "public"), + or( + ilike(recipes.title, `%${q}%`), + ilike(recipes.description, `%${q}%`) + )!, + ]; + + if (difficulty) { + conditions.push(eq(recipes.difficulty, difficulty)); + } + + if (maxMins !== undefined) { + conditions.push( + sql`(${recipes.prepMins} + ${recipes.cookMins}) <= ${maxMins}` + ); + } + + for (const tag of dietaryTags) { + conditions.push(sql`${recipes.dietaryTags}->>${tag} = 'true'`); + } + + const where = and(...conditions); + + // --- Main data query --- + const rows = await db + .select({ + id: recipes.id, + title: recipes.title, + description: recipes.description, + difficulty: recipes.difficulty, + baseServings: recipes.baseServings, + prepMins: recipes.prepMins, + cookMins: recipes.cookMins, + authorId: recipes.authorId, + authorName: users.name, + createdAt: recipes.createdAt, + }) + .from(recipes) + .innerJoin(users, eq(recipes.authorId, users.id)) + .where(where) + .orderBy(desc(recipes.createdAt)) + .limit(limit) + .offset(offset); + + // --- Count query --- + const countResult = await db + .select({ total: sql`count(*)::int` }) + .from(recipes) + .innerJoin(users, eq(recipes.authorId, users.id)) + .where(where); + + const total = countResult[0]?.total ?? 0; + + return NextResponse.json({ data: rows, total, limit, offset }); +} diff --git a/apps/web/components/search/explore-page-content.tsx b/apps/web/components/search/explore-page-content.tsx new file mode 100644 index 0000000..86aeb60 --- /dev/null +++ b/apps/web/components/search/explore-page-content.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useRef } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Flame, Clock, ChefHat, Search } from "lucide-react"; +import type { RecipeResult } from "@/app/(app)/explore/page"; + +const DIFFICULTY_COLORS: Record = { + easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + medium: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + hard: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", +}; + +function RecipeCard({ recipe }: { recipe: RecipeResult }) { + const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); + return ( + +
+

+ {recipe.title} +

+ {recipe.difficulty && ( + + {recipe.difficulty} + + )} +
+
+ {totalMins > 0 && ( + + + {totalMins}m + + )} + {recipe.authorName && ( + + + {recipe.authorName} + + )} +
+ + ); +} + +function HorizontalScroll({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +type Props = { + trending: RecipeResult[]; + recent: RecipeResult[]; +}; + +export function ExplorePageContent({ trending, recent }: Props) { + const router = useRouter(); + const inputRef = useRef(null); + + const handleSearchSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const q = inputRef.current?.value.trim(); + if (q) router.push(`/search?q=${encodeURIComponent(q)}`); + else router.push("/search"); + }; + + return ( +
+ {/* Search bar */} +
+

Explore

+
+ + + +
+ + {/* Trending this week */} +
+
+ +

Trending this week

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

+ No trending recipes yet. Be the first to share one! +

+ ) : ( + + {trending.map((recipe) => ( +
+ +
+ ))} +
+ )} +
+ + {/* Recently added */} +
+
+ +

Recently added

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

+ No public recipes yet. +

+ ) : ( +
+ {recent.map((recipe) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/components/search/search-page-content.tsx b/apps/web/components/search/search-page-content.tsx new file mode 100644 index 0000000..b35b492 --- /dev/null +++ b/apps/web/components/search/search-page-content.tsx @@ -0,0 +1,305 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Search, Clock, ChefHat } from "lucide-react"; + +type RecipeResult = { + id: string; + title: string; + description: string | null; + difficulty: string | null; + prepMins: number | null; + cookMins: number | null; + authorName: string | null; +}; + +type SearchResponse = { + data: RecipeResult[]; + total: number; + limit: number; + offset: number; +}; + +const DIFFICULTY_COLORS: Record = { + easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + medium: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + hard: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", +}; + +function RecipeCard({ recipe }: { recipe: RecipeResult }) { + const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); + return ( + +
+

+ {recipe.title} +

+ {recipe.difficulty && ( + + {recipe.difficulty} + + )} +
+ {recipe.description && ( +

{recipe.description}

+ )} +
+ {totalMins > 0 && ( + + + {totalMins}m + + )} + {recipe.authorName && ( + + + {recipe.authorName} + + )} +
+ + ); +} + +export function SearchPageContent({ initialQuery }: { initialQuery: string }) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [query, setQuery] = useState(initialQuery); + const [inputValue, setInputValue] = useState(initialQuery); + const [difficulty, setDifficulty] = useState("any"); + const [maxMins, setMaxMins] = useState(""); + const [results, setResults] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + + const inputRef = useRef(null); + const debounceRef = useRef | null>(null); + + const fetchResults = useCallback( + async (q: string, diff: string, maxM: string, off: number, append = false) => { + if (!q.trim()) { + setResults([]); + setTotal(0); + setOffset(0); + return; + } + + if (off === 0) setLoading(true); + else setLoadingMore(true); + + try { + const params = new URLSearchParams({ q, limit: "20", offset: String(off) }); + if (diff && diff !== "any") params.set("difficulty", diff); + if (maxM) params.set("maxMins", maxM); + + const res = await fetch(`/api/v1/search?${params}`); + if (!res.ok) return; + + const json: SearchResponse = await res.json(); + if (append) { + setResults((prev) => [...prev, ...json.data]); + } else { + setResults(json.data); + } + setTotal(json.total); + setOffset(off + json.data.length); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, + [] + ); + + // Auto-focus input on mount + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // Fetch on mount if initialQuery provided + useEffect(() => { + if (initialQuery) { + fetchResults(initialQuery, "any", "", 0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Sync query param into URL + const updateUrl = useCallback( + (q: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (q) params.set("q", q); + else params.delete("q"); + router.replace(`/search?${params}`, { scroll: false }); + }, + [router, searchParams] + ); + + const handleInputChange = (value: string) => { + setInputValue(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setQuery(value); + updateUrl(value); + fetchResults(value, difficulty, maxMins, 0); + }, 300); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (debounceRef.current) clearTimeout(debounceRef.current); + setQuery(inputValue); + updateUrl(inputValue); + fetchResults(inputValue, difficulty, maxMins, 0); + }; + + const handleFilterChange = (newDiff: string, newMaxMins: string) => { + fetchResults(query, newDiff, newMaxMins, 0); + }; + + const handleDifficultyChange = (value: string | null) => { + const v = value ?? "any"; + setDifficulty(v); + handleFilterChange(v, maxMins); + }; + + const handleMaxMinsChange = (value: string) => { + setMaxMins(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + handleFilterChange(difficulty, value); + }, 300); + }; + + const handleLoadMore = () => { + fetchResults(query, difficulty, maxMins, offset, true); + }; + + const hasMore = results.length < total; + const hasQuery = query.trim().length > 0; + + return ( +
+
+

Search Recipes

+ + {/* Search input */} +
+ + handleInputChange(e.target.value)} + placeholder="Search for recipes..." + className="pl-10 h-12 text-base" + /> + + + {/* Filter bar */} +
+ + +
+ handleMaxMinsChange(e.target.value)} + className="w-36" + /> +
+ + {hasQuery && !loading && ( + + {total} {total === 1 ? "recipe" : "recipes"} found + + )} +
+
+ + {/* States */} + {!hasQuery && ( +
+ +

Search for recipes...

+

Try "pasta", "vegan dessert", or "quick dinner"

+
+ )} + + {hasQuery && loading && ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+ )} + + {hasQuery && !loading && results.length === 0 && ( +
+ +

No recipes found for “{query}”

+

Try different keywords or remove filters

+
+ )} + + {!loading && results.length > 0 && ( + <> +
+ {results.map((recipe) => ( + + ))} +
+ + {hasMore && ( +
+ +
+ )} + + )} +
+ ); +}