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:
@@ -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",
|
||||
|
||||
@@ -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 } } } } });
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user