{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 (
+
+ );
+}
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")}
+
+