(FEAT): Added loading spinners to search and implemented context.

Filter context seems to be working! Using local storage so it can
persist.
This commit is contained in:
Hayden Hargreaves 2025-12-01 13:45:22 -07:00
parent 4093f9fd9c
commit 031df19b44
9 changed files with 127 additions and 27 deletions

View File

@ -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 type { SearchFilters } from "../../types/search";
import FilterButton from "../buttons/FilterButton"; import FilterButton from "../buttons/FilterButton";
import RecipeSearchFilterDropdown from "./RecipeSearchFilterDropdown"; import RecipeSearchFilterDropdown from "./RecipeSearchFilterDropdown";
@ -6,6 +6,7 @@ import { SearchRecipes } from "../../services/RecipeService";
import { isApiError } from "../../types/api/error"; import { isApiError } from "../../types/api/error";
import type { Recipe } from "../../types/recipe"; import type { Recipe } from "../../types/recipe";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { FilterContext } from "../../context/FilterContext";
interface RecipeSearchBarProps { interface RecipeSearchBarProps {
// filters: SearchFilters; // filters: SearchFilters;
@ -14,20 +15,17 @@ interface RecipeSearchBarProps {
searchOnLoad: boolean; searchOnLoad: boolean;
favorites: boolean; favorites: boolean;
setRecipes: React.Dispatch<React.SetStateAction<Recipe[]>> | null; setRecipes: React.Dispatch<React.SetStateAction<Recipe[]>> | null;
// Loading is optional
loading?: boolean;
setLoading?: React.Dispatch<React.SetStateAction<boolean>>;
}; };
export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, setRecipes }: RecipeSearchBarProps) { export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, setRecipes, loading, setLoading }: RecipeSearchBarProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { filters, setFilters } = use(FilterContext);
const [displayDropdown, setDisplayDropdown] = useState<boolean>(false); const [displayDropdown, setDisplayDropdown] = useState<boolean>(false);
const [filters, setFilters] = useState<SearchFilters>({
Search: "",
MealType: 0,
Time: 0,
Difficulty: 0,
ServingSize: 0,
Favorites: favorites
});
// SERVER FUNCTIONS // SERVER FUNCTIONS
const fetchSearchResults = async () => { const fetchSearchResults = async () => {
@ -35,14 +33,23 @@ export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, set
await navigate("/v2/web/search"); await navigate("/v2/web/search");
return; return;
} }
const result = await SearchRecipes(filters);
if (isApiError(result)) {
console.error(result.message);
return;
}
if (setRecipes) // Should not allow many queries, thought we should allow redirect through loading
setRecipes(result); 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 // HANDLERS
@ -69,6 +76,13 @@ export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, set
void fetchSearchResults(); void fetchSearchResults();
}, [searchOnLoad]); }, [searchOnLoad]);
useEffect(() => {
setFilters({
...filters,
Favorites: favorites
});
}, [favorites]);
return ( return (
<form className="w-full px-4 my-8" onSubmit={(e) => void searchHandler(e)}> <form className="w-full px-4 my-8" onSubmit={(e) => void searchHandler(e)}>

View File

@ -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<FilterContextType>({
filters: {
Search: "",
MealType: 0,
Time: 0,
Difficulty: 0,
ServingSize: 0,
Favorites: false,
},
setFilters: () => { return },
});

View File

@ -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<SearchFilters>(() => {
// 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 (
<FilterContext value={{ filters, setFilters }}>
{children}
</FilterContext>
)
}

View File

@ -5,6 +5,7 @@ import App from './App.tsx'
import { AuthProvider } from './context/AuthProvider.tsx' import { AuthProvider } from './context/AuthProvider.tsx'
import { CookiesProvider } from 'react-cookie' import { CookiesProvider } from 'react-cookie'
import axios from "axios"; import axios from "axios";
import { FilterProvider } from './context/FilterProvider.tsx'
// Set the with 'withCredentials' by default // Set the with 'withCredentials' by default
axios.defaults.withCredentials = true; axios.defaults.withCredentials = true;
@ -13,7 +14,9 @@ createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<CookiesProvider> <CookiesProvider>
<AuthProvider> <AuthProvider>
<App /> <FilterProvider>
<App />
</FilterProvider>
</AuthProvider> </AuthProvider>
</CookiesProvider> </CookiesProvider>
</StrictMode>, </StrictMode>,

View File

@ -4,19 +4,31 @@ import RecipeSearchBar from "../components/inputs/RecipeSearchBar";
import type { Recipe } from "../types/recipe"; import type { Recipe } from "../types/recipe";
import RecipeSearchResult from "../components/items/RecipeSearchResult"; import RecipeSearchResult from "../components/items/RecipeSearchResult";
import Spinner from "../components/Spinner";
export default function Favorites() { export default function Favorites() {
const [recipes, setRecipes] = useState<Recipe[]>([]); const [recipes, setRecipes] = useState<Recipe[]>([]);
const [loading, setLoading] = useState<boolean>(false);
return ( return (
<> <>
<Banner content="Favorites" /> <Banner content="Favorites" />
<RecipeSearchBar redirect={false} searchOnLoad={true} favorites={true} setRecipes={setRecipes}/> <RecipeSearchBar redirect={false} searchOnLoad={true} favorites={true} setRecipes={setRecipes} loading={loading} setLoading={setLoading} />
<hr className="text-gray-300 w-full" /> <hr className="text-gray-300 w-full" />
<div className="flex flex-col w-full p-4 items-center"> <div className="flex flex-col w-full p-4 items-center">
{recipes?.map(recipe => <RecipeSearchResult key={recipe.Id} recipe={recipe} />)} {loading && (
<p className="text-gray-700 text-sm py-4">{recipes ? "End of results" : "No results"}</p> <div className="w-full flex items-center justify-center gap-x-2 py-4">
<Spinner content="Loading recipes..." />
</div>
)}
{!loading && (
<>
{recipes?.map(recipe => <RecipeSearchResult key={recipe.Id} recipe={recipe} />)}
<p className="text-gray-700 text-sm py-4">{recipes ? "End of results" : "No reuslts"}</p>
</>
)}
</div> </div>
</> </>
); );

View File

@ -25,7 +25,6 @@ export default function Home() {
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
// TODO: Fetch other items when needed
// Fetch the recipe of the week // Fetch the recipe of the week
useEffect(() => { useEffect(() => {
async function fetch() { async function fetch() {

View File

@ -170,7 +170,7 @@ export default function Profile() {
</section> </section>
{/* Logout Section TODO: Click event*/} {/* Logout Section */}
<section className="w-full flex flex-col justify-center items-center py-8 border-t border-gray-300 mt-auto"> <section className="w-full flex flex-col justify-center items-center py-8 border-t border-gray-300 mt-auto">
<button onClick={logoutHandler} className="text-center border border-red-500 text-red-500 w-9/10 md:w-1/3 py-2 rounded-lg hover:cursor-pointer hover:bg-red-100 duration-300"> <button onClick={logoutHandler} className="text-center border border-red-500 text-red-500 w-9/10 md:w-1/3 py-2 rounded-lg hover:cursor-pointer hover:bg-red-100 duration-300">
Logout Logout

View File

@ -3,19 +3,30 @@ import Banner from "../components/Banner";
import RecipeSearchBar from "../components/inputs/RecipeSearchBar"; import RecipeSearchBar from "../components/inputs/RecipeSearchBar";
import { type Recipe } from "../types/recipe"; import { type Recipe } from "../types/recipe";
import RecipeSearchResult from "../components/items/RecipeSearchResult"; import RecipeSearchResult from "../components/items/RecipeSearchResult";
import Spinner from "../components/Spinner";
export default function SearchPage() { export default function SearchPage() {
const [recipes, setRecipes] = useState<Recipe[]>([]); const [recipes, setRecipes] = useState<Recipe[]>([]);
const [loading, setLoading] = useState<boolean>(false);
return ( return (
<> <>
<Banner content="Recipe Search" /> <Banner content="Recipe Search" />
<RecipeSearchBar redirect={false} searchOnLoad={true} favorites={false} setRecipes={setRecipes} /> <RecipeSearchBar redirect={false} searchOnLoad={true} favorites={false} setRecipes={setRecipes} loading={loading} setLoading={setLoading} />
<hr className="text-gray-300 w-full" /> <hr className="text-gray-300 w-full" />
<div className="flex flex-col w-full p-4 items-center"> <div className="flex flex-col w-full p-4 items-center">
{recipes?.map(recipe => <RecipeSearchResult key={recipe.Id} recipe={recipe} />)} {loading && (
<p className="text-gray-700 text-sm py-4">{recipes ? "End of results" : "No reuslts"}</p> <div className="w-full flex items-center justify-center gap-x-2 py-4">
<Spinner content="Loading recipes..." />
</div>
)}
{!loading && (
<>
{recipes?.map(recipe => <RecipeSearchResult key={recipe.Id} recipe={recipe} />)}
<p className="text-gray-700 text-sm py-4">{recipes ? "End of results" : "No reuslts"}</p>
</>
)}
</div> </div>
</> </>
); );

View File

@ -5,7 +5,6 @@ export interface RecipeDuration {
Cook: number; Cook: number;
} }
// BUG: This might need to be integers? Not sure yet.
export type RecipeMeal = "breakfast" | "lunch" | "dinner" | "dessert" | "snack" | "side" | "other"; export type RecipeMeal = "breakfast" | "lunch" | "dinner" | "dessert" | "snack" | "side" | "other";
export interface RecipeIngredient { export interface RecipeIngredient {