mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-10-27 08:14:30 -04:00
fix: Refactor and Optimize Explore Page Search (#6070)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
@@ -1,715 +0,0 @@
|
|||||||
<template>
|
|
||||||
<v-container
|
|
||||||
fluid
|
|
||||||
class="px-0"
|
|
||||||
>
|
|
||||||
<div class="search-container pb-8">
|
|
||||||
<form
|
|
||||||
class="search-box pa-2"
|
|
||||||
@submit.prevent="search"
|
|
||||||
>
|
|
||||||
<div class="d-flex justify-center mb-2">
|
|
||||||
<v-text-field
|
|
||||||
ref="input"
|
|
||||||
v-model="state.search"
|
|
||||||
variant="outlined"
|
|
||||||
hide-details
|
|
||||||
clearable
|
|
||||||
color="primary"
|
|
||||||
:placeholder="$t('search.search-placeholder')"
|
|
||||||
:prepend-inner-icon="$globals.icons.search"
|
|
||||||
@keyup.enter="hideKeyboard"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="search-row">
|
|
||||||
<!-- Category Filter -->
|
|
||||||
<SearchFilter
|
|
||||||
v-if="categories"
|
|
||||||
v-model="selectedCategories"
|
|
||||||
v-model:require-all="state.requireAllCategories"
|
|
||||||
:items="categories"
|
|
||||||
>
|
|
||||||
<v-icon start>
|
|
||||||
{{ $globals.icons.categories }}
|
|
||||||
</v-icon>
|
|
||||||
{{ $t("category.categories") }}
|
|
||||||
</SearchFilter>
|
|
||||||
|
|
||||||
<!-- Tag Filter -->
|
|
||||||
<SearchFilter
|
|
||||||
v-if="tags"
|
|
||||||
v-model="selectedTags"
|
|
||||||
v-model:require-all="state.requireAllTags"
|
|
||||||
:items="tags"
|
|
||||||
>
|
|
||||||
<v-icon start>
|
|
||||||
{{ $globals.icons.tags }}
|
|
||||||
</v-icon>
|
|
||||||
{{ $t("tag.tags") }}
|
|
||||||
</SearchFilter>
|
|
||||||
|
|
||||||
<!-- Tool Filter -->
|
|
||||||
<SearchFilter
|
|
||||||
v-if="tools"
|
|
||||||
v-model="selectedTools"
|
|
||||||
v-model:require-all="state.requireAllTools"
|
|
||||||
:items="tools"
|
|
||||||
>
|
|
||||||
<v-icon start>
|
|
||||||
{{ $globals.icons.potSteam }}
|
|
||||||
</v-icon>
|
|
||||||
{{ $t("tool.tools") }}
|
|
||||||
</SearchFilter>
|
|
||||||
|
|
||||||
<!-- Food Filter -->
|
|
||||||
<SearchFilter
|
|
||||||
v-if="foods"
|
|
||||||
v-model="selectedFoods"
|
|
||||||
v-model:require-all="state.requireAllFoods"
|
|
||||||
:items="foods"
|
|
||||||
>
|
|
||||||
<v-icon start>
|
|
||||||
{{ $globals.icons.foods }}
|
|
||||||
</v-icon>
|
|
||||||
{{ $t("general.foods") }}
|
|
||||||
</SearchFilter>
|
|
||||||
|
|
||||||
<!-- Household Filter -->
|
|
||||||
<SearchFilter
|
|
||||||
v-if="households.length > 1"
|
|
||||||
v-model="selectedHouseholds"
|
|
||||||
:items="households"
|
|
||||||
radio
|
|
||||||
>
|
|
||||||
<v-icon start>
|
|
||||||
{{ $globals.icons.household }}
|
|
||||||
</v-icon>
|
|
||||||
{{ $t("household.households") }}
|
|
||||||
</SearchFilter>
|
|
||||||
|
|
||||||
<!-- Sort Options -->
|
|
||||||
<v-menu
|
|
||||||
offset-y
|
|
||||||
nudge-bottom="3"
|
|
||||||
>
|
|
||||||
<template #activator="{ props }">
|
|
||||||
<v-btn
|
|
||||||
class="ml-auto"
|
|
||||||
size="small"
|
|
||||||
color="accent"
|
|
||||||
v-bind="props"
|
|
||||||
>
|
|
||||||
<v-icon :start="!$vuetify.display.xs">
|
|
||||||
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
|
|
||||||
</v-icon>
|
|
||||||
{{ $vuetify.display.xs ? null : sortText }}
|
|
||||||
</v-btn>
|
|
||||||
</template>
|
|
||||||
<v-card>
|
|
||||||
<v-list>
|
|
||||||
<v-list-item
|
|
||||||
slim
|
|
||||||
density="comfortable"
|
|
||||||
:prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortDescending : $globals.icons.sortAscending"
|
|
||||||
:title="state.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
|
|
||||||
@click="toggleOrderDirection()"
|
|
||||||
/>
|
|
||||||
<v-divider />
|
|
||||||
<v-list-item
|
|
||||||
v-for="v in sortable"
|
|
||||||
:key="v.name"
|
|
||||||
:active="state.orderBy === v.value"
|
|
||||||
slim
|
|
||||||
density="comfortable"
|
|
||||||
:prepend-icon="v.icon"
|
|
||||||
:title="v.name"
|
|
||||||
@click="setOrderBy(v.value)"
|
|
||||||
/>
|
|
||||||
</v-list>
|
|
||||||
</v-card>
|
|
||||||
</v-menu>
|
|
||||||
|
|
||||||
<!-- Settings -->
|
|
||||||
<v-menu
|
|
||||||
offset-y
|
|
||||||
bottom
|
|
||||||
start
|
|
||||||
nudge-bottom="3"
|
|
||||||
:close-on-content-click="false"
|
|
||||||
>
|
|
||||||
<template #activator="{ props }">
|
|
||||||
<v-btn
|
|
||||||
size="small"
|
|
||||||
color="accent"
|
|
||||||
dark
|
|
||||||
v-bind="props"
|
|
||||||
>
|
|
||||||
<v-icon size="small">
|
|
||||||
{{ $globals.icons.cog }}
|
|
||||||
</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</template>
|
|
||||||
<v-card>
|
|
||||||
<v-card-text>
|
|
||||||
<v-switch
|
|
||||||
v-model="state.auto"
|
|
||||||
:label="$t('search.auto-search')"
|
|
||||||
single-line
|
|
||||||
/>
|
|
||||||
<v-btn
|
|
||||||
block
|
|
||||||
color="primary"
|
|
||||||
@click="reset"
|
|
||||||
>
|
|
||||||
{{ $t("general.reset") }}
|
|
||||||
</v-btn>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
</v-menu>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="!state.auto"
|
|
||||||
class="search-button-container"
|
|
||||||
>
|
|
||||||
<v-btn
|
|
||||||
size="x-large"
|
|
||||||
color="primary"
|
|
||||||
type="submit"
|
|
||||||
block
|
|
||||||
>
|
|
||||||
<v-icon start>
|
|
||||||
{{ $globals.icons.search }}
|
|
||||||
</v-icon>
|
|
||||||
{{ $t("search.search") }}
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<v-divider />
|
|
||||||
<v-container class="mt-6 px-md-6">
|
|
||||||
<RecipeCardSection
|
|
||||||
v-if="state.ready"
|
|
||||||
class="mt-n5"
|
|
||||||
:icon="$globals.icons.silverwareForkKnife"
|
|
||||||
:title="$t('general.recipes')"
|
|
||||||
:recipes="recipes"
|
|
||||||
:query="passedQueryWithSeed"
|
|
||||||
disable-sort
|
|
||||||
@item-selected="filterItems"
|
|
||||||
@replace-recipes="replaceRecipes"
|
|
||||||
@append-recipes="appendRecipes"
|
|
||||||
/>
|
|
||||||
</v-container>
|
|
||||||
</v-container>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { watchDebounced } from "@vueuse/shared";
|
|
||||||
import SearchFilter from "~/components/Domain/SearchFilter.vue";
|
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
|
||||||
import {
|
|
||||||
useCategoryStore,
|
|
||||||
usePublicCategoryStore,
|
|
||||||
useFoodStore,
|
|
||||||
usePublicFoodStore,
|
|
||||||
useHouseholdStore,
|
|
||||||
usePublicHouseholdStore,
|
|
||||||
useTagStore,
|
|
||||||
usePublicTagStore,
|
|
||||||
useToolStore,
|
|
||||||
usePublicToolStore,
|
|
||||||
} from "~/composables/store";
|
|
||||||
import { useUserSearchQuerySession, useUserSortPreferences } from "~/composables/use-users/preferences";
|
|
||||||
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
|
|
||||||
import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
|
||||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
|
||||||
import { useLazyRecipes } from "~/composables/recipes";
|
|
||||||
import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
|
||||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
|
||||||
components: { SearchFilter, RecipeCardSection },
|
|
||||||
setup() {
|
|
||||||
const router = useRouter();
|
|
||||||
const i18n = useI18n();
|
|
||||||
const $auth = useMealieAuth();
|
|
||||||
const { $globals } = useNuxtApp();
|
|
||||||
|
|
||||||
const { isOwnGroup } = useLoggedInState();
|
|
||||||
const state = ref({
|
|
||||||
auto: true,
|
|
||||||
ready: false,
|
|
||||||
search: "",
|
|
||||||
orderBy: "created_at",
|
|
||||||
orderDirection: "desc" as "asc" | "desc",
|
|
||||||
|
|
||||||
// and/or
|
|
||||||
requireAllCategories: false,
|
|
||||||
requireAllTags: false,
|
|
||||||
requireAllTools: false,
|
|
||||||
requireAllFoods: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
|
||||||
const searchQuerySession = useUserSearchQuerySession();
|
|
||||||
const sortPreferences = useUserSortPreferences();
|
|
||||||
|
|
||||||
watch(() => state.value.orderBy, (newValue) => {
|
|
||||||
sortPreferences.value.orderBy = newValue;
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(() => state.value.orderDirection, (newValue) => {
|
|
||||||
sortPreferences.value.orderDirection = newValue;
|
|
||||||
});
|
|
||||||
|
|
||||||
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
|
||||||
const categories = isOwnGroup.value ? useCategoryStore() : usePublicCategoryStore(groupSlug.value);
|
|
||||||
const selectedCategories = ref<NoUndefinedField<RecipeCategory>[]>([]);
|
|
||||||
|
|
||||||
const foods = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
|
|
||||||
const selectedFoods = ref<IngredientFood[]>([]);
|
|
||||||
|
|
||||||
const households = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value);
|
|
||||||
const selectedHouseholds = ref([] as NoUndefinedField<HouseholdSummary>[]);
|
|
||||||
|
|
||||||
const tags = isOwnGroup.value ? useTagStore() : usePublicTagStore(groupSlug.value);
|
|
||||||
const selectedTags = ref<NoUndefinedField<RecipeTag>[]>([]);
|
|
||||||
|
|
||||||
const tools = isOwnGroup.value ? useToolStore() : usePublicToolStore(groupSlug.value);
|
|
||||||
const selectedTools = ref<NoUndefinedField<RecipeTool>[]>([]);
|
|
||||||
|
|
||||||
function calcPassedQuery(): RecipeSearchQuery {
|
|
||||||
return {
|
|
||||||
// the search clear button sets search to null, which still renders the query param for a moment,
|
|
||||||
// whereas an empty string is not rendered
|
|
||||||
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());
|
|
||||||
|
|
||||||
// we calculate this separately because otherwise we can't check for query changes
|
|
||||||
const passedQueryWithSeed = computed(() => {
|
|
||||||
return {
|
|
||||||
...passedQuery.value,
|
|
||||||
_searchSeed: Date.now().toString(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryDefaults = {
|
|
||||||
search: "",
|
|
||||||
orderBy: "created_at",
|
|
||||||
orderDirection: "desc" as "asc" | "desc",
|
|
||||||
requireAllCategories: false,
|
|
||||||
requireAllTags: false,
|
|
||||||
requireAllTools: false,
|
|
||||||
requireAllFoods: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toIDArray(array: { id: string }[]) {
|
|
||||||
// we sort the array to make sure the query is always the same
|
|
||||||
return array.map(item => item.id).sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideKeyboard() {
|
|
||||||
input.value.blur();
|
|
||||||
}
|
|
||||||
|
|
||||||
const input: Ref<any> = ref(null);
|
|
||||||
|
|
||||||
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 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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// For some reason these were returning NodeJS.Timeout
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortText = computed(() => {
|
|
||||||
const sort = sortable.find(s => s.value === state.value.orderBy);
|
|
||||||
if (!sort) return "";
|
|
||||||
return `${sort.name}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortable = [
|
|
||||||
{
|
|
||||||
icon: $globals.icons.orderAlphabeticalAscending,
|
|
||||||
name: i18n.t("general.sort-alphabetically"),
|
|
||||||
value: "name",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: $globals.icons.newBox,
|
|
||||||
name: i18n.t("general.created"),
|
|
||||||
value: "created_at",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: $globals.icons.chefHat,
|
|
||||||
name: i18n.t("general.last-made"),
|
|
||||||
value: "last_made",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: $globals.icons.star,
|
|
||||||
name: i18n.t("general.rating"),
|
|
||||||
value: "rating",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: $globals.icons.update,
|
|
||||||
name: i18n.t("general.updated"),
|
|
||||||
value: "updated_at",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: $globals.icons.diceMultiple,
|
|
||||||
name: i18n.t("general.random"),
|
|
||||||
value: "random",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.query,
|
|
||||||
() => {
|
|
||||||
if (!Object.keys(route.query).length) {
|
|
||||||
reset();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
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<RecipeTag>[];
|
|
||||||
}
|
|
||||||
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 || ""));
|
|
||||||
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
// 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;
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sortText,
|
|
||||||
search,
|
|
||||||
reset,
|
|
||||||
state,
|
|
||||||
categories: categories.store as unknown as NoUndefinedField<RecipeCategory>[],
|
|
||||||
tags: tags.store as unknown as NoUndefinedField<RecipeTag>[],
|
|
||||||
foods: foods.store,
|
|
||||||
tools: tools.store as unknown as NoUndefinedField<RecipeTool>[],
|
|
||||||
households: households.store as unknown as NoUndefinedField<HouseholdSummary>[],
|
|
||||||
|
|
||||||
sortable,
|
|
||||||
toggleOrderDirection,
|
|
||||||
setOrderBy,
|
|
||||||
hideKeyboard,
|
|
||||||
input,
|
|
||||||
|
|
||||||
selectedCategories,
|
|
||||||
selectedFoods,
|
|
||||||
selectedHouseholds,
|
|
||||||
selectedTags,
|
|
||||||
selectedTools,
|
|
||||||
appendRecipes,
|
|
||||||
assignSorted,
|
|
||||||
recipes,
|
|
||||||
removeRecipe,
|
|
||||||
replaceRecipes,
|
|
||||||
passedQueryWithSeed,
|
|
||||||
|
|
||||||
filterItems,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="css">
|
|
||||||
.search-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.65rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box {
|
|
||||||
width: 950px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-button-container {
|
|
||||||
margin: 3rem auto 0 auto;
|
|
||||||
max-width: 500px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<v-container
|
||||||
|
fluid
|
||||||
|
class="px-0"
|
||||||
|
>
|
||||||
|
<RecipeExplorerPageSearch
|
||||||
|
ref="searchComponent"
|
||||||
|
@ready="onSearchReady"
|
||||||
|
/>
|
||||||
|
<v-divider />
|
||||||
|
<v-container class="mt-6 px-md-6">
|
||||||
|
<RecipeCardSection
|
||||||
|
v-if="ready"
|
||||||
|
class="mt-n5"
|
||||||
|
:icon="$globals.icons.silverwareForkKnife"
|
||||||
|
:title="$t('general.recipes')"
|
||||||
|
:recipes="recipes"
|
||||||
|
:query="searchQuery"
|
||||||
|
disable-sort
|
||||||
|
@item-selected="onItemSelected"
|
||||||
|
@replace-recipes="replaceRecipes"
|
||||||
|
@append-recipes="appendRecipes"
|
||||||
|
/>
|
||||||
|
</v-container>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import RecipeExplorerPageSearch from "./RecipeExplorerPageParts/RecipeExplorerPageSearch.vue";
|
||||||
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
|
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
|
||||||
|
import { useLazyRecipes } from "~/composables/recipes";
|
||||||
|
|
||||||
|
export default defineNuxtComponent({
|
||||||
|
components: { RecipeCardSection, RecipeExplorerPageSearch },
|
||||||
|
setup() {
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
|
const { recipes, appendRecipes, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||||
|
|
||||||
|
const ready = ref(false);
|
||||||
|
const searchComponent = ref<InstanceType<typeof RecipeExplorerPageSearch>>();
|
||||||
|
|
||||||
|
const searchQuery = computed(() => {
|
||||||
|
return searchComponent.value?.passedQueryWithSeed || {};
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSearchReady() {
|
||||||
|
ready.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onItemSelected(item: any, urlPrefix: string) {
|
||||||
|
searchComponent.value?.filterItems(item, urlPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
searchComponent,
|
||||||
|
searchQuery,
|
||||||
|
recipes,
|
||||||
|
appendRecipes,
|
||||||
|
replaceRecipes,
|
||||||
|
onSearchReady,
|
||||||
|
onItemSelected,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
<template>
|
||||||
|
<div class="search-container pb-8">
|
||||||
|
<form
|
||||||
|
class="search-box pa-2"
|
||||||
|
@submit.prevent="search"
|
||||||
|
>
|
||||||
|
<div class="d-flex justify-center mb-2">
|
||||||
|
<v-text-field
|
||||||
|
ref="input"
|
||||||
|
v-model="state.search"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
color="primary"
|
||||||
|
:placeholder="$t('search.search-placeholder')"
|
||||||
|
:prepend-inner-icon="$globals.icons.search"
|
||||||
|
@keyup.enter="hideKeyboard"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="search-row">
|
||||||
|
<RecipeExplorerPageSearchFilters />
|
||||||
|
<!-- Sort Options -->
|
||||||
|
<v-menu
|
||||||
|
offset-y
|
||||||
|
nudge-bottom="3"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
class="ml-auto"
|
||||||
|
size="small"
|
||||||
|
color="accent"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<v-icon :start="!$vuetify.display.xs">
|
||||||
|
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
|
||||||
|
</v-icon>
|
||||||
|
{{ $vuetify.display.xs ? null : sortText }}
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-card>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item
|
||||||
|
slim
|
||||||
|
density="comfortable"
|
||||||
|
:prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortDescending : $globals.icons.sortAscending"
|
||||||
|
:title="state.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
|
||||||
|
@click="toggleOrderDirection"
|
||||||
|
/>
|
||||||
|
<v-divider />
|
||||||
|
<v-list-item
|
||||||
|
v-for="v in sortable"
|
||||||
|
:key="v.name"
|
||||||
|
:active="state.orderBy === v.value"
|
||||||
|
slim
|
||||||
|
density="comfortable"
|
||||||
|
:prepend-icon="v.icon"
|
||||||
|
:title="v.name"
|
||||||
|
@click="setOrderBy(v.value)"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<v-menu
|
||||||
|
offset-y
|
||||||
|
bottom
|
||||||
|
start
|
||||||
|
nudge-bottom="3"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
color="accent"
|
||||||
|
dark
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<v-icon size="small">
|
||||||
|
{{ $globals.icons.cog }}
|
||||||
|
</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-card>
|
||||||
|
<v-card-text>
|
||||||
|
<v-switch
|
||||||
|
v-model="state.auto"
|
||||||
|
:label="$t('search.auto-search')"
|
||||||
|
single-line
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
color="primary"
|
||||||
|
@click="reset"
|
||||||
|
>
|
||||||
|
{{ $t("general.reset") }}
|
||||||
|
</v-btn>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-menu>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!state.auto"
|
||||||
|
class="search-button-container"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
size="x-large"
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
|
{{ $globals.icons.search }}
|
||||||
|
</v-icon>
|
||||||
|
{{ $t("search.search") }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import RecipeExplorerPageSearchFilters from "./RecipeExplorerPageSearchFilters.vue";
|
||||||
|
import { useRecipeExplorerSearch } from "~/composables/use-recipe-explorer-search";
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
ready: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const route = useRoute();
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
passedQueryWithSeed,
|
||||||
|
search,
|
||||||
|
reset,
|
||||||
|
toggleOrderDirection,
|
||||||
|
setOrderBy,
|
||||||
|
filterItems,
|
||||||
|
initialize,
|
||||||
|
} = useRecipeExplorerSearch(groupSlug);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
passedQueryWithSeed,
|
||||||
|
filterItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await initialize();
|
||||||
|
emit("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortText = computed(() => {
|
||||||
|
const sort = sortable.value.find(s => s.value === state.value.orderBy);
|
||||||
|
if (!sort) return "";
|
||||||
|
return `${sort.name}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortable = computed(() => [
|
||||||
|
{
|
||||||
|
icon: $globals.icons.orderAlphabeticalAscending,
|
||||||
|
name: i18n.t("general.sort-alphabetically"),
|
||||||
|
value: "name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: $globals.icons.newBox,
|
||||||
|
name: i18n.t("general.created"),
|
||||||
|
value: "created_at",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: $globals.icons.chefHat,
|
||||||
|
name: i18n.t("general.last-made"),
|
||||||
|
value: "last_made",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: $globals.icons.star,
|
||||||
|
name: i18n.t("general.rating"),
|
||||||
|
value: "rating",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: $globals.icons.update,
|
||||||
|
name: i18n.t("general.updated"),
|
||||||
|
value: "updated_at",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: $globals.icons.diceMultiple,
|
||||||
|
name: i18n.t("general.random"),
|
||||||
|
value: "random",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const input: Ref<any> = ref(null);
|
||||||
|
|
||||||
|
function hideKeyboard() {
|
||||||
|
input.value?.blur();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
width: 950px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button-container {
|
||||||
|
margin: 3rem auto 0 auto;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Category Filter -->
|
||||||
|
<SearchFilter
|
||||||
|
v-if="categories"
|
||||||
|
v-model="selectedCategories"
|
||||||
|
v-model:require-all="state.requireAllCategories"
|
||||||
|
:items="categories"
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
|
{{ $globals.icons.categories }}
|
||||||
|
</v-icon>
|
||||||
|
{{ $t("category.categories") }}
|
||||||
|
</SearchFilter>
|
||||||
|
|
||||||
|
<!-- Tag Filter -->
|
||||||
|
<SearchFilter
|
||||||
|
v-if="tags"
|
||||||
|
v-model="selectedTags"
|
||||||
|
v-model:require-all="state.requireAllTags"
|
||||||
|
:items="tags"
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
|
{{ $globals.icons.tags }}
|
||||||
|
</v-icon>
|
||||||
|
{{ $t("tag.tags") }}
|
||||||
|
</SearchFilter>
|
||||||
|
|
||||||
|
<!-- Tool Filter -->
|
||||||
|
<SearchFilter
|
||||||
|
v-if="tools"
|
||||||
|
v-model="selectedTools"
|
||||||
|
v-model:require-all="state.requireAllTools"
|
||||||
|
:items="tools"
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
|
{{ $globals.icons.potSteam }}
|
||||||
|
</v-icon>
|
||||||
|
{{ $t("tool.tools") }}
|
||||||
|
</SearchFilter>
|
||||||
|
|
||||||
|
<!-- Food Filter -->
|
||||||
|
<SearchFilter
|
||||||
|
v-if="foods"
|
||||||
|
v-model="selectedFoods"
|
||||||
|
v-model:require-all="state.requireAllFoods"
|
||||||
|
:items="foods"
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
|
{{ $globals.icons.foods }}
|
||||||
|
</v-icon>
|
||||||
|
{{ $t("general.foods") }}
|
||||||
|
</SearchFilter>
|
||||||
|
|
||||||
|
<!-- Household Filter -->
|
||||||
|
<SearchFilter
|
||||||
|
v-if="households.length > 1"
|
||||||
|
v-model="selectedHouseholds"
|
||||||
|
:items="households"
|
||||||
|
radio
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
|
{{ $globals.icons.household }}
|
||||||
|
</v-icon>
|
||||||
|
{{ $t("household.households") }}
|
||||||
|
</SearchFilter>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
|
import { useRecipeExplorerSearch } from "~/composables/use-recipe-explorer-search";
|
||||||
|
import {
|
||||||
|
useCategoryStore,
|
||||||
|
usePublicCategoryStore,
|
||||||
|
useFoodStore,
|
||||||
|
usePublicFoodStore,
|
||||||
|
useHouseholdStore,
|
||||||
|
usePublicHouseholdStore,
|
||||||
|
useTagStore,
|
||||||
|
usePublicTagStore,
|
||||||
|
useToolStore,
|
||||||
|
usePublicToolStore,
|
||||||
|
} from "~/composables/store";
|
||||||
|
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
selectedCategories,
|
||||||
|
selectedFoods,
|
||||||
|
selectedHouseholds,
|
||||||
|
selectedTags,
|
||||||
|
selectedTools,
|
||||||
|
} = useRecipeExplorerSearch(groupSlug);
|
||||||
|
|
||||||
|
const { store: categories } = isOwnGroup.value ? useCategoryStore() : usePublicCategoryStore(groupSlug.value);
|
||||||
|
const { store: tags } = isOwnGroup.value ? useTagStore() : usePublicTagStore(groupSlug.value);
|
||||||
|
const { store: tools } = isOwnGroup.value ? useToolStore() : usePublicToolStore(groupSlug.value);
|
||||||
|
const { store: foods } = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
|
||||||
|
const { store: households } = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value);
|
||||||
|
</script>
|
||||||
@@ -9,10 +9,11 @@
|
|||||||
>
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<v-badge
|
<v-badge
|
||||||
:model-value="selected.length > 0"
|
v-memo="[selectedCount]"
|
||||||
|
:model-value="selectedCount > 0"
|
||||||
size="small"
|
size="small"
|
||||||
color="primary"
|
color="primary"
|
||||||
:content="selected.length"
|
:content="selectedCount"
|
||||||
>
|
>
|
||||||
<v-btn
|
<v-btn
|
||||||
size="small"
|
size="small"
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="state.search"
|
v-model="state.search"
|
||||||
|
v-memo="[state.search]"
|
||||||
class="mb-2"
|
class="mb-2"
|
||||||
hide-details
|
hide-details
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
@@ -43,7 +45,7 @@
|
|||||||
hide-details
|
hide-details
|
||||||
class="my-auto"
|
class="my-auto"
|
||||||
color="primary"
|
color="primary"
|
||||||
:label="`${requireAll ? $t('search.has-all') : $t('search.has-any')}`"
|
:label="requireAllValue ? $t('search.has-all') : $t('search.has-any')"
|
||||||
/>
|
/>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -73,7 +75,8 @@
|
|||||||
>
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
:key="item.id"
|
:key="`radio-${item.id}`"
|
||||||
|
v-memo="[item.id, item.name, selectedRadio?.id]"
|
||||||
:value="item"
|
:value="item"
|
||||||
:title="item.name"
|
:title="item.name"
|
||||||
>
|
>
|
||||||
@@ -101,7 +104,8 @@
|
|||||||
>
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
:key="item.id"
|
:key="`checkbox-${item.id}`"
|
||||||
|
v-memo="[item.id, item.name, selectedIds.has(item.id)]"
|
||||||
:value="item"
|
:value="item"
|
||||||
:title="item.name"
|
:title="item.name"
|
||||||
>
|
>
|
||||||
@@ -134,6 +138,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { watchDebounced } from "@vueuse/core";
|
||||||
|
|
||||||
export interface SelectableItem {
|
export interface SelectableItem {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -165,6 +171,9 @@ export default defineNuxtComponent({
|
|||||||
menu: false,
|
menu: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Use shallowRef for better performance with arrays
|
||||||
|
const debouncedSearch = shallowRef("");
|
||||||
|
|
||||||
const requireAllValue = computed({
|
const requireAllValue = computed({
|
||||||
get: () => props.requireAll,
|
get: () => props.requireAll,
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
@@ -172,6 +181,7 @@ export default defineNuxtComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Use shallowRef to prevent deep reactivity on large arrays
|
||||||
const selected = computed({
|
const selected = computed({
|
||||||
get: () => props.modelValue as SelectableItem[],
|
get: () => props.modelValue as SelectableItem[],
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
@@ -186,21 +196,40 @@ export default defineNuxtComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watchDebounced(
|
||||||
|
() => state.search,
|
||||||
|
(newSearch) => {
|
||||||
|
debouncedSearch.value = newSearch;
|
||||||
|
},
|
||||||
|
{ debounce: 500, maxWait: 1500, immediate: false }, // Increased debounce time
|
||||||
|
);
|
||||||
|
|
||||||
const filtered = computed(() => {
|
const filtered = computed(() => {
|
||||||
if (!state.search) {
|
const items = props.items;
|
||||||
return props.items;
|
const search = debouncedSearch.value;
|
||||||
|
|
||||||
|
if (!search || search.length < 2) { // Only filter after 2 characters
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
return props.items.filter(item => item.name.toLowerCase().includes(state.search.toLowerCase()));
|
const searchLower = search.toLowerCase();
|
||||||
|
return items.filter(item => item.name.toLowerCase().includes(searchLower));
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedCount = computed(() => selected.value.length);
|
||||||
|
const selectedIds = computed(() => {
|
||||||
|
return new Set(selected.value.map(item => item.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCheckboxClick = (item: SelectableItem) => {
|
const handleCheckboxClick = (item: SelectableItem) => {
|
||||||
console.log(selected.value, item);
|
const currentSelection = selected.value;
|
||||||
if (selected.value.includes(item)) {
|
const isSelected = selectedIds.value.has(item.id);
|
||||||
selected.value = selected.value.filter(i => i !== item);
|
|
||||||
|
if (isSelected) {
|
||||||
|
selected.value = currentSelection.filter(i => i.id !== item.id);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
selected.value.push(item);
|
selected.value = [...currentSelection, item];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,6 +250,8 @@ export default defineNuxtComponent({
|
|||||||
state,
|
state,
|
||||||
selected,
|
selected,
|
||||||
selectedRadio,
|
selectedRadio,
|
||||||
|
selectedCount,
|
||||||
|
selectedIds,
|
||||||
filtered,
|
filtered,
|
||||||
handleCheckboxClick,
|
handleCheckboxClick,
|
||||||
handleRadioClick,
|
handleRadioClick,
|
||||||
|
|||||||
465
frontend/composables/use-recipe-explorer-search.ts
Normal file
465
frontend/composables/use-recipe-explorer-search.ts
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
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];
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import RecipeExplorerPage from "~/components/Domain/Recipe/RecipeExplorerPage.vue";
|
import RecipeExplorerPage from "~/components/Domain/Recipe/RecipeExplorerPage/RecipeExplorerPage.vue";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
components: { RecipeExplorerPage },
|
components: { RecipeExplorerPage },
|
||||||
|
|||||||
Reference in New Issue
Block a user