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[]>; selectedFoods: Ref; selectedHouseholds: Ref[]>; selectedTags: Ref[]>; selectedTools: Ref[]>; passedQueryWithSeed: ComputedRef; search: () => Promise; reset: () => void; toggleOrderDirection: () => void; setOrderBy: (value: string) => void; filterItems: (item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) => void; initialize: () => Promise; } // Memo storage for singleton instances const memo: Record = {}; function createRecipeExplorerSearchState(groupSlug: ComputedRef): 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[]>([]); const selectedFoods = ref([]); const selectedHouseholds = ref[]>([]); const selectedTags = ref[]>([]); const selectedTools = ref[]>([]); // 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(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 { 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[]; } 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[]; } else if (urlPrefix === "tools") { const result = tools.store.value.filter(tool => (tool.id).includes(item.id || "")); selectedTools.value = result as NoUndefinedField[]; } } 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[] = []; 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[]; }, ), ); } 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[]; }, ), ); } 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[]; }, ), ); } 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[] ?? []; }, ), ); } 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): 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]; }