feat: merge Explore and Activity Feed, unify recipe cards

Explore and Feed covered overlapping ground (recommendations, following,
trending) in two separate places with three different card designs.
Explore now hosts Discover/Following/For You tabs, and every recipe
card everywhere on the page matches the Recipes page's cover-photo
card instead of the old text-only search result.

v0.36.0
This commit is contained in:
Arnaud
2026-07-17 16:06:17 +02:00
parent 1577b8de01
commit 5763bd3318
17 changed files with 601 additions and 445 deletions
+9 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.35.0";
export const APP_VERSION = "0.36.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,14 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.36.0",
date: "2026-07-17 12:00",
added: [
"Explore and the Activity Feed are now one page — Explore has Discover/Following/For You tabs, so browsing and following recipes no longer live in two separate places.",
"Explore's recipe cards (search results, trending, recently added, following, for you) now use the same cover-photo card as the Recipes page, instead of the old text-only search card.",
],
},
{
version: "0.35.0",
date: "2026-07-14 18:20",
+10
View File
@@ -320,8 +320,11 @@ export function generateOpenApiSpec(): object {
id: z.string(), title: z.string(), description: z.string().nullable(),
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public"]),
tags: z.array(z.string()), isBatchCook: z.boolean(), sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
createdAt: z.string().datetime(), updatedAt: z.string().datetime(), authorId: z.string(),
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
photos: z.array(z.object({ storageKey: z.string(), isCover: z.boolean() })),
dishCount: z.number().int(), isFavorited: z.boolean(),
}));
registry.registerPath({ method: "get", path: "/api/v1/feed", summary: "Activity feed (pull-based) — recent recipes from users you follow", security, request: { query: z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: z.object({ data: z.array(FeedRecipeRef), total: z.number(), limit: z.number(), offset: z.number(), message: z.string().optional() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: LimitOffset }, responses: { 200: { description: "Paginated collections", content: { "application/json": { schema: PaginatedCollections } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
@@ -605,6 +608,9 @@ export function generateOpenApiSpec(): object {
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public"]),
aiGenerated: z.boolean(), createdAt: z.string().datetime(), authorId: z.string(),
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
tags: z.array(z.string()), isBatchCook: z.boolean(), sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
photos: z.array(z.object({ storageKey: z.string(), isCover: z.boolean() })),
dishCount: z.number().int(), isFavorited: z.boolean(),
}));
registry.registerPath({ method: "get", path: "/api/v1/feed/trending", summary: "Trending public recipes (by favorites in the last 7 days)", description: "Public — no auth required. Excludes private authors.", security: [], request: { query: z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).default(0) }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: z.object({ data: z.array(TrendingRecipeRef), total: z.number(), limit: z.number(), offset: z.number() }) } } } } });
@@ -614,8 +620,12 @@ export function generateOpenApiSpec(): object {
const SearchResultRef = registry.register("SearchResult", z.object({
id: z.string(), title: z.string(), description: z.string().nullable(), difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
visibility: z.enum(["private", "unlisted", "public"]), tags: z.array(z.string()), isBatchCook: z.boolean(),
sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
authorId: z.string(), authorName: z.string(), createdAt: z.string().datetime(),
avgRating: z.number().nullable(), ratingCount: z.number().int(),
photos: z.array(z.object({ storageKey: z.string(), isCover: z.boolean() })),
dishCount: z.number().int(), isFavorited: z.boolean(),
}));
registry.registerPath({ method: "get", path: "/api/v1/search", summary: "Full-text search across public recipes — title, description, ingredient names, and tags", description: "Public — no auth required. Matches only public recipes by non-private authors.", security: [], request: { query: z.object({ q: z.string().min(1).max(200), difficulty: z.enum(["easy", "medium", "hard"]).optional(), recipeType: z.enum(["dish", "drink"]).optional(), maxMins: z.coerce.number().optional().describe("Max prepMins + cookMins combined"), dietary: z.string().optional().describe("Comma-separated: vegan,vegetarian,glutenFree,dairyFree"), tags: z.string().optional().describe("Comma-separated exact-tag filter chips, up to 5"), limit: z.coerce.number().min(1).max(50).default(20), offset: z.coerce.number().min(0).default(0) }) }, responses: { 200: { description: "Paginated results", content: { "application/json": { schema: z.object({ data: z.array(SearchResultRef), total: z.number(), limit: z.number(), offset: z.number() }) } } }, 400: { description: "Missing or empty 'q'", content: { "application/json": { schema: ApiErrorRef } } } } });
+54
View File
@@ -0,0 +1,54 @@
import { db, recipePhotos, recipeBatchDishes, favorites, inArray, eq, and } from "@epicure/db";
export type CardExtras = {
photos: { storageKey: string; isCover: boolean }[];
dishCount: number;
isFavorited: boolean;
};
/** Batches the photo/batch-dish/favorite lookups a recipe grid card needs, for
* list endpoints whose main query selects individual recipe columns rather
* than going through `db.query.recipes.findMany`'s relational loader. */
export async function attachCardExtras<T extends { id: string }>(
recipeList: T[],
viewerId?: string
): Promise<(T & CardExtras)[]> {
const ids = recipeList.map((r) => r.id);
if (ids.length === 0) return [];
const [photoRows, dishRows, favoritedRows] = await Promise.all([
db
.select({ recipeId: recipePhotos.recipeId, storageKey: recipePhotos.storageKey, isCover: recipePhotos.isCover })
.from(recipePhotos)
.where(inArray(recipePhotos.recipeId, ids)),
db
.select({ recipeId: recipeBatchDishes.recipeId })
.from(recipeBatchDishes)
.where(inArray(recipeBatchDishes.recipeId, ids)),
viewerId
? db
.select({ recipeId: favorites.recipeId })
.from(favorites)
.where(and(eq(favorites.userId, viewerId), inArray(favorites.recipeId, ids)))
: Promise.resolve([]),
]);
const photosByRecipe = new Map<string, { storageKey: string; isCover: boolean }[]>();
for (const p of photoRows) {
const arr = photosByRecipe.get(p.recipeId) ?? [];
arr.push({ storageKey: p.storageKey, isCover: p.isCover });
photosByRecipe.set(p.recipeId, arr);
}
const dishCountByRecipe = new Map<string, number>();
for (const d of dishRows) dishCountByRecipe.set(d.recipeId, (dishCountByRecipe.get(d.recipeId) ?? 0) + 1);
const favoritedSet = new Set(favoritedRows.map((f) => f.recipeId));
return recipeList.map((r) => ({
...r,
photos: photosByRecipe.get(r.id) ?? [],
dishCount: dishCountByRecipe.get(r.id) ?? 0,
isFavorited: favoritedSet.has(r.id),
}));
}