From 031df19b440c31a1cb870a7a8eaeb1065c2abdb3 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Mon, 1 Dec 2025 13:45:22 -0700 Subject: [PATCH] (FEAT): Added loading spinners to search and implemented context. Filter context seems to be working! Using local storage so it can persist. --- web/src/components/inputs/RecipeSearchBar.tsx | 48 ++++++++++++------- web/src/context/FilterContext.tsx | 19 ++++++++ web/src/context/FilterProvider.tsx | 43 +++++++++++++++++ web/src/main.tsx | 5 +- web/src/pages/Favorites.tsx | 18 +++++-- web/src/pages/Home.tsx | 1 - web/src/pages/Profile.tsx | 2 +- web/src/pages/Search.tsx | 17 +++++-- web/src/types/recipe.ts | 1 - 9 files changed, 127 insertions(+), 27 deletions(-) create mode 100644 web/src/context/FilterContext.tsx create mode 100644 web/src/context/FilterProvider.tsx diff --git a/web/src/components/inputs/RecipeSearchBar.tsx b/web/src/components/inputs/RecipeSearchBar.tsx index 9c2323c..4392fae 100644 --- a/web/src/components/inputs/RecipeSearchBar.tsx +++ b/web/src/components/inputs/RecipeSearchBar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ChangeEvent, type FormEvent } from "react"; +import { use, useEffect, useState, type ChangeEvent, type FormEvent } from "react"; import type { SearchFilters } from "../../types/search"; import FilterButton from "../buttons/FilterButton"; import RecipeSearchFilterDropdown from "./RecipeSearchFilterDropdown"; @@ -6,6 +6,7 @@ import { SearchRecipes } from "../../services/RecipeService"; import { isApiError } from "../../types/api/error"; import type { Recipe } from "../../types/recipe"; import { useNavigate } from "react-router-dom"; +import { FilterContext } from "../../context/FilterContext"; interface RecipeSearchBarProps { // filters: SearchFilters; @@ -14,20 +15,17 @@ interface RecipeSearchBarProps { searchOnLoad: boolean; favorites: boolean; setRecipes: React.Dispatch> | null; + + // Loading is optional + loading?: boolean; + setLoading?: React.Dispatch>; }; -export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, setRecipes }: RecipeSearchBarProps) { +export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, setRecipes, loading, setLoading }: RecipeSearchBarProps) { const navigate = useNavigate(); + const { filters, setFilters } = use(FilterContext); const [displayDropdown, setDisplayDropdown] = useState(false); - const [filters, setFilters] = useState({ - Search: "", - MealType: 0, - Time: 0, - Difficulty: 0, - ServingSize: 0, - Favorites: favorites - }); // SERVER FUNCTIONS const fetchSearchResults = async () => { @@ -35,14 +33,23 @@ export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, set await navigate("/v2/web/search"); return; } - const result = await SearchRecipes(filters); - if (isApiError(result)) { - console.error(result.message); - return; - } - if (setRecipes) - setRecipes(result); + // Should not allow many queries, thought we should allow redirect through loading + if (loading) return; + if (setLoading) setLoading(true); + + try { + const result = await SearchRecipes(filters); + if (isApiError(result)) { + console.error(result.message); + return; + } + + if (setRecipes) + setRecipes(result); + } finally { + if (setLoading) setLoading(false); + } } // HANDLERS @@ -69,6 +76,13 @@ export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, set void fetchSearchResults(); }, [searchOnLoad]); + useEffect(() => { + setFilters({ + ...filters, + Favorites: favorites + }); + }, [favorites]); + return (
void searchHandler(e)}> diff --git a/web/src/context/FilterContext.tsx b/web/src/context/FilterContext.tsx new file mode 100644 index 0000000..2523729 --- /dev/null +++ b/web/src/context/FilterContext.tsx @@ -0,0 +1,19 @@ +import { createContext } from "react"; +import type { SearchFilters } from "../types/search"; + +interface FilterContextType { + filters: SearchFilters; + setFilters: (filters: SearchFilters) => void; +} + +export const FilterContext = createContext({ + filters: { + Search: "", + MealType: 0, + Time: 0, + Difficulty: 0, + ServingSize: 0, + Favorites: false, + }, + setFilters: () => { return }, +}); diff --git a/web/src/context/FilterProvider.tsx b/web/src/context/FilterProvider.tsx new file mode 100644 index 0000000..4ffe7dd --- /dev/null +++ b/web/src/context/FilterProvider.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { FilterContext } from "./FilterContext"; +import type { SearchFilters } from "../types/search"; + +const STORE_KEY = "potion_app_search_filters"; + +const DEFAULT_FILTERS: SearchFilters = { + Search: "", + MealType: 0, + Time: 0, + Difficulty: 0, + ServingSize: 0, + Favorites: false, +}; + +export function FilterProvider({ children }: { children: ReactNode }) { + const [filters, setFilters] = useState(() => { + // Window would not be found, something is wrong with the browser + if (typeof window === "undefined") return DEFAULT_FILTERS; + + try { + const stored = window.localStorage.getItem(STORE_KEY); + return stored ? (JSON.parse(stored) as SearchFilters) : DEFAULT_FILTERS; + } catch { + return DEFAULT_FILTERS; + } + }); + + useEffect(() => { + try { + window.localStorage.setItem(STORE_KEY, JSON.stringify(filters)); + } catch { + // TODO: Error here? + // ignore quota / access errors + } + }, [filters]); + + return ( + + {children} + + ) +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 0ec7f69..6b6173d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -5,6 +5,7 @@ import App from './App.tsx' import { AuthProvider } from './context/AuthProvider.tsx' import { CookiesProvider } from 'react-cookie' import axios from "axios"; +import { FilterProvider } from './context/FilterProvider.tsx' // Set the with 'withCredentials' by default axios.defaults.withCredentials = true; @@ -13,7 +14,9 @@ createRoot(document.getElementById('root')!).render( - + + + , diff --git a/web/src/pages/Favorites.tsx b/web/src/pages/Favorites.tsx index 63bca36..3cf5e2c 100644 --- a/web/src/pages/Favorites.tsx +++ b/web/src/pages/Favorites.tsx @@ -4,19 +4,31 @@ import RecipeSearchBar from "../components/inputs/RecipeSearchBar"; import type { Recipe } from "../types/recipe"; import RecipeSearchResult from "../components/items/RecipeSearchResult"; +import Spinner from "../components/Spinner"; export default function Favorites() { const [recipes, setRecipes] = useState([]); + const [loading, setLoading] = useState(false); return ( <> - +
- {recipes?.map(recipe => )} -

{recipes ? "End of results" : "No results"}

+ {loading && ( +
+ +
+ )} + {!loading && ( + <> + {recipes?.map(recipe => )} +

{recipes ? "End of results" : "No reuslts"}

+ + )} +
); diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index d1d9c38..e298408 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -25,7 +25,6 @@ export default function Home() { const [error, setError] = useState(""); - // TODO: Fetch other items when needed // Fetch the recipe of the week useEffect(() => { async function fetch() { diff --git a/web/src/pages/Profile.tsx b/web/src/pages/Profile.tsx index 6d20c2d..1d97f58 100644 --- a/web/src/pages/Profile.tsx +++ b/web/src/pages/Profile.tsx @@ -170,7 +170,7 @@ export default function Profile() { - {/* Logout Section TODO: Click event*/} + {/* Logout Section */}