9da57dd1d0
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>
86 lines
2.9 KiB
TypeScript
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,
|
|
}];
|
|
}),
|
|
}))}
|
|
/>
|
|
);
|
|
}
|