2ffa05e4cf
The existing recipe chat (RecipeChatPanel) is tightly scoped to one recipe — recipeId is required and its system prompt embeds that recipe's full ingredient/step context. Added a sibling, not a modification: a new floating assistant on the recipes list page for questions that aren't about any specific recipe (technique, substitutions, timing, food safety). New /api/v1/ai/cooking-chat route (same quota/rate-limit/model-resolution pattern as recipe-chat, minus the recipe lookup) and CookingAssistantPanel component (same floating-button + Sheet UI). Positioned at bottom-24 rather than bottom-6 so it doesn't sit in the same band as the recipe list's floating bulk-action bar during select mode. Verified locally: asked a real cooking question through the actual UI, got a correctly-formatted markdown answer with no recipe context leaking in (confirmed the request payload only carries the question, no recipeId).
148 lines
5.7 KiB
TypeScript
148 lines
5.7 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { headers } from "next/headers";
|
|
import Link from "next/link";
|
|
import { auth } from "@/lib/auth/server";
|
|
import { db, recipes, favorites, sql, count } from "@epicure/db";
|
|
import { eq, desc, asc, and, ilike, or, inArray } from "@epicure/db";
|
|
import { RecipesHeader } from "@/components/recipe/recipes-header";
|
|
import { RecipesEmptyState } from "@/components/recipe/recipes-empty-state";
|
|
import { RecipesGrid } from "@/components/recipe/recipes-grid";
|
|
import { CookingAssistantPanel } from "@/components/recipe/cooking-assistant-panel";
|
|
import { getMessages } from "@/lib/i18n/server";
|
|
|
|
export const metadata: Metadata = {};
|
|
|
|
const PAGE_SIZE = 24;
|
|
|
|
type SearchParams = Promise<{
|
|
q?: string;
|
|
sort?: string;
|
|
visibility?: string;
|
|
difficulty?: string;
|
|
tag?: string;
|
|
page?: string;
|
|
batchCook?: string;
|
|
}>;
|
|
|
|
const SORT_MAP = {
|
|
updated_desc: desc(recipes.updatedAt),
|
|
updated_asc: asc(recipes.updatedAt),
|
|
created_desc: desc(recipes.createdAt),
|
|
created_asc: asc(recipes.createdAt),
|
|
title_asc: asc(recipes.title),
|
|
title_desc: desc(recipes.title),
|
|
} as const;
|
|
|
|
type SortKey = keyof typeof SORT_MAP;
|
|
|
|
export default async function RecipesPage({ searchParams }: { searchParams: SearchParams }) {
|
|
const session = await auth.api.getSession({ headers: await headers() });
|
|
if (!session) return null;
|
|
const m = getMessages((session.user as { locale?: string }).locale);
|
|
|
|
const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook } = await searchParams;
|
|
const query = (q ?? "").trim().slice(0, 200);
|
|
const sortKey: SortKey = (sort && sort in SORT_MAP ? sort : "updated_desc") as SortKey;
|
|
const tagFilter = tag?.trim().slice(0, 50) || undefined;
|
|
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
|
|
const offset = (page - 1) * PAGE_SIZE;
|
|
|
|
const visibilityFilter = visibility && ["private", "unlisted", "public"].includes(visibility)
|
|
? (visibility as "private" | "unlisted" | "public")
|
|
: undefined;
|
|
const difficultyFilter = difficulty && ["easy", "medium", "hard"].includes(difficulty)
|
|
? (difficulty as "easy" | "medium" | "hard")
|
|
: undefined;
|
|
const batchCookFilter = batchCook === "1" || batchCook === "0" ? batchCook : undefined;
|
|
|
|
const where = and(
|
|
eq(recipes.authorId, session.user.id),
|
|
batchCookFilter ? eq(recipes.isBatchCook, batchCookFilter === "1") : undefined,
|
|
query
|
|
? or(ilike(recipes.title, `%${query}%`), ilike(recipes.description, `%${query}%`))
|
|
: undefined,
|
|
visibilityFilter ? eq(recipes.visibility, visibilityFilter) : undefined,
|
|
difficultyFilter ? eq(recipes.difficulty, difficultyFilter) : undefined,
|
|
tagFilter ? sql`${recipes.tags} @> ARRAY[${tagFilter}]::text[]` : undefined,
|
|
);
|
|
|
|
const [userRecipes, totalRow] = await Promise.all([
|
|
db.query.recipes.findMany({
|
|
where,
|
|
orderBy: SORT_MAP[sortKey],
|
|
with: { photos: true, batchDishes: { columns: { id: true } } },
|
|
limit: PAGE_SIZE,
|
|
offset,
|
|
}),
|
|
db.select({ count: count() }).from(recipes).where(where),
|
|
]);
|
|
|
|
const total = totalRow[0]?.count ?? 0;
|
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
|
|
const recipeIds = userRecipes.map((r) => r.id);
|
|
const favoritedRows = recipeIds.length > 0
|
|
? await db
|
|
.select({ recipeId: favorites.recipeId })
|
|
.from(favorites)
|
|
.where(and(eq(favorites.userId, session.user.id), inArray(favorites.recipeId, recipeIds)))
|
|
: [];
|
|
const favoritedIds = new Set(favoritedRows.map((r) => r.recipeId));
|
|
const recipesWithFavorites = userRecipes.map((r) => ({ ...r, isFavorited: favoritedIds.has(r.id), dishCount: r.batchDishes.length }));
|
|
|
|
const pageHref = (p: number) => {
|
|
const params = new URLSearchParams();
|
|
if (query) params.set("q", query);
|
|
if (sortKey !== "updated_desc") params.set("sort", sortKey);
|
|
if (visibilityFilter) params.set("visibility", visibilityFilter);
|
|
if (difficultyFilter) params.set("difficulty", difficultyFilter);
|
|
if (tagFilter) params.set("tag", tagFilter);
|
|
if (batchCookFilter) params.set("batchCook", batchCookFilter);
|
|
if (p > 1) params.set("page", String(p));
|
|
const qs = params.toString();
|
|
return qs ? `/recipes?${qs}` : "/recipes";
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span className="rounded-full bg-accent px-3 py-1 font-medium">{m.recipes.tabMine}</span>
|
|
<Link href="/recipes/favorites" className="rounded-full px-3 py-1 text-muted-foreground hover:bg-accent">
|
|
{m.recipes.tabFavorites}
|
|
</Link>
|
|
</div>
|
|
<RecipesHeader
|
|
count={total}
|
|
initialQuery={query}
|
|
initialSort={sortKey}
|
|
initialVisibility={visibilityFilter ?? ""}
|
|
initialDifficulty={difficultyFilter ?? ""}
|
|
initialTag={tagFilter ?? ""}
|
|
initialBatchCook={batchCookFilter ?? ""}
|
|
/>
|
|
<RecipesEmptyState query={query} count={total} />
|
|
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${batchCookFilter}-${page}`} recipes={recipesWithFavorites} />
|
|
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-center gap-2 pt-2">
|
|
{page > 1 && (
|
|
<Link href={pageHref(page - 1)} className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent">
|
|
Previous
|
|
</Link>
|
|
)}
|
|
<span className="text-sm text-muted-foreground px-2">
|
|
Page {page} of {totalPages}
|
|
</span>
|
|
{page < totalPages && (
|
|
<Link href={pageHref(page + 1)} className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent">
|
|
Next
|
|
</Link>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<CookingAssistantPanel />
|
|
</div>
|
|
);
|
|
}
|