Untitled
unknown
plain_text
a year ago
19 kB
8
Indexable
// 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>
);
}
Editor is loading...
Leave a Comment