257908da7f
Search bar forced its own full-width row (w-full), pushing Sort/Filter to a second row below it — on mobile that's title + action buttons + search row + sort/filter row + toolbar row before any recipe appears. Sort/Filter now go icon-only below sm: (text labels hidden, matching the convention already used for compact-view badges), letting Search share one row with both instead of forcing its own. Also tightened the title size and inter-section spacing on mobile (unchanged at sm: and up). Verified at a real 390px mobile viewport: first recipe card now clears with room to spare, versus barely visible at the same viewport height before. Desktop screenshot confirms zero visual change there.
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-4 sm: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>
|
|
);
|
|
}
|