mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 10:13:32 -04:00 
			
		
		
		
	
		
			
				
	
	
		
			466 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			466 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { watchDebounced } from "@vueuse/shared";
 | |
| import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
 | |
| import type { NoUndefinedField } from "~/lib/api/types/non-generated";
 | |
| import type { HouseholdSummary } from "~/lib/api/types/household";
 | |
| import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
 | |
| import {
 | |
|   useCategoryStore,
 | |
|   usePublicCategoryStore,
 | |
|   useFoodStore,
 | |
|   usePublicFoodStore,
 | |
|   useHouseholdStore,
 | |
|   usePublicHouseholdStore,
 | |
|   useTagStore,
 | |
|   usePublicTagStore,
 | |
|   useToolStore,
 | |
|   usePublicToolStore,
 | |
| } from "~/composables/store";
 | |
| import { useLoggedInState } from "~/composables/use-logged-in-state";
 | |
| import { useUserSearchQuerySession, useUserSortPreferences } from "~/composables/use-users/preferences";
 | |
| 
 | |
| // Type for the composable return value
 | |
| interface RecipeExplorerSearchState {
 | |
|   state: Ref<{
 | |
|     auto: boolean;
 | |
|     ready: boolean;
 | |
|     search: string;
 | |
|     orderBy: string;
 | |
|     orderDirection: "asc" | "desc";
 | |
|     requireAllCategories: boolean;
 | |
|     requireAllTags: boolean;
 | |
|     requireAllTools: boolean;
 | |
|     requireAllFoods: boolean;
 | |
|   }>;
 | |
|   selectedCategories: Ref<NoUndefinedField<RecipeCategory>[]>;
 | |
|   selectedFoods: Ref<IngredientFood[]>;
 | |
|   selectedHouseholds: Ref<NoUndefinedField<HouseholdSummary>[]>;
 | |
|   selectedTags: Ref<NoUndefinedField<RecipeTag>[]>;
 | |
|   selectedTools: Ref<NoUndefinedField<RecipeTool>[]>;
 | |
|   passedQueryWithSeed: ComputedRef<RecipeSearchQuery & { _searchSeed: string }>;
 | |
|   search: () => Promise<void>;
 | |
|   reset: () => void;
 | |
|   toggleOrderDirection: () => void;
 | |
|   setOrderBy: (value: string) => void;
 | |
|   filterItems: (item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) => void;
 | |
|   initialize: () => Promise<void>;
 | |
| }
 | |
| 
 | |
| // Memo storage for singleton instances
 | |
| const memo: Record<string, RecipeExplorerSearchState> = {};
 | |
| 
 | |
| function createRecipeExplorerSearchState(groupSlug: ComputedRef<string>): RecipeExplorerSearchState {
 | |
|   const router = useRouter();
 | |
|   const route = useRoute();
 | |
| 
 | |
|   const { isOwnGroup } = useLoggedInState();
 | |
|   const searchQuerySession = useUserSearchQuerySession();
 | |
|   const sortPreferences = useUserSortPreferences();
 | |
| 
 | |
|   // State management
 | |
|   const state = ref({
 | |
|     auto: true,
 | |
|     ready: false,
 | |
|     search: "",
 | |
|     orderBy: "created_at",
 | |
|     orderDirection: "desc" as "asc" | "desc",
 | |
|     requireAllCategories: false,
 | |
|     requireAllTags: false,
 | |
|     requireAllTools: false,
 | |
|     requireAllFoods: false,
 | |
|   });
 | |
| 
 | |
|   // Store references
 | |
|   const categories = isOwnGroup ? useCategoryStore() : usePublicCategoryStore(groupSlug.value);
 | |
|   const foods = isOwnGroup ? useFoodStore() : usePublicFoodStore(groupSlug.value);
 | |
|   const households = isOwnGroup ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value);
 | |
|   const tags = isOwnGroup ? useTagStore() : usePublicTagStore(groupSlug.value);
 | |
