From 70eb565eec0e313100ba589965aba36c3ae38306 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Mon, 13 Jul 2026 11:57:07 +0200 Subject: [PATCH] feat: bulk tag editing, bulk export, and move recipes between collections Extends the existing bulk-select infra: recipes list gets tag add/remove and multi-recipe Markdown export; collection pages get a select mode to move recipes to another collection or remove them from the current one. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 7 + apps/web/app/(app)/collections/[id]/page.tsx | 6 + apps/web/app/api/v1/collections/[id]/route.ts | 6 +- .../app/api/v1/recipes/bulk/export/route.ts | 51 ++++++ apps/web/app/api/v1/recipes/bulk/route.ts | 34 +++- .../collections/collection-recipes-grid.tsx | 164 ++++++++++++++++++ .../recipe/add-to-collection-dialog.tsx | 26 ++- .../components/recipe/bulk-tags-dialog.tsx | 143 +++++++++++++++ apps/web/components/recipe/recipes-grid.tsx | 59 ++++++- apps/web/lib/changelog.ts | 11 +- apps/web/messages/en.json | 20 ++- apps/web/messages/fr.json | 20 ++- apps/web/package.json | 2 +- package.json | 2 +- 14 files changed, 533 insertions(+), 18 deletions(-) create mode 100644 apps/web/app/api/v1/recipes/bulk/export/route.ts create mode 100644 apps/web/components/collections/collection-recipes-grid.tsx create mode 100644 apps/web/components/recipe/bulk-tags-dialog.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f518d..9cd6690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ 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.10.0 — 2026-07-13 11:55 + +### Added +- **Bulk tag editing**: add or remove tags across several selected recipes at once. +- **Bulk export**: export several selected recipes to a single Markdown file. +- **Move recipes between collections**: select recipes inside a collection to move them to another collection or remove them from this one. + ## 0.9.6 — 2026-07-13 09:31 ### Added diff --git a/apps/web/app/(app)/collections/[id]/page.tsx b/apps/web/app/(app)/collections/[id]/page.tsx index 662511e..a749f40 100644 --- a/apps/web/app/(app)/collections/[id]/page.tsx +++ b/apps/web/app/(app)/collections/[id]/page.tsx @@ -6,6 +6,7 @@ import { Printer, UtensilsCrossed } from "lucide-react"; import { auth } from "@/lib/auth/server"; import { db, collections, eq, and, or } from "@epicure/db"; import { RecipeCard } from "@/components/recipe/recipe-card"; +import { CollectionRecipesGrid } from "@/components/collections/collection-recipes-grid"; import { ForkCollectionButton } from "@/components/collections/fork-collection-button"; import { ShareCollectionButton } from "@/components/collections/share-collection-button"; import { buttonVariants } from "@/components/ui/button"; @@ -73,6 +74,11 @@ export default async function CollectionPage({ params }: Params) { {col.recipes.length === 0 ? ( + ) : isOwner ? ( + (r.recipe ? [r.recipe] : []))} + /> ) : (
{col.recipes.map(({ recipe }) => ( diff --git a/apps/web/app/api/v1/collections/[id]/route.ts b/apps/web/app/api/v1/collections/[id]/route.ts index 49c8e0f..a285091 100644 --- a/apps/web/app/api/v1/collections/[id]/route.ts +++ b/apps/web/app/api/v1/collections/[id]/route.ts @@ -37,6 +37,7 @@ export async function PUT(req: NextRequest, { params }: Params) { addRecipeId: z.string().optional(), addRecipeIds: z.array(z.string()).max(200).optional(), removeRecipeId: z.string().optional(), + removeRecipeIds: z.array(z.string()).max(200).optional(), }).safeParse(body); if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); @@ -66,9 +67,10 @@ export async function PUT(req: NextRequest, { params }: Params) { } } - if (data.removeRecipeId) { + const idsToRemove = [...(data.removeRecipeId ? [data.removeRecipeId] : []), ...(data.removeRecipeIds ?? [])]; + if (idsToRemove.length > 0) { await db.delete(collectionRecipes).where( - and(eq(collectionRecipes.collectionId, id), eq(collectionRecipes.recipeId, data.removeRecipeId)) + and(eq(collectionRecipes.collectionId, id), inArray(collectionRecipes.recipeId, idsToRemove)) ); } diff --git a/apps/web/app/api/v1/recipes/bulk/export/route.ts b/apps/web/app/api/v1/recipes/bulk/export/route.ts new file mode 100644 index 0000000..607b5a3 --- /dev/null +++ b/apps/web/app/api/v1/recipes/bulk/export/route.ts @@ -0,0 +1,51 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { requireSession } from "@/lib/api-auth"; +import { db, recipes, eq, and, inArray } from "@epicure/db"; +import { recipeToMarkdown } from "@/lib/markdown/recipe"; + +const ExportSchema = z.object({ + ids: z.array(z.string()).min(1).max(100), +}); + +export async function POST(req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + + const body = ExportSchema.safeParse(await req.json()); + if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 }); + + const owned = await db.query.recipes.findMany({ + where: and(inArray(recipes.id, body.data.ids), eq(recipes.authorId, session!.user.id)), + with: { + ingredients: { orderBy: (t, { asc }) => asc(t.order) }, + steps: { orderBy: (t, { asc }) => asc(t.order) }, + batchDishes: { orderBy: (t, { asc }) => asc(t.order) }, + }, + }); + + if (owned.length === 0) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const byId = new Map(owned.map((r) => [r.id, r])); + const ordered = body.data.ids.flatMap((id) => (byId.has(id) ? [byId.get(id)!] : [])); + + const markdown = ordered + .map((recipe) => + recipeToMarkdown({ + title: recipe.title, + description: recipe.description, + baseServings: recipe.baseServings, + prepMins: recipe.prepMins, + cookMins: recipe.cookMins, + difficulty: recipe.difficulty, + sourceUrl: recipe.sourceUrl, + ingredients: recipe.ingredients, + steps: recipe.steps, + isBatchCook: recipe.isBatchCook, + batchDishes: recipe.batchDishes, + }) + ) + .join("\n---\n\n"); + + return NextResponse.json({ markdown }); +} diff --git a/apps/web/app/api/v1/recipes/bulk/route.ts b/apps/web/app/api/v1/recipes/bulk/route.ts index c44bc6d..e55c154 100644 --- a/apps/web/app/api/v1/recipes/bulk/route.ts +++ b/apps/web/app/api/v1/recipes/bulk/route.ts @@ -10,6 +10,8 @@ const BulkDeleteSchema = z.object({ const BulkUpdateSchema = z.object({ ids: z.array(z.string()).min(1).max(100), visibility: z.enum(["private", "unlisted", "public"]).optional(), + addTags: z.array(z.string().min(1).max(40)).max(20).optional(), + removeTags: z.array(z.string().min(1).max(40)).max(20).optional(), }); export async function DELETE(req: NextRequest) { @@ -33,13 +35,33 @@ export async function PATCH(req: NextRequest) { const body = BulkUpdateSchema.safeParse(await req.json()); if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 }); - const { ids, visibility } = body.data; - if (!visibility) return NextResponse.json({ error: "Nothing to update" }, { status: 400 }); + const { ids, visibility, addTags, removeTags } = body.data; + if (!visibility && !addTags?.length && !removeTags?.length) { + return NextResponse.json({ error: "Nothing to update" }, { status: 400 }); + } - await db - .update(recipes) - .set({ visibility, updatedAt: new Date() }) - .where(and(inArray(recipes.id, ids), eq(recipes.authorId, session!.user.id))); + if (visibility) { + await db + .update(recipes) + .set({ visibility, updatedAt: new Date() }) + .where(and(inArray(recipes.id, ids), eq(recipes.authorId, session!.user.id))); + } + + if (addTags?.length || removeTags?.length) { + const owned = await db.query.recipes.findMany({ + where: and(inArray(recipes.id, ids), eq(recipes.authorId, session!.user.id)), + columns: { id: true, tags: true }, + }); + + await Promise.all( + owned.map((r) => { + let nextTags = r.tags; + if (addTags?.length) nextTags = [...new Set([...nextTags, ...addTags])]; + if (removeTags?.length) nextTags = nextTags.filter((tag) => !removeTags.includes(tag)); + return db.update(recipes).set({ tags: nextTags, updatedAt: new Date() }).where(eq(recipes.id, r.id)); + }) + ); + } return NextResponse.json({ ok: true }); } diff --git a/apps/web/components/collections/collection-recipes-grid.tsx b/apps/web/components/collections/collection-recipes-grid.tsx new file mode 100644 index 0000000..b6beb34 --- /dev/null +++ b/apps/web/components/collections/collection-recipes-grid.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { ListChecks, X, FolderInput, FolderMinus, Check } from "lucide-react"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { RecipeCard } from "@/components/recipe/recipe-card"; +import { AddToCollectionDialog } from "@/components/recipe/add-to-collection-dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { cn } from "@/lib/utils"; + +type Recipe = { + id: string; + title: string; + description: string | null; + baseServings: number; + prepMins: number | null; + cookMins: number | null; + difficulty: "easy" | "medium" | "hard" | null; + visibility: "private" | "unlisted" | "public"; + updatedAt: Date; + photos?: Array<{ storageKey: string; isCover: boolean }>; + sourceUrl?: string | null; +}; + +export function CollectionRecipesGrid({ collectionId, recipes: initialRecipes }: { collectionId: string; recipes: Recipe[] }) { + const t = useTranslations("collections"); + const tCommon = useTranslations("common"); + const [recipes, setRecipes] = useState(initialRecipes); + const [selectMode, setSelectMode] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [moveOpen, setMoveOpen] = useState(false); + const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false); + const [busy, setBusy] = useState(false); + + const toggleSelect = useCallback((id: string) => { + setSelected((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }, []); + + const exitSelect = () => { + setSelectMode(false); + setSelected(new Set()); + }; + + async function removeFromCollection() { + setBusy(true); + try { + const res = await fetch(`/api/v1/collections/${collectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ removeRecipeIds: [...selected] }), + }); + if (!res.ok) throw new Error(); + setRecipes((prev) => prev.filter((r) => !selected.has(r.id))); + toast.success(t("removedFromCollection", { count: selected.size })); + exitSelect(); + } catch { + toast.error(t("removeFromCollectionFailed")); + } finally { + setBusy(false); + } + } + + if (recipes.length === 0) return null; + + return ( +
+
+ +
+ +
+ {recipes.map((recipe) => ( +
toggleSelect(recipe.id) : undefined}> + {selectMode && ( +
+ {selected.has(recipe.id) && } +
+ )} +
+ +
+
+ ))} +
+ + {selectMode && selected.size > 0 && ( +
+
+ {selected.size} +
+ + +
+
+ )} + + { + setRecipes((prev) => prev.filter((r) => !selected.has(r.id))); + exitSelect(); + }} + /> + + + + + {t("removeFromCollectionConfirmTitle", { count: selected.size })} + {t("removeFromCollectionConfirmDescription")} + + + {tCommon("cancel")} + { setRemoveConfirmOpen(false); void removeFromCollection(); }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t("removeFromCollection")} + + + + +
+ ); +} diff --git a/apps/web/components/recipe/add-to-collection-dialog.tsx b/apps/web/components/recipe/add-to-collection-dialog.tsx index de8aeb2..9dfa0c0 100644 --- a/apps/web/components/recipe/add-to-collection-dialog.tsx +++ b/apps/web/components/recipe/add-to-collection-dialog.tsx @@ -20,11 +20,14 @@ export function AddToCollectionDialog({ onOpenChange, recipeIds, onDone, + sourceCollectionId, }: { open: boolean; onOpenChange: (open: boolean) => void; recipeIds: string[]; onDone: () => void; + /** When set, this is a "move" — after adding to the target, the recipes are removed from this collection. */ + sourceCollectionId?: string; }) { const t = useTranslations("recipe"); const [collections, setCollections] = useState([]); @@ -42,10 +45,10 @@ export function AddToCollectionDialog({ fetch("/api/v1/collections?limit=50") .then((res) => (res.ok ? res.json() : null)) .then((data: { data: CollectionSummary[] } | null) => { - setCollections(data?.data ?? []); + setCollections((data?.data ?? []).filter((c) => c.id !== sourceCollectionId)); }) .finally(() => setLoading(false)); - }, [open]); + }, [open, sourceCollectionId]); async function addTo(collectionId: string, collectionName: string) { setBusyId(collectionId); @@ -56,7 +59,20 @@ export function AddToCollectionDialog({ body: JSON.stringify({ addRecipeIds: recipeIds }), }); if (!res.ok) throw new Error(); - toast.success(t("addToCollectionSuccess", { count: recipeIds.length, name: collectionName })); + + if (sourceCollectionId) { + await fetch(`/api/v1/collections/${sourceCollectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ removeRecipeIds: recipeIds }), + }); + } + + toast.success( + sourceCollectionId + ? t("moveToCollectionSuccess", { count: recipeIds.length, name: collectionName }) + : t("addToCollectionSuccess", { count: recipeIds.length, name: collectionName }) + ); onOpenChange(false); onDone(); } catch { @@ -91,7 +107,9 @@ export function AddToCollectionDialog({ - {t("addToCollectionTitle", { count: recipeIds.length })} + {sourceCollectionId + ? t("moveToCollectionTitle", { count: recipeIds.length }) + : t("addToCollectionTitle", { count: recipeIds.length })} diff --git a/apps/web/components/recipe/bulk-tags-dialog.tsx b/apps/web/components/recipe/bulk-tags-dialog.tsx new file mode 100644 index 0000000..368e512 --- /dev/null +++ b/apps/web/components/recipe/bulk-tags-dialog.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { Tag, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; + +export function BulkTagsDialog({ + open, + onOpenChange, + recipeIds, + availableTags, + onDone, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + recipeIds: string[]; + availableTags: string[]; + onDone: (addTags: string[], removeTags: string[]) => void; +}) { + const t = useTranslations("recipe"); + const tCommon = useTranslations("common"); + const [addInput, setAddInput] = useState(""); + const [toAdd, setToAdd] = useState([]); + const [toRemove, setToRemove] = useState([]); + const [saving, setSaving] = useState(false); + + function commitAddInput() { + const tag = addInput.trim(); + if (tag && !toAdd.includes(tag)) setToAdd((prev) => [...prev, tag]); + setAddInput(""); + } + + function toggleRemove(tag: string) { + setToRemove((prev) => (prev.includes(tag) ? prev.filter((x) => x !== tag) : [...prev, tag])); + } + + async function save() { + if (toAdd.length === 0 && toRemove.length === 0) { + onOpenChange(false); + return; + } + setSaving(true); + try { + const res = await fetch("/api/v1/recipes/bulk", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: recipeIds, addTags: toAdd, removeTags: toRemove }), + }); + if (!res.ok) throw new Error(); + toast.success(t("bulkTagsUpdated", { count: recipeIds.length })); + onOpenChange(false); + onDone(toAdd, toRemove); + setToAdd([]); + setToRemove([]); + } catch { + toast.error(t("bulkUpdateFailed")); + } finally { + setSaving(false); + } + } + + return ( + + + + + + {t("bulkTagsTitle", { count: recipeIds.length })} + + + +
+
+

{t("bulkTagsAddLabel")}

+
+ setAddInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + commitAddInput(); + } + }} + placeholder={t("bulkTagsAddPlaceholder")} + /> + +
+ {toAdd.length > 0 && ( +
+ {toAdd.map((tag) => ( + + {tag} + + + ))} +
+ )} +
+ + {availableTags.length > 0 && ( +
+

{t("bulkTagsRemoveLabel")}

+
+ {availableTags.map((tag) => ( + + ))} +
+
+ )} +
+ + + + + +
+
+ ); +} diff --git a/apps/web/components/recipe/recipes-grid.tsx b/apps/web/components/recipe/recipes-grid.tsx index 7c1e8db..7c465a8 100644 --- a/apps/web/components/recipe/recipes-grid.tsx +++ b/apps/web/components/recipe/recipes-grid.tsx @@ -3,8 +3,9 @@ import { useState, useCallback, useEffect } from "react"; import Image from "next/image"; import { useTranslations } from "next-intl"; -import { Trash2, Globe, Lock, Link2, X, Check, ListChecks, LayoutGrid, List, Rows3, FolderPlus, ChefHat, ExternalLink } from "lucide-react"; +import { Trash2, Globe, Lock, Link2, X, Check, ListChecks, LayoutGrid, List, Rows3, FolderPlus, ChefHat, ExternalLink, Tag, Download } from "lucide-react"; import { AddToCollectionDialog } from "@/components/recipe/add-to-collection-dialog"; +import { BulkTagsDialog } from "@/components/recipe/bulk-tags-dialog"; import { Button, buttonVariants } from "@/components/ui/button"; import { DropdownMenu, @@ -407,6 +408,8 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) const [viewMode, setViewMode] = useState("grid"); const [addToCollectionOpen, setAddToCollectionOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [bulkTagsOpen, setBulkTagsOpen] = useState(false); + const [exporting, setExporting] = useState(false); useEffect(() => { const stored = localStorage.getItem(VIEW_STORAGE_KEY) as ViewMode | null; @@ -473,9 +476,47 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) } } + function applyBulkTags(addTags: string[], removeTags: string[]) { + setRecipes((prev) => + prev.map((r) => + selected.has(r.id) + ? { ...r, tags: [...new Set([...r.tags.filter((tag) => !removeTags.includes(tag)), ...addTags])] } + : r + ) + ); + exitSelect(); + } + + async function bulkExport() { + setExporting(true); + try { + const res = await fetch("/api/v1/recipes/bulk/export", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: [...selected] }), + }); + if (!res.ok) throw new Error(); + const { markdown } = (await res.json()) as { markdown: string }; + const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "recipes.md"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch { + toast.error(t("bulkExportFailed")); + } finally { + setExporting(false); + } + } + if (recipes.length === 0) return null; const allSelected = selected.size === recipes.length; + const availableTagsForRemoval = [...new Set(recipes.filter((r) => selected.has(r.id)).flatMap((r) => r.tags))]; return (
@@ -555,6 +596,14 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) {t("addToCollection")} + +