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,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