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.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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})`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.53.1",
|
||||
"version": "0.54.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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) => [
|
||||
|
||||
Reference in New Issue
Block a user