initial Vue components
This commit is contained in:
parent
a605113b00
commit
0559143f0e
@ -178,26 +178,27 @@ def get_facet(qs, params, space):
|
|||||||
facets = {}
|
facets = {}
|
||||||
ratings = params.getlist('ratings', [])
|
ratings = params.getlist('ratings', [])
|
||||||
keyword_list = params.getlist('keywords', [])
|
keyword_list = params.getlist('keywords', [])
|
||||||
ingredient_list = params.getlist('foods', [])
|
food_list = params.getlist('foods', [])
|
||||||
book_list = params.getlist('book', [])
|
book_list = params.getlist('book', [])
|
||||||
search_keywords_or = params.get('keywords_or', True)
|
search_keywords_or = params.get('keywords_or', True)
|
||||||
search_foods_or = params.get('foods_or', True)
|
search_foods_or = params.get('foods_or', True)
|
||||||
search_books_or = params.get('books_or', True)
|
search_books_or = params.get('books_or', True)
|
||||||
|
|
||||||
# if using an OR search, will annotate all keywords, otherwise, just those that appear in results
|
# this returns a list of keywords in the queryset and how many times it appears
|
||||||
if search_keywords_or:
|
keywords = Keyword.objects.filter(recipe__in=qs).annotate(recipe_count=Count('recipe'))
|
||||||
keywords = Keyword.objects.filter(space=space).annotate(recipe_count=Count('recipe'))
|
|
||||||
else:
|
|
||||||
keywords = Keyword.objects.filter(recipe__in=qs, space=space).annotate(recipe_count=Count('recipe'))
|
|
||||||
# custom django-tree function annotates a queryset to make building a tree easier.
|
# custom django-tree function annotates a queryset to make building a tree easier.
|
||||||
# see https://django-treebeard.readthedocs.io/en/latest/api.html#treebeard.models.Node.get_annotated_list_qs for details
|
# see https://django-treebeard.readthedocs.io/en/latest/api.html#treebeard.models.Node.get_annotated_list_qs for details
|
||||||
kw_a = annotated_qs(keywords, root=True, fill=True)
|
kw_a = annotated_qs(keywords, root=True, fill=True)
|
||||||
|
|
||||||
|
# return list of foods in the recipe queryset and how many times they appear
|
||||||
|
foods = Food.objects.filter(ingredient__step__recipe__in=list(qs.values_list('id', flat=True))).annotate(recipe_count=Count('ingredient'))
|
||||||
|
food_a = annotated_qs(foods, root=True, fill=True)
|
||||||
|
|
||||||
# TODO add rating facet
|
# TODO add rating facet
|
||||||
facets['Ratings'] = []
|
facets['Ratings'] = []
|
||||||
facets['Keywords'] = fill_annotated_parents(kw_a, keyword_list)
|
facets['Keywords'] = fill_annotated_parents(kw_a, keyword_list)
|
||||||
# TODO add food facet
|
# TODO add food facet
|
||||||
facets['Foods'] = []
|
facets['Foods'] = fill_annotated_parents(food_a, food_list)
|
||||||
# TODO add book facet
|
# TODO add book facet
|
||||||
facets['Books'] = []
|
facets['Books'] = []
|
||||||
facets['Recent'] = ViewLog.objects.filter(
|
facets['Recent'] = ViewLog.objects.filter(
|
||||||
|
@ -47,7 +47,7 @@ class SpaceFilterSerializer(serializers.ListSerializer):
|
|||||||
|
|
||||||
def to_representation(self, data):
|
def to_representation(self, data):
|
||||||
if (type(data) == QuerySet and data.query.is_sliced):
|
if (type(data) == QuerySet and data.query.is_sliced):
|
||||||
# if query is sliced it came from api request not nested serializer
|
# if query is sliced or if is a MP_NodeQuerySet it came from api request not nested serializer
|
||||||
return super().to_representation(data)
|
return super().to_representation(data)
|
||||||
if self.child.Meta.model == User:
|
if self.child.Meta.model == User:
|
||||||
data = data.filter(userpreference__space=self.context['request'].space)
|
data = data.filter(userpreference__space=self.context['request'].space)
|
||||||
@ -211,9 +211,9 @@ class KeywordSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
|
|||||||
return str(obj)
|
return str(obj)
|
||||||
|
|
||||||
def get_image(self, obj):
|
def get_image(self, obj):
|
||||||
recipes = obj.recipe_set.all().exclude(image__isnull=True).exclude(image__exact='')
|
recipes = obj.recipe_set.all().filter(space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
|
||||||
if len(recipes) == 0:
|
if len(recipes) == 0:
|
||||||
recipes = Recipe.objects.filter(keywords__in=obj.get_tree()).exclude(image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree
|
recipes = Recipe.objects.filter(keywords__in=obj.get_tree(), space=obj.space).exclude(image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree
|
||||||
if len(recipes) != 0:
|
if len(recipes) != 0:
|
||||||
return random.choice(recipes).image.url
|
return random.choice(recipes).image.url
|
||||||
else:
|
else:
|
||||||
@ -292,16 +292,15 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
|
|||||||
numrecipe = serializers.SerializerMethodField('count_recipes')
|
numrecipe = serializers.SerializerMethodField('count_recipes')
|
||||||
|
|
||||||
def get_image(self, obj):
|
def get_image(self, obj):
|
||||||
if obj.recipe:
|
if obj.recipe and obj.space == obj.recipe.space:
|
||||||
recipes = Recipe.objects.filter(id=obj.recipe).exclude(image__isnull=True).exclude(image__exact='')
|
if obj.recipe.image and obj.recipe.image != '':
|
||||||
if len(recipes) == 0:
|
return obj.recipe.image.url
|
||||||
return recipes.image.url
|
|
||||||
# if food is not also a recipe, look for recipe images that use the food
|
# if food is not also a recipe, look for recipe images that use the food
|
||||||
recipes = Recipe.objects.filter(steps__ingredients__food=obj).exclude(image__isnull=True).exclude(image__exact='')
|
recipes = Recipe.objects.filter(steps__ingredients__food=obj, space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
|
||||||
|
|
||||||
# if no recipes found - check whole tree
|
# if no recipes found - check whole tree
|
||||||
if len(recipes) == 0:
|
if len(recipes) == 0:
|
||||||
recipes = Recipe.objects.filter(steps__ingredients__food__in=obj.get_tree()).exclude(image__isnull=True).exclude(image__exact='')
|
recipes = Recipe.objects.filter(steps__ingredients__food__in=obj.get_tree(), space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
|
||||||
|
|
||||||
if len(recipes) != 0:
|
if len(recipes) != 0:
|
||||||
return random.choice(recipes).image.url
|
return random.choice(recipes).image.url
|
||||||
@ -309,7 +308,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def count_recipes(self, obj):
|
def count_recipes(self, obj):
|
||||||
return Recipe.objects.filter(steps__ingredients__food=obj).count()
|
return Recipe.objects.filter(steps__ingredients__food=obj, space=obj.space).count()
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
validated_data['name'] = validated_data['name'].strip()
|
validated_data['name'] = validated_data['name'].strip()
|
||||||
|
1
cookbook/static/vue/css/food_list_view.css
Normal file
1
cookbook/static/vue/css/food_list_view.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
.shake[data-v-33424c9e]{-webkit-animation:shake-data-v-33424c9e .82s cubic-bezier(.36,.07,.19,.97) both;animation:shake-data-v-33424c9e .82s cubic-bezier(.36,.07,.19,.97) both;transform:translateZ(0);-webkit-backface-visibility:hidden;backface-visibility:hidden;perspective:1000px}@-webkit-keyframes shake-data-v-33424c9e{10%,90%{transform:translate3d(-1px,0,0)}20%,80%{transform:translate3d(2px,0,0)}30%,50%,70%{transform:translate3d(-4px,0,0)}40%,60%{transform:translate3d(4px,0,0)}}@keyframes shake-data-v-33424c9e{10%,90%{transform:translate3d(-1px,0,0)}20%,80%{transform:translate3d(2px,0,0)}30%,50%,70%{transform:translate3d(-4px,0,0)}40%,60%{transform:translate3d(4px,0,0)}}
|
1
cookbook/static/vue/food_list_view.html
Normal file
1
cookbook/static/vue/food_list_view.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
<!DOCTYPE html><html lang=""><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Vue App</title><link href="css/chunk-vendors.css" rel="preload" as="style"><link href="css/food_list_view.css" rel="preload" as="style"><link href="js/chunk-vendors.js" rel="preload" as="script"><link href="js/food_list_view.js" rel="preload" as="script"><link href="css/chunk-vendors.css" rel="stylesheet"><link rel="icon" type="image/png" sizes="32x32" href="img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="img/icons/favicon-16x16.png"><link rel="manifest" href="manifest.json"><meta name="theme-color" content="#4DBA87"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black"><meta name="apple-mobile-web-app-title" content="Recipes"><link rel="apple-touch-icon" href="img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="img/icons/safari-pinned-tab.svg" color="#4DBA87"><meta name="msapplication-TileImage" content="img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><div id="app"></div><script src="js/chunk-vendors.js"></script></body></html>
|
1
cookbook/static/vue/js/food_list_view.js
Normal file
1
cookbook/static/vue/js/food_list_view.js
Normal file
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
@ -278,13 +278,13 @@ def test_move(u1_s1, obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, space_1):
|
|||||||
r = u1_s1.put(
|
r = u1_s1.put(
|
||||||
reverse(MOVE_URL, args=[obj_1.id, 9999])
|
reverse(MOVE_URL, args=[obj_1.id, 9999])
|
||||||
)
|
)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 404
|
||||||
|
|
||||||
# attempt to move to wrong space
|
# attempt to move to wrong space
|
||||||
r = u1_s1.put(
|
r = u1_s1.put(
|
||||||
reverse(MOVE_URL, args=[obj_1_1.id, obj_3.id])
|
reverse(MOVE_URL, args=[obj_1_1.id, obj_3.id])
|
||||||
)
|
)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 404
|
||||||
|
|
||||||
# run diagnostic to find problems - none should be found
|
# run diagnostic to find problems - none should be found
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
@ -363,13 +363,13 @@ def test_merge(
|
|||||||
r = u1_s1.put(
|
r = u1_s1.put(
|
||||||
reverse(MERGE_URL, args=[obj_1_1.id, 9999])
|
reverse(MERGE_URL, args=[obj_1_1.id, 9999])
|
||||||
)
|
)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 404
|
||||||
|
|
||||||
# attempt to move to wrong space
|
# attempt to move to wrong space
|
||||||
r = u1_s1.put(
|
r = u1_s1.put(
|
||||||
reverse(MERGE_URL, args=[obj_2.id, obj_3.id])
|
reverse(MERGE_URL, args=[obj_2.id, obj_3.id])
|
||||||
)
|
)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 404
|
||||||
|
|
||||||
# attempt to merge with child
|
# attempt to merge with child
|
||||||
r = u1_s1.put(
|
r = u1_s1.put(
|
||||||
|
@ -145,7 +145,7 @@ class MergeMixin(ViewSetMixin): # TODO update Units to use merge API
|
|||||||
source = self.model.objects.get(pk=pk, space=self.request.space)
|
source = self.model.objects.get(pk=pk, space=self.request.space)
|
||||||
except (self.model.DoesNotExist):
|
except (self.model.DoesNotExist):
|
||||||
content = {'error': True, 'msg': _(f'No {self.basename} with id {pk} exists')}
|
content = {'error': True, 'msg': _(f'No {self.basename} with id {pk} exists')}
|
||||||
return Response(content, status=status.HTTP_400_BAD_REQUEST)
|
return Response(content, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
if int(target) == source.id:
|
if int(target) == source.id:
|
||||||
content = {'error': True, 'msg': _('Cannot merge with the same object!')}
|
content = {'error': True, 'msg': _('Cannot merge with the same object!')}
|
||||||
@ -156,7 +156,7 @@ class MergeMixin(ViewSetMixin): # TODO update Units to use merge API
|
|||||||
target = self.model.objects.get(pk=target, space=self.request.space)
|
target = self.model.objects.get(pk=target, space=self.request.space)
|
||||||
except (self.model.DoesNotExist):
|
except (self.model.DoesNotExist):
|
||||||
content = {'error': True, 'msg': _(f'No {self.basename} with id {target} exists')}
|
content = {'error': True, 'msg': _(f'No {self.basename} with id {target} exists')}
|
||||||
return Response(content, status=status.HTTP_400_BAD_REQUEST)
|
return Response(content, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if target in source.get_descendants_and_self():
|
if target in source.get_descendants_and_self():
|
||||||
@ -205,7 +205,7 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
|
|||||||
except self.model.DoesNotExist:
|
except self.model.DoesNotExist:
|
||||||
self.queryset = self.model.objects.none()
|
self.queryset = self.model.objects.none()
|
||||||
if root == 0:
|
if root == 0:
|
||||||
self.queryset = self.model.get_root_nodes()
|
self.queryset = self.model.get_root_nodes() | self.model.objects.filter(depth=0, space=self.request.space)
|
||||||
else:
|
else:
|
||||||
self.queryset = self.model.objects.get(id=root).get_children()
|
self.queryset = self.model.objects.get(id=root).get_children()
|
||||||
elif tree:
|
elif tree:
|
||||||
|
614
vue/src/apps/FoodListView/FoodListView.vue
Normal file
614
vue/src/apps/FoodListView/FoodListView.vue
Normal file
@ -0,0 +1,614 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app" style="margin-bottom: 4vh">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-2 d-none d-md-block">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-8 col-12">
|
||||||
|
<div class="container-fluid d-flex flex-column flex-grow-1" :class="{'vh-100' : show_split}">
|
||||||
|
<!-- expanded options box -->
|
||||||
|
<div class="row flex-shrink-0">
|
||||||
|
<div class="col col-md-12">
|
||||||
|
<b-collapse id="collapse_advanced" class="mt-2" v-model="advanced_visible">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3" style="margin-top: 1vh">
|
||||||
|
<div class="btn btn-primary btn-block text-uppercase" @click="startAction({'action':'new'})">
|
||||||
|
{{ this.$t('New_Food') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3" style="margin-top: 1vh">
|
||||||
|
<button class="btn btn-primary btn-block text-uppercase" @click="resetSearch">
|
||||||
|
{{ this.$t('Reset_Search') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3" style="position: relative; margin-top: 1vh">
|
||||||
|
<b-form-checkbox v-model="show_split" name="check-button"
|
||||||
|
class="shadow-none"
|
||||||
|
style="position:relative;top: 50%; transform: translateY(-50%);" switch>
|
||||||
|
{{ this.$t('show_split_screen') }}
|
||||||
|
</b-form-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</b-collapse>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row flex-shrink-0">
|
||||||
|
<!-- search box -->
|
||||||
|
<div class="col col-md">
|
||||||
|
<b-input-group class="mt-3">
|
||||||
|
<b-input class="form-control" v-model="search_input"
|
||||||
|
v-bind:placeholder="this.$t('Search')"></b-input>
|
||||||
|
<b-input-group-append>
|
||||||
|
<b-button v-b-toggle.collapse_advanced variant="primary" class="shadow-none">
|
||||||
|
<i class="fas fa-caret-down" v-if="!advanced_visible"></i>
|
||||||
|
<i class="fas fa-caret-up" v-if="advanced_visible"></i>
|
||||||
|
</b-button>
|
||||||
|
</b-input-group-append>
|
||||||
|
</b-input-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- split side search -->
|
||||||
|
<div class="col col-md" v-if="show_split">
|
||||||
|
<b-input-group class="mt-3">
|
||||||
|
<b-input class="form-control" v-model="search_input2"
|
||||||
|
v-bind:placeholder="this.$t('Search')"></b-input>
|
||||||
|
</b-input-group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- only show scollbars in split mode, but this doesn't interact well with infinite scroll, maybe a different componenet? -->
|
||||||
|
<div class="row" :class="{'overflow-hidden' : show_split}" style="margin-top: 2vh">
|
||||||
|
<div class="col col-md" :class="{'mh-100 overflow-auto' : show_split}">
|
||||||
|
<food-card
|
||||||
|
v-for="f in foods"
|
||||||
|
v-bind:key="f.id"
|
||||||
|
:food="f"
|
||||||
|
:draggable="true"
|
||||||
|
@item-action="startAction($event, 'left')"
|
||||||
|
></food-card>
|
||||||
|
<infinite-loading
|
||||||
|
:identifier='left'
|
||||||
|
@infinite="infiniteHandler($event, 'left')"
|
||||||
|
spinner="waveDots">
|
||||||
|
</infinite-loading>
|
||||||
|
</div>
|
||||||
|
<!-- right side keyword cards -->
|
||||||
|
<div class="col col-md mh-100 overflow-auto " v-if="show_split">
|
||||||
|
<food-card
|
||||||
|
v-for="f in foods2"
|
||||||
|
v-bind:key="f.id"
|
||||||
|
:food="f"
|
||||||
|
draggable="true"
|
||||||
|
@item-action="startAction($event, 'right')"
|
||||||
|
></food-card>
|
||||||
|
<infinite-loading
|
||||||
|
:identifier='right'
|
||||||
|
@infinite="infiniteHandler($event, 'right')"
|
||||||
|
spinner="waveDots">
|
||||||
|
</infinite-loading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-none d-md-block">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TODO Modals can probably be made generic and moved to component -->
|
||||||
|
<!-- edit modal -->
|
||||||
|
<b-modal class="modal"
|
||||||
|
:id="'id_modal_keyword_edit'"
|
||||||
|
@shown="prepareEmoji"
|
||||||
|
:title="this.$t('Edit_Keyword')"
|
||||||
|
:ok-title="this.$t('Save')"
|
||||||
|
:cancel-title="this.$t('Cancel')"
|
||||||
|
@ok="saveKeyword">
|
||||||
|
<form>
|
||||||
|
<label for="id_keyword_name_edit">{{ this.$t('Name') }}</label>
|
||||||
|
<input class="form-control" type="text" id="id_keyword_name_edit" v-model="this_item.name">
|
||||||
|
<label for="id_keyword_description_edit">{{ this.$t('Description') }}</label>
|
||||||
|
<input class="form-control" type="text" id="id_keyword_description_edit" v-model="this_item.description">
|
||||||
|
<label for="id_keyword_icon_edit">{{ this.$t('Icon') }}</label>
|
||||||
|
<twemoji-textarea
|
||||||
|
id="id_keyword_icon_edit"
|
||||||
|
ref="_edit"
|
||||||
|
:emojiData="emojiDataAll"
|
||||||
|
:emojiGroups="emojiGroups"
|
||||||
|
triggerType="hover"
|
||||||
|
recentEmojisFeat="true"
|
||||||
|
recentEmojisStorage="local"
|
||||||
|
@contentChanged="setIcon"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</b-modal>
|
||||||
|
<!-- delete modal -->
|
||||||
|
<b-modal class="modal"
|
||||||
|
:id="'id_modal_keyword_delete'"
|
||||||
|
:title="this.$t('Delete_Keyword')"
|
||||||
|
:ok-title="this.$t('Delete')"
|
||||||
|
:cancel-title="this.$t('Cancel')"
|
||||||
|
@ok="delKeyword(this_item.id)">
|
||||||
|
{{this.$t("delete_confimation", {'kw': this_item.name})}} {{this_item.name}}
|
||||||
|
</b-modal>
|
||||||
|
<!-- move modal -->
|
||||||
|
<b-modal class="modal"
|
||||||
|
:id="'id_modal_keyword_move'"
|
||||||
|
:title="this.$t('Move_Keyword')"
|
||||||
|
:ok-title="this.$t('Move')"
|
||||||
|
:cancel-title="this.$t('Cancel')"
|
||||||
|
@ok="moveKeyword(this_item.id, this_item.target.id)">
|
||||||
|
{{ this.$t("move_selection", {'child': this_item.name}) }}
|
||||||
|
<generic-multiselect
|
||||||
|
@change="this_item.target=$event.val"
|
||||||
|
label="name"
|
||||||
|
search_function="listKeywords"
|
||||||
|
:multiple="false"
|
||||||
|
:sticky_options="[{'id': 0,'name': $t('Root')}]"
|
||||||
|
:tree_api="true"
|
||||||
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||||
|
:placeholder="this.$t('Search')">
|
||||||
|
</generic-multiselect>
|
||||||
|
</b-modal>
|
||||||
|
<!-- merge modal -->
|
||||||
|
<b-modal class="modal"
|
||||||
|
:id="'id_modal_keyword_merge'"
|
||||||
|
:title="this.$t('Merge_Keyword')"
|
||||||
|
:ok-title="this.$t('Merge')"
|
||||||
|
:cancel-title="this.$t('Cancel')"
|
||||||
|
@ok="mergeKeyword(this_item.id, this_item.target.id)">
|
||||||
|
{{ this.$t("merge_selection", {'source': this_item.name, 'type': this.$t('keyword')}) }}
|
||||||
|
<generic-multiselect
|
||||||
|
@change="this_item.target=$event.val"
|
||||||
|
label="name"
|
||||||
|
search_function="listKeywords"
|
||||||
|
:multiple="false"
|
||||||
|
:tree_api="true"
|
||||||
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||||
|
:placeholder="this.$t('Search')">
|
||||||
|
</generic-multiselect>
|
||||||
|
</b-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
axios.defaults.xsrfCookieName = 'csrftoken'
|
||||||
|
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
|
||||||
|
|
||||||
|
import Vue from 'vue'
|
||||||
|
import {BootstrapVue} from 'bootstrap-vue'
|
||||||
|
|
||||||
|
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
||||||
|
import _debounce from 'lodash/debounce'
|
||||||
|
|
||||||
|
import {ResolveUrlMixin} from "@/utils/utils";
|
||||||
|
|
||||||
|
import {ApiApiFactory} from "@/utils/openapi/api.ts";
|
||||||
|
import FoodCard from "@/components/FoodCard";
|
||||||
|
import GenericMultiselect from "@/components/GenericMultiselect";
|
||||||
|
import InfiniteLoading from 'vue-infinite-loading';
|
||||||
|
|
||||||
|
// would move with modals if made generic
|
||||||
|
import {TwemojiTextarea} from '@kevinfaguiar/vue-twemoji-picker';
|
||||||
|
// TODO add localization
|
||||||
|
import EmojiAllData from '@kevinfaguiar/vue-twemoji-picker/emoji-data/en/emoji-all-groups.json';
|
||||||
|
import EmojiGroups from '@kevinfaguiar/vue-twemoji-picker/emoji-data/emoji-groups.json';
|
||||||
|
// end move with generic modals
|
||||||
|
|
||||||
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'FoodListView',
|
||||||
|
mixins: [ResolveUrlMixin],
|
||||||
|
components: {TwemojiTextarea, FoodCard, GenericMultiselect, InfiniteLoading},
|
||||||
|
computed: {
|
||||||
|
// move with generic modals
|
||||||
|
emojiDataAll() {
|
||||||
|
return EmojiAllData;
|
||||||
|
},
|
||||||
|
emojiGroups() {
|
||||||
|
return EmojiGroups;
|
||||||
|
}
|
||||||
|
// end move with generic modals
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
foods: [],
|
||||||
|
foods2: [],
|
||||||
|
show_split: false,
|
||||||
|
search_input: '',
|
||||||
|
search_input2: '',
|
||||||
|
advanced_visible: false,
|
||||||
|
right_page: 0,
|
||||||
|
right: +new Date(),
|
||||||
|
isDirtyRight: false,
|
||||||
|
left_page: 0,
|
||||||
|
left: +new Date(),
|
||||||
|
isDirtyLeft: false,
|
||||||
|
this_item: {
|
||||||
|
'id': -1,
|
||||||
|
'name': '',
|
||||||
|
'description': '',
|
||||||
|
'icon': '',
|
||||||
|
'target': {
|
||||||
|
'id': -1,
|
||||||
|
'name': ''
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
search_input: _debounce(function() {
|
||||||
|
this.left_page = 0
|
||||||
|
this.foods = []
|
||||||
|
this.left += 1
|
||||||
|
}, 700),
|
||||||
|
search_input2: _debounce(function() {
|
||||||
|
this.right_page = 0
|
||||||
|
this.foods2 = []
|
||||||
|
this.right += 1
|
||||||
|
}, 700)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
makeToast: function (title, message, variant = null) {
|
||||||
|
//TODO remove duplicate function in favor of central one
|
||||||
|
this.$bvToast.toast(message, {
|
||||||
|
title: title,
|
||||||
|
variant: variant,
|
||||||
|
toaster: 'b-toaster-top-center',
|
||||||
|
solid: true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
resetSearch: function () {
|
||||||
|
if (this.search_input !== '') {
|
||||||
|
this.search_input = ''
|
||||||
|
} else {
|
||||||
|
this.left_page = 0
|
||||||
|
this.foods = []
|
||||||
|
this.left += 1
|
||||||
|
}
|
||||||
|
if (this.search_input2 !== '') {
|
||||||
|
this.search_input2 = ''
|
||||||
|
} else {
|
||||||
|
this.right_page = 0
|
||||||
|
this.foods2 = []
|
||||||
|
this.right += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
// TODO should model actions be included with the context menu? the card? a seperate mixin avaible to all?
|
||||||
|
startAction: function(e, col) {
|
||||||
|
let target = e.target || null
|
||||||
|
let source = e.source || null
|
||||||
|
|
||||||
|
if (e.action == 'delete') {
|
||||||
|
this.this_item = source
|
||||||
|
this.$bvModal.show('id_modal_keyword_delete')
|
||||||
|
} else if (e.action == 'new') {
|
||||||
|
this.this_item = {}
|
||||||
|
this.$bvModal.show('id_modal_keyword_edit')
|
||||||
|
} else if (e.action == 'edit') {
|
||||||
|
this.this_item = source
|
||||||
|
this.$bvModal.show('id_modal_keyword_edit')
|
||||||
|
} else if (e.action === 'move') {
|
||||||
|
this.this_item = source
|
||||||
|
if (target == null) {
|
||||||
|
this.$bvModal.show('id_modal_keyword_move')
|
||||||
|
} else {
|
||||||
|
this.moveKeyword(source.id, target.id)
|
||||||
|
}
|
||||||
|
} else if (e.action === 'merge') {
|
||||||
|
this.this_item = source
|
||||||
|
if (target == null) {
|
||||||
|
this.$bvModal.show('id_modal_keyword_merge')
|
||||||
|
} else {
|
||||||
|
this.mergeKeyword(e.source.id, e.target.id)
|
||||||
|
}
|
||||||
|
} else if (e.action === 'get-children') {
|
||||||
|
if (source.expanded) {
|
||||||
|
Vue.set(source, 'expanded', false)
|
||||||
|
} else {
|
||||||
|
this.this_item = source
|
||||||
|
this.getChildren(col, source)
|
||||||
|
}
|
||||||
|
} else if (e.action === 'get-recipes') {
|
||||||
|
if (source.show_recipes) {
|
||||||
|
Vue.set(source, 'show_recipes', false)
|
||||||
|
} else {
|
||||||
|
this.this_item = source
|
||||||
|
this.getRecipes(col, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
saveFood: function () {
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
let food = {
|
||||||
|
name: this.this_item.name,
|
||||||
|
description: this.this_item.description,
|
||||||
|
icon: this.this_item.icon,
|
||||||
|
}
|
||||||
|
if (!this.this_item.id) { // if there is no item id assume its a new item
|
||||||
|
apiClient.createFood(food).then(result => {
|
||||||
|
// place all new foods at the top of the list - could sort instead
|
||||||
|
this.foods = [result.data].concat(this.foods)
|
||||||
|
// this creates a deep copy to make sure that columns stay independent
|
||||||
|
if (this.show_split){
|
||||||
|
this.foods2 = [JSON.parse(JSON.stringify(result.data))].concat(this.foods2)
|
||||||
|
} else {
|
||||||
|
this.foods2 = []
|
||||||
|
}
|
||||||
|
this.this_item={}
|
||||||
|
}).catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
this.this_item = {}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
apiClient.partialUpdateFood(this.this_item.id, food).then(result => {
|
||||||
|
this.refreshCard(this.this_item.id)
|
||||||
|
this.this_item={}
|
||||||
|
}).catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
this.this_item = {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
delFood: function (id) {
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
|
apiClient.destroyFood(id).then(response => {
|
||||||
|
this.destroyCard(id)
|
||||||
|
}).catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
this.this_item = {}
|
||||||
|
})
|
||||||
|
|
||||||
|
},
|
||||||
|
moveFood: function (source_id, target_id) {
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
apiClient.moveFood(String(source_id), String(target_id)).then(result => {
|
||||||
|
if (target_id === 0) {
|
||||||
|
let food = this.findFood(this.foods, source_id) || this.findFood(this.foods2, source_id)
|
||||||
|
food.parent = null
|
||||||
|
|
||||||
|
if (this.show_split){
|
||||||
|
this.destroyCard(source_id) // order matters, destroy old card before adding it back in at root
|
||||||
|
|
||||||
|
this.foods = [food].concat(this.foods)
|
||||||
|
this.foods2 = [JSON.parse(JSON.stringify(food))].concat(this.foods2)
|
||||||
|
} else {
|
||||||
|
this.destroyCard(source_id)
|
||||||
|
this.foods = [food].concat(this.foods)
|
||||||
|
this.foods2 = []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.destroyCard(source_id)
|
||||||
|
this.refreshCard(target_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
}).catch((err) => {
|
||||||
|
// TODO none of the error checking works because the openapi generated functions don't throw an error?
|
||||||
|
// or i'm capturing it incorrectly
|
||||||
|
console.log(err)
|
||||||
|
this.makeToast(this.$t('Error'), err.bodyText, 'danger')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
mergeFood: function (source_id, target_id) {
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
apiClient.mergeFood(String(source_id), String(target_id)).then(result => {
|
||||||
|
this.destroyCard(source_id)
|
||||||
|
this.refreshCard(target_id)
|
||||||
|
}).catch((err) => {
|
||||||
|
console.log('Error', err)
|
||||||
|
this.makeToast(this.$t('Error'), err.bodyText, 'danger')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
// TODO: DRY the listFood functions (refresh, get children, infinityHandler ) can probably all be consolidated into a single function
|
||||||
|
getChildren: function(col, food){
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
let parent = {}
|
||||||
|
let query = undefined
|
||||||
|
let page = undefined
|
||||||
|
let root = food.id
|
||||||
|
let tree = undefined
|
||||||
|
let pageSize = 200
|
||||||
|
|
||||||
|
apiClient.listFoods(query, root, tree, page, pageSize).then(result => {
|
||||||
|
if (col == 'left') {
|
||||||
|
parent = this.findFood(this.keywords, food.id)
|
||||||
|
} else if (col == 'right'){
|
||||||
|
parent = this.findFood(this.keywords2, food.id)
|
||||||
|
}
|
||||||
|
if (parent) {
|
||||||
|
Vue.set(parent, 'children', result.data.results)
|
||||||
|
Vue.set(parent, 'expanded', true)
|
||||||
|
Vue.set(parent, 'show_recipes', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
}).catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
this.makeToast(this.$t('Error'), err.bodyText, 'danger')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getRecipes: function(col, food){
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
let parent = {}
|
||||||
|
let pageSize = 200
|
||||||
|
console.log(apiClient.listRecipes)
|
||||||
|
|
||||||
|
apiClient.listRecipes(
|
||||||
|
undefined, undefined, String(food.id), undefined, undefined, undefined,
|
||||||
|
undefined, undefined, undefined, undefined, undefined, pageSize, undefined
|
||||||
|
).then(result => {
|
||||||
|
if (col == 'left') {
|
||||||
|
parent = this.findFood(this.foods, food.id)
|
||||||
|
} else if (col == 'right'){
|
||||||
|
parent = this.findFood(this.foods2, food.id)
|
||||||
|
}
|
||||||
|
if (parent) {
|
||||||
|
Vue.set(parent, 'recipes', result.data.results)
|
||||||
|
Vue.set(parent, 'show_recipes', true)
|
||||||
|
Vue.set(parent, 'expanded', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
}).catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
this.makeToast(this.$t('Error'), err.bodyText, 'danger')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
refreshCard: function(id){
|
||||||
|
let target = {}
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
let idx = undefined
|
||||||
|
let idx2 = undefined
|
||||||
|
apiClient.retrieveFood(id).then(result => {
|
||||||
|
target = this.findFood(this.foods, id) || this.findFood(this.foods2, id)
|
||||||
|
|
||||||
|
if (target.parent) {
|
||||||
|
let parent = this.findFood(this.foods, target.parent)
|
||||||
|
let parent2 = this.findFood(this.foods2, target.parent)
|
||||||
|
|
||||||
|
if (parent) {
|
||||||
|
if (parent.expanded){
|
||||||
|
idx = parent.children.indexOf(parent.children.find(kw => kw.id === target.id))
|
||||||
|
Vue.set(parent.children, idx, result.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parent2){
|
||||||
|
if (parent2.expanded){
|
||||||
|
idx2 = parent2.children.indexOf(parent2.children.find(kw => kw.id === target.id))
|
||||||
|
// deep copy to force columns to be indepedent
|
||||||
|
Vue.set(parent2.children, idx2, JSON.parse(JSON.stringify(result.data)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
idx = this.foods.indexOf(this.foods.find(food => food.id === target.id))
|
||||||
|
idx2 = this.foods2.indexOf(this.foods2.find(food => food.id === target.id))
|
||||||
|
Vue.set(this.foods, idx, result.data)
|
||||||
|
Vue.set(this.foods2, idx2, JSON.parse(JSON.stringify(result.data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
},
|
||||||
|
findFood: function(food_list, id){
|
||||||
|
if (food_list.length == 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let food = food_list.filter(fd => fd.id == id)
|
||||||
|
if (food.length == 1) {
|
||||||
|
return food[0]
|
||||||
|
} else if (food.length == 0) {
|
||||||
|
for (const f of food_list.filter(fd => fd.expanded == true)) {
|
||||||
|
food = this.findFood(f.children, id)
|
||||||
|
if (food) {
|
||||||
|
return food
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.log('something terrible happened')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// this would move with modals with mixin?
|
||||||
|
prepareEmoji: function() {
|
||||||
|
this.$refs._edit.addText(this.this_item.icon || '');
|
||||||
|
this.$refs._edit.blur()
|
||||||
|
document.getElementById('btn-emoji-default').disabled = true;
|
||||||
|
},
|
||||||
|
// this would move with modals with mixin?
|
||||||
|
setIcon: function(icon) {
|
||||||
|
this.this_item.icon = icon
|
||||||
|
},
|
||||||
|
infiniteHandler: function($state, col) {
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
let query = (col==='left') ? this.search_input : this.search_input2
|
||||||
|
let page = (col==='left') ? this.left_page + 1 : this.right_page + 1
|
||||||
|
let root = undefined
|
||||||
|
let tree = undefined
|
||||||
|
let pageSize = undefined
|
||||||
|
|
||||||
|
if (query === '') {
|
||||||
|
query = undefined
|
||||||
|
root = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
apiClient.listFoods(query, root, tree, page, pageSize).then(result => {
|
||||||
|
if (result.data.results.length){
|
||||||
|
if (col ==='left') {
|
||||||
|
this.left_page+=1
|
||||||
|
this.foods = this.foods.concat(result.data.results)
|
||||||
|
$state.loaded();
|
||||||
|
if (this.foods.length >= result.data.count) {
|
||||||
|
$state.complete();
|
||||||
|
}
|
||||||
|
} else if (col ==='right') {
|
||||||
|
this.right_page+=1
|
||||||
|
this.foods2 = this.foods2.concat(result.data.results)
|
||||||
|
$state.loaded();
|
||||||
|
if (this.foods2.length >= result.data.count) {
|
||||||
|
$state.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('no data returned')
|
||||||
|
$state.complete();
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
this.makeToast(this.$t('Error'), err.bodyText, 'danger')
|
||||||
|
$state.complete();
|
||||||
|
})
|
||||||
|
},
|
||||||
|
destroyCard: function(id) {
|
||||||
|
let fd = this.findFood(this.foods, id)
|
||||||
|
let fd2 = this.findFood(this.foods2, id)
|
||||||
|
let p_id = undefined
|
||||||
|
if (fd) {
|
||||||
|
p_id = fd.parent
|
||||||
|
} else if (fd2) {
|
||||||
|
p_id = fd2.parent
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p_id) {
|
||||||
|
let parent = this.findFood(this.foods, p_id)
|
||||||
|
let parent2 = this.findFood(this.v2, p_id)
|
||||||
|
if (parent){
|
||||||
|
Vue.set(parent, 'numchild', parent.numchild - 1)
|
||||||
|
if (parent.expanded) {
|
||||||
|
let idx = parent.children.indexOf(parent.children.find(kw => kw.id === id))
|
||||||
|
Vue.delete(parent.children, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parent2){
|
||||||
|
Vue.set(parent2, 'numchild', parent2.numchild - 1)
|
||||||
|
if (parent2.expanded) {
|
||||||
|
let idx = parent2.children.indexOf(parent2.children.find(kw => kw.id === id))
|
||||||
|
Vue.delete(parent2.children, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.foods = this.foods.filter(kw => kw.id != id)
|
||||||
|
this.foods2 = this.foods2.filter(kw => kw.id != id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
</style>
|
10
vue/src/apps/FoodListView/main.js
Normal file
10
vue/src/apps/FoodListView/main.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import App from './FoodListView'
|
||||||
|
import i18n from '@/i18n'
|
||||||
|
|
||||||
|
Vue.config.productionTip = false
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
i18n,
|
||||||
|
render: h => h(App),
|
||||||
|
}).$mount('#app')
|
@ -368,7 +368,6 @@ export default {
|
|||||||
},
|
},
|
||||||
delKeyword: function (id) {
|
delKeyword: function (id) {
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
let p_id = null
|
|
||||||
|
|
||||||
apiClient.destroyKeyword(id).then(response => {
|
apiClient.destroyKeyword(id).then(response => {
|
||||||
this.destroyCard(id)
|
this.destroyCard(id)
|
||||||
|
@ -159,11 +159,15 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<b-input-group class="mt-2">
|
<b-input-group class="mt-2">
|
||||||
<generic-multiselect @change="genericSelectChanged" parent_variable="search_foods"
|
<!-- <generic-multiselect @change="genericSelectChanged" parent_variable="search_foods"
|
||||||
:initial_selection="settings.search_foods"
|
:initial_selection="settings.search_foods"
|
||||||
search_function="listFoods" label="name"
|
search_function="listFoods" label="name"
|
||||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||||
v-bind:placeholder="$t('Ingredients')" :limit="20"></generic-multiselect>
|
v-bind:placeholder="$t('Ingredients')"></generic-multiselect> -->
|
||||||
|
<treeselect v-model="settings.search_foods" :options="facets.Foods" :flat="true"
|
||||||
|
searchNested multiple :placeholder="$t('Ingredients')" :normalizer="normalizer"
|
||||||
|
@input="refreshData(false)"
|
||||||
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"/>
|
||||||
<b-input-group-append>
|
<b-input-group-append>
|
||||||
<b-input-group-text>
|
<b-input-group-text>
|
||||||
<b-form-checkbox v-model="settings.search_foods_or" name="check-button"
|
<b-form-checkbox v-model="settings.search_foods_or" name="check-button"
|
||||||
@ -376,9 +380,7 @@ export default {
|
|||||||
apiClient.listRecipes(
|
apiClient.listRecipes(
|
||||||
this.settings.search_input,
|
this.settings.search_input,
|
||||||
this.settings.search_keywords,
|
this.settings.search_keywords,
|
||||||
this.settings.search_foods.map(function (A) {
|
this.settings.search_foods,
|
||||||
return A["id"];
|
|
||||||
}),
|
|
||||||
this.settings.search_books.map(function (A) {
|
this.settings.search_books.map(function (A) {
|
||||||
return A["id"];
|
return A["id"];
|
||||||
}),
|
}),
|
||||||
|
215
vue/src/components/FoodCard.vue
Normal file
215
vue/src/components/FoodCard.vue
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
<template>
|
||||||
|
<div row>
|
||||||
|
<b-card no-body d-flex flex-column :class="{'border border-primary' : over, 'shake': isError}"
|
||||||
|
refs="foodCard"
|
||||||
|
style="height: 10vh;" :style="{'cursor:grab' : draggable}"
|
||||||
|
@dragover.prevent
|
||||||
|
@dragenter.prevent
|
||||||
|
:draggable="draggable"
|
||||||
|
@dragstart="handleDragStart($event)"
|
||||||
|
@dragenter="handleDragEnter($event)"
|
||||||
|
@dragleave="handleDragLeave($event)"
|
||||||
|
@drop="handleDragDrop($event)">
|
||||||
|
<b-row no-gutters style="height:inherit;">
|
||||||
|
<b-col no-gutters md="3" style="justify-content: center; height:inherit;">
|
||||||
|
<b-card-img-lazy style="object-fit: cover; height: 10vh;" :src="food_image" v-bind:alt="$t('Recipe_Image')"></b-card-img-lazy>
|
||||||
|
</b-col>
|
||||||
|
<b-col no-gutters md="9" style="height:inherit;">
|
||||||
|
<b-card-body class="m-0 py-0" style="height:inherit;">
|
||||||
|
<b-card-text class=" h-100 my-0 d-flex flex-column" style="text-overflow: ellipsis">
|
||||||
|
<h5 class="m-0 mt-1 text-truncate">{{ food.name }}</h5>
|
||||||
|
<div class= "m-0 text-truncate">{{ food.description }}</div>
|
||||||
|
<div class="mt-auto mb-1 d-flex flex-row justify-content-end">
|
||||||
|
<div v-if="food.numchild !=0" class="mx-2 btn btn-link btn-sm"
|
||||||
|
style="z-index: 800;" v-on:click="$emit('item-action',{'action':'get-children','source':food})">
|
||||||
|
<div v-if="!food.expanded">{{food.numchild}} {{$t('Foods')}}</div>
|
||||||
|
<div v-else>{{$t('Hide Foods')}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mx-2 btn btn-link btn-sm" style="z-index: 800;"
|
||||||
|
v-on:click="$emit('item-action',{'action':'get-recipes','source':food})">
|
||||||
|
<div v-if="!food.show_recipes">{{food.numrecipe}} {{$t('Recipes')}}</div>
|
||||||
|
<div v-else>{{$t('Hide Recipes')}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</b-card-text>
|
||||||
|
</b-card-body>
|
||||||
|
</b-col>
|
||||||
|
<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">
|
||||||
|
<generic-context-menu style="float:right"
|
||||||
|
:show_merge="true"
|
||||||
|
:show_move="true"
|
||||||
|
@item-action="$emit('item-action', {'action': $event, 'source': food})">
|
||||||
|
</generic-context-menu>
|
||||||
|
</div>
|
||||||
|
</b-row>
|
||||||
|
</b-card>
|
||||||
|
<!-- recursively add child foods -->
|
||||||
|
<div class="row" v-if="food.expanded">
|
||||||
|
<div class="col-md-11 offset-md-1">
|
||||||
|
<food-card v-for="child in food.children"
|
||||||
|
:food="child"
|
||||||
|
v-bind:key="child.id"
|
||||||
|
draggable="true"
|
||||||
|
@item-action="$emit('item-action', $event)">
|
||||||
|
</food-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- conditionally view recipes -->
|
||||||
|
<div class="row" v-if="food.show_recipes">
|
||||||
|
<div class="col-md-11 offset-md-1">
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));grid-gap: 1rem;">
|
||||||
|
<recipe-card v-for="r in food.recipes"
|
||||||
|
v-bind:key="r.id"
|
||||||
|
:recipe="r">
|
||||||
|
</recipe-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- this should be made a generic component, would also require mixin for functions that generate the popup and put in parent container-->
|
||||||
|
<b-list-group ref="tooltip" variant="light" v-show="show_menu" v-on-clickaway="closeMenu" style="z-index:999; cursor:pointer">
|
||||||
|
<b-list-group-item action v-on:click="$emit('item-action',{'action': 'move', 'target': food, 'source': source}); closeMenu()">
|
||||||
|
{{$t('Move')}}: {{$t('move_confirmation', {'child': source.name,'parent':food.name})}}
|
||||||
|
</b-list-group-item>
|
||||||
|
<b-list-group-item action v-on:click="$emit('item-action',{'action': 'merge', 'target': food, 'source': source}); closeMenu()">
|
||||||
|
{{$t('Merge')}}: {{ $t('merge_confirmation', {'source': source.name,'target':food.name}) }}
|
||||||
|
</b-list-group-item>
|
||||||
|
<b-list-group-item action v-on:click="closeMenu()">
|
||||||
|
{{$t('Cancel')}}
|
||||||
|
</b-list-group-item>
|
||||||
|
<!-- TODO add to shopping list -->
|
||||||
|
<!-- TODO add to and/or manage pantry -->
|
||||||
|
</b-list-group>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import GenericContextMenu from "@/components/GenericContextMenu";
|
||||||
|
import RecipeCard from "@/components/RecipeCard";
|
||||||
|
import { mixin as clickaway } from 'vue-clickaway';
|
||||||
|
import { createPopper } from '@popperjs/core';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "FoodCard",
|
||||||
|
components: { GenericContextMenu, RecipeCard },
|
||||||
|
mixins: [clickaway],
|
||||||
|
props: {
|
||||||
|
food: Object,
|
||||||
|
draggable: {type: Boolean, default: false}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
food_image: '',
|
||||||
|
over: false,
|
||||||
|
show_menu: false,
|
||||||
|
dragMenu: undefined,
|
||||||
|
isError: false,
|
||||||
|
source: {},
|
||||||
|
target: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (this.food == null || this.food.image == null) {
|
||||||
|
this.food_image = window.IMAGE_PLACEHOLDER
|
||||||
|
} else {
|
||||||
|
this.food_image = this.food.image
|
||||||
|
}
|
||||||
|
this.dragMenu = this.$refs.tooltip
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleDragStart: function(e) {
|
||||||
|
this.isError = false
|
||||||
|
e.dataTransfer.setData('source', JSON.stringify(this.food))
|
||||||
|
},
|
||||||
|
handleDragEnter: function(e) {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget) && e.relatedTarget != null) {
|
||||||
|
this.over = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleDragLeave: function(e) {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||||
|
this.over = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleDragDrop: function(e) {
|
||||||
|
let source = JSON.parse(e.dataTransfer.getData('source'))
|
||||||
|
if (source.id != this.food.id){
|
||||||
|
this.source = source
|
||||||
|
let menuLocation = {getBoundingClientRect: this.generateLocation(e.pageX, e.pageY),}
|
||||||
|
this.show_menu = true
|
||||||
|
let popper = createPopper(
|
||||||
|
menuLocation,
|
||||||
|
this.dragMenu,
|
||||||
|
{
|
||||||
|
placement: 'bottom-start',
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: 'preventOverflow',
|
||||||
|
options: {
|
||||||
|
rootBoundary: 'document',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'flip',
|
||||||
|
options: {
|
||||||
|
fallbackPlacements: ['bottom-end', 'top-start', 'top-end', 'left-start', 'right-start'],
|
||||||
|
rootBoundary: 'document',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
popper.update()
|
||||||
|
this.over = false
|
||||||
|
this.$emit({'action': 'drop', 'target': this.food, 'source': this.source})
|
||||||
|
} else {
|
||||||
|
this.isError = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
generateLocation: function (x = 0, y = 0) {
|
||||||
|
return () => ({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
top: y,
|
||||||
|
right: x,
|
||||||
|
bottom: y,
|
||||||
|
left: x,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
closeMenu: function(){
|
||||||
|
this.show_menu = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shake {
|
||||||
|
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
backface-visibility: hidden;
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
10%,
|
||||||
|
90% {
|
||||||
|
transform: translate3d(-1px, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
20%,
|
||||||
|
80% {
|
||||||
|
transform: translate3d(2px, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
30%,
|
||||||
|
50%,
|
||||||
|
70% {
|
||||||
|
transform: translate3d(-4px, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
40%,
|
||||||
|
60% {
|
||||||
|
transform: translate3d(4px, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -29,6 +29,10 @@ const pages = {
|
|||||||
entry: './src/apps/KeywordListView/main.js',
|
entry: './src/apps/KeywordListView/main.js',
|
||||||
chunks: ['chunk-vendors']
|
chunks: ['chunk-vendors']
|
||||||
},
|
},
|
||||||
|
'food_list_view': {
|
||||||
|
entry: './src/apps/FoodListView/main.js',
|
||||||
|
chunks: ['chunk-vendors']
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
@ -1 +1 @@
|
|||||||
{"status":"done","chunks":{"recipe_search_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/recipe_search_view.js"],"recipe_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/recipe_view.js"],"offline_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/offline_view.js"],"import_response_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/import_response_view.js"],"supermarket_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/supermarket_view.js"],"user_file_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/user_file_view.js"],"keyword_list_view":["css/chunk-vendors.css","js/chunk-vendors.js","css/keyword_list_view.css","js/keyword_list_view.js"]},"assets":{"../../templates/sw.js":{"name":"../../templates/sw.js","path":"../../templates/sw.js"},"css/chunk-vendors.css":{"name":"css/chunk-vendors.css","path":"css/chunk-vendors.css"},"js/chunk-vendors.js":{"name":"js/chunk-vendors.js","path":"js/chunk-vendors.js"},"js/import_response_view.js":{"name":"js/import_response_view.js","path":"js/import_response_view.js"},"css/keyword_list_view.css":{"name":"css/keyword_list_view.css","path":"css/keyword_list_view.css"},"js/keyword_list_view.js":{"name":"js/keyword_list_view.js","path":"js/keyword_list_view.js"},"js/offline_view.js":{"name":"js/offline_view.js","path":"js/offline_view.js"},"js/recipe_search_view.js":{"name":"js/recipe_search_view.js","path":"js/recipe_search_view.js"},"js/recipe_view.js":{"name":"js/recipe_view.js","path":"js/recipe_view.js"},"js/supermarket_view.js":{"name":"js/supermarket_view.js","path":"js/supermarket_view.js"},"js/user_file_view.js":{"name":"js/user_file_view.js","path":"js/user_file_view.js"},"recipe_search_view.html":{"name":"recipe_search_view.html","path":"recipe_search_view.html"},"recipe_view.html":{"name":"recipe_view.html","path":"recipe_view.html"},"offline_view.html":{"name":"offline_view.html","path":"offline_view.html"},"import_response_view.html":{"name":"import_response_view.html","path":"import_response_view.html"},"supermarket_view.html":{"name":"supermarket_view.html","path":"supermarket_view.html"},"user_file_view.html":{"name":"user_file_view.html","path":"user_file_view.html"},"keyword_list_view.html":{"name":"keyword_list_view.html","path":"keyword_list_view.html"},"manifest.json":{"name":"manifest.json","path":"manifest.json"}}}
|
{"status":"done","chunks":{"recipe_search_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/recipe_search_view.js"],"recipe_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/recipe_view.js"],"offline_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/offline_view.js"],"import_response_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/import_response_view.js"],"supermarket_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/supermarket_view.js"],"user_file_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/user_file_view.js"],"keyword_list_view":["css/chunk-vendors.css","js/chunk-vendors.js","css/keyword_list_view.css","js/keyword_list_view.js"],"food_list_view":["css/chunk-vendors.css","js/chunk-vendors.js","css/food_list_view.css","js/food_list_view.js"]},"assets":{"../../templates/sw.js":{"name":"../../templates/sw.js","path":"../../templates/sw.js"},"css/chunk-vendors.css":{"name":"css/chunk-vendors.css","path":"css/chunk-vendors.css"},"js/chunk-vendors.js":{"name":"js/chunk-vendors.js","path":"js/chunk-vendors.js"},"css/food_list_view.css":{"name":"css/food_list_view.css","path":"css/food_list_view.css"},"js/food_list_view.js":{"name":"js/food_list_view.js","path":"js/food_list_view.js"},"js/import_response_view.js":{"name":"js/import_response_view.js","path":"js/import_response_view.js"},"css/keyword_list_view.css":{"name":"css/keyword_list_view.css","path":"css/keyword_list_view.css"},"js/keyword_list_view.js":{"name":"js/keyword_list_view.js","path":"js/keyword_list_view.js"},"js/offline_view.js":{"name":"js/offline_view.js","path":"js/offline_view.js"},"js/recipe_search_view.js":{"name":"js/recipe_search_view.js","path":"js/recipe_search_view.js"},"js/recipe_view.js":{"name":"js/recipe_view.js","path":"js/recipe_view.js"},"js/supermarket_view.js":{"name":"js/supermarket_view.js","path":"js/supermarket_view.js"},"js/user_file_view.js":{"name":"js/user_file_view.js","path":"js/user_file_view.js"},"recipe_search_view.html":{"name":"recipe_search_view.html","path":"recipe_search_view.html"},"recipe_view.html":{"name":"recipe_view.html","path":"recipe_view.html"},"offline_view.html":{"name":"offline_view.html","path":"offline_view.html"},"import_response_view.html":{"name":"import_response_view.html","path":"import_response_view.html"},"supermarket_view.html":{"name":"supermarket_view.html","path":"supermarket_view.html"},"user_file_view.html":{"name":"user_file_view.html","path":"user_file_view.html"},"keyword_list_view.html":{"name":"keyword_list_view.html","path":"keyword_list_view.html"},"food_list_view.html":{"name":"food_list_view.html","path":"food_list_view.html"},"manifest.json":{"name":"manifest.json","path":"manifest.json"}}}
|
Loading…
Reference in New Issue
Block a user