- {Array.from({ length: 6 }).map((_, i) => (
-
-
-
-
+ {!ideasLoading && ideas.length > 0 && (
+
+ {ideas.map((idea) => (
+
+
+
{idea.title}
+
+ {tRecipe(`difficulty.${idea.difficulty}`)}
+
+
+
{idea.description}
+
+ {idea.tags.map((tag) => (
+ {tag}
+ ))}
+ {idea.totalMins && (
+
+ {tRecipe("total", { mins: idea.totalMins })}
+
+ )}
+
+
+
+ ))}
- ))}
-
- )}
+ )}
+
- {!ideasLoading && ideas.length > 0 && (
-
- {ideas.map((idea) => (
-
-
-
{idea.title}
-
- {tRecipe(`difficulty.${idea.difficulty}`)}
-
-
-
{idea.description}
-
- {idea.tags.map((tag) => (
- {tag}
- ))}
- {idea.totalMins && (
-
- {tRecipe("total", { mins: idea.totalMins })}
-
- )}
-
-
-
- ))}
-
- )}
-
+
+
+
+
{t("trendingThisWeek")}
+
+ {trending.length === 0 ? (
+
+ {t("noTrending")}
+
+ ) : (
+
+ {trending.map((recipe) => (
+
+
+
+
+
+ ))}
+
+ )}
+
-
-
-
-
{t("trendingThisWeek")}
-
- {trending.length === 0 ? (
-
- {t("noTrending")}
-
+
+
+
+
{t("recentlyAdded")}
+
+ {recent.length === 0 ? (
+
+ {t("noRecent")}
+
+ ) : (
+
+ )}
+
+ >
+ )}
+
+ {tab === "following" && (
+ followedCount === 0 ? (
+
) : (
-
- {trending.map((recipe) => (
-
-
-
- ))}
-
- )}
-
+
}
+ />
+ )
+ )}
-
-
-
-
{t("recentlyAdded")}
-
- {recent.length === 0 ? (
-
- {t("noRecent")}
-
- ) : (
-
- {recent.map((recipe) => (
-
- ))}
-
- )}
-
+ {tab === "forYou" && (
+
}
+ />
+ )}
>
)}
diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts
index 3eb9c0c..19b08bf 100644
--- a/apps/web/lib/changelog.ts
+++ b/apps/web/lib/changelog.ts
@@ -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",
diff --git a/apps/web/lib/openapi.ts b/apps/web/lib/openapi.ts
index 3daa454..9c4133f 100644
--- a/apps/web/lib/openapi.ts
+++ b/apps/web/lib/openapi.ts
@@ -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 } } } } });
diff --git a/apps/web/lib/recipe-card-extras.ts b/apps/web/lib/recipe-card-extras.ts
new file mode 100644
index 0000000..6a30d6d
--- /dev/null
+++ b/apps/web/lib/recipe-card-extras.ts
@@ -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