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>
This commit is contained in:
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||||
|
|
||||||
|
## 0.54.0 — 2026-07-19 17:05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Collections now have the same visibility options as recipes — private, followers-only, unlisted, or public — replacing the old public/private toggle. Fork, favorite, and viewing a collection all respect the new followers-only option the same way recipes do.
|
||||||
|
- Collection PDF exports now include a QR code linking back to the collection, same as recipe PDFs.
|
||||||
|
|
||||||
## 0.53.1 — 2026-07-19 16:10
|
## 0.53.1 — 2026-07-19 16:10
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { headers } from "next/headers";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Printer, UtensilsCrossed, StickyNote } from "lucide-react";
|
import { Printer, UtensilsCrossed, StickyNote } from "lucide-react";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db, collections, eq, and, or } from "@epicure/db";
|
import { db, collections, eq, and } from "@epicure/db";
|
||||||
|
import { collectionVisibleToViewer } from "@/lib/visibility";
|
||||||
import { RecipeGridCard } from "@/components/recipe/recipe-grid-card";
|
import { RecipeGridCard } from "@/components/recipe/recipe-grid-card";
|
||||||
import { CollectionRecipesGrid } from "@/components/collections/collection-recipes-grid";
|
import { CollectionRecipesGrid } from "@/components/collections/collection-recipes-grid";
|
||||||
import { ForkCollectionButton } from "@/components/collections/fork-collection-button";
|
import { ForkCollectionButton } from "@/components/collections/fork-collection-button";
|
||||||
@@ -32,10 +33,7 @@ export default async function CollectionPage({ params }: Params) {
|
|||||||
const m = getMessages((session.user as { locale?: string }).locale);
|
const m = getMessages((session.user as { locale?: string }).locale);
|
||||||
|
|
||||||
const col = await db.query.collections.findFirst({
|
const col = await db.query.collections.findFirst({
|
||||||
where: and(
|
where: and(eq(collections.id, id), collectionVisibleToViewer(session.user.id)),
|
||||||
eq(collections.id, id),
|
|
||||||
or(eq(collections.userId, session.user.id), eq(collections.isPublic, true))
|
|
||||||
),
|
|
||||||
with: {
|
with: {
|
||||||
recipes: {
|
recipes: {
|
||||||
orderBy: (t, { asc }) => asc(t.position),
|
orderBy: (t, { asc }) => asc(t.position),
|
||||||
@@ -63,7 +61,7 @@ export default async function CollectionPage({ params }: Params) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{recipeList.length} recipe{recipeList.length !== 1 ? "s" : ""} · {col.isPublic ? "Public" : "Private"}
|
{recipeList.length} recipe{recipeList.length !== 1 ? "s" : ""} · {m.recipe.visibility[col.visibility]}
|
||||||
</p>
|
</p>
|
||||||
{isOwner && col.notes && (
|
{isOwner && col.notes && (
|
||||||
<div className="mt-2 flex items-start gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-sm text-muted-foreground max-w-2xl">
|
<div className="mt-2 flex items-start gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-sm text-muted-foreground max-w-2xl">
|
||||||
@@ -103,11 +101,11 @@ export default async function CollectionPage({ params }: Params) {
|
|||||||
initialDescription={col.description}
|
initialDescription={col.description}
|
||||||
initialNotes={col.notes}
|
initialNotes={col.notes}
|
||||||
initialTags={col.tags}
|
initialTags={col.tags}
|
||||||
initialIsPublic={col.isPublic}
|
initialVisibility={col.visibility}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isOwner && <DeleteCollectionDialog collectionId={id} />}
|
{isOwner && <DeleteCollectionDialog collectionId={id} />}
|
||||||
{!isOwner && col.isPublic && (
|
{!isOwner && (col.visibility === "public" || col.visibility === "unlisted") && (
|
||||||
<ForkCollectionButton collectionId={id} />
|
<ForkCollectionButton collectionId={id} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default async function ExploreCollectionsPage() {
|
|||||||
collectionFavorites,
|
collectionFavorites,
|
||||||
and(eq(collectionFavorites.collectionId, collections.id), gte(collectionFavorites.createdAt, sevenDaysAgo))
|
and(eq(collectionFavorites.collectionId, collections.id), gte(collectionFavorites.createdAt, sevenDaysAgo))
|
||||||
)
|
)
|
||||||
.where(eq(collections.isPublic, true))
|
.where(eq(collections.visibility, "public"))
|
||||||
.groupBy(collections.id, users.id)
|
.groupBy(collections.id, users.id)
|
||||||
.orderBy(desc(sql`count(${collectionFavorites.collectionId})`))
|
.orderBy(desc(sql`count(${collectionFavorites.collectionId})`))
|
||||||
.limit(12);
|
.limit(12);
|
||||||
@@ -63,7 +63,7 @@ export default async function ExploreCollectionsPage() {
|
|||||||
})
|
})
|
||||||
.from(collections)
|
.from(collections)
|
||||||
.innerJoin(users, eq(collections.userId, users.id))
|
.innerJoin(users, eq(collections.userId, users.id))
|
||||||
.where(eq(collections.isPublic, true))
|
.where(eq(collections.visibility, "public"))
|
||||||
.orderBy(desc(collections.createdAt))
|
.orderBy(desc(collections.createdAt))
|
||||||
.limit(12);
|
.limit(12);
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default async function CollectionsPage({
|
|||||||
name: col.name,
|
name: col.name,
|
||||||
description: col.description,
|
description: col.description,
|
||||||
tags: col.tags,
|
tags: col.tags,
|
||||||
isPublic: col.isPublic,
|
visibility: col.visibility,
|
||||||
recipeCount: countByCollection.get(col.id) ?? 0,
|
recipeCount: countByCollection.get(col.id) ?? 0,
|
||||||
thumbnails: col.recipes.flatMap((r) => {
|
thumbnails: col.recipes.flatMap((r) => {
|
||||||
if (!r.recipe) return [];
|
if (!r.recipe) return [];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db, collections, collectionFavorites, eq, and, or } from "@epicure/db";
|
import { db, collections, collectionFavorites, eq, and } from "@epicure/db";
|
||||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
|
import { collectionVisibleToViewer } from "@/lib/visibility";
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const collection = await db.query.collections.findFirst({
|
const collection = await db.query.collections.findFirst({
|
||||||
where: and(eq(collections.id, id), or(eq(collections.isPublic, true), eq(collections.userId, session!.user.id))),
|
where: and(eq(collections.id, id), collectionVisibleToViewer(session!.user.id)),
|
||||||
columns: { id: true },
|
columns: { id: true },
|
||||||
});
|
});
|
||||||
if (!collection) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!collection) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db, collections, collectionRecipes, eq, and, or } from "@epicure/db";
|
import { db, collections, collectionRecipes, eq, and } from "@epicure/db";
|
||||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
|
import { collectionVisibleToViewer } from "@/lib/visibility";
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
@@ -9,12 +10,10 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
if (response) return response;
|
if (response) return response;
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
// Allow forking public collections or own collections
|
// Allow forking any collection visible to this viewer (own, or public/
|
||||||
|
// unlisted/followers-if-following) — never a private collection of someone else's.
|
||||||
const source = await db.query.collections.findFirst({
|
const source = await db.query.collections.findFirst({
|
||||||
where: and(
|
where: and(eq(collections.id, id), collectionVisibleToViewer(session!.user.id)),
|
||||||
eq(collections.id, id),
|
|
||||||
or(eq(collections.isPublic, true), eq(collections.userId, session!.user.id))
|
|
||||||
),
|
|
||||||
with: { recipes: { columns: { recipeId: true } } },
|
with: { recipes: { columns: { recipeId: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,7 +25,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
userId: session!.user.id,
|
userId: session!.user.id,
|
||||||
name: `${source.name} (fork)`,
|
name: `${source.name} (fork)`,
|
||||||
description: source.description,
|
description: source.description,
|
||||||
isPublic: false,
|
visibility: "private",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
|||||||
description: z.string().max(500).nullable().optional(),
|
description: z.string().max(500).nullable().optional(),
|
||||||
notes: z.string().max(2000).nullable().optional(),
|
notes: z.string().max(2000).nullable().optional(),
|
||||||
tags: z.array(z.string().min(1).max(50)).max(20).optional(),
|
tags: z.array(z.string().min(1).max(50)).max(20).optional(),
|
||||||
isPublic: z.boolean().optional(),
|
visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(),
|
||||||
addRecipeId: z.string().optional(),
|
addRecipeId: z.string().optional(),
|
||||||
addRecipeIds: z.array(z.string()).max(200).optional(),
|
addRecipeIds: z.array(z.string()).max(200).optional(),
|
||||||
removeRecipeId: z.string().optional(),
|
removeRecipeId: z.string().optional(),
|
||||||
@@ -45,13 +45,13 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
|||||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||||
|
|
||||||
const data = parsed.data;
|
const data = parsed.data;
|
||||||
if (data.name || data.description !== undefined || data.notes !== undefined || data.tags !== undefined || data.isPublic !== undefined) {
|
if (data.name || data.description !== undefined || data.notes !== undefined || data.tags !== undefined || data.visibility !== undefined) {
|
||||||
await db.update(collections).set({
|
await db.update(collections).set({
|
||||||
...(data.name && { name: data.name }),
|
...(data.name && { name: data.name }),
|
||||||
...(data.description !== undefined && { description: data.description }),
|
...(data.description !== undefined && { description: data.description }),
|
||||||
...(data.notes !== undefined && { notes: data.notes }),
|
...(data.notes !== undefined && { notes: data.notes }),
|
||||||
...(data.tags !== undefined && { tags: data.tags }),
|
...(data.tags !== undefined && { tags: data.tags }),
|
||||||
...(data.isPublic !== undefined && { isPublic: data.isPublic }),
|
...(data.visibility !== undefined && { visibility: data.visibility }),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
}).where(eq(collections.id, id));
|
}).where(eq(collections.id, id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { requireSessionOrApiKey } from "@/lib/api-auth";
|
|||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
description: z.string().max(500).optional(),
|
description: z.string().max(500).optional(),
|
||||||
isPublic: z.boolean().default(false),
|
visibility: z.enum(["private", "unlisted", "public", "followers"]).default("private"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
@@ -61,7 +61,7 @@ export async function POST(req: NextRequest) {
|
|||||||
userId: session!.user.id,
|
userId: session!.user.id,
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
description: parsed.data.description,
|
description: parsed.data.description,
|
||||||
isPublic: parsed.data.isPublic,
|
visibility: parsed.data.visibility,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ id }, { status: 201 });
|
return NextResponse.json({ id }, { status: 201 });
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import QRCode from "qrcode";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db, collections, eq, and, or } from "@epicure/db";
|
import { db, collections, eq, and } from "@epicure/db";
|
||||||
|
import { collectionVisibleToViewer } from "@/lib/visibility";
|
||||||
import { PrintTrigger } from "@/components/recipe/print-trigger";
|
import { PrintTrigger } from "@/components/recipe/print-trigger";
|
||||||
import { formatIngredientQuantity } from "@/lib/unit-conversion";
|
import { formatIngredientQuantity } from "@/lib/unit-conversion";
|
||||||
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
||||||
@@ -17,10 +19,7 @@ export default async function CollectionPrintPage({ params }: Params) {
|
|||||||
const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
|
const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
|
||||||
|
|
||||||
const col = await db.query.collections.findFirst({
|
const col = await db.query.collections.findFirst({
|
||||||
where: and(
|
where: and(eq(collections.id, id), collectionVisibleToViewer(session.user.id)),
|
||||||
eq(collections.id, id),
|
|
||||||
or(eq(collections.userId, session.user.id), eq(collections.isPublic, true))
|
|
||||||
),
|
|
||||||
with: {
|
with: {
|
||||||
recipes: {
|
recipes: {
|
||||||
with: {
|
with: {
|
||||||
@@ -39,6 +38,13 @@ export default async function CollectionPrintPage({ params }: Params) {
|
|||||||
|
|
||||||
const recipeEntries = col.recipes.filter((r) => r.recipe !== null);
|
const recipeEntries = col.recipes.filter((r) => r.recipe !== null);
|
||||||
|
|
||||||
|
// Same rationale as the recipe print page's QR: only link a URL an
|
||||||
|
// anonymous scanner could actually resolve.
|
||||||
|
const shareUrl = col.visibility === "public" || col.visibility === "unlisted"
|
||||||
|
? `${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"}/collections/${id}`
|
||||||
|
: null;
|
||||||
|
const qrDataUrl = shareUrl ? await QRCode.toDataURL(shareUrl, { margin: 1, width: 120 }) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
<style>{`
|
||||||
@@ -71,6 +77,9 @@ export default async function CollectionPrintPage({ params }: Params) {
|
|||||||
.timer { font-size: 0.85em; color: #666; font-family: system-ui, sans-serif; margin-left: 8px; }
|
.timer { font-size: 0.85em; color: #666; font-family: system-ui, sans-serif; margin-left: 8px; }
|
||||||
.cookbook-cover { text-align: center; margin-bottom: 40px; }
|
.cookbook-cover { text-align: center; margin-bottom: 40px; }
|
||||||
.cookbook-cover h1 { font-size: 2.6em; }
|
.cookbook-cover h1 { font-size: 2.6em; }
|
||||||
|
.qr-block { text-align: center; margin: 0 auto; font-family: system-ui, sans-serif; }
|
||||||
|
.qr-block img { width: 84px; height: 84px; display: block; margin: 0 auto; }
|
||||||
|
.qr-block span { display: block; font-size: 0.65em; color: #999; margin-top: 4px; }
|
||||||
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; font-family: system-ui, sans-serif; }
|
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; font-family: system-ui, sans-serif; }
|
||||||
.print-btn {
|
.print-btn {
|
||||||
position: fixed; top: 16px; right: 16px; padding: 8px 16px;
|
position: fixed; top: 16px; right: 16px; padding: 8px 16px;
|
||||||
@@ -88,6 +97,13 @@ export default async function CollectionPrintPage({ params }: Params) {
|
|||||||
<p style={{ fontFamily: "system-ui, sans-serif", fontSize: "0.85em", color: "#888" }}>
|
<p style={{ fontFamily: "system-ui, sans-serif", fontSize: "0.85em", color: "#888" }}>
|
||||||
{recipeEntries.length} recipe{recipeEntries.length !== 1 ? "s" : ""}
|
{recipeEntries.length} recipe{recipeEntries.length !== 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
|
{qrDataUrl && (
|
||||||
|
<div className="qr-block">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- data: URI, next/image can't optimize it anyway */}
|
||||||
|
<img src={qrDataUrl} alt={m.collections.qrCodeAlt} />
|
||||||
|
<span>{m.collections.qrCodeCaption}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recipeEntries.map(({ recipe }) => {
|
{recipeEntries.map(({ recipe }) => {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ type Collection = {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
isPublic: boolean;
|
visibility: "private" | "unlisted" | "public" | "followers";
|
||||||
recipeCount: number;
|
recipeCount: number;
|
||||||
thumbnails: Thumbnail[];
|
thumbnails: Thumbnail[];
|
||||||
};
|
};
|
||||||
@@ -70,6 +70,7 @@ function CollectionThumbCollage({ thumbnails }: { thumbnails: Thumbnail[] }) {
|
|||||||
|
|
||||||
export function CollectionsPageContent({ collections, query }: Props) {
|
export function CollectionsPageContent({ collections, query }: Props) {
|
||||||
const t = useTranslations("collections");
|
const t = useTranslations("collections");
|
||||||
|
const tRecipe = useTranslations("recipe");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [search, setSearch] = useState(query);
|
const [search, setSearch] = useState(query);
|
||||||
@@ -129,7 +130,9 @@ export function CollectionsPageContent({ collections, query }: Props) {
|
|||||||
<div className="p-4 space-y-2">
|
<div className="p-4 space-y-2">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h2 className="font-semibold group-hover:text-primary transition-colors line-clamp-1">{col.name}</h2>
|
<h2 className="font-semibold group-hover:text-primary transition-colors line-clamp-1">{col.name}</h2>
|
||||||
{col.isPublic && <Badge variant="secondary" className="text-xs shrink-0">{t("public")}</Badge>}
|
{col.visibility !== "private" && (
|
||||||
|
<Badge variant="secondary" className="text-xs shrink-0">{tRecipe(`visibility.${col.visibility}`)}</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{col.description && <p className="text-sm text-muted-foreground line-clamp-2">{col.description}</p>}
|
{col.description && <p className="text-sm text-muted-foreground line-clamp-2">{col.description}</p>}
|
||||||
{col.tags.length > 0 && (
|
{col.tags.length > 0 && (
|
||||||
|
|||||||
@@ -12,23 +12,26 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
type Visibility = "private" | "unlisted" | "public" | "followers";
|
||||||
|
|
||||||
export function EditCollectionDialog({
|
export function EditCollectionDialog({
|
||||||
collectionId,
|
collectionId,
|
||||||
initialName,
|
initialName,
|
||||||
initialDescription,
|
initialDescription,
|
||||||
initialNotes,
|
initialNotes,
|
||||||
initialTags,
|
initialTags,
|
||||||
initialIsPublic,
|
initialVisibility,
|
||||||
}: {
|
}: {
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
initialName: string;
|
initialName: string;
|
||||||
initialDescription: string | null;
|
initialDescription: string | null;
|
||||||
initialNotes: string | null;
|
initialNotes: string | null;
|
||||||
initialTags: string[];
|
initialTags: string[];
|
||||||
initialIsPublic: boolean;
|
initialVisibility: Visibility;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("collections");
|
const t = useTranslations("collections");
|
||||||
const tRecipeForm = useTranslations("recipeForm");
|
const tRecipeForm = useTranslations("recipeForm");
|
||||||
|
const tRecipe = useTranslations("recipe");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -37,7 +40,7 @@ export function EditCollectionDialog({
|
|||||||
const [notes, setNotes] = useState(initialNotes ?? "");
|
const [notes, setNotes] = useState(initialNotes ?? "");
|
||||||
const [tags, setTags] = useState<string[]>(initialTags);
|
const [tags, setTags] = useState<string[]>(initialTags);
|
||||||
const [tagInput, setTagInput] = useState("");
|
const [tagInput, setTagInput] = useState("");
|
||||||
const [isPublic, setIsPublic] = useState(initialIsPublic);
|
const [visibility, setVisibility] = useState<Visibility>(initialVisibility);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const tagInputRef = useRef<HTMLInputElement>(null);
|
const tagInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -73,7 +76,7 @@ export function EditCollectionDialog({
|
|||||||
description: description.trim() || null,
|
description: description.trim() || null,
|
||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
tags,
|
tags,
|
||||||
isPublic,
|
visibility,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
@@ -151,10 +154,20 @@ export function EditCollectionDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<div className="space-y-2">
|
||||||
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} className="rounded" />
|
<Label htmlFor="edit-col-visibility">{tRecipe("visibilityMenuLabel")}</Label>
|
||||||
{t("makePublic")}
|
<select
|
||||||
</label>
|
id="edit-col-visibility"
|
||||||
|
value={visibility}
|
||||||
|
onChange={(e) => setVisibility(e.target.value as Visibility)}
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs"
|
||||||
|
>
|
||||||
|
<option value="private">{tRecipe("visibility.private")}</option>
|
||||||
|
<option value="followers">{tRecipe("visibility.followers")}</option>
|
||||||
|
<option value="unlisted">{tRecipe("visibility.unlisted")}</option>
|
||||||
|
<option value="public">{tRecipe("visibility.public")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -11,15 +11,18 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
type Visibility = "private" | "unlisted" | "public" | "followers";
|
||||||
|
|
||||||
export function NewCollectionButton() {
|
export function NewCollectionButton() {
|
||||||
const t = useTranslations("collections");
|
const t = useTranslations("collections");
|
||||||
const tSocial = useTranslations("social");
|
const tSocial = useTranslations("social");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
|
const tRecipe = useTranslations("recipe");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [isPublic, setIsPublic] = useState(false);
|
const [visibility, setVisibility] = useState<Visibility>("private");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
@@ -29,13 +32,13 @@ export function NewCollectionButton() {
|
|||||||
const res = await fetch("/api/v1/collections", {
|
const res = await fetch("/api/v1/collections", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: name.trim(), description: description.trim() || undefined, isPublic }),
|
body: JSON.stringify({ name: name.trim(), description: description.trim() || undefined, visibility }),
|
||||||
});
|
});
|
||||||
if (!res.ok) { toast.error(t("createFailed")); return; }
|
if (!res.ok) { toast.error(t("createFailed")); return; }
|
||||||
const { id } = await res.json() as { id: string };
|
const { id } = await res.json() as { id: string };
|
||||||
toast.success(tSocial("collectionCreated"));
|
toast.success(tSocial("collectionCreated"));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setName(""); setDescription(""); setIsPublic(false);
|
setName(""); setDescription(""); setVisibility("private");
|
||||||
router.push(`/collections/${id}`);
|
router.push(`/collections/${id}`);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -60,10 +63,20 @@ export function NewCollectionButton() {
|
|||||||
<Label htmlFor="col-desc">{t("descriptionLabel")}</Label>
|
<Label htmlFor="col-desc">{t("descriptionLabel")}</Label>
|
||||||
<Textarea id="col-desc" value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder={t("descriptionPlaceholder")} />
|
<Textarea id="col-desc" value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder={t("descriptionPlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<div className="space-y-2">
|
||||||
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} className="rounded" />
|
<Label htmlFor="col-visibility">{tRecipe("visibilityMenuLabel")}</Label>
|
||||||
{t("makePublic")}
|
<select
|
||||||
</label>
|
id="col-visibility"
|
||||||
|
value={visibility}
|
||||||
|
onChange={(e) => setVisibility(e.target.value as Visibility)}
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs"
|
||||||
|
>
|
||||||
|
<option value="private">{tRecipe("visibility.private")}</option>
|
||||||
|
<option value="followers">{tRecipe("visibility.followers")}</option>
|
||||||
|
<option value="unlisted">{tRecipe("visibility.unlisted")}</option>
|
||||||
|
<option value="public">{tRecipe("visibility.public")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>{tCommon("cancel")}</Button>
|
<Button variant="outline" onClick={() => setOpen(false)}>{tCommon("cancel")}</Button>
|
||||||
<Button onClick={create} disabled={!name.trim() || saving}>{saving ? t("creating") : t("create")}</Button>
|
<Button onClick={create} disabled={!name.trim() || saving}>{saving ? t("creating") : t("create")}</Button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||||
export const APP_VERSION = "0.53.1";
|
export const APP_VERSION = "0.54.0";
|
||||||
|
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,14 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.54.0",
|
||||||
|
date: "2026-07-19 17:05",
|
||||||
|
added: [
|
||||||
|
"Collections now have the same visibility options as recipes — private, followers-only, unlisted, or public — replacing the old public/private toggle. Fork, favorite, and viewing a collection all respect the new followers-only option the same way recipes do.",
|
||||||
|
"Collection PDF exports now include a QR code linking back to the collection, same as recipe PDFs (only for unlisted/public collections, matching the recipe QR's own rule about what an anonymous scanner could actually open).",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.53.1",
|
version: "0.53.1",
|
||||||
date: "2026-07-19 16:10",
|
date: "2026-07-19 16:10",
|
||||||
|
|||||||
@@ -189,7 +189,8 @@ export function generateOpenApiSpec(): object {
|
|||||||
}));
|
}));
|
||||||
const CollectionRef = registry.register("Collection", z.object({
|
const CollectionRef = registry.register("Collection", z.object({
|
||||||
id: z.string(), userId: z.string(), name: z.string(),
|
id: z.string(), userId: z.string(), name: z.string(),
|
||||||
description: z.string().nullable(), notes: z.string().nullable(), tags: z.array(z.string()), isPublic: z.boolean(),
|
description: z.string().nullable(), notes: z.string().nullable(), tags: z.array(z.string()),
|
||||||
|
visibility: z.enum(["private", "unlisted", "public", "followers"]),
|
||||||
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
|
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
|
||||||
recipes: z.array(CollectionRecipeEntryRef),
|
recipes: z.array(CollectionRecipeEntryRef),
|
||||||
}));
|
}));
|
||||||
@@ -356,7 +357,8 @@ export function generateOpenApiSpec(): object {
|
|||||||
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/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 } } } } });
|
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 } } } } });
|
||||||
const CreateCollectionRef = registry.register("CreateCollection", z.object({
|
const CreateCollectionRef = registry.register("CreateCollection", z.object({
|
||||||
name: z.string().min(1).max(100), description: z.string().max(500).optional(), isPublic: z.boolean().default(false),
|
name: z.string().min(1).max(100), description: z.string().max(500).optional(),
|
||||||
|
visibility: z.enum(["private", "unlisted", "public", "followers"]).default("private"),
|
||||||
}));
|
}));
|
||||||
registry.registerPath({ method: "post", path: "/api/v1/collections", summary: "Create a collection", security, request: { body: { content: { "application/json": { schema: CreateCollectionRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "post", path: "/api/v1/collections", summary: "Create a collection", security, request: { body: { content: { "application/json": { schema: CreateCollectionRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
|
||||||
@@ -368,7 +370,7 @@ export function generateOpenApiSpec(): object {
|
|||||||
const UpdateCollectionRef = registry.register("UpdateCollection", z.object({
|
const UpdateCollectionRef = registry.register("UpdateCollection", z.object({
|
||||||
name: z.string().min(1).max(100).optional(), description: z.string().max(500).nullable().optional(),
|
name: z.string().min(1).max(100).optional(), description: z.string().max(500).nullable().optional(),
|
||||||
notes: z.string().max(2000).nullable().optional(), tags: z.array(z.string().min(1).max(50)).max(20).optional(),
|
notes: z.string().max(2000).nullable().optional(), tags: z.array(z.string().min(1).max(50)).max(20).optional(),
|
||||||
isPublic: z.boolean().optional(),
|
visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(),
|
||||||
addRecipeId: z.string().optional(), addRecipeIds: z.array(z.string()).max(200).optional(),
|
addRecipeId: z.string().optional(), addRecipeIds: z.array(z.string()).max(200).optional(),
|
||||||
removeRecipeId: z.string().optional(), removeRecipeIds: z.array(z.string()).max(200).optional(),
|
removeRecipeId: z.string().optional(), removeRecipeIds: z.array(z.string()).max(200).optional(),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { recipes, eq, or, and, inArray, sql } from "@epicure/db";
|
import { recipes, collections, eq, or, and, inArray, sql } from "@epicure/db";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drizzle condition: true when `viewerId` may view a given recipe row — the
|
* Drizzle condition: true when `viewerId` may view a given recipe row — the
|
||||||
@@ -25,3 +25,15 @@ export function visibleToViewer(viewerId: string) {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same rationale/pattern as visibleToViewer(), for collections. */
|
||||||
|
export function collectionVisibleToViewer(viewerId: string) {
|
||||||
|
return or(
|
||||||
|
eq(collections.userId, viewerId),
|
||||||
|
inArray(collections.visibility, ["public", "unlisted"]),
|
||||||
|
and(
|
||||||
|
eq(collections.visibility, "followers"),
|
||||||
|
sql`exists (select 1 from user_follows where user_follows.follower_id = ${viewerId} and user_follows.following_id = ${collections.userId})`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1207,6 +1207,8 @@
|
|||||||
"noTrending": "No trending collections yet",
|
"noTrending": "No trending collections yet",
|
||||||
"noRecent": "No public collections yet",
|
"noRecent": "No public collections yet",
|
||||||
"exportPdf": "Export as PDF",
|
"exportPdf": "Export as PDF",
|
||||||
|
"qrCodeAlt": "QR code linking to this collection",
|
||||||
|
"qrCodeCaption": "Scan to view online",
|
||||||
"shareTitle": "Share collection",
|
"shareTitle": "Share collection",
|
||||||
"shareDescription": "Invite people to view or edit this collection.",
|
"shareDescription": "Invite people to view or edit this collection.",
|
||||||
"new": "New collection",
|
"new": "New collection",
|
||||||
|
|||||||
@@ -1198,6 +1198,8 @@
|
|||||||
"noTrending": "Aucune collection tendance pour l'instant",
|
"noTrending": "Aucune collection tendance pour l'instant",
|
||||||
"noRecent": "Aucune collection publique pour l'instant",
|
"noRecent": "Aucune collection publique pour l'instant",
|
||||||
"exportPdf": "Exporter en PDF",
|
"exportPdf": "Exporter en PDF",
|
||||||
|
"qrCodeAlt": "Code QR menant à cette collection",
|
||||||
|
"qrCodeCaption": "Scannez pour voir en ligne",
|
||||||
"shareTitle": "Partager la collection",
|
"shareTitle": "Partager la collection",
|
||||||
"shareDescription": "Invitez des personnes à voir ou modifier cette collection.",
|
"shareDescription": "Invitez des personnes à voir ou modifier cette collection.",
|
||||||
"new": "Nouvelle collection",
|
"new": "Nouvelle collection",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.53.1",
|
"version": "0.54.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.53.1",
|
"version": "0.54.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "collections" ADD COLUMN "visibility" "visibility" DEFAULT 'private' NOT NULL;--> statement-breakpoint
|
||||||
|
UPDATE "collections" SET "visibility" = 'public' WHERE "is_public" = true;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "collections" DROP COLUMN "is_public";
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -351,6 +351,20 @@
|
|||||||
"when": 1784478763332,
|
"when": 1784478763332,
|
||||||
"tag": "0049_silky_bruce_banner",
|
"tag": "0049_silky_bruce_banner",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 50,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784481830949,
|
||||||
|
"tag": "0050_stale_vargas",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 51,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784481857800,
|
||||||
|
"tag": "0051_chief_karen_page",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
import { recipes, recipeBatchDishes } from "./recipes";
|
import { recipes, recipeBatchDishes, visibilityEnum } from "./recipes";
|
||||||
|
|
||||||
export const notificationTypeEnum = pgEnum("notification_type", [
|
export const notificationTypeEnum = pgEnum("notification_type", [
|
||||||
"follow",
|
"follow",
|
||||||
@@ -63,7 +63,7 @@ export const collections = pgTable("collections", {
|
|||||||
description: text("description"),
|
description: text("description"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
tags: text("tags").array().notNull().default([]),
|
tags: text("tags").array().notNull().default([]),
|
||||||
isPublic: boolean("is_public").notNull().default(false),
|
visibility: visibilityEnum("visibility").notNull().default("private"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
}, (t) => [
|
}, (t) => [
|
||||||
|
|||||||
Reference in New Issue
Block a user