|   const tools = isOwnGroup ? useToolStore() : usePublicToolStore(groupSlug.value);
 | |
| 
 | |
|   // Selected items
 | |
|   const selectedCategories = ref<NoUndefinedField<RecipeCategory>[]>([]);
 | |
|   const selectedFoods = ref<IngredientFood[]>([]);
 | |
|   const selectedHouseholds = ref<NoUndefinedField<HouseholdSummary>[]>([]);
 | |
|   const selectedTags = ref<NoUndefinedField<RecipeTag>[]>([]);
 | |
|   const selectedTools = ref<NoUndefinedField<RecipeTool>[]>([]);
 | |
| 
 | |
|   // Query defaults
 | |
|   const queryDefaults = {
 | |
|     search: "",
 | |
|     orderBy: "created_at",
 | |
|     orderDirection: "desc" as "asc" | "desc",
 | |
|     requireAllCategories: false,
 | |
|     requireAllTags: false,
 | |
|     requireAllTools: false,
 | |
|     requireAllFoods: false,
 | |
|   };
 | |
| 
 | |
|   // Sync sort preferences
 | |
|   watch(() => state.value.orderBy, (newValue) => {
 | |
|     sortPreferences.value.orderBy = newValue;
 | |
|   });
 | |
| 
 | |
|   watch(() => state.value.orderDirection, (newValue) => {
 | |
|     sortPreferences.value.orderDirection = newValue;
 | |
|   });
 | |
| 
 | |
|   // Utility functions
 | |
|   function toIDArray(array: { id: string }[]) {
 | |
|     return array.map(item => item.id).sort();
 | |
|   }
 | |
| 
 | |
