mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-11-03 18:53:17 -05: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];
 | 
						|
}
 |