From a8406e9963fcb2f0dd320bae15f711c42793449d Mon Sep 17 00:00:00 2001 From: Arnaud Date: Thu, 9 Jul 2026 15:06:34 +0200 Subject: [PATCH] feat: add bulk "add to collection" action on recipes page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select recipes → add to an existing collection or create a new one inline. Fixes duplicate "visibility" i18n key bug found during verification. Co-Authored-By: Claude Sonnet 5 --- apps/web/app/api/v1/collections/[id]/route.ts | 17 +- .../recipe/add-to-collection-dialog.tsx | 148 ++++++++++++++++++ apps/web/components/recipe/recipes-grid.tsx | 15 +- apps/web/messages/en.json | 8 + apps/web/messages/fr.json | 8 + 5 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 apps/web/components/recipe/add-to-collection-dialog.tsx diff --git a/apps/web/app/api/v1/collections/[id]/route.ts b/apps/web/app/api/v1/collections/[id]/route.ts index fcdb923..49c8e0f 100644 --- a/apps/web/app/api/v1/collections/[id]/route.ts +++ b/apps/web/app/api/v1/collections/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { db, collections, collectionRecipes, recipes, eq, and, or, ne } from "@epicure/db"; +import { db, collections, collectionRecipes, recipes, eq, and, or, ne, inArray } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; type Params = { params: Promise<{ id: string }> }; @@ -35,6 +35,7 @@ export async function PUT(req: NextRequest, { params }: Params) { description: z.string().max(500).optional(), isPublic: z.boolean().optional(), addRecipeId: z.string().optional(), + addRecipeIds: z.array(z.string()).max(200).optional(), removeRecipeId: z.string().optional(), }).safeParse(body); if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); @@ -49,15 +50,19 @@ export async function PUT(req: NextRequest, { params }: Params) { }).where(eq(collections.id, id)); } - if (data.addRecipeId) { - const recipe = await db.query.recipes.findFirst({ + const idsToAdd = [...(data.addRecipeId ? [data.addRecipeId] : []), ...(data.addRecipeIds ?? [])]; + if (idsToAdd.length > 0) { + const owned = await db.query.recipes.findMany({ where: and( - eq(recipes.id, data.addRecipeId), + inArray(recipes.id, idsToAdd), or(eq(recipes.authorId, session!.user.id), ne(recipes.visibility, "private")) ), + columns: { id: true }, }); - if (recipe) { - await db.insert(collectionRecipes).values({ collectionId: id, recipeId: data.addRecipeId }).onConflictDoNothing(); + if (owned.length > 0) { + await db.insert(collectionRecipes) + .values(owned.map((r) => ({ collectionId: id, recipeId: r.id }))) + .onConflictDoNothing(); } } diff --git a/apps/web/components/recipe/add-to-collection-dialog.tsx b/apps/web/components/recipe/add-to-collection-dialog.tsx new file mode 100644 index 0000000..de8aeb2 --- /dev/null +++ b/apps/web/components/recipe/add-to-collection-dialog.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { FolderPlus, FolderOpen, Plus, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +type CollectionSummary = { id: string; name: string }; + +export function AddToCollectionDialog({ + open, + onOpenChange, + recipeIds, + onDone, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + recipeIds: string[]; + onDone: () => void; +}) { + const t = useTranslations("recipe"); + const [collections, setCollections] = useState([]); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(""); + const [showNewForm, setShowNewForm] = useState(false); + const [busyId, setBusyId] = useState(null); + + useEffect(() => { + if (!open) return; + setShowNewForm(false); + setNewName(""); + setLoading(true); + fetch("/api/v1/collections?limit=50") + .then((res) => (res.ok ? res.json() : null)) + .then((data: { data: CollectionSummary[] } | null) => { + setCollections(data?.data ?? []); + }) + .finally(() => setLoading(false)); + }, [open]); + + async function addTo(collectionId: string, collectionName: string) { + setBusyId(collectionId); + try { + const res = await fetch(`/api/v1/collections/${collectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ addRecipeIds: recipeIds }), + }); + if (!res.ok) throw new Error(); + toast.success(t("addToCollectionSuccess", { count: recipeIds.length, name: collectionName })); + onOpenChange(false); + onDone(); + } catch { + toast.error(t("addToCollectionFailed")); + } finally { + setBusyId(null); + } + } + + async function createAndAdd() { + if (!newName.trim()) return; + setCreating(true); + try { + const createRes = await fetch("/api/v1/collections", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newName.trim() }), + }); + if (!createRes.ok) throw new Error(); + const { id } = (await createRes.json()) as { id: string }; + await addTo(id, newName.trim()); + } catch { + toast.error(t("createCollectionFailed")); + } finally { + setCreating(false); + } + } + + return ( + + + + + + {t("addToCollectionTitle", { count: recipeIds.length })} + + + +
+ {loading ? ( +

+ ) : ( +
+ {collections.length === 0 && !showNewForm && ( +

{t("noCollectionsYet")}

+ )} + {collections.map((c) => ( + + ))} +
+ )} + + {showNewForm ? ( +
+ setNewName(e.target.value)} + placeholder={t("newCollectionNamePlaceholder")} + onKeyDown={(e) => e.key === "Enter" && void createAndAdd()} + disabled={creating} + /> + +
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/web/components/recipe/recipes-grid.tsx b/apps/web/components/recipe/recipes-grid.tsx index c788d92..c4e0ea4 100644 --- a/apps/web/components/recipe/recipes-grid.tsx +++ b/apps/web/components/recipe/recipes-grid.tsx @@ -2,7 +2,8 @@ import { useState, useCallback, useEffect } from "react"; import { useTranslations } from "next-intl"; -import { Trash2, Globe, Lock, Link2, X, Check, ListChecks, LayoutGrid, List, Rows3 } from "lucide-react"; +import { Trash2, Globe, Lock, Link2, X, Check, ListChecks, LayoutGrid, List, Rows3, FolderPlus } from "lucide-react"; +import { AddToCollectionDialog } from "@/components/recipe/add-to-collection-dialog"; import { Button, buttonVariants } from "@/components/ui/button"; import { DropdownMenu, @@ -262,6 +263,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) const [selected, setSelected] = useState>(new Set()); const [busy, setBusy] = useState(false); const [viewMode, setViewMode] = useState("grid"); + const [addToCollectionOpen, setAddToCollectionOpen] = useState(false); useEffect(() => { const stored = localStorage.getItem(VIEW_STORAGE_KEY) as ViewMode | null; @@ -409,6 +411,10 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) +