Files
Epicure/apps/web/app/(app)/collections/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

86 lines
2.9 KiB
TypeScript

import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, collections, collectionRecipes, eq, and, or, ilike, sql } from "@epicure/db";
import { CollectionsPageContent } from "@/components/collections/collections-page-content";
import { getPublicUrl } from "@/lib/storage";
export const metadata: Metadata = {};
export default async function CollectionsPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const { q } = await searchParams;
const query = q?.trim();
const where = query
? and(
eq(collections.userId, session.user.id),
or(
ilike(collections.name, `%${query}%`),
ilike(collections.description, `%${query}%`),
sql`exists (
select 1 from collection_recipes cr
inner join recipes r on r.id = cr.recipe_id
where cr.collection_id = ${collections.id} and r.title ilike ${`%${query}%`}
)`
)
)
: eq(collections.userId, session.user.id);
const [userCollections, countRows] = await Promise.all([
db.query.collections.findMany({
where,
orderBy: (t, { desc }) => desc(t.updatedAt),
with: {
recipes: {
limit: 4,
orderBy: (t, { asc }) => asc(t.position),
with: { recipe: { with: { photos: true } } },
},
},
}),
// Separate grouped count — the `with: { recipes: { limit: 4 } }` above is
// capped for thumbnail previews, so `.recipes.length` off that relation
// would only ever report up to 4, never the real total.
db
.select({ collectionId: collectionRecipes.collectionId, count: sql<number>`count(*)::int` })
.from(collectionRecipes)
.innerJoin(collections, eq(collectionRecipes.collectionId, collections.id))
.where(eq(collections.userId, session.user.id))
.groupBy(collectionRecipes.collectionId),
]);
const countByCollection = new Map(countRows.map((r) => [r.collectionId, r.count]));
return (
<CollectionsPageContent
query={query ?? ""}
collections={userCollections.map((col) => ({
id: col.id,
name: col.name,
description: col.description,
tags: col.tags,
visibility: col.visibility,
recipeCount: countByCollection.get(col.id) ?? 0,
thumbnails: col.recipes.flatMap((r) => {
if (!r.recipe) return [];
const cover = r.recipe.photos.find((p) => p.isCover) ?? r.recipe.photos[0];
return [{
recipeId: r.recipe.id,
recipeType: r.recipe.recipeType,
coverIcon: r.recipe.coverIcon,
coverColor: r.recipe.coverColor,
photoUrl: cover ? getPublicUrl(cover.storageKey) : null,
}];
}),
}))}
/>
);
}