Files
Epicure/apps/web/app/(app)/collections/explore/page.tsx
T
Arnaud 9da57dd1d0 feat: collections visibility enum (matches recipes) + QR on collection PDF (v0.54.0)
Replaces collections.isPublic (boolean) with collections.visibility
(private/unlisted/public/followers — same enum recipes use). Two-step
migration (0050 adds+backfills, 0051 drops isPublic) since drizzle-kit's
add+drop-in-one-diff rename heuristic needs an interactive prompt we
can't satisfy here.

New collectionVisibleToViewer(viewerId) in lib/visibility.ts mirrors the
existing recipe helper (author always sees own; public/unlisted visible
to anyone; followers-only via the same user_follows EXISTS pattern) —
used by the collection detail page, its print view, fork, and favorite,
replacing their old `or(isPublic, own)` checks.

Create/edit collection dialogs get the same 4-option visibility select
as the recipe form instead of a public/private checkbox.

Collection PDF export now generates a QR code (qrcode, same as the
recipe PDF) linking to /collections/{id}, shown only when visibility is
public/unlisted — same "would an anonymous scanner actually resolve
this" rule as the recipe QR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 19:26:34 +02:00

114 lines
3.6 KiB
TypeScript

import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import {
db,
collections,
users,
collectionFavorites,
collectionRecipes,
eq,
desc,
sql,
and,
gte,
count,
inArray,
} from "@epicure/db";
import { ExploreCollectionsContent } from "@/components/collections/explore-collections-content";
export const metadata: Metadata = {};
export type CollectionResult = {
id: string;
name: string;
description: string | null;
authorName: string | null;
recipeCount: number;
starCount: number;
starred: boolean;
};
export default async function ExploreCollectionsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const trendingRows = await db
.select({
id: collections.id,
name: collections.name,
description: collections.description,
authorName: users.name,
recentStars: count(collectionFavorites.collectionId),
})
.from(collections)
.innerJoin(users, eq(collections.userId, users.id))
.leftJoin(
collectionFavorites,
and(eq(collectionFavorites.collectionId, collections.id), gte(collectionFavorites.createdAt, sevenDaysAgo))
)
.where(eq(collections.visibility, "public"))
.groupBy(collections.id, users.id)
.orderBy(desc(sql`count(${collectionFavorites.collectionId})`))
.limit(12);
const recentRows = await db
.select({
id: collections.id,
name: collections.name,
description: collections.description,
authorName: users.name,
})
.from(collections)
.innerJoin(users, eq(collections.userId, users.id))
.where(eq(collections.visibility, "public"))
.orderBy(desc(collections.createdAt))
.limit(12);
const allIds = [...new Set([...trendingRows.map((r) => r.id), ...recentRows.map((r) => r.id)])];
const [recipeCounts, totalStars, myStars] = await Promise.all([
allIds.length > 0
? db.select({ collectionId: collectionRecipes.collectionId, n: count() })
.from(collectionRecipes)
.where(inArray(collectionRecipes.collectionId, allIds))
.groupBy(collectionRecipes.collectionId)
: [],
allIds.length > 0
? db.select({ collectionId: collectionFavorites.collectionId, n: count() })
.from(collectionFavorites)
.where(inArray(collectionFavorites.collectionId, allIds))
.groupBy(collectionFavorites.collectionId)
: [],
allIds.length > 0
? db.query.collectionFavorites.findMany({
where: and(eq(collectionFavorites.userId, session.user.id), inArray(collectionFavorites.collectionId, allIds)),
columns: { collectionId: true },
})
: [],
]);
const recipeCountMap = new Map(recipeCounts.map((r) => [r.collectionId, r.n]));
const starCountMap = new Map(totalStars.map((r) => [r.collectionId, r.n]));
const starredSet = new Set(myStars.map((s) => s.collectionId));
function toResult(row: { id: string; name: string; description: string | null; authorName: string | null }): CollectionResult {
return {
id: row.id,
name: row.name,
description: row.description,
authorName: row.authorName,
recipeCount: recipeCountMap.get(row.id) ?? 0,
starCount: starCountMap.get(row.id) ?? 0,
starred: starredSet.has(row.id),
};
}
const trending = trendingRows.map(toResult);
const recent = recentRows.map(toResult);
return <ExploreCollectionsContent trending={trending} recent={recent} />;
}