|   function calcPassedQuery(): RecipeSearchQuery {
 | |
|     return {
 | |
|       search: state.value.search ? state.value.search : "",
 | |
|       categories: toIDArray(selectedCategories.value),
 | |
|       foods: toIDArray(selectedFoods.value),
 | |
|       households: toIDArray(selectedHouseholds.value),
 | |
|       tags: toIDArray(selectedTags.value),
 | |
|       tools: toIDArray(selectedTools.value),
 | |
|       requireAllCategories: state.value.requireAllCategories,
 | |
|       requireAllTags: state.value.requireAllTags,
 | |
|       requireAllTools: state.value.requireAllTools,
 | |
|       requireAllFoods: state.value.requireAllFoods,
 | |
|       orderBy: state.value.orderBy,
 | |
|       orderDirection: state.value.orderDirection,
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   const passedQuery = ref<RecipeSearchQuery>(calcPassedQuery());
 | |
| 
 | |
|   const passedQueryWithSeed = computed(() => {
 | |
|     return {
 | |
|       ...passedQuery.value,
 | |
|       _searchSeed: Date.now().toString(),
 | |
|     };
 | |
|   });
 | |
| 
 | |
|   // Wait utility for async hydration
 | |
|   function waitUntilAndExecute(
 | |
|     condition: () => boolean,
 | |
|     callback: () => void,
 | |
|     opts = { timeout: 2000, interval: 500 },
 | |
|   ): Promise<void> {
 | |
|     return new Promise((resolve, reject) => {
 | |
|       const state = {
 | |
|         timeout: undefined as number | undefined,
 | |
|         interval: undefined as number | undefined,
 | |
|       };
 | |
| 
 | |
|       const check = () => {
 | |
|         if (condition()) {
 | |
|           clearInterval(state.interval);
 | |
|           clearTimeout(state.timeout);
 | |
|           callback();
 | |
|           resolve();
 | |
|         }
 | |
|       };
 | |
| 
 | |
|       state.interval = setInterval(check, opts.interval) as unknown as number;
 | |
|       state.timeout = setTimeout(() => {
 | |
|         clearInterval(state.interval);
 | |
|         reject(new Error("Timeout"));
 | |
|       }, opts.timeout) as unknown as number;
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   // Main functions
 | |
|   function reset() {
 | |
|     state.value.search = queryDefaults.search;
 | |
|     state.value.orderBy = queryDefaults.orderBy;
 | |
|     state.value.orderDirection = queryDefaults.orderDirection;
 | |
|     sortPreferences.value.orderBy = queryDefaults.orderBy;
 | |
|     sortPreferences.value.orderDirection = queryDefaults.orderDirection;
 | |
|     state.value.requireAllCategories = queryDefaults.requireAllCategories;
 | |
|     state.value.requireAllTags = queryDefaults.requireAllTags;
 | |
|     state.value.requireAllTools = queryDefaults.requireAllTools;
 | |
|     state.value.requireAllFoods = queryDefaults.requireAllFoods;
 | |
|     selectedCategories.value = [];
 | |
|     selectedFoods.value = [];
 | |
|     selectedHouseholds.value = [];
 | |
|     selectedTags.value = [];
 | |
|     selectedTools.value = [];
 | |
|   }
 | |
| 
 | |
|   function toggleOrderDirection() {
 | |
|     state.value.orderDirection = state.value.orderDirection === "asc" ? "desc" : "asc";
 | |
|     sortPreferences.value.orderDirection = state.value.orderDirection;
 | |
|   }
 | |
| 
 | |
|   function setOrderBy(value: string) {
 | |
|     state.value.orderBy = value;
 | |
|     sortPreferences.value.orderBy = value;
 | |
|   }
 | |
| 
 | |
|   async function search() {
 | |
|     const oldQueryValueString = JSON.stringify(passedQuery.value);
 | |
|     const newQueryValue = calcPassedQuery();
 | |
|     const newQueryValueString = JSON.stringify(newQueryValue);
 | |
|     if (oldQueryValueString === newQueryValueString) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     passedQuery.value = newQueryValue;
 | |
|     const query = {
 | |
|       categories: passedQuery.value.categories,
 | |
|       foods: passedQuery.value.foods,
 | |
|       tags: passedQuery.value.tags,
 | |
|       tools: passedQuery.value.tools,
 | |
|       // Only add the query param if it's not the default value
 | |
|       ...{
 | |
|         auto: state.value.auto ? undefined : "false",
 | |
|         search: passedQuery.value.search === queryDefaults.search ? undefined : passedQuery.value.search,
 | |
|         households: !passedQuery.value.households?.length || passedQuery.value.households?.length === households.store.value.length ? undefined : passedQuery.value.households,
 | |
|         requireAllCategories: passedQuery.value.requireAllCategories ? "true" : undefined,
 | |
|         requireAllTags: passedQuery.value.requireAllTags ? "true" : undefined,
 | |
|         requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined,
 | |
|         requireAllFoods: passedQuery.value.requireAllFoods ? "true" : undefined,
 | |
|       },
 | |
|     };
 | |
|     await router.push({ query });
 | |
|     searchQuerySession.value.recipe = JSON.stringify(query);
 | |
|   }
 | |
| 
 | |
|   function filterItems(item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) {
 | |
|     if (urlPrefix === "categories") {
 | |
|       const result = categories.store.value.filter(category => (category.id as string).includes(item.id as string));
 | |
|       selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
 | |
|     }
 | |
|     else if (urlPrefix === "tags") {
 | |
|       const result = tags.store.value.filter(tag => (tag.id as string).includes(item.id as string));
 | |
|       selectedTags.value = result as NoUndefinedField<RecipeTag>[];
 | |
|     }
 | |
|     else if (urlPrefix === "tools") {
 | |
|       const result = tools.store.value.filter(tool => (tool.id).includes(item.id || ""));
 | |
|       selectedTools.value = result as NoUndefinedField<RecipeTool>[];
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   async function hydrateSearch() {
 | |
|     const query = router.currentRoute.value.query;
 | |
|     if (query.auto?.length) {
 | |
|       state.value.auto = query.auto === "true";
 | |
|     }
 | |
| 
 | |
|     if (query.search?.length) {
 | |
|       state.value.search = query.search as string;
 | |
|     }
 | |
|     else {
 | |
|       state.value.search = queryDefaults.search;
 | |
|     }
 | |
| 
 | |
|     state.value.orderBy = sortPreferences.value.orderBy;
 | |
|     state.value.orderDirection = sortPreferences.value.orderDirection as "asc" | "desc";
 | |
| 
 | |
|     if (query.requireAllCategories?.length) {
 | |
|       state.value.requireAllCategories = query.requireAllCategories === "true";
 | |
|     }
 | |
|     else {
 | |
|       state.value.requireAllCategories = queryDefaults.requireAllCategories;
 | |
|     }
 | |
| 
 | |
|     if (query.requireAllTags?.length) {
 | |
|       state.value.requireAllTags = query.requireAllTags === "true";
 | |
|     }
 | |
|     else {
 | |
|       state.value.requireAllTags = queryDefaults.requireAllTags;
 | |
|     }
 | |
| 
 | |
|     if (query.requireAllTools?.length) {
 | |
|       state.value.requireAllTools = query.requireAllTools === "true";
 | |
|     }
 | |
|     else {
 | |
|       state.value.requireAllTools = queryDefaults.requireAllTools;
 | |
|     }
 | |
| 
 | |
|     if (query.requireAllFoods?.length) {
 | |
|       state.value.requireAllFoods = query.requireAllFoods === "true";
 | |
|     }
 | |
|     else {
 | |
|       state.value.requireAllFoods = queryDefaults.requireAllFoods;
 | |
|     }
 | |
| 
 | |
|     const promises: Promise<void>[] = [];
 | |
| 
 | |
|     if (query.categories?.length) {
 | |
|       promises.push(
 | |
|         waitUntilAndExecute(
 | |
|           () => categories.store.value.length > 0,
 | |
|           () => {
 | |
|             const result = categories.store.value.filter(item =>
 | |
|               (query.categories as string[]).includes(item.id as string),
 | |
|             );
 | |
|             selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
 | |
|           },
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
|     else {
 | |
|       selectedCategories.value = [];
 | |
|     }
 | |
| 
 | |
|     if (query.tags?.length) {
 | |
|       promises.push(
 | |
|         waitUntilAndExecute(
 | |
|           () => tags.store.value.length > 0,
 | |
|           () => {
 | |
|             const result = tags.store.value.filter(item => (query.tags as string[]).includes(item.id as string));
 | |
|             selectedTags.value = result as NoUndefinedField<RecipeTag>[];
 | |
|           },
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
|     else {
 | |
|       selectedTags.value = [];
 | |
|     }
 | |
| 
 | |
|     if (query.tools?.length) {
 | |
|       promises.push(
 | |
|         waitUntilAndExecute(
 | |
|           () => tools.store.value.length > 0,
 | |
|           () => {
 | |
|             const result = tools.store.value.filter(item => (query.tools as string[]).includes(item.id));
 | |
|             selectedTools.value = result as NoUndefinedField<RecipeTool>[];
 | |
|           },
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
|     else {
 | |
|       selectedTools.value = [];
 | |
|     }
 | |
| 
 | |
|     if (query.foods?.length) {
 | |
|       promises.push(
 | |
|         waitUntilAndExecute(
 | |
|           () => {
 | |
|             if (foods.store.value) {
 | |
|               return foods.store.value.length > 0;
 | |
|             }
 | |
|             return false;
 | |
|           },
 | |
|           () => {
 | |
|             const result = foods.store.value?.filter(item => (query.foods as string[]).includes(item.id));
 | |
|             selectedFoods.value = result ?? [];
 | |
|           },
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
|     else {
 | |
|       selectedFoods.value = [];
 | |
|     }
 | |
| 
 | |
|     if (query.households?.length) {
 | |
|       promises.push(
 | |
|         waitUntilAndExecute(
 | |
|           () => {
 | |
|             if (households.store.value) {
 | |
|               return households.store.value.length > 0;
 | |
|             }
 | |
|             return false;
 | |
|           },
 | |
|           () => {
 | |
|             const result = households.store.value?.filter(item => (query.households as string[]).includes(item.id));
 | |
|             selectedHouseholds.value = result as NoUndefinedField<HouseholdSummary>[] ?? [];
 | |
|           },
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
|     else {
 | |
|       selectedHouseholds.value = [];
 | |
|     }
 | |
| 
 | |
|     await Promise.allSettled(promises);
 | |
|   }
 | |
| 
 | |
|   async function initialize() {
 | |
|     // Restore the user's last search query
 | |
|     if (searchQuerySession.value.recipe && !(Object.keys(route.query).length > 0)) {
 | |
|       try {
 | |
|         const query = JSON.parse(searchQuerySession.value.recipe);
 | |
|         await router.replace({ query });
 | |
|       }
 | |
|       catch {
 | |
|         searchQuerySession.value.recipe = "";
 | |
|         router.replace({ query: {} });
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     await hydrateSearch();
 | |
|     await search();
 | |
|     state.value.ready = true;
 | |
|   }
 | |
| 
 | |
|   // Watch for route query changes
 | |
|   watch(
 | |
|     () => route.query,
 | |
|     () => {
 | |
|       if (!Object.keys(route.query).length) {
 | |
|         reset();
 | |
|       }
 | |
|     },
 | |
|   );
 | |
| 
 | |
|   // Auto-search when parameters change
 | |
|   watchDebounced(
 | |
|     [
 | |
|       () => state.value.search,
 | |
|       () => state.value.requireAllCategories,
 | |
|       () => state.value.requireAllTags,
 | |
|       () => state.value.requireAllTools,
 | |
|       () => state.value.requireAllFoods,
 | |
|       () => state.value.orderBy,
 | |
|       () => state.value.orderDirection,
 | |
|       selectedCategories,
 | |
|       selectedFoods,
 | |
|       selectedHouseholds,
 | |
|       selectedTags,
 | |
|       selectedTools,
 | |
|     ],
 | |
|     async () => {
 | |
|       if (state.value.ready && state.value.auto) {
 | |
|         await search();
 | |
|       }
 | |
|     },
 | |
|     {
 | |
|       debounce: 500,
 | |
|     },
 | |
|   );
 | |
| 
 | |
|   const composableInstance: RecipeExplorerSearchState = {
 | |
|     // State
 | |
|     state,
 | |
|     selectedCategories,
 | |
|     selectedFoods,
 | |
|     selectedHouseholds,
 | |
|     selectedTags,
 | |
|     selectedTools,
 | |
| 
 | |
|     // Computed
 | |
|     passedQueryWithSeed,
 | |
| 
 | |
|     // Methods
 | |
|     search,
 | |
|     reset,
 | |
|     toggleOrderDirection,
 | |
|     setOrderBy,
 | |
|     filterItems,
 | |
|     initialize,
 | |
|   };
 | |
| 
 | |
|   return composableInstance;
 | |
| }
 | |
| 
 | |
| export function useRecipeExplorerSearch(groupSlug: ComputedRef<string>): RecipeExplorerSearchState {
 | |
|   const key = groupSlug.value;
 | |
| 
 | |
|   if (!memo[key]) {
 | |
|     memo[key] = createRecipeExplorerSearchState(groupSlug);
 | |
|   }
 | |
| 
 | |
|   return memo[key];
 | |
| }
 | |
| 
 | |
| export function clearRecipeExplorerSearchState(groupSlug: string) {
 | |
|   // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
 | |
|   delete memo[groupSlug];
 | |
| }
 |