further improvements to search view

This commit is contained in:
vabene1111 2021-06-28 18:35:20 +02:00
parent 78885987f0
commit 272341f1dc
14 changed files with 208 additions and 52 deletions

View File

@ -2,7 +2,7 @@ from decimal import Decimal
from gettext import gettext as _ from gettext import gettext as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import QuerySet, Sum from django.db.models import QuerySet, Sum, Avg
from drf_writable_nested import (UniqueFieldsMixin, from drf_writable_nested import (UniqueFieldsMixin,
WritableNestedModelSerializer) WritableNestedModelSerializer)
from rest_framework import serializers from rest_framework import serializers
@ -330,8 +330,24 @@ class NutritionInformationSerializer(serializers.ModelSerializer):
fields = ('id', 'carbohydrates', 'fats', 'proteins', 'calories', 'source') fields = ('id', 'carbohydrates', 'fats', 'proteins', 'calories', 'source')
class RecipeOverviewSerializer(WritableNestedModelSerializer): class RecipeBaseSerializer(WritableNestedModelSerializer):
def get_recipe_rating(self, obj):
rating = obj.cooklog_set.filter(created_by=self.context['request'].user, rating__gt=0).aggregate(Avg('rating'))
if rating['rating__avg']:
return rating['rating__avg']
return 0
def get_recipe_last_cooked(self, obj):
last = obj.cooklog_set.filter(created_by=self.context['request'].user).last()
if last:
return last.created_at
return None
class RecipeOverviewSerializer(RecipeBaseSerializer):
keywords = KeywordLabelSerializer(many=True) keywords = KeywordLabelSerializer(many=True)
rating = serializers.SerializerMethodField('get_recipe_rating')
last_cooked = serializers.SerializerMethodField('get_recipe_last_cooked')
def create(self, validated_data): def create(self, validated_data):
pass pass
@ -344,22 +360,24 @@ class RecipeOverviewSerializer(WritableNestedModelSerializer):
fields = ( fields = (
'id', 'name', 'description', 'image', 'keywords', 'working_time', 'id', 'name', 'description', 'image', 'keywords', 'working_time',
'waiting_time', 'created_by', 'created_at', 'updated_at', 'waiting_time', 'created_by', 'created_at', 'updated_at',
'internal', 'servings', 'file_path' 'internal', 'servings', 'servings_text', 'rating', 'last_cooked',
) )
read_only_fields = ['image', 'created_by', 'created_at'] read_only_fields = ['image', 'created_by', 'created_at']
class RecipeSerializer(WritableNestedModelSerializer): class RecipeSerializer(RecipeBaseSerializer):
nutrition = NutritionInformationSerializer(allow_null=True, required=False) nutrition = NutritionInformationSerializer(allow_null=True, required=False)
steps = StepSerializer(many=True) steps = StepSerializer(many=True)
keywords = KeywordSerializer(many=True) keywords = KeywordSerializer(many=True)
rating = serializers.SerializerMethodField('get_recipe_rating')
last_cooked = serializers.SerializerMethodField('get_recipe_last_cooked')
class Meta: class Meta:
model = Recipe model = Recipe
fields = ( fields = (
'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time', 'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time',
'waiting_time', 'created_by', 'created_at', 'updated_at', 'waiting_time', 'created_by', 'created_at', 'updated_at',
'internal', 'nutrition', 'servings', 'file_path', 'servings_text', 'internal', 'nutrition', 'servings', 'file_path', 'servings_text', 'rating', 'last_cooked',
) )
read_only_fields = ['image', 'created_by', 'created_at'] read_only_fields = ['image', 'created_by', 'created_at']

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -15,7 +15,9 @@
<b-input-group class="mt-3"> <b-input-group class="mt-3">
<b-input class="form-control" v-model="settings.search_input" v-bind:placeholder="$t('Search')"></b-input> <b-input class="form-control" v-model="settings.search_input" v-bind:placeholder="$t('Search')"></b-input>
<b-input-group-append> <b-input-group-append>
<b-button v-b-toggle.collapse_advanced_search v-bind:class="{'btn-primary': !isAdvancedSettingsSet(), 'btn-danger': isAdvancedSettingsSet()}" class="shadow-none btn"><i <b-button v-b-toggle.collapse_advanced_search
v-bind:class="{'btn-primary': !isAdvancedSettingsSet(), 'btn-danger': isAdvancedSettingsSet()}"
class="shadow-none btn"><i
class="fas fa-caret-down" v-if="!settings.advanced_search_visible"></i><i class="fas fa-caret-up" class="fas fa-caret-down" v-if="!settings.advanced_search_visible"></i><i class="fas fa-caret-up"
v-if="settings.advanced_search_visible"></i> v-if="settings.advanced_search_visible"></i>
</b-button> </b-button>
@ -37,21 +39,16 @@
:href="resolveDjangoUrl('data_import_url')">{{ $t('Import') }}</a> :href="resolveDjangoUrl('data_import_url')">{{ $t('Import') }}</a>
</div> </div>
<div class="col-md-3" style="margin-top: 1vh"> <div class="col-md-3" style="margin-top: 1vh">
<button class="btn btn-primary btn-block text-uppercase" @click="resetSearch"> <button class="btn btn-block text-uppercase" v-b-tooltip.hover :title="$t('show_only_internal')"
{{ $t('Reset_Search') }} v-bind:class="{'btn-success':settings.search_internal, 'btn-primary':!settings.search_internal}"
@click="settings.search_internal = !settings.search_internal;refreshData()">
{{ $t('Internal') }}
</button> </button>
</div> </div>
<div class="col-md-2" style="position: relative; margin-top: 1vh">
<b-form-checkbox v-model="settings.search_internal" name="check-button"
@change="refreshData(false)"
class="shadow-none"
style="position:relative;top: 50%; transform: translateY(-50%);" switch>
{{ $t('show_only_internal') }}
</b-form-checkbox>
</div>
<div class="col-md-1" style="position: relative; margin-top: 1vh"> <div class="col-md-3" style="position: relative; margin-top: 1vh">
<button id="id_settings_button" class="btn btn-primary btn-block"><i class="fas fa-cog"></i> <button id="id_settings_button" class="btn btn-primary btn-block text-uppercase"><i
class="fas fa-cog"></i>
</button> </button>
</div> </div>
@ -175,7 +172,16 @@
</div> </div>
</div> </div>
<div class="row" style="margin-top: 2vh"> <div class="row">
<div class="col col-md-12 text-right" style="margin-top: 2vh">
<span class="text-muted">
{{ $t('Page') }} {{ settings.pagination_page }}/{{ pagination_count }} <a href="#" @click="resetSearch"><i
class="fas fa-times-circle"></i> {{ $t('Reset') }}</a>
</span>
</div>
</div>
<div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));grid-gap: 1rem;"> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));grid-gap: 1rem;">
@ -194,10 +200,16 @@
</div> </div>
</div> </div>
<div class="row" style="margin-top: 2vh; text-align: center"> <div class="row" style="margin-top: 2vh">
<div class="col col-md-12"> <div class="col col-md-12">
<b-button @click="loadMore()" class="btn-block btn-success" v-if="pagination_more">{{ $t('Load_More') }} <b-pagination pills
</b-button> v-model="settings.pagination_page"
:total-rows="pagination_count"
per-page="25"
@change="pageChange"
align="center">
</b-pagination>
</div> </div>
</div> </div>
@ -233,6 +245,8 @@ import GenericMultiselect from "@/components/GenericMultiselect";
Vue.use(BootstrapVue) Vue.use(BootstrapVue)
let SETTINGS_COOKIE_NAME = 'search_settings'
export default { export default {
name: 'RecipeSearchView', name: 'RecipeSearchView',
mixins: [ResolveUrlMixin], mixins: [ResolveUrlMixin],
@ -243,6 +257,7 @@ export default {
meal_plans: [], meal_plans: [],
last_viewed_recipes: [], last_viewed_recipes: [],
settings_loaded: false,
settings: { settings: {
search_input: '', search_input: '',
search_internal: false, search_internal: false,
@ -256,19 +271,33 @@ export default {
advanced_search_visible: false, advanced_search_visible: false,
show_meal_plan: true, show_meal_plan: true,
recently_viewed: 5, recently_viewed: 5,
pagination_page: 1,
}, },
pagination_more: true, pagination_count: 0,
pagination_page: 1,
} }
}, },
mounted() { mounted() {
this.$nextTick(function () { this.$nextTick(function () {
if (this.$cookies.isKey('search_settings_v2')) { if (this.$cookies.isKey(SETTINGS_COOKIE_NAME)) {
this.settings = this.$cookies.get("search_settings_v2") let cookie_val = this.$cookies.get(SETTINGS_COOKIE_NAME)
for (let i of Object.keys(cookie_val)) {
this.$set(this.settings, i, cookie_val[i])
}
//TODO i have no idea why the above code does not suffice to update the
//TODO pagination UI element as $set should update all values reactively but it does not
setTimeout(function () {
this.$set(this.settings, 'pagination_page', 0)
}.bind(this), 50)
setTimeout(function () {
this.$set(this.settings, 'pagination_page', cookie_val['pagination_page'])
}.bind(this), 51)
} }
let urlParams = new URLSearchParams(window.location.search); let urlParams = new URLSearchParams(window.location.search);
let apiClient = new ApiApiFactory() let apiClient = new ApiApiFactory()
@ -278,7 +307,7 @@ export default {
let keyword = {id: x, name: 'loading'} let keyword = {id: x, name: 'loading'}
this.settings.search_keywords.push(keyword) this.settings.search_keywords.push(keyword)
apiClient.retrieveKeyword(x).then(result => { apiClient.retrieveKeyword(x).then(result => {
this.$set(this.settings.search_keywords,this.settings.search_keywords.indexOf(keyword), result.data) this.$set(this.settings.search_keywords, this.settings.search_keywords.indexOf(keyword), result.data)
}) })
} }
} }
@ -293,7 +322,7 @@ export default {
watch: { watch: {
settings: { settings: {
handler() { handler() {
this.$cookies.set("search_settings_v2", this.settings, -1) this.$cookies.set(SETTINGS_COOKIE_NAME, this.settings, -1)
}, },
deep: true deep: true
}, },
@ -327,16 +356,11 @@ export default {
this.settings.search_internal, this.settings.search_internal,
undefined, undefined,
this.pagination_page, this.settings.pagination_page,
).then(result => { ).then(result => {
this.pagination_more = (result.data.next !== null) window.scrollTo(0, 0);
if (page_load) { this.pagination_count = result.data.count
for (let x of result.data.results) { this.recipes = result.data.results
this.recipes.push(x)
}
} else {
this.recipes = result.data.results
}
}) })
}, },
loadMealPlan: function () { loadMealPlan: function () {
@ -378,13 +402,14 @@ export default {
this.settings.search_keywords = [] this.settings.search_keywords = []
this.settings.search_foods = [] this.settings.search_foods = []
this.settings.search_books = [] this.settings.search_books = []
this.settings.pagination_page = 1
this.refreshData(false) this.refreshData(false)
}, },
loadMore: function (page) { pageChange: function (page) {
this.pagination_page++ this.settings.pagination_page = page
this.refreshData(true) this.refreshData()
}, },
isAdvancedSettingsSet(){ isAdvancedSettingsSet() {
return ((this.settings.search_keywords.length + this.settings.search_foods.length + this.settings.search_books.length) > 0) return ((this.settings.search_keywords.length + this.settings.search_foods.length + this.settings.search_books.length) > 0)
} }
} }

View File

@ -11,6 +11,13 @@
</div> </div>
</div> </div>
<div class="row text-center">
<div class="col col-md-12">
<recipe-rating :recipe="recipe"></recipe-rating> <br/>
<last-cooked :recipe="recipe"></last-cooked>
</div>
</div>
<div class="my-auto"> <div class="my-auto">
<div class="col-12" style="text-align: center"> <div class="col-12" style="text-align: center">
<i>{{ recipe.description }}</i> <i>{{ recipe.description }}</i>
@ -166,6 +173,8 @@ import moment from 'moment'
import Keywords from "@/components/Keywords"; import Keywords from "@/components/Keywords";
import LoadingSpinner from "@/components/LoadingSpinner"; import LoadingSpinner from "@/components/LoadingSpinner";
import AddRecipeToBook from "@/components/AddRecipeToBook"; import AddRecipeToBook from "@/components/AddRecipeToBook";
import RecipeRating from "@/components/RecipeRating";
import LastCooked from "@/components/LastCooked";
Vue.prototype.moment = moment Vue.prototype.moment = moment
@ -178,6 +187,8 @@ export default {
ToastMixin, ToastMixin,
], ],
components: { components: {
LastCooked,
RecipeRating,
PdfViewer, PdfViewer,
ImageViewer, ImageViewer,
Ingredient, Ingredient,

View File

@ -0,0 +1,28 @@
<template>
<span>
<b-badge pill variant="primary" v-if="recipe.last_cooked !== null"><i class="fas fa-utensils"></i> {{
formatDate(recipe.last_cooked)
}}</b-badge>
</span>
</template>
<script>
import moment from "moment/moment";
export default {
name: "LastCooked",
props: {
recipe: Object
},
methods: {
formatDate: function (datetime) {
moment.locale(window.navigator.language);
return moment(datetime).format('L')
}
}
}
</script>
<style scoped>
</style>

View File

@ -7,7 +7,9 @@
top></b-card-img-lazy> top></b-card-img-lazy>
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right" <div class="card-img-overlay h-100 d-flex flex-column justify-content-right"
style="float:right; text-align: right; padding-top: 10px; padding-right: 5px"> style="float:right; text-align: right; padding-top: 10px; padding-right: 5px">
<a><recipe-context-menu :recipe="recipe" style="float:right" v-if="recipe !== null"></recipe-context-menu></a> <a>
<recipe-context-menu :recipe="recipe" style="float:right" v-if="recipe !== null"></recipe-context-menu>
</a>
</div> </div>
</a> </a>
@ -21,12 +23,18 @@
<b-card-text style="text-overflow: ellipsis;"> <b-card-text style="text-overflow: ellipsis;">
<template v-if="recipe !== null"> <template v-if="recipe !== null">
{{ recipe.description }} {{ recipe.description }}
<recipe-rating :recipe="recipe"></recipe-rating> <br/> <!-- TODO UGLY! -->
<last-cooked :recipe="recipe"></last-cooked>
<keywords :recipe="recipe" style="margin-top: 4px"></keywords> <keywords :recipe="recipe" style="margin-top: 4px"></keywords>
<b-badge pill variant="info" v-if="!recipe.internal">{{ $t('External') }}</b-badge> <b-badge pill variant="info" v-if="!recipe.internal">{{ $t('External') }}</b-badge>
<b-badge pill variant="success" <b-badge pill variant="success"
v-if="Date.parse(recipe.created_at) > new Date(Date.now() - (7 * (1000 * 60 * 60 * 24)))"> v-if="Date.parse(recipe.created_at) > new Date(Date.now() - (7 * (1000 * 60 * 60 * 24)))">
{{ $t('New') }} {{ $t('New') }}
</b-badge> </b-badge>
</template> </template>
<template v-else>{{ meal_plan.note }}</template> <template v-else>{{ meal_plan.note }}</template>
</b-card-text> </b-card-text>
@ -44,13 +52,19 @@
import RecipeContextMenu from "@/components/RecipeContextMenu"; import RecipeContextMenu from "@/components/RecipeContextMenu";
import Keywords from "@/components/Keywords"; import Keywords from "@/components/Keywords";
import {resolveDjangoUrl, ResolveUrlMixin} from "@/utils/utils"; import {resolveDjangoUrl, ResolveUrlMixin} from "@/utils/utils";
import RecipeRating from "@/components/RecipeRating";
import moment from "moment/moment";
import Vue from "vue";
import LastCooked from "@/components/LastCooked";
Vue.prototype.moment = moment
export default { export default {
name: "RecipeCard", name: "RecipeCard",
mixins: [ mixins: [
ResolveUrlMixin, ResolveUrlMixin,
], ],
components: {Keywords, RecipeContextMenu}, components: {LastCooked, RecipeRating, Keywords, RecipeContextMenu},
props: { props: {
recipe: Object, recipe: Object,
meal_plan: Object, meal_plan: Object,

View File

@ -0,0 +1,24 @@
<template>
<div>
<span style="display: inline-block;" v-if="recipe.rating > 0">
<i class="fas fa-star fa-xs" v-for="i in Math.floor(recipe.rating)" v-bind:key="i"></i>
<i class="fas fa-star-half-alt fa-xs" v-if="recipe.rating % 1 > 0"></i>
</span>
</div>
</template>
<script>
export default {
name: "RecipeRating",
props: {
recipe: Object
}
}
</script>
<style scoped>
</style>

View File

@ -564,7 +564,19 @@ export interface MealPlanRecipe {
* @type {string} * @type {string}
* @memberof MealPlanRecipe * @memberof MealPlanRecipe
*/ */
file_path?: string; servings_text?: string;
/**
*
* @type {string}
* @memberof MealPlanRecipe
*/
rating?: string;
/**
*
* @type {string}
* @memberof MealPlanRecipe
*/
last_cooked?: string;
} }
/** /**
* *
@ -699,6 +711,18 @@ export interface Recipe {
* @memberof Recipe * @memberof Recipe
*/ */
servings_text?: string; servings_text?: string;
/**
*
* @type {string}
* @memberof Recipe
*/
rating?: string;
/**
*
* @type {string}
* @memberof Recipe
*/
last_cooked?: string;
} }
/** /**
* *
@ -956,7 +980,19 @@ export interface RecipeOverview {
* @type {string} * @type {string}
* @memberof RecipeOverview * @memberof RecipeOverview
*/ */
file_path?: string; servings_text?: string;
/**
*
* @type {string}
* @memberof RecipeOverview
*/
rating?: string;
/**
*
* @type {string}
* @memberof RecipeOverview
*/
last_cooked?: string;
} }
/** /**
* *