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.
This commit is contained in:
@@ -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 <ExplorePageContent trending={trending} recent={recent} />;
|
||||
}
|
||||
@@ -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 <SearchPageContent initialQuery={q ?? ""} />;
|
||||
}
|
||||
@@ -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<number>`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 });
|
||||
}
|
||||
Reference in New Issue
Block a user