"use client";
import { useState, useCallback, useMemo } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { ListChecks, X, FolderInput, FolderMinus, Check, GripVertical, Search } from "lucide-react";
import {
DndContext,
type DragEndEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RecipeGridCard, type GridCardRecipe } from "@/components/recipe/recipe-grid-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";
function SortableRecipeCard({
recipe,
selectMode,
selected,
onToggle,
dragDisabled,
}: {
recipe: GridCardRecipe;
selectMode: boolean;
selected: boolean;
onToggle: () => void;
dragDisabled: boolean;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: recipe.id,
disabled: dragDisabled,
});
const style = { transform: CSS.Transform.toString(transform), transition };
return (
{selectMode && (
{selected && }
)}
{!selectMode && !dragDisabled && (
)}
{selectMode ? (
) : (
)}
);
}
export function CollectionRecipesGrid({ collectionId, recipes: initialRecipes }: { collectionId: string; recipes: GridCardRecipe[] }) {
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 [search, setSearch] = useState("");
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
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);
}
}
async function persistOrder(ordered: GridCardRecipe[]) {
try {
const res = await fetch(`/api/v1/collections/${collectionId}/reorder`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recipeIds: ordered.map((r) => r.id) }),
});
if (!res.ok) throw new Error();
} catch {
toast.error(t("reorderFailed"));
}
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = recipes.findIndex((r) => r.id === active.id);
const newIndex = recipes.findIndex((r) => r.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(recipes, oldIndex, newIndex);
setRecipes(reordered);
void persistOrder(reordered);
}
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return recipes;
return recipes.filter((r) => r.title.toLowerCase().includes(q));
}, [recipes, search]);
const searchActive = search.trim().length > 0;
if (recipes.length === 0) return null;
return (
{filtered.length === 0 ? (
{t("noRecipeSearchResults")}
) : (
r.id)} strategy={verticalListSortingStrategy}>
{filtered.map((recipe) => (
toggleSelect(recipe.id)}
dragDisabled={selectMode || searchActive}
/>
))}
)}
{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")}
);
}