mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-30 19:54:44 -04:00
feat(frontend): ✨ food filter and add back search dialog (#794)
* return ingredients and foods on summary
* filter on foods
* update search page to TS and comp-api
* add additional search fields
* feat(frontend): ✨ add back search dialog
* update docs
* formatting
* update sidebar - remove dropdown
Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
parent
60275447f0
commit
d4bf81dee6
@ -41,6 +41,10 @@
|
|||||||
|
|
||||||
### 🥙 Recipes
|
### 🥙 Recipes
|
||||||
|
|
||||||
|
**Search**
|
||||||
|
- Search now includes the ability to fuzzy search ingredients
|
||||||
|
- Search now includes an additional filter for "Foods" which will filter (Include/Exclude) matches based on your selection.
|
||||||
|
|
||||||
**Recipe General**
|
**Recipe General**
|
||||||
- Recipes are now only viewable by group members
|
- Recipes are now only viewable by group members
|
||||||
- You can now import multiple URLs at a time pre-tagged using the bulk importer. This task runs in the background so no need to wait for it to finish.
|
- You can now import multiple URLs at a time pre-tagged using the bulk importer. This task runs in the background so no need to wait for it to finish.
|
||||||
|
@ -1,66 +1,64 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-lazy>
|
<v-expand-transition>
|
||||||
<v-expand-transition>
|
<v-card
|
||||||
<v-card
|
:ripple="false"
|
||||||
:ripple="false"
|
class="mx-auto"
|
||||||
class="mx-auto"
|
hover
|
||||||
hover
|
:to="$listeners.selected ? undefined : `/recipe/${slug}`"
|
||||||
:to="$listeners.selected ? undefined : `/recipe/${slug}`"
|
@click="$emit('selected')"
|
||||||
@click="$emit('selected')"
|
>
|
||||||
>
|
<v-list-item three-line>
|
||||||
<v-list-item three-line>
|
<slot name="avatar">
|
||||||
<slot name="avatar">
|
<v-list-item-avatar tile size="125" class="v-mobile-img rounded-sm my-0 ml-n4">
|
||||||
<v-list-item-avatar tile size="125" class="v-mobile-img rounded-sm my-0 ml-n4">
|
<v-img
|
||||||
<v-img
|
v-if="!fallBackImage"
|
||||||
v-if="!fallBackImage"
|
:src="getImage(slug)"
|
||||||
:src="getImage(slug)"
|
@load="fallBackImage = false"
|
||||||
@load="fallBackImage = false"
|
@error="fallBackImage = true"
|
||||||
@error="fallBackImage = true"
|
></v-img>
|
||||||
></v-img>
|
<v-icon v-else color="primary" class="icon-position" size="100">
|
||||||
<v-icon v-else color="primary" class="icon-position" size="100">
|
{{ $globals.icons.primary }}
|
||||||
{{ $globals.icons.primary }}
|
</v-icon>
|
||||||
</v-icon>
|
</v-list-item-avatar>
|
||||||
</v-list-item-avatar>
|
</slot>
|
||||||
</slot>
|
<v-list-item-content>
|
||||||
<v-list-item-content>
|
<v-list-item-title class="mb-1">{{ name }} </v-list-item-title>
|
||||||
<v-list-item-title class="mb-1">{{ name }} </v-list-item-title>
|
<v-list-item-subtitle> {{ description }} </v-list-item-subtitle>
|
||||||
<v-list-item-subtitle> {{ description }} </v-list-item-subtitle>
|
<div class="d-flex justify-center align-center">
|
||||||
<div class="d-flex justify-center align-center">
|
<slot name="actions">
|
||||||
<slot name="actions">
|
<RecipeFavoriteBadge v-if="loggedIn" :slug="slug" show-always />
|
||||||
<RecipeFavoriteBadge v-if="loggedIn" :slug="slug" show-always />
|
<v-rating
|
||||||
<v-rating
|
color="secondary"
|
||||||
color="secondary"
|
class="ml-auto"
|
||||||
class="ml-auto"
|
background-color="secondary lighten-3"
|
||||||
background-color="secondary lighten-3"
|
dense
|
||||||
dense
|
length="5"
|
||||||
length="5"
|
size="15"
|
||||||
size="15"
|
:value="rating"
|
||||||
:value="rating"
|
></v-rating>
|
||||||
></v-rating>
|
<v-spacer></v-spacer>
|
||||||
<v-spacer></v-spacer>
|
<RecipeContextMenu
|
||||||
<RecipeContextMenu
|
:slug="slug"
|
||||||
:slug="slug"
|
:menu-icon="$globals.icons.dotsHorizontal"
|
||||||
:menu-icon="$globals.icons.dotsHorizontal"
|
:name="name"
|
||||||
:name="name"
|
:recipe-id="recipeId"
|
||||||
:recipe-id="recipeId"
|
:use-items="{
|
||||||
:use-items="{
|
delete: true,
|
||||||
delete: true,
|
edit: true,
|
||||||
edit: true,
|
download: true,
|
||||||
download: true,
|
mealplanner: true,
|
||||||
mealplanner: true,
|
print: false,
|
||||||
print: false,
|
share: true,
|
||||||
share: true,
|
}"
|
||||||
}"
|
@deleted="$emit('delete', slug)"
|
||||||
@deleted="$emit('delete', slug)"
|
/>
|
||||||
/>
|
</slot>
|
||||||
</slot>
|
</div>
|
||||||
</div>
|
</v-list-item-content>
|
||||||
</v-list-item-content>
|
</v-list-item>
|
||||||
</v-list-item>
|
<slot />
|
||||||
<slot />
|
</v-card>
|
||||||
</v-card>
|
</v-expand-transition>
|
||||||
</v-expand-transition>
|
|
||||||
</v-lazy>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
171
frontend/components/Domain/Recipe/RecipeDialogSearch.vue
Normal file
171
frontend/components/Domain/Recipe/RecipeDialogSearch.vue
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<slot v-bind="{ open, close }"> </slot>
|
||||||
|
<v-dialog v-model="dialog" max-width="988px" content-class="top-dialog" :scrollable="false">
|
||||||
|
<v-app-bar sticky dark color="primary lighten-1" :rounded="!$vuetify.breakpoint.xs">
|
||||||
|
<v-text-field
|
||||||
|
id="arrow-search"
|
||||||
|
v-model="search"
|
||||||
|
autofocus
|
||||||
|
solo
|
||||||
|
flat
|
||||||
|
autocomplete="off"
|
||||||
|
background-color="primary lighten-1"
|
||||||
|
color="white"
|
||||||
|
dense
|
||||||
|
class="mx-2 arrow-search"
|
||||||
|
hide-details
|
||||||
|
single-line
|
||||||
|
placeholder="Search"
|
||||||
|
:prepend-inner-icon="$globals.icons.search"
|
||||||
|
></v-text-field>
|
||||||
|
|
||||||
|
<v-btn v-if="$vuetify.breakpoint.xs" x-small fab light @click="dialog = false">
|
||||||
|
<v-icon>
|
||||||
|
{{ $globals.icons.close }}
|
||||||
|
</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-app-bar>
|
||||||
|
<v-card class="mt-1 pa-1 scroll" max-height="700px" relative :loading="loading">
|
||||||
|
<v-card-actions>
|
||||||
|
<div class="mr-auto">
|
||||||
|
{{ $t("search.results") }}
|
||||||
|
</div>
|
||||||
|
<router-link to="/search"> {{ $t("search.advanced-search") }} </router-link>
|
||||||
|
</v-card-actions>
|
||||||
|
|
||||||
|
<RecipeCardMobile
|
||||||
|
v-for="(recipe, index) in results.slice(0, 10)"
|
||||||
|
:key="index"
|
||||||
|
:tabindex="index"
|
||||||
|
class="ma-1 arrow-nav"
|
||||||
|
:name="recipe.name"
|
||||||
|
:description="recipe.description || ''"
|
||||||
|
:slug="recipe.slug"
|
||||||
|
:rating="recipe.rating"
|
||||||
|
:image="recipe.image"
|
||||||
|
:recipe-id="recipe.id"
|
||||||
|
:route="true"
|
||||||
|
v-on="$listeners.selected ? { selected: () => handleSelect(recipe) } : {}"
|
||||||
|
/>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, toRefs, reactive, ref } from "@nuxtjs/composition-api";
|
||||||
|
import { watch } from "vue-demi";
|
||||||
|
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||||
|
import { useRecipes, allRecipes, useRecipeSearch } from "~/composables/recipes";
|
||||||
|
import { RecipeSummary } from "~/types/api-types/recipe";
|
||||||
|
const SELECTED_EVENT = "selected";
|
||||||
|
export default defineComponent({
|
||||||
|
components: {
|
||||||
|
RecipeCardMobile,
|
||||||
|
},
|
||||||
|
|
||||||
|
setup(_, context) {
|
||||||
|
const { refreshRecipes } = useRecipes(true, false);
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
loading: false,
|
||||||
|
selectedIndex: -1,
|
||||||
|
searchResults: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Dialong State Management
|
||||||
|
const dialog = ref(false);
|
||||||
|
|
||||||
|
// Reset or Grab Recipes on Change
|
||||||
|
watch(dialog, async (val) => {
|
||||||
|
if (!val) {
|
||||||
|
search.value = "";
|
||||||
|
state.selectedIndex = -1;
|
||||||
|
} else if (allRecipes.value && allRecipes.value.length <= 0) {
|
||||||
|
state.loading = true;
|
||||||
|
await refreshRecipes();
|
||||||
|
state.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
dialog.value = true;
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
dialog.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Basic Search
|
||||||
|
|
||||||
|
const { search, results } = useRecipeSearch(allRecipes);
|
||||||
|
// ===========================================================================
|
||||||
|
// Select Handler
|
||||||
|
|
||||||
|
function handleSelect(recipe: RecipeSummary) {
|
||||||
|
close();
|
||||||
|
context.emit(SELECTED_EVENT, recipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allRecipes, refreshRecipes, ...toRefs(state), dialog, open, close, handleSelect, search, results };
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {},
|
||||||
|
watch: {
|
||||||
|
$route() {
|
||||||
|
this.dialog = false;
|
||||||
|
},
|
||||||
|
dialog() {
|
||||||
|
if (!this.dialog) {
|
||||||
|
document.removeEventListener("keyup", this.onUpDown);
|
||||||
|
} else {
|
||||||
|
document.addEventListener("keyup", this.onUpDown);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onUpDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
console.log(document.activeElement);
|
||||||
|
// (document.activeElement as HTMLElement).click();
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.selectedIndex--;
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.selectedIndex++;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.selectRecipe();
|
||||||
|
},
|
||||||
|
selectRecipe() {
|
||||||
|
const recipeCards = document.getElementsByClassName("arrow-nav");
|
||||||
|
if (recipeCards) {
|
||||||
|
if (this.selectedIndex < 0) {
|
||||||
|
this.selectedIndex = -1;
|
||||||
|
document.getElementById("arrow-search")?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.selectedIndex >= recipeCards.length) {
|
||||||
|
this.selectedIndex = recipeCards.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
(recipeCards[this.selectedIndex] as HTMLElement).focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scroll {
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
</style>
|
@ -10,24 +10,29 @@
|
|||||||
<div btn class="pl-2">
|
<div btn class="pl-2">
|
||||||
<v-toolbar-title style="cursor: pointer" @click="$router.push('/')"> Mealie </v-toolbar-title>
|
<v-toolbar-title style="cursor: pointer" @click="$router.push('/')"> Mealie </v-toolbar-title>
|
||||||
</div>
|
</div>
|
||||||
|
<RecipeDialogSearch ref="domSearchDialog" />
|
||||||
{{ value }}
|
|
||||||
|
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<!-- <v-tooltip bottom>
|
|
||||||
<template #activator="{ on, attrs }">
|
<div v-if="!$vuetify.breakpoint.xs" style="max-width: 500px" @click="activateSearch">
|
||||||
<v-btn icon class="mr-1" small v-bind="attrs" v-on="on">
|
<v-text-field
|
||||||
<v-icon v-text="isDark ? $globals.icons.weatherSunny : $globals.icons.weatherNight"> </v-icon>
|
readonly
|
||||||
</v-btn>
|
class="mt-6 rounded-xl"
|
||||||
</template>
|
rounded
|
||||||
<span>{{ isDark ? $t("settings.theme.switch-to-light-mode") : $t("settings.theme.switch-to-dark-mode") }}</span>
|
dark
|
||||||
</v-tooltip> -->
|
solo
|
||||||
<!-- <div v-if="false" style="width: 350px"></div>
|
dense
|
||||||
<div v-else>
|
flat
|
||||||
<v-btn icon @click="$refs.recipeSearch.open()">
|
:prepend-inner-icon="$globals.icons.search"
|
||||||
<v-icon> {{ $globals.icons.search }} </v-icon>
|
background-color="primary lighten-1"
|
||||||
</v-btn>
|
color="white"
|
||||||
</div> -->
|
placeholder="Press '/'"
|
||||||
|
>
|
||||||
|
</v-text-field>
|
||||||
|
</div>
|
||||||
|
<v-btn v-else icon @click="activateSearch">
|
||||||
|
<v-icon> {{ $globals.icons.search }}</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
<!-- Navigation Menu -->
|
<!-- Navigation Menu -->
|
||||||
<template v-if="menu">
|
<template v-if="menu">
|
||||||
@ -44,19 +49,47 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "@nuxtjs/composition-api";
|
import { defineComponent, ref } from "@nuxtjs/composition-api";
|
||||||
|
import RecipeDialogSearch from "~/components/Domain/Recipe/RecipeDialogSearch.vue";
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
components: { RecipeDialogSearch },
|
||||||
props: {
|
props: {
|
||||||
value: {
|
|
||||||
type: Boolean,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
menu: {
|
menu: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
setup() {
|
||||||
|
const domSearchDialog = ref(null);
|
||||||
|
|
||||||
|
function activateSearch() {
|
||||||
|
// @ts-ignore
|
||||||
|
domSearchDialog?.value?.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activateSearch,
|
||||||
|
domSearchDialog,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
document.addEventListener("keyup", this.handleKeyEvent);
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
document.removeEventListener("keyup", this.handleKeyEvent);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleKeyEvent(e: any) {
|
||||||
|
if (
|
||||||
|
e.key === "/" &&
|
||||||
|
// @ts-ignore
|
||||||
|
!document.activeElement.id.startsWith("input")
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.activateSearch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -5,3 +5,4 @@ export { useUnits } from "./use-recipe-units";
|
|||||||
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes, useSorter } from "./use-recipes";
|
export { useRecipes, recentRecipes, allRecipes, useLazyRecipes, useSorter } from "./use-recipes";
|
||||||
export { useTags, useCategories, allCategories, allTags } from "./use-tags-categories";
|
export { useTags, useCategories, allCategories, allTags } from "./use-tags-categories";
|
||||||
export { parseIngredientText } from "./use-recipe-ingredients";
|
export { parseIngredientText } from "./use-recipe-ingredients";
|
||||||
|
export { useRecipeSearch } from "./use-recipe-search";
|
||||||
|
46
frontend/composables/recipes/use-recipe-search.ts
Normal file
46
frontend/composables/recipes/use-recipe-search.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { computed, reactive, ref, Ref } from "@nuxtjs/composition-api";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
import { Recipe } from "~/types/api-types/recipe";
|
||||||
|
|
||||||
|
export const useRecipeSearch = (recipes: Ref<Recipe[] | null>) => {
|
||||||
|
const localState = reactive({
|
||||||
|
options: {
|
||||||
|
shouldSort: true,
|
||||||
|
threshold: 0.6,
|
||||||
|
location: 0,
|
||||||
|
distance: 100,
|
||||||
|
findAllMatches: true,
|
||||||
|
maxPatternLength: 32,
|
||||||
|
minMatchCharLength: 2,
|
||||||
|
keys: ["name", "description", "recipeIngredient.note", "recipeIngredient.food.name"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const search = ref("");
|
||||||
|
|
||||||
|
const fuse = computed(() => {
|
||||||
|
return new Fuse(recipes.value || [], localState.options);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fuzzyRecipes = computed(() => {
|
||||||
|
if (search.value.trim() === "") {
|
||||||
|
return recipes.value;
|
||||||
|
}
|
||||||
|
const result = fuse.value.search(search.value.trim());
|
||||||
|
return result.map((x) => x.item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = computed(() => {
|
||||||
|
if (!fuzzyRecipes.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fuzzyRecipes.value.length > 0 && search.value.length != null && search.value.length >= 1) {
|
||||||
|
return fuzzyRecipes.value;
|
||||||
|
} else {
|
||||||
|
return recipes.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { results, search };
|
||||||
|
};
|
@ -24,16 +24,14 @@ export function useRouteQuery<T extends string | string[]>(name: string, default
|
|||||||
|
|
||||||
return computed<any>({
|
return computed<any>({
|
||||||
get() {
|
get() {
|
||||||
console.log("Getter");
|
|
||||||
const data = route.value.query[name];
|
const data = route.value.query[name];
|
||||||
if (data == null) return defaultValue ?? null;
|
if (data == null) return defaultValue ?? null;
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
set(v) {
|
set(v) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
console.log("Setter");
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
router.value.replace({ query: { ...route.value.query, [name]: v } });
|
router.replace({ query: { ...route.value.query, [name]: v } });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -146,23 +146,10 @@ export default defineComponent({
|
|||||||
],
|
],
|
||||||
topLinks: [
|
topLinks: [
|
||||||
{
|
{
|
||||||
icon: this.$globals.icons.calendar,
|
icon: this.$globals.icons.calendarMultiselect,
|
||||||
restricted: true,
|
|
||||||
title: this.$t("meal-plan.meal-planner"),
|
title: this.$t("meal-plan.meal-planner"),
|
||||||
children: [
|
to: "/meal-plan/planner",
|
||||||
{
|
restricted: true,
|
||||||
icon: this.$globals.icons.calendarMultiselect,
|
|
||||||
title: this.$t("meal-plan.planner"),
|
|
||||||
to: "/meal-plan/planner",
|
|
||||||
restricted: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: this.$globals.icons.calendarWeek,
|
|
||||||
title: this.$t("meal-plan.dinner-this-week"),
|
|
||||||
to: "/meal-plan/this-week",
|
|
||||||
restricted: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: this.$globals.icons.formatListCheck,
|
icon: this.$globals.icons.formatListCheck,
|
||||||
|
@ -120,7 +120,6 @@ export default defineComponent({
|
|||||||
|
|
||||||
watch(token, (newToken) => {
|
watch(token, (newToken) => {
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
console.log(token);
|
|
||||||
form.groupToken = newToken;
|
form.groupToken = newToken;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -1,76 +1,128 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container>
|
<v-container fluid>
|
||||||
<v-row dense>
|
<v-container fluid class="pa-0">
|
||||||
<v-col>
|
<v-row dense>
|
||||||
<v-text-field
|
<v-col>
|
||||||
v-model="searchString"
|
<v-text-field
|
||||||
outlined
|
v-model="searchString"
|
||||||
color="primary accent-3"
|
outlined
|
||||||
:placeholder="$t('search.search-placeholder')"
|
autofocus
|
||||||
:append-icon="$globals.icons.search"
|
color="primary accent-3"
|
||||||
>
|
:placeholder="$t('search.search-placeholder')"
|
||||||
</v-text-field>
|
:prepend-inner-icon="$globals.icons.search"
|
||||||
</v-col>
|
clearable
|
||||||
<v-col cols="12" md="2" sm="12">
|
>
|
||||||
<v-text-field v-model="maxResults" class="mt-0 pt-0" :label="$t('search.max-results')" type="number" outlined />
|
</v-text-field>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
<v-col cols="12" md="2" sm="12">
|
||||||
|
<v-text-field
|
||||||
|
v-model="maxResults"
|
||||||
|
class="mt-0 pt-0"
|
||||||
|
:label="$t('search.max-results')"
|
||||||
|
type="number"
|
||||||
|
outlined
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
<ToggleState>
|
<ToggleState>
|
||||||
<template #activator="{ state, toggle }">
|
<template #activator="{ state, toggle }">
|
||||||
<v-switch :value="state" color="info" class="ma-0 pa-0" label="Advanced" @input="toggle" @click="toggle">
|
<v-switch :value="state" color="info" class="ma-0 pa-0" label="Advanced" @input="toggle" @click="toggle">
|
||||||
Advanced
|
Advanced
|
||||||
</v-switch>
|
</v-switch>
|
||||||
</template>
|
</template>
|
||||||
<template #default="{ state }">
|
<template #default="{ state }">
|
||||||
<v-expand-transition>
|
<v-expand-transition>
|
||||||
<v-row v-show="state" dense class="my-0 dense flex-row align-center justify-space-around">
|
<v-row v-show="state" dense class="my-0 dense flex-row align-center justify-space-around">
|
||||||
<v-col>
|
<v-col>
|
||||||
<h3 class="pl-2 text-center headline">
|
<h3 class="pl-2 text-center headline">
|
||||||
{{ $t("category.category-filter") }}
|
{{ $t("category.category-filter") }}
|
||||||
</h3>
|
</h3>
|
||||||
<RecipeSearchFilterSelector class="mb-1" @update="updateCatParams" />
|
<RecipeSearchFilterSelector class="mb-1" @update="updateCatParams" />
|
||||||
<RecipeCategoryTagSelector
|
<RecipeCategoryTagSelector
|
||||||
v-model="includeCategories"
|
v-model="includeCategories"
|
||||||
:solo="true"
|
:solo="true"
|
||||||
:dense="false"
|
:dense="false"
|
||||||
:return-object="false"
|
:return-object="false"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col>
|
<v-col>
|
||||||
<h3 class="pl-2 text-center headline">
|
<h3 class="pl-2 text-center headline">
|
||||||
{{ $t("search.tag-filter") }}
|
{{ $t("search.tag-filter") }}
|
||||||
</h3>
|
</h3>
|
||||||
<RecipeSearchFilterSelector class="mb-1" @update="updateTagParams" />
|
<RecipeSearchFilterSelector class="mb-1" @update="updateTagParams" />
|
||||||
<RecipeCategoryTagSelector
|
<RecipeCategoryTagSelector
|
||||||
v-model="includeTags"
|
v-model="includeTags"
|
||||||
:solo="true"
|
:solo="true"
|
||||||
:dense="false"
|
:dense="false"
|
||||||
:return-object="false"
|
:return-object="false"
|
||||||
:tag-selector="true"
|
:tag-selector="true"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
<v-col>
|
||||||
</v-expand-transition>
|
<h3 class="pl-2 text-center headline">Food Filter</h3>
|
||||||
</template>
|
<RecipeSearchFilterSelector class="mb-1" @update="updateFoodParams" />
|
||||||
</ToggleState>
|
<v-autocomplete
|
||||||
|
v-model="includeFoods"
|
||||||
<RecipeCardSection
|
hide-details
|
||||||
class="mt-n5"
|
chips
|
||||||
:title-icon="$globals.icons.magnify"
|
deletable-chips
|
||||||
:recipes="showRecipes.slice(0, maxResults)"
|
solo
|
||||||
@sort="assignFuzzy"
|
multiple
|
||||||
/>
|
:items="foods || []"
|
||||||
|
item-text="name"
|
||||||
|
class="mx-1 py-0 mb-8"
|
||||||
|
:prepend-inner-icon="$globals.icons.foods"
|
||||||
|
label="Choose Food"
|
||||||
|
>
|
||||||
|
<template #selection="data">
|
||||||
|
<v-chip
|
||||||
|
:key="data.index"
|
||||||
|
class="ma-1"
|
||||||
|
:input-value="data.selected"
|
||||||
|
close
|
||||||
|
label
|
||||||
|
color="accent"
|
||||||
|
dark
|
||||||
|
@click:close="includeFoods.splice(data.index, 1)"
|
||||||
|
>
|
||||||
|
{{ data.item.name || data.item }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-autocomplete>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-expand-transition>
|
||||||
|
</template>
|
||||||
|
</ToggleState>
|
||||||
|
</v-container>
|
||||||
|
<v-container>
|
||||||
|
<RecipeCardSection
|
||||||
|
class="mt-n5"
|
||||||
|
:title-icon="$globals.icons.magnify"
|
||||||
|
:recipes="showRecipes.slice(0, maxResults)"
|
||||||
|
@sort="assignFuzzy"
|
||||||
|
/>
|
||||||
|
</v-container>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
import { defineComponent } from "@nuxtjs/composition-api";
|
import { defineComponent, toRefs, computed } from "@nuxtjs/composition-api";
|
||||||
|
import { reactive } from "vue-demi";
|
||||||
import RecipeSearchFilterSelector from "~/components/Domain/Recipe/RecipeSearchFilterSelector.vue";
|
import RecipeSearchFilterSelector from "~/components/Domain/Recipe/RecipeSearchFilterSelector.vue";
|
||||||
import RecipeCategoryTagSelector from "~/components/Domain/Recipe/RecipeCategoryTagSelector.vue";
|
import RecipeCategoryTagSelector from "~/components/Domain/Recipe/RecipeCategoryTagSelector.vue";
|
||||||
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
|
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
|
||||||
import { useRecipes, allRecipes } from "~/composables/recipes";
|
import { useRecipes, allRecipes, useFoods } from "~/composables/recipes";
|
||||||
|
import { RecipeSummary } from "~/types/api-types/recipe";
|
||||||
|
import { Tag } from "~/api/class-interfaces/tags";
|
||||||
|
import { useRouteQuery } from "~/composables/use-router";
|
||||||
|
|
||||||
|
interface GenericFilter {
|
||||||
|
exclude: boolean;
|
||||||
|
matchAny: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
@ -81,23 +133,36 @@ export default defineComponent({
|
|||||||
setup() {
|
setup() {
|
||||||
const { assignSorted } = useRecipes(true);
|
const { assignSorted } = useRecipes(true);
|
||||||
|
|
||||||
return { assignSorted, allRecipes };
|
// ================================================================
|
||||||
},
|
// Global State
|
||||||
data() {
|
|
||||||
return {
|
const state = reactive({
|
||||||
maxResults: 21,
|
maxResults: 21,
|
||||||
searchResults: [],
|
|
||||||
|
// Filters
|
||||||
|
includeCategories: [] as string[],
|
||||||
catFilter: {
|
catFilter: {
|
||||||
exclude: false,
|
exclude: false,
|
||||||
matchAny: false,
|
matchAny: false,
|
||||||
},
|
} as GenericFilter,
|
||||||
|
|
||||||
|
includeTags: [] as string[],
|
||||||
tagFilter: {
|
tagFilter: {
|
||||||
exclude: false,
|
exclude: false,
|
||||||
matchAny: false,
|
matchAny: false,
|
||||||
},
|
} as GenericFilter,
|
||||||
sortedResults: [],
|
|
||||||
includeCategories: [],
|
includeFoods: [] as string[],
|
||||||
includeTags: [],
|
foodFilter: {
|
||||||
|
exclude: false,
|
||||||
|
matchAny: false,
|
||||||
|
} as GenericFilter,
|
||||||
|
|
||||||
|
// Recipes Holders
|
||||||
|
searchResults: [] as RecipeSummary[],
|
||||||
|
sortedResults: [] as RecipeSummary[],
|
||||||
|
|
||||||
|
// Search Options
|
||||||
options: {
|
options: {
|
||||||
shouldSort: true,
|
shouldSort: true,
|
||||||
threshold: 0.6,
|
threshold: 0.6,
|
||||||
@ -106,67 +171,74 @@ export default defineComponent({
|
|||||||
findAllMatches: true,
|
findAllMatches: true,
|
||||||
maxPatternLength: 32,
|
maxPatternLength: 32,
|
||||||
minMatchCharLength: 2,
|
minMatchCharLength: 2,
|
||||||
keys: ["name", "description"],
|
keys: ["name", "description", "recipeIngredient.note", "recipeIngredient.food.name"],
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
},
|
|
||||||
head() {
|
// ================================================================
|
||||||
return {
|
// Search Functions
|
||||||
title: this.$t("search.search"),
|
|
||||||
};
|
const searchString = useRouteQuery("q", "");
|
||||||
},
|
|
||||||
computed: {
|
const filteredRecipes = computed(() => {
|
||||||
searchString: {
|
if (!allRecipes.value) {
|
||||||
set(q) {
|
return [];
|
||||||
this.$router.replace({ query: { ...this.$route.query, q } });
|
}
|
||||||
},
|
// TODO: Fix Type Declarations for RecipeSummary
|
||||||
get() {
|
return allRecipes.value.filter((recipe: RecipeSummary) => {
|
||||||
return this.$route.query.q || "";
|
const includesTags = check(
|
||||||
},
|
state.includeTags,
|
||||||
},
|
|
||||||
filteredRecipes() {
|
// @ts-ignore
|
||||||
return this.allRecipes.filter((recipe) => {
|
recipe.tags.map((x: Tag) => x.name),
|
||||||
const includesTags = this.check(
|
state.tagFilter.matchAny,
|
||||||
this.includeTags,
|
state.tagFilter.exclude
|
||||||
recipe.tags.map((x) => x.name),
|
|
||||||
this.tagFilter.matchAny,
|
|
||||||
this.tagFilter.exclude
|
|
||||||
);
|
);
|
||||||
const includesCats = this.check(
|
const includesCats = check(
|
||||||
this.includeCategories,
|
state.includeCategories,
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
|
||||||
recipe.recipeCategory.map((x) => x.name),
|
recipe.recipeCategory.map((x) => x.name),
|
||||||
this.catFilter.matchAny,
|
state.catFilter.matchAny,
|
||||||
this.catFilter.exclude
|
state.catFilter.exclude
|
||||||
);
|
);
|
||||||
return [includesTags, includesCats].every((x) => x === true);
|
const includesFoods = check(
|
||||||
|
state.includeFoods,
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
recipe.recipeIngredient.map((x) => x?.food?.name || ""),
|
||||||
|
state.foodFilter.matchAny,
|
||||||
|
state.foodFilter.exclude
|
||||||
|
);
|
||||||
|
return [includesTags, includesCats, includesFoods].every((x) => x === true);
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
fuse() {
|
|
||||||
return new Fuse(this.filteredRecipes, this.options);
|
const fuse = computed(() => {
|
||||||
},
|
return new Fuse(filteredRecipes.value, state.options);
|
||||||
fuzzyRecipes() {
|
});
|
||||||
if (this.searchString.trim() === "") {
|
|
||||||
return this.filteredRecipes;
|
const fuzzyRecipes = computed(() => {
|
||||||
|
if (searchString.value.trim() === "") {
|
||||||
|
return filteredRecipes.value;
|
||||||
}
|
}
|
||||||
const result = this.fuse.search(this.searchString.trim());
|
const result = fuse.value.search(searchString.value.trim());
|
||||||
return result.map((x) => x.item);
|
return result.map((x) => x.item);
|
||||||
},
|
});
|
||||||
isSearching() {
|
|
||||||
return this.searchString && this.searchString.length > 0;
|
const showRecipes = computed(() => {
|
||||||
},
|
if (state.sortedResults.length > 0) {
|
||||||
showRecipes() {
|
return state.sortedResults;
|
||||||
if (this.sortedResults.length > 0) {
|
|
||||||
return this.sortedResults;
|
|
||||||
} else {
|
} else {
|
||||||
return this.fuzzyRecipes;
|
return fuzzyRecipes.value;
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
},
|
|
||||||
methods: {
|
// ================================================================
|
||||||
assignFuzzy(val) {
|
// Utility Functions
|
||||||
this.sortedResults = val;
|
|
||||||
},
|
function check(filterBy: string[], recipeList: string[], matchAny: boolean, exclude: boolean) {
|
||||||
check(filterBy, recipeList, matchAny, exclude) {
|
|
||||||
let isMatch = true;
|
let isMatch = true;
|
||||||
if (filterBy.length === 0) return isMatch;
|
if (filterBy.length === 0) return isMatch;
|
||||||
|
|
||||||
@ -179,14 +251,41 @@ export default defineComponent({
|
|||||||
return exclude ? !isMatch : isMatch;
|
return exclude ? !isMatch : isMatch;
|
||||||
} else;
|
} else;
|
||||||
return false;
|
return false;
|
||||||
},
|
}
|
||||||
|
|
||||||
updateTagParams(params) {
|
function assignFuzzy(val: RecipeSummary[]) {
|
||||||
this.tagFilter = params;
|
state.sortedResults = val;
|
||||||
},
|
}
|
||||||
updateCatParams(params) {
|
function updateTagParams(params: GenericFilter) {
|
||||||
this.catFilter = params;
|
state.tagFilter = params;
|
||||||
},
|
}
|
||||||
|
function updateCatParams(params: GenericFilter) {
|
||||||
|
state.catFilter = params;
|
||||||
|
}
|
||||||
|
function updateFoodParams(params: GenericFilter) {
|
||||||
|
state.foodFilter = params;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { foods } = useFoods();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...toRefs(state),
|
||||||
|
allRecipes,
|
||||||
|
assignFuzzy,
|
||||||
|
assignSorted,
|
||||||
|
check,
|
||||||
|
foods,
|
||||||
|
searchString,
|
||||||
|
showRecipes,
|
||||||
|
updateCatParams,
|
||||||
|
updateFoodParams,
|
||||||
|
updateTagParams,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
head() {
|
||||||
|
return {
|
||||||
|
title: this.$t("search.search") as string,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@ -131,8 +131,8 @@ export interface RecipeSummary {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
image?: unknown;
|
image?: unknown;
|
||||||
description?: string;
|
description?: string;
|
||||||
recipeCategory?: string[];
|
recipeCategory: string[];
|
||||||
tags?: string[];
|
tags: string[];
|
||||||
rating?: number;
|
rating?: number;
|
||||||
dateAdded?: string;
|
dateAdded?: string;
|
||||||
dateUpdated?: string;
|
dateUpdated?: string;
|
||||||
|
@ -3,6 +3,7 @@ from typing import Any
|
|||||||
|
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from mealie.db.models.recipe.ingredient import RecipeIngredient
|
||||||
from mealie.db.models.recipe.recipe import RecipeModel
|
from mealie.db.models.recipe.recipe import RecipeModel
|
||||||
from mealie.db.models.recipe.settings import RecipeSettings
|
from mealie.db.models.recipe.settings import RecipeSettings
|
||||||
from mealie.schema.recipe import Recipe
|
from mealie.schema.recipe import Recipe
|
||||||
@ -64,7 +65,11 @@ class RecipeDataAccessModel(AccessModel[Recipe, RecipeModel]):
|
|||||||
def summary(self, group_id, start=0, limit=99999) -> Any:
|
def summary(self, group_id, start=0, limit=99999) -> Any:
|
||||||
return (
|
return (
|
||||||
self.session.query(RecipeModel)
|
self.session.query(RecipeModel)
|
||||||
.options(joinedload(RecipeModel.recipe_category), joinedload(RecipeModel.tags))
|
.options(
|
||||||
|
joinedload(RecipeModel.recipe_category),
|
||||||
|
joinedload(RecipeModel.tags),
|
||||||
|
joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.food)),
|
||||||
|
)
|
||||||
.filter(RecipeModel.group_id == group_id)
|
.filter(RecipeModel.group_id == group_id)
|
||||||
.offset(start)
|
.offset(start)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
@ -76,6 +76,8 @@ class RecipeSummary(CamelModel):
|
|||||||
rating: Optional[int]
|
rating: Optional[int]
|
||||||
org_url: Optional[str] = Field(None, alias="orgURL")
|
org_url: Optional[str] = Field(None, alias="orgURL")
|
||||||
|
|
||||||
|
recipe_ingredient: Optional[list[RecipeIngredient]] = []
|
||||||
|
|
||||||
date_added: Optional[datetime.date]
|
date_added: Optional[datetime.date]
|
||||||
date_updated: Optional[datetime.datetime]
|
date_updated: Optional[datetime.datetime]
|
||||||
|
|
||||||
|
@ -60,7 +60,15 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
|
|||||||
|
|
||||||
def get_all(self, start=0, limit=None):
|
def get_all(self, start=0, limit=None):
|
||||||
items = self.db.recipes.summary(self.user.group_id, start=start, limit=limit)
|
items = self.db.recipes.summary(self.user.group_id, start=start, limit=limit)
|
||||||
return [RecipeSummary.construct(**x.__dict__) for x in items]
|
|
||||||
|
new_items = []
|
||||||
|
for item in items:
|
||||||
|
# Pydantic/FastAPI can't seem to serialize the ingredient field on thier own.
|
||||||
|
new_item = item.__dict__
|
||||||
|
new_item["recipe_ingredient"] = [x.__dict__ for x in item.recipe_ingredient]
|
||||||
|
new_items.append(new_item)
|
||||||
|
|
||||||
|
return [RecipeSummary.construct(**x) for x in new_items]
|
||||||
|
|
||||||
def create_one(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
|
def create_one(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
|
||||||
create_data = recipe_creation_factory(self.user, name=create_data.name, additional_attrs=create_data.dict())
|
create_data = recipe_creation_factory(self.user, name=create_data.name, additional_attrs=create_data.dict())
|
||||||
|
Loading…
x
Reference in New Issue
Block a user