mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-10-27 16:24:31 -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];
|
|
}
|