Untitled

 avatar
unknown
plain_text
a month ago
19 kB
4
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>
  );
}
Leave a Comment