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:
Arnaud
2026-07-19 19:26:34 +02:00
parent b1f745da66
commit 9da57dd1d0
25 changed files with 11217 additions and 56 deletions
+6
View File
@@ -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.
## 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
### Fixed
+6 -8
View File
@@ -4,7 +4,8 @@ import { headers } from "next/headers";
import Link from "next/link";
import { Printer, UtensilsCrossed, StickyNote } from "lucide-react";
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 { CollectionRecipesGrid } from "@/components/collections/collection-recipes-grid";
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 col = await db.query.collections.findFirst({
where: and(
eq(collections.id, id),
or(eq(collections.userId, session.user.id), eq(collections.isPublic, true))
),
where: and(eq(collections.id, id), collectionVisibleToViewer(session.user.id)),
with: {
recipes: {
orderBy: (t, { asc }) => asc(t.position),
@@ -63,7 +61,7 @@ export default async function CollectionPage({ params }: Params) {
</div>
)}
<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>
{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">
@@ -103,11 +101,11 @@ export default async function CollectionPage({ params }: Params) {
initialDescription={col.description}
initialNotes={col.notes}
initialTags={col.tags}
initialIsPublic={col.isPublic}
initialVisibility={col.visibility}
/>
)}
{isOwner && <DeleteCollectionDialog collectionId={id} />}
{!isOwner && col.isPublic && (
{!isOwner && (col.visibility === "public" || col.visibility === "unlisted") && (
<ForkCollectionButton collectionId={id} />
)}
</div>
@@ -49,7 +49,7 @@ export default async function ExploreCollectionsPage() {
collectionFavorites,
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)
.orderBy(desc(sql`count(${collectionFavorites.collectionId})`))
.limit(12);
@@ -63,7 +63,7 @@ export default async function ExploreCollectionsPage() {
})
.from(collections)
.innerJoin(users, eq(collections.userId, users.id))
.where(eq(collections.isPublic, true))
.where(eq(collections.visibility, "public"))
.orderBy(desc(collections.createdAt))
.limit(12);
+1 -1
View File
@@ -66,7 +66,7 @@ export default async function CollectionsPage({
name: col.name,
description: col.description,
tags: col.tags,
isPublic: col.isPublic,
visibility: col.visibility,
recipeCount: countByCollection.get(col.id) ?? 0,
thumbnails: col.recipes.flatMap((r) => {
if (!r.recipe) return [];
@@ -1,6 +1,7 @@
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 { collectionVisibleToViewer } from "@/lib/visibility";
type Params = { params: Promise<{ id: string }> };
@@ -10,7 +11,7 @@ export async function POST(req: NextRequest, { params }: Params) {
const { id } = await params;
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 },
});
if (!collection) return NextResponse.json({ error: "Not found" }, { status: 404 });
@@ -1,6 +1,7 @@
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 { collectionVisibleToViewer } from "@/lib/visibility";
type Params = { params: Promise<{ id: string }> };
@@ -9,12 +10,10 @@ export async function POST(req: NextRequest, { params }: Params) {
if (response) return response;
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({
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)),
with: { recipes: { columns: { recipeId: true } } },
});
@@ -26,7 +25,7 @@ export async function POST(req: NextRequest, { params }: Params) {
userId: session!.user.id,
name: `${source.name} (fork)`,
description: source.description,
isPublic: false,
visibility: "private",
createdAt: new Date(),
updatedAt: new Date(),
});
@@ -36,7 +36,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
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(),
isPublic: z.boolean().optional(),
visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(),
addRecipeId: z.string().optional(),
addRecipeIds: z.array(z.string()).max(200).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 });
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({
...(data.name && { name: data.name }),
...(data.description !== undefined && { description: data.description }),
...(data.notes !== undefined && { notes: data.notes }),
...(data.tags !== undefined && { tags: data.tags }),
...(data.isPublic !== undefined && { isPublic: data.isPublic }),
...(data.visibility !== undefined && { visibility: data.visibility }),
updatedAt: new Date(),
}).where(eq(collections.id, id));
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { requireSessionOrApiKey } from "@/lib/api-auth";
const Schema = z.object({
name: z.string().min(1).max(100),
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) {
@@ -61,7 +61,7 @@ export async function POST(req: NextRequest) {
userId: session!.user.id,
name: parsed.data.name,
description: parsed.data.description,
isPublic: parsed.data.isPublic,
visibility: parsed.data.visibility,
});
return NextResponse.json({ id }, { status: 201 });
+21 -5
View File
@@ -1,7 +1,9 @@
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import QRCode from "qrcode";
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 { formatIngredientQuantity } from "@/lib/unit-conversion";
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 col = await db.query.collections.findFirst({
where: and(
eq(collections.id, id),
or(eq(collections.userId, session.user.id), eq(collections.isPublic, true))
),
where: and(eq(collections.id, id), collectionVisibleToViewer(session.user.id)),
with: {
recipes: {
with: {
@@ -39,6 +38,13 @@ export default async function CollectionPrintPage({ params }: Params) {
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 (
<>
<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; }
.cookbook-cover { text-align: center; margin-bottom: 40px; }
.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; }
.print-btn {
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" }}>
{recipeEntries.length} recipe{recipeEntries.length !== 1 ? "s" : ""}
</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>
{recipeEntries.map(({ recipe }) => {
@@ -26,7 +26,7 @@ type Collection = {
name: string;
description: string | null;
tags: string[];
isPublic: boolean;
visibility: "private" | "unlisted" | "public" | "followers";
recipeCount: number;
thumbnails: Thumbnail[];
};
@@ -70,6 +70,7 @@ function CollectionThumbCollage({ thumbnails }: { thumbnails: Thumbnail[] }) {
export function CollectionsPageContent({ collections, query }: Props) {
const t = useTranslations("collections");
const tRecipe = useTranslations("recipe");
const router = useRouter();
const pathname = usePathname();
const [search, setSearch] = useState(query);
@@ -129,7 +130,9 @@ export function CollectionsPageContent({ collections, query }: Props) {
<div className="p-4 space-y-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>
{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>
{col.description && <p className="text-sm text-muted-foreground line-clamp-2">{col.description}</p>}
{col.tags.length > 0 && (
@@ -12,23 +12,26 @@ import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
type Visibility = "private" | "unlisted" | "public" | "followers";
export function EditCollectionDialog({
collectionId,
initialName,
initialDescription,
initialNotes,
initialTags,
initialIsPublic,
initialVisibility,
}: {
collectionId: string;
initialName: string;
initialDescription: string | null;
initialNotes: string | null;
initialTags: string[];
initialIsPublic: boolean;
initialVisibility: Visibility;
}) {
const t = useTranslations("collections");
const tRecipeForm = useTranslations("recipeForm");
const tRecipe = useTranslations("recipe");
const tCommon = useTranslations("common");
const router = useRouter();
const [open, setOpen] = useState(false);
@@ -37,7 +40,7 @@ export function EditCollectionDialog({
const [notes, setNotes] = useState(initialNotes ?? "");
const [tags, setTags] = useState<string[]>(initialTags);
const [tagInput, setTagInput] = useState("");
const [isPublic, setIsPublic] = useState(initialIsPublic);
const [visibility, setVisibility] = useState<Visibility>(initialVisibility);
const [saving, setSaving] = useState(false);
const tagInputRef = useRef<HTMLInputElement>(null);
@@ -73,7 +76,7 @@ export function EditCollectionDialog({
description: description.trim() || null,
notes: notes.trim() || null,
tags,
isPublic,
visibility,
}),
});
if (!res.ok) throw new Error();
@@ -151,10 +154,20 @@ export function EditCollectionDialog({
</div>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} className="rounded" />
{t("makePublic")}
</label>
<div className="space-y-2">
<Label htmlFor="edit-col-visibility">{tRecipe("visibilityMenuLabel")}</Label>
<select
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>
<DialogFooter>
@@ -11,15 +11,18 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
type Visibility = "private" | "unlisted" | "public" | "followers";
export function NewCollectionButton() {
const t = useTranslations("collections");
const tSocial = useTranslations("social");
const tCommon = useTranslations("common");
const tRecipe = useTranslations("recipe");
const router = useRouter();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isPublic, setIsPublic] = useState(false);
const [visibility, setVisibility] = useState<Visibility>("private");
const [saving, setSaving] = useState(false);
async function create() {
@@ -29,13 +32,13 @@ export function NewCollectionButton() {
const res = await fetch("/api/v1/collections", {
method: "POST",
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; }
const { id } = await res.json() as { id: string };
toast.success(tSocial("collectionCreated"));
setOpen(false);
setName(""); setDescription(""); setIsPublic(false);
setName(""); setDescription(""); setVisibility("private");
router.push(`/collections/${id}`);
router.refresh();
} finally {
@@ -60,10 +63,20 @@ export function NewCollectionButton() {
<Label htmlFor="col-desc">{t("descriptionLabel")}</Label>
<Textarea id="col-desc" value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder={t("descriptionPlaceholder")} />
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} className="rounded" />
{t("makePublic")}
</label>
<div className="space-y-2">
<Label htmlFor="col-visibility">{tRecipe("visibilityMenuLabel")}</Label>
<select
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">
<Button variant="outline" onClick={() => setOpen(false)}>{tCommon("cancel")}</Button>
<Button onClick={create} disabled={!name.trim() || saving}>{saving ? t("creating") : t("create")}</Button>
+9 -1
View File
@@ -1,5 +1,5 @@
// 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 = {
version: string;
@@ -11,6 +11,14 @@ export type 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",
date: "2026-07-19 16:10",
+5 -3
View File
@@ -189,7 +189,8 @@ export function generateOpenApiSpec(): object {
}));
const CollectionRef = registry.register("Collection", z.object({
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(),
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/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({
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 } } } } });
@@ -368,7 +370,7 @@ export function generateOpenApiSpec(): object {
const UpdateCollectionRef = registry.register("UpdateCollection", z.object({
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(),
isPublic: z.boolean().optional(),
visibility: z.enum(["private", "unlisted", "public", "followers"]).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(),
}));
+13 -1
View File
@@ -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
@@ -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})`
)
);
}
+2
View File
@@ -1207,6 +1207,8 @@
"noTrending": "No trending collections yet",
"noRecent": "No public collections yet",
"exportPdf": "Export as PDF",
"qrCodeAlt": "QR code linking to this collection",
"qrCodeCaption": "Scan to view online",
"shareTitle": "Share collection",
"shareDescription": "Invite people to view or edit this collection.",
"new": "New collection",
+2
View File
@@ -1198,6 +1198,8 @@
"noTrending": "Aucune collection tendance pour l'instant",
"noRecent": "Aucune collection publique pour l'instant",
"exportPdf": "Exporter en PDF",
"qrCodeAlt": "Code QR menant à cette collection",
"qrCodeCaption": "Scannez pour voir en ligne",
"shareTitle": "Partager la collection",
"shareDescription": "Invitez des personnes à voir ou modifier cette collection.",
"new": "Nouvelle collection",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.53.1",
"version": "0.54.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.53.1",
"version": "0.54.0",
"private": true,
"scripts": {
"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,
"tag": "0049_silky_bruce_banner",
"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
}
]
}
+2 -2
View File
@@ -11,7 +11,7 @@ import {
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users } from "./users";
import { recipes, recipeBatchDishes } from "./recipes";
import { recipes, recipeBatchDishes, visibilityEnum } from "./recipes";
export const notificationTypeEnum = pgEnum("notification_type", [
"follow",
@@ -63,7 +63,7 @@ export const collections = pgTable("collections", {
description: text("description"),
notes: text("notes"),
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(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
}, (t) => [