mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04:00
Feature/search page (#259)
* add pillow dependencies * advanced search page * advanced search apge * remove extra dependencies * add pre-run script Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
parent
ec7fa6332d
commit
6d5f3e7496
14
Dockerfile
14
Dockerfile
@ -14,25 +14,11 @@ RUN apk add --no-cache libxml2-dev \
|
|||||||
libffi-dev \
|
libffi-dev \
|
||||||
python3 \
|
python3 \
|
||||||
python3-dev \
|
python3-dev \
|
||||||
openssl \
|
|
||||||
bash \
|
|
||||||
sudo \
|
|
||||||
# freetype-dev \
|
|
||||||
# fribidi-dev \
|
|
||||||
# harfbuzz-dev \
|
|
||||||
jpeg-dev \
|
jpeg-dev \
|
||||||
lcms2-dev \
|
lcms2-dev \
|
||||||
openjpeg-dev \
|
openjpeg-dev \
|
||||||
# tcl-dev \
|
|
||||||
# tiff-dev \
|
|
||||||
# tk-dev \
|
|
||||||
zlib-dev
|
zlib-dev
|
||||||
|
|
||||||
ADD depends /depends
|
|
||||||
RUN cd /depends \
|
|
||||||
&& chmod +x install_webp.sh \
|
|
||||||
&& chmod +x download-and-extract.sh \
|
|
||||||
&& ./install_webp.sh
|
|
||||||
|
|
||||||
ENV ENV True
|
ENV ENV True
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
@ -1,18 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<v-select
|
||||||
<v-select
|
:items="allCategories"
|
||||||
:items="allCategories"
|
v-model="selected"
|
||||||
v-model="selected"
|
label="Categories"
|
||||||
label="Categories"
|
chips
|
||||||
chips
|
deletable-chips
|
||||||
deletable-chips
|
:dense="dense"
|
||||||
dense
|
item-text="name"
|
||||||
item-text="name"
|
multiple
|
||||||
multiple
|
:solo="solo"
|
||||||
return-object
|
:return-object="returnObject"
|
||||||
@input="emitChange"
|
:flat="flat"
|
||||||
></v-select>
|
@input="emitChange"
|
||||||
</div>
|
>
|
||||||
|
<template v-slot:selection="data">
|
||||||
|
<v-chip
|
||||||
|
class="ma-1"
|
||||||
|
:input-value="data.selected"
|
||||||
|
close
|
||||||
|
@click:close="removeByIndex(data.index)"
|
||||||
|
label
|
||||||
|
color="accent"
|
||||||
|
dark
|
||||||
|
>
|
||||||
|
{{ data.item.name }}
|
||||||
|
</v-chip>
|
||||||
|
</template></v-select
|
||||||
|
>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -20,6 +34,15 @@ const MOUNTED_EVENT = "mounted";
|
|||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
value: Array,
|
value: Array,
|
||||||
|
solo: {
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
dense: {
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
returnObject: {
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -34,6 +57,9 @@ export default {
|
|||||||
allCategories() {
|
allCategories() {
|
||||||
return this.$store.getters.getAllCategories;
|
return this.$store.getters.getAllCategories;
|
||||||
},
|
},
|
||||||
|
flat() {
|
||||||
|
return this.selected.length > 0 && this.solo;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
emitChange() {
|
emitChange() {
|
||||||
@ -42,6 +68,9 @@ export default {
|
|||||||
setInit(val) {
|
setInit(val) {
|
||||||
this.selected = val;
|
this.selected = val;
|
||||||
},
|
},
|
||||||
|
removeByIndex(index) {
|
||||||
|
this.selected.splice(index, 1);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
79
frontend/src/components/FormHelpers/TagSelector.vue
Normal file
79
frontend/src/components/FormHelpers/TagSelector.vue
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<v-select
|
||||||
|
:items="allTags"
|
||||||
|
v-model="selected"
|
||||||
|
label="Tags"
|
||||||
|
chips
|
||||||
|
deletable-chips
|
||||||
|
:dense="dense"
|
||||||
|
:solo="solo"
|
||||||
|
:flat="flat"
|
||||||
|
item-text="name"
|
||||||
|
multiple
|
||||||
|
:return-object="returnObject"
|
||||||
|
@input="emitChange"
|
||||||
|
>
|
||||||
|
<template v-slot:selection="data">
|
||||||
|
<v-chip
|
||||||
|
class="ma-1"
|
||||||
|
:input-value="data.selected"
|
||||||
|
close
|
||||||
|
@click:close="removeByIndex(data.index)"
|
||||||
|
label
|
||||||
|
color="accent"
|
||||||
|
dark
|
||||||
|
>
|
||||||
|
{{ data.item.name }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const MOUNTED_EVENT = "mounted";
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Array,
|
||||||
|
solo: {
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
dense: {
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
returnObject: {
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selected: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$emit(MOUNTED_EVENT);
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
allTags() {
|
||||||
|
return this.$store.getters.getAllTags;
|
||||||
|
},
|
||||||
|
flat() {
|
||||||
|
return this.selected.length > 0 && this.solo;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
emitChange() {
|
||||||
|
this.$emit("input", this.selected);
|
||||||
|
},
|
||||||
|
setInit(val) {
|
||||||
|
this.selected = val;
|
||||||
|
},
|
||||||
|
removeByIndex(index) {
|
||||||
|
this.selected.splice(index, 1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
@ -7,7 +7,7 @@
|
|||||||
>
|
>
|
||||||
<v-list-item>
|
<v-list-item>
|
||||||
<v-list-item-avatar rounded size="125" class="mt-0 ml-n4">
|
<v-list-item-avatar rounded size="125" class="mt-0 ml-n4">
|
||||||
<v-img :src="getImage(image)"> </v-img>
|
<v-img :src="getImage(slug)"> </v-img>
|
||||||
</v-list-item-avatar>
|
</v-list-item-avatar>
|
||||||
<v-list-item-content class="align-self-start">
|
<v-list-item-content class="align-self-start">
|
||||||
<v-list-item-title>
|
<v-list-item-title>
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
@click="$emit('click')"
|
@click="$emit('click')"
|
||||||
min-height="275"
|
min-height="275"
|
||||||
>
|
>
|
||||||
<v-img height="200" :src="getImage(image)">
|
<v-img height="200" :src="getImage(slug)">
|
||||||
<v-expand-transition v-if="description">
|
<v-expand-transition v-if="description">
|
||||||
<div
|
<div
|
||||||
v-if="hover"
|
v-if="hover"
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="items && items.length > 0">
|
<div v-if="items.length > 0">
|
||||||
<h2 v-if="title" class="mt-4">{{ title }}</h2>
|
<h2 v-if="title" class="mt-4">{{ title }}</h2>
|
||||||
<v-chip
|
<v-chip
|
||||||
label
|
label
|
||||||
@ -19,7 +19,9 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
items: Array,
|
items: {
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
@ -51,6 +51,11 @@ export default {
|
|||||||
to: "/recipes/all",
|
to: "/recipes/all",
|
||||||
title: this.$t("page.all-recipes"),
|
title: this.$t("page.all-recipes"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: "mdi-magnify",
|
||||||
|
to: "/search",
|
||||||
|
title: "search",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -27,7 +27,7 @@
|
|||||||
@click="navOnClick ? null : selected(item.item.slug, item.item.name)"
|
@click="navOnClick ? null : selected(item.item.slug, item.item.name)"
|
||||||
>
|
>
|
||||||
<v-list-item-avatar>
|
<v-list-item-avatar>
|
||||||
<v-img :src="getImage(item.item.image)"></v-img>
|
<v-img :src="getImage(item.item.slug)"></v-img>
|
||||||
</v-list-item-avatar>
|
</v-list-item-avatar>
|
||||||
<v-list-item-content
|
<v-list-item-content
|
||||||
@click="
|
@click="
|
||||||
@ -136,6 +136,7 @@ export default {
|
|||||||
this.fuseResults = this.result;
|
this.fuseResults = this.result;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
searchSlug() {
|
searchSlug() {
|
||||||
this.selected(this.searchSlug);
|
this.selected(this.searchSlug);
|
||||||
},
|
},
|
||||||
|
@ -1,58 +0,0 @@
|
|||||||
<template>
|
|
||||||
<v-container>
|
|
||||||
<v-row justify="center">
|
|
||||||
<v-col cols="1"> </v-col>
|
|
||||||
<v-col>
|
|
||||||
<SearchBar @results="updateResults" :show-results="false" />
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="2">
|
|
||||||
<v-btn icon>
|
|
||||||
<v-icon large> mdi-filter </v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<v-row v-if="searchResults">
|
|
||||||
<v-col
|
|
||||||
:sm="6"
|
|
||||||
:md="6"
|
|
||||||
:lg="4"
|
|
||||||
:xl="3"
|
|
||||||
v-for="item in searchResults.slice(0, 10)"
|
|
||||||
:key="item.item.name"
|
|
||||||
>
|
|
||||||
<RecipeCard
|
|
||||||
:name="item.item.name"
|
|
||||||
:description="item.item.description"
|
|
||||||
:slug="item.item.slug"
|
|
||||||
:rating="item.item.rating"
|
|
||||||
:image="item.item.image"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</v-container>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import SearchBar from "../components/UI/Search/SearchBar";
|
|
||||||
import RecipeCard from "../components/Recipe/RecipeCard";
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
SearchBar,
|
|
||||||
RecipeCard,
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
searchResults: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
updateResults(results) {
|
|
||||||
this.searchResults = results;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
</style>
|
|
69
frontend/src/pages/SearchPage/FilterSelector.vue
Normal file
69
frontend/src/pages/SearchPage/FilterSelector.vue
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<v-toolbar dense flat>
|
||||||
|
<v-btn-toggle
|
||||||
|
dense
|
||||||
|
v-model="selected"
|
||||||
|
tile
|
||||||
|
color="primary accent-3"
|
||||||
|
@change="emitMulti"
|
||||||
|
group
|
||||||
|
mandatory
|
||||||
|
>
|
||||||
|
<v-btn :value="false">
|
||||||
|
Include
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn :value="true">
|
||||||
|
Exclude
|
||||||
|
</v-btn>
|
||||||
|
</v-btn-toggle>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn-toggle
|
||||||
|
dense
|
||||||
|
v-model="match"
|
||||||
|
tile
|
||||||
|
color="primary accent-3"
|
||||||
|
@change="emitMulti"
|
||||||
|
group
|
||||||
|
mandatory
|
||||||
|
>
|
||||||
|
<v-btn :value="false">
|
||||||
|
And
|
||||||
|
</v-btn>
|
||||||
|
<v-btn :value="true">
|
||||||
|
Or
|
||||||
|
</v-btn>
|
||||||
|
</v-btn-toggle>
|
||||||
|
</v-toolbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
default: "include", // Optionas: "include", "exclude", "any"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selected: false,
|
||||||
|
match: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
emitChange() {
|
||||||
|
this.$emit("input", this.selected);
|
||||||
|
},
|
||||||
|
emitMulti() {
|
||||||
|
const updateData = {
|
||||||
|
exclude: this.selected,
|
||||||
|
matchAny: this.match,
|
||||||
|
};
|
||||||
|
this.$emit("update", updateData);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
178
frontend/src/pages/SearchPage/index.vue
Normal file
178
frontend/src/pages/SearchPage/index.vue
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<v-container>
|
||||||
|
<CategorySidebar />
|
||||||
|
<v-card flat>
|
||||||
|
<v-row dense>
|
||||||
|
<v-col>
|
||||||
|
<v-text-field
|
||||||
|
v-model="searchString"
|
||||||
|
outlined
|
||||||
|
color="primary accent-3"
|
||||||
|
placeholder="Placeholder"
|
||||||
|
append-icon="mdi-magnify"
|
||||||
|
>
|
||||||
|
</v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="2" sm="12">
|
||||||
|
<v-text-field
|
||||||
|
class="mt-0 pt-0"
|
||||||
|
label="Max Results"
|
||||||
|
v-model="maxResults"
|
||||||
|
type="number"
|
||||||
|
outlined
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row dense class="mt-0 flex-row align-center justify-space-around">
|
||||||
|
<v-col>
|
||||||
|
<h3 class="pl-2 text-center headline">Category Filter</h3>
|
||||||
|
<FilterSelector class="mb-1" @update="updateCatParams" />
|
||||||
|
<CategorySelector
|
||||||
|
:solo="true"
|
||||||
|
:dense="false"
|
||||||
|
v-model="includeCategories"
|
||||||
|
:return-object="false"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col>
|
||||||
|
<h3 class="pl-2 text-center headline">Tag Filter</h3>
|
||||||
|
<FilterSelector class="mb-1" @update="updateTagParams" />
|
||||||
|
<TagSelector
|
||||||
|
:solo="true"
|
||||||
|
:dense="false"
|
||||||
|
v-model="includeTags"
|
||||||
|
:return-object="false"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row v-if="fuzzyRecipes">
|
||||||
|
<v-col
|
||||||
|
:sm="6"
|
||||||
|
:md="6"
|
||||||
|
:lg="4"
|
||||||
|
:xl="3"
|
||||||
|
v-for="item in fuzzyRecipes.slice(0, maxResults)"
|
||||||
|
:key="item.name"
|
||||||
|
>
|
||||||
|
<RecipeCard
|
||||||
|
:name="item.item.name"
|
||||||
|
:description="item.item.description"
|
||||||
|
:slug="item.item.slug"
|
||||||
|
:rating="item.item.rating"
|
||||||
|
:image="item.item.image"
|
||||||
|
:tags="item.item.tags"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
import RecipeCard from "@/components/Recipe/RecipeCard";
|
||||||
|
import CategorySidebar from "@/components/UI/CategorySidebar";
|
||||||
|
import CategorySelector from "@/components/FormHelpers/CategorySelector";
|
||||||
|
import TagSelector from "@/components/FormHelpers/TagSelector";
|
||||||
|
import FilterSelector from "./FilterSelector.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
RecipeCard,
|
||||||
|
CategorySidebar,
|
||||||
|
CategorySelector,
|
||||||
|
TagSelector,
|
||||||
|
FilterSelector,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
searchString: "",
|
||||||
|
maxResults: 21,
|
||||||
|
searchResults: [],
|
||||||
|
catFilter: {
|
||||||
|
exclude: false,
|
||||||
|
matchAny: false,
|
||||||
|
},
|
||||||
|
tagFilter: {
|
||||||
|
exclude: false,
|
||||||
|
matchAny: false,
|
||||||
|
},
|
||||||
|
includeCategories: [],
|
||||||
|
includeTags: [],
|
||||||
|
options: {
|
||||||
|
shouldSort: true,
|
||||||
|
threshold: 0.6,
|
||||||
|
location: 0,
|
||||||
|
distance: 100,
|
||||||
|
findAllMatches: true,
|
||||||
|
maxPatternLength: 32,
|
||||||
|
minMatchCharLength: 2,
|
||||||
|
keys: ["name", "description"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
allRecipes() {
|
||||||
|
return this.$store.getters.getRecentRecipes;
|
||||||
|
},
|
||||||
|
filteredRecipes() {
|
||||||
|
return this.allRecipes.filter(recipe => {
|
||||||
|
const includesTags = this.check(
|
||||||
|
this.includeTags,
|
||||||
|
recipe.tags,
|
||||||
|
this.tagFilter.matchAny,
|
||||||
|
this.tagFilter.exclude
|
||||||
|
);
|
||||||
|
const includesCats = this.check(
|
||||||
|
this.includeCategories,
|
||||||
|
recipe.recipeCategory,
|
||||||
|
this.catFilter.matchAny,
|
||||||
|
this.catFilter.exclude
|
||||||
|
);
|
||||||
|
return [includesTags, includesCats].every(x => x === true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fuse() {
|
||||||
|
return new Fuse(this.filteredRecipes, this.options);
|
||||||
|
},
|
||||||
|
fuzzyRecipes() {
|
||||||
|
if (this.searchString.trim() === "") {
|
||||||
|
return this.filteredRecipes.map(x => ({ item: x }));
|
||||||
|
}
|
||||||
|
const result = this.fuse.search(this.searchString.trim());
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
isSearching() {
|
||||||
|
return this.searchString && this.searchString.length > 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
check(filterBy, recipeList, matchAny, exclude) {
|
||||||
|
let isMatch = true;
|
||||||
|
if (filterBy.length === 0) return isMatch;
|
||||||
|
|
||||||
|
if (recipeList) {
|
||||||
|
if (matchAny) {
|
||||||
|
isMatch = filterBy.some(t => recipeList.includes(t)); // Checks if some items are a match
|
||||||
|
} else {
|
||||||
|
isMatch = filterBy.every(t => recipeList.includes(t)); // Checks if every items is a match
|
||||||
|
}
|
||||||
|
return exclude ? !isMatch : isMatch;
|
||||||
|
} else;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTagParams(params) {
|
||||||
|
this.tagFilter = params;
|
||||||
|
},
|
||||||
|
updateCatParams(params) {
|
||||||
|
this.catFilter = params;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
# Initialize Database Prerun
|
# Initialize Database Prerun
|
||||||
python mealie/db/init_db.py
|
python mealie/db/init_db.py
|
||||||
|
python mealie/services/image/minify.py
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
# TODO
|
# TODO
|
||||||
|
Loading…
x
Reference in New Issue
Block a user