Untitled
// RecipePage.jsx import React, { useState, useEffect, useMemo, useRef, useCallback } from "react"; import RecipeCard from "./RecipeCard"; import { supabase } from "./createClient"; import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"; import debounce from "lodash.debounce"; export default function RecipePage() { // State variables const [recipes, setRecipes] = useState([]); const [isCreating, setIsCreating] = useState(false); const [newRecipe, setNewRecipe] = useState({ title: "", description: "", ingredients: "", steps: "", tags: "", difficulty: "Easy", }); const [difficultyFilter, setDifficultyFilter] = useState(""); const [tagFilter, setTagFilter] = useState(""); const [sortOption, setSortOption] = useState(""); const [searchQuery, setSearchQuery] = useState(""); const [selectedRecipeIds, setSelectedRecipeIds] = useState([]); // Pagination / infinite scroll const [page, setPage] = useState(1); const pageSize = 20; const [hasMore, setHasMore] = useState(true); const [isLoading, setIsLoading] = useState(false); // IntersectionObserver references const observer = useRef(); const sentinelRef = useRef(); /************************************************************ * SERVER-SIDE FETCHING ************************************************************/ const fetchRecipes = useCallback( async (currentPage) => { try { // Start a query on the "Recipes" table let query = supabase.from("Recipes").select("*"); // --- SEARCH --- // Example search using title or description if (searchQuery) { // Proper syntax: title.ilike.%XYZ%,description.ilike.%XYZ% query = query.or( `title.ilike.%${searchQuery}%,description.ilike.%${searchQuery}%` ); } // --- FILTER: difficulty --- if (difficultyFilter) { query = query.eq("difficulty", difficultyFilter); } // --- FILTER: tag (assuming 'tags' is an array column in DB) --- if (tagFilter) { // If you want an exact match on a tag array, use .contains // If you want partial match, you’d do something else query = query.contains("tags", [tagFilter]); } // --- SORTING / ORDERING --- // If no sort option selected, use the "order" field // (which is updated via drag-and-drop in the DB). switch (sortOption) { case "title-asc": query = query.order("title", { ascending: true }); break; case "title-desc": query = query.order("title", { ascending: false }); break; case "diff-asc": query = query.order("difficulty", { ascending: true }); break; case "diff-desc": query = query.order("difficulty", { ascending: false }); break; default: // Use the custom 'order' column query = query.order("order", { ascending: true }); break; } // --- PAGINATION --- query = query.range( (currentPage - 1) * pageSize, currentPage * pageSize - 1 ); const { data, error } = await query; if (error) { console.error("Supabase fetch error:", error); return []; } console.log(`Fetched ${data.length} recipes for page ${currentPage}.`); return data; } catch (err) { console.error("fetchRecipes error:", err); return []; } }, [searchQuery, difficultyFilter, tagFilter, sortOption, pageSize] ); // Load more (next page) const loadMoreRecipes = useCallback(async () => { if (isLoading || !hasMore) { console.log("Either loading is in progress or no more recipes to load."); return; } console.log("Initiating loadMoreRecipes..."); setIsLoading(true); try { const fetchedRecipes = await fetchRecipes(page); // If the length is < pageSize, it means there are no more // recipes left for the next call setHasMore(fetchedRecipes.length === pageSize); // Append fetched recipes, avoiding duplicates setRecipes((prev) => { const uniqueFetched = fetchedRecipes.filter( (fetched) => !prev.some((recipe) => recipe.id === fetched.id) ); console.log(`Appending ${uniqueFetched.length} fetched recipes.`); return [...prev, ...uniqueFetched]; }); setPage((prev) => prev + 1); } catch (err) { console.error("Error during loadMoreRecipes:", err); } finally { setIsLoading(false); } }, [isLoading, hasMore, page, fetchRecipes]); // Debounce loadMore to avoid spam-calls const debouncedLoadMore = useMemo( () => debounce(loadMoreRecipes, 300), [loadMoreRecipes] ); // On first mount OR whenever filter/sort/search changes, we reset and refetch useEffect(() => { const initializeRecipes = async () => { console.log("Initializing recipes..."); setIsLoading(true); setPage(1); setHasMore(true); setRecipes([]); const fetched = await fetchRecipes(1); console.log(`Fetched ${fetched.length} initial recipes.`); setRecipes(fetched); // Next time we scroll, we’ll fetch page #2 setPage(2); setIsLoading(false); }; initializeRecipes(); }, [fetchRecipes]); /************************************************************ * INFINITE SCROLL LOGIC ************************************************************/ useEffect(() => { if (isLoading) return; if (observer.current) observer.current.disconnect(); const callback = (entries) => { if (entries[0].isIntersecting && hasMore) { console.log("Sentinel is intersecting. Loading more recipes..."); debouncedLoadMore(); } }; const options = { root: null, rootMargin: "0px 0px -20% 0px", threshold: 0, }; observer.current = new IntersectionObserver(callback, options); if (sentinelRef.current) { observer.current.observe(sentinelRef.current); console.log("Observer is now observing the sentinel."); } return () => { if (observer.current) { observer.current.disconnect(); console.log("Observer has been disconnected."); } debouncedLoadMore.cancel(); }; }, [debouncedLoadMore, isLoading, hasMore]); /************************************************************ * DRAG-AND-DROP (Custom “order” field) ************************************************************/ const handleDragEnd = async (result) => { if (!result.destination) return; const reorderedRecipes = Array.from(recipes); const [moved] = reorderedRecipes.splice(result.source.index, 1); reorderedRecipes.splice(result.destination.index, 0, moved); // Reassign "order" in memory const updatedRecipes = reorderedRecipes.map((recipe, idx) => ({ ...recipe, order: idx + 1, // 1-based })); setRecipes(updatedRecipes); // Update each recipe’s “order” field in DB try { for (const update of updatedRecipes) { const { error } = await supabase .from("Recipes") .update({ order: update.order }) .eq("id", update.id); if (error) { console.error("Failed to update recipe order in the database:", error); } } console.log("Order updated successfully in the DB."); } catch (err) { console.error("Error during order update:", err); } }; /************************************************************ * RECIPE UPDATE, DELETE, CREATE ************************************************************/ const handleUpdateRecipe = (updatedRecipe) => { setRecipes((prev) => prev.map((r) => (r.id === updatedRecipe.id ? updatedRecipe : r)) ); }; const handleDeleteRecipe = async (id) => { try { const { error } = await supabase.from("Recipes").delete().eq("id", id); if (error) { console.error(error); return; } setRecipes((prev) => prev.filter((r) => r.id !== id)); setSelectedRecipeIds((prev) => prev.filter((item) => item !== id)); } catch (err) { console.error(err); } }; const handleCreate = async () => { try { const ingredientsArray = newRecipe.ingredients .split(",") .map((item) => item.trim()) .filter(Boolean); const stepsArray = newRecipe.steps .split(",") .map((item) => item.trim()) .filter(Boolean); const tagsArray = newRecipe.tags .split(",") .map((item) => item.trim()) .filter(Boolean); const { data, error } = await supabase .from("Recipes") .insert([ { title: newRecipe.title, description: newRecipe.description, ingredients: ingredientsArray, steps: stepsArray, tags: tagsArray, difficulty: newRecipe.difficulty, lastUpdated: new Date().toISOString(), order: recipes.length + 1, // put newly created at the bottom }, ]) .select(); if (error) { console.error("Error creating recipe:", error); return; } if (data && data.length > 0) { const createdRecipe = data[0]; setRecipes((prev) => { // Prevent duplicates if (prev.some((r) => r.id === createdRecipe.id)) { console.warn(`Duplicate recipe ID detected: ${createdRecipe.id}`); return prev; } console.log(`Created and appending recipe ID: ${createdRecipe.id}`); return [...prev, createdRecipe]; }); } setIsCreating(false); setNewRecipe({ title: "", description: "", ingredients: "", steps: "", tags: "", difficulty: "Easy", }); } catch (err) { console.error("Error in handleCreate:", err); } }; /************************************************************ * SELECTION & SHARING ************************************************************/ const handleSelectChange = (id) => { setSelectedRecipeIds((prev) => { if (prev.includes(id)) return prev.filter((item) => item !== id); return [...prev, id]; }); }; const handleShareSelected = () => { const selected = recipes.filter((r) => selectedRecipeIds.includes(r.id)); const details = selected .map((r) => { const ingredientsList = r.ingredients.join(", "); const stepsList = r.steps.join(", "); const tagsList = r.tags.join(", "); return `Title: ${r.title}\nDescription: ${r.description}\nIngredients: ${ingredientsList}\nSteps: ${stepsList}\nTags: ${tagsList}\nDifficulty: ${r.difficulty}\nLast Updated: ${new Date( r.lastUpdated ).toLocaleString()}`; }) .join("\n\n"); const mailtoURL = `mailto:?subject=Check out these recipes&body=Here are the recipes:\n\n${encodeURIComponent( details )}`; window.open(mailtoURL, "_blank"); }; /************************************************************ * RENDERING ************************************************************/ return ( <div className="recipe-page" style={{ height: "100vh", overflowY: "auto", padding: "20px", boxSizing: "border-box", }} > <h2>Recipe List</h2> <div className="filter-sort-controls" style={{ marginBottom: "20px", display: "flex", flexWrap: "wrap", gap: "10px", }} > <button onClick={() => setIsCreating(!isCreating)} style={{ padding: "10px" }}> {isCreating ? "Cancel" : "Create Recipe"} </button> <input type="text" placeholder="Search title or description" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} style={{ padding: "10px", flex: "1 1 200px" }} /> <select value={difficultyFilter} onChange={(e) => setDifficultyFilter(e.target.value)} style={{ padding: "10px" }} > <option value="">All Difficulties</option> <option value="Easy">Easy</option> <option value="Medium">Medium</option> <option value="Hard">Hard</option> </select> <input type="text" placeholder="Filter by tag" value={tagFilter} onChange={(e) => setTagFilter(e.target.value)} style={{ padding: "10px", flex: "1 1 200px" }} /> <select value={sortOption} onChange={(e) => setSortOption(e.target.value)} style={{ padding: "10px" }} > <option value="">Sort by DB Order (Drag & Drop)</option> <option value="title-asc">Title (A-Z)</option> <option value="title-desc">Title (Z-A)</option> <option value="diff-asc">Difficulty (A-Z)</option> <option value="diff-desc">Difficulty (Z-A)</option> </select> {selectedRecipeIds.length > 0 && ( <button onClick={handleShareSelected} style={{ padding: "10px" }}> Share </button> )} </div> {isCreating && ( <div className="create-recipe-form" style={{ border: "1px solid #ccc", padding: "20px", marginBottom: "20px", borderRadius: "5px", }} > <h3>Create New Recipe</h3> <label style={{ display: "block", marginBottom: "10px" }}> Title: <input type="text" value={newRecipe.title} onChange={(e) => setNewRecipe({ ...newRecipe, title: e.target.value }) } style={{ width: "100%", padding: "10px" }} /> </label> <label style={{ display: "block", marginBottom: "10px" }}> Description: <textarea value={newRecipe.description} onChange={(e) => setNewRecipe({ ...newRecipe, description: e.target.value }) } style={{ width: "100%", padding: "10px" }} /> </label> <label style={{ display: "block", marginBottom: "10px" }}> Ingredients (comma-separated): <input type="text" value={newRecipe.ingredients} onChange={(e) => setNewRecipe({ ...newRecipe, ingredients: e.target.value }) } style={{ width: "100%", padding: "10px" }} /> </label> <label style={{ display: "block", marginBottom: "10px" }}> Steps (comma-separated): <input type="text" value={newRecipe.steps} onChange={(e) => setNewRecipe({ ...newRecipe, steps: e.target.value }) } style={{ width: "100%", padding: "10px" }} /> </label> <label style={{ display: "block", marginBottom: "10px" }}> Tags (comma-separated): <input type="text" value={newRecipe.tags} onChange={(e) => setNewRecipe({ ...newRecipe, tags: e.target.value }) } style={{ width: "100%", padding: "10px" }} /> </label> <label style={{ display: "block", marginBottom: "10px" }}> Difficulty: <select value={newRecipe.difficulty} onChange={(e) => setNewRecipe({ ...newRecipe, difficulty: e.target.value }) } style={{ width: "100%", padding: "10px" }} > <option value="Easy">Easy</option> <option value="Medium">Medium</option> <option value="Hard">Hard</option> </select> </label> <button onClick={handleCreate} style={{ padding: "10px 20px" }}> Create </button> </div> )} <DragDropContext onDragEnd={handleDragEnd}> <Droppable droppableId="recipe-list"> {(provided) => ( <section className="recipe-list" {...provided.droppableProps} ref={provided.innerRef} style={{ minHeight: "100px" }} > {recipes.map((recipe, index) => ( <Draggable key={`${recipe.id}-${recipe.lastUpdated}`} draggableId={recipe.id.toString()} index={index} > {(draggableProvided) => ( <div ref={draggableProvided.innerRef} {...draggableProvided.draggableProps} {...draggableProvided.dragHandleProps} style={{ userSelect: "none", padding: "16px", margin: "0 0 8px 0", minHeight: "50px", backgroundColor: "#fff", color: "black", border: "1px solid #ccc", borderRadius: "5px", ...draggableProvided.draggableProps.style, }} > <RecipeCard recipe={recipe} isSelected={selectedRecipeIds.includes(recipe.id)} onSelectChange={handleSelectChange} onUpdate={handleUpdateRecipe} onDelete={handleDeleteRecipe} /> </div> )} </Draggable> ))} {provided.placeholder} {/* Sentinel element for IntersectionObserver */} <div ref={sentinelRef} style={{ height: "1px" }} aria-hidden="true" /> </section> )} </Droppable> </DragDropContext> {/* Loading indicator */} {isLoading && ( <div className="spinner" role="status" aria-live="polite"> <div className="double-bounce1"></div> <div className="double-bounce2"></div> <span className="sr-only">Loading more recipes...</span> </div> )} {/* Optionally show: {!hasMore && <p>No more recipes to load.</p>} */} </div> ); }
Leave a Comment