Merge branch 'develop' of https://github.com/vabene1111/recipes into develop

# Conflicts:
#	vue/src/components/Modals/LookupInput.vue
This commit is contained in:
Kaibu
2022-01-17 23:48:12 +01:00
22 changed files with 591 additions and 323 deletions

View File

@@ -46,6 +46,7 @@ SHOPPING_MIN_AUTOSYNC_INTERVAL=5
# STICKY_NAV_PREF_DEFAULT=1
# If base URL is something other than just / (you are serving a subfolder in your proxy for instance http://recipe_app/recipes/)
# Be sure to not have a trailing slash: e.g. '/recipes' instead of '/recipes/'
# SCRIPT_NAME=/recipes
# If staticfiles are stored at a different location uncomment and change accordingly, MUST END IN /

View File

@@ -15,7 +15,7 @@ from .models import (BookmarkletImport, Comment, CookLog, Food, FoodInheritField
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
TelegramBot, Unit, UserFile, UserPreference, ViewLog)
TelegramBot, Unit, UserFile, UserPreference, ViewLog, Automation)
class CustomUserAdmin(UserAdmin):
@@ -29,11 +29,52 @@ admin.site.register(User, CustomUserAdmin)
admin.site.unregister(Group)
@admin.action(description='Delete all data from a space')
def delete_space_action(modeladmin, request, queryset):
for space in queryset:
CookLog.objects.filter(space=space).delete()
ViewLog.objects.filter(space=space).delete()
ImportLog.objects.filter(space=space).delete()
BookmarkletImport.objects.filter(space=space).delete()
Comment.objects.filter(recipe__space=space).delete()
Keyword.objects.filter(space=space).delete()
Ingredient.objects.filter(space=space).delete()
Food.objects.filter(space=space).delete()
Unit.objects.filter(space=space).delete()
Step.objects.filter(space=space).delete()
NutritionInformation.objects.filter(space=space).delete()
RecipeBookEntry.objects.filter(book__space=space).delete()
RecipeBook.objects.filter(space=space).delete()
MealType.objects.filter(space=space).delete()
MealPlan.objects.filter(space=space).delete()
ShareLink.objects.filter(space=space).delete()
Recipe.objects.filter(space=space).delete()
RecipeImport.objects.filter(space=space).delete()
SyncLog.objects.filter(sync__space=space).delete()
Sync.objects.filter(space=space).delete()
Storage.objects.filter(space=space).delete()
ShoppingListEntry.objects.filter(shoppinglist__space=space).delete()
ShoppingListRecipe.objects.filter(shoppinglist__space=space).delete()
ShoppingList.objects.filter(space=space).delete()
SupermarketCategoryRelation.objects.filter(supermarket__space=space).delete()
SupermarketCategory.objects.filter(space=space).delete()
Supermarket.objects.filter(space=space).delete()
InviteLink.objects.filter(space=space).delete()
UserFile.objects.filter(space=space).delete()
Automation.objects.filter(space=space).delete()
class SpaceAdmin(admin.ModelAdmin):
list_display = ('name', 'created_by', 'max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
search_fields = ('name', 'created_by__username')
list_filter = ('max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
date_hierarchy = 'created_at'
actions = [delete_space_action]
admin.site.register(Space, SpaceAdmin)
@@ -171,6 +212,8 @@ class RecipeAdmin(admin.ModelAdmin):
admin.site.register(Recipe, RecipeAdmin)
admin.site.register(Unit)
# admin.site.register(FoodInheritField)

View File

@@ -172,6 +172,8 @@ class RecipeSearch():
def keyword_filters(self, keywords=None, operator=True):
if not keywords:
return
if not isinstance(keywords, list):
keywords = [keywords]
if operator == True:
# TODO creating setting to include descendants of keywords a setting
self._queryset = self._queryset.filter(keywords__in=Keyword.include_descendants(Keyword.objects.filter(pk__in=keywords)))
@@ -184,6 +186,8 @@ class RecipeSearch():
def food_filters(self, foods=None, operator=True):
if not foods:
return
if not isinstance(foods, list):
foods = [foods]
if operator == True:
# TODO creating setting to include descendants of food a setting
self._queryset = self._queryset.filter(steps__ingredients__food__in=Food.include_descendants(Food.objects.filter(pk__in=foods)))
@@ -198,7 +202,9 @@ class RecipeSearch():
raise NotImplementedError
if not units:
return
self._queryset = self._queryset.filter(steps__ingredients__unit__id=units)
if not isinstance(units, list):
units = [units]
self._queryset = self._queryset.filter(steps__ingredients__unit__in=units)
def rating_filter(self, rating=None):
if rating is None:
@@ -217,6 +223,8 @@ class RecipeSearch():
def book_filters(self, books=None, operator=True):
if not books:
return
if not isinstance(books, list):
books = [books]
if operator == True:
self._queryset = self._queryset.filter(recipebookentry__book__id__in=books)
else:
@@ -228,6 +236,8 @@ class RecipeSearch():
raise NotImplementedError
if not steps:
return
if not isinstance(steps, list):
steps = [unistepsts]
self._queryset = self._queryset.filter(steps__id__in=steps)
def build_fulltext_filters(self, string=None):
@@ -490,7 +500,7 @@ class RecipeFacet():
'space': self._request.space,
}
elif self.hash_key is not None:
self._recipe_list = self._cache.get('recipe_list', None)
self._recipe_list = self._cache.get('recipe_list', [])
self._search_params = {
'keyword_list': self._cache.get('keyword_list', None),
'food_list': self._cache.get('food_list', None),
@@ -637,7 +647,7 @@ class RecipeFacet():
depth = getattr(keyword, 'depth', 0) + 1
steplen = depth * Keyword.steplen
return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('keywords', depth, steplen)), 0)
return queryset.annotate(count=Coalesce(1, 0)
).filter(depth=depth, count__gt=0
).values('id', 'name', 'count', 'numchild').order_by('name')
@@ -645,7 +655,7 @@ class RecipeFacet():
depth = getattr(food, 'depth', 0) + 1
steplen = depth * Food.steplen
return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('steps__ingredients__food', depth, steplen)), 0)
return queryset.annotate(count=Coalesce(1, 0)
).filter(depth__lte=depth, count__gt=0
).values('id', 'name', 'count', 'numchild').order_by('name')

View File

@@ -2,7 +2,6 @@ import json
from io import BytesIO, StringIO
from re import match
from zipfile import ZipFile
from django.utils.text import get_valid_filename
from rest_framework.renderers import JSONRenderer
@@ -57,8 +56,7 @@ class Default(Integration):
pass
recipe_zip_obj.close()
export_zip_obj.writestr(get_valid_filename(r.name) + '.zip', recipe_zip_stream.getvalue())
export_zip_obj.writestr(str(r.pk) + '.zip', recipe_zip_stream.getvalue())
export_zip_obj.close()
return [[ 'export.zip', export_zip_stream.getvalue() ]]

View File

@@ -42,7 +42,7 @@ class Integration:
try:
last_kw = Keyword.objects.filter(name__regex=r'^(Import [0-9]+)', space=request.space).latest('created_at')
name = f'Import {int(last_kw.name.replace("Import ", "")) + 1}'
except ObjectDoesNotExist:
except (ObjectDoesNotExist, ValueError):
name = 'Import 1'
parent, created = Keyword.objects.get_or_create(name='Import', space=request.space)
@@ -53,7 +53,7 @@ class Integration:
icon=icon,
space=request.space
)
except IntegrityError: # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now.
except (IntegrityError, ValueError): # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now.
self.keyword = parent.add_child(
name=f'{name} {str(uuid.uuid4())[0:8]}',
description=description,
@@ -86,12 +86,10 @@ class Integration:
export_obj.close()
export_file = export_stream.getvalue()
response = HttpResponse(export_file, content_type='application/force-download')
response['Content-Disposition'] = 'attachment; filename="' + export_filename + '"'
return response
def import_file_name_filter(self, zip_info_object):
"""
Since zipfile.namelist() returns all files in all subdirectories this function allows filtering of files
@@ -262,7 +260,6 @@ class Integration:
"""
raise NotImplementedError('Method not implemented in integration')
def get_files_from_recipes(self, recipes, cookie):
"""
Takes a list of recipe object and converts it to a array containing each file.

View File

@@ -29,7 +29,11 @@ class Nextcloud(Provider):
client = Nextcloud.get_client(monitor.storage)
files = client.list(monitor.path)
try:
files.pop(0) # remove first element because its the folder itself
except IndexError:
pass # folder is emtpy, no recipes will be imported
import_count = 0
for file in files:

View File

@@ -165,9 +165,10 @@ class FoodInheritFieldSerializer(WritableNestedModelSerializer):
read_only_fields = ['id']
class UserPreferenceSerializer(serializers.ModelSerializer):
class UserPreferenceSerializer(WritableNestedModelSerializer):
food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', many=True, allow_null=True, required=False, read_only=True)
plan_share = UserNameSerializer(many=True, allow_null=True, required=False, read_only=True)
shopping_share = UserNameSerializer(many=True, allow_null=True, required=False)
def create(self, validated_data):
if not validated_data.get('user', None):
@@ -475,7 +476,7 @@ class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
# check if root type is recipe to prevent infinite recursion
# can be improved later to allow multi level embedding
if obj.step_recipe and type(self.parent.root) == RecipeSerializer:
return StepRecipeSerializer(obj.step_recipe).data
return StepRecipeSerializer(obj.step_recipe, context={'request': self.context['request']}).data
class Meta:
model = Step
@@ -496,6 +497,11 @@ class StepRecipeSerializer(WritableNestedModelSerializer):
class NutritionInformationSerializer(serializers.ModelSerializer):
carbohydrates = CustomDecimalField()
fats = CustomDecimalField()
proteins = CustomDecimalField()
calories = CustomDecimalField()
def create(self, validated_data):
validated_data['space'] = self.context['request'].space

View File

@@ -29,12 +29,16 @@
{% endif %}
<button class="btn btn-success" type="submit">{% trans "Sign In" %}</button>
{% if SIGNUP_ENABLED %}
<a class="btn btn-secondary" href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
{% endif %}
{% if EMAIL_ENABLED %}
<a class="btn btn-warning float-right d-none d-xl-block d-lg-block"
href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a>
<p class="d-xl-none d-lg-none">{% trans 'Lost your password?' %} <a href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a></p>
<p class="d-xl-none d-lg-none">{% trans 'Lost your password?' %} <a
href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a></p>
{% endif %}
</form>
</div>
@@ -62,5 +66,8 @@
</div>
{% endif %}
<script>
$('#id_login').focus()
</script>
{% endblock %}

View File

@@ -71,4 +71,8 @@
</div>
<script>
$('#id_username').focus()
</script>
{% endblock %}

View File

@@ -660,13 +660,14 @@
</div>
<script src="{% url 'javascript-catalog' %}"></script>
<script src="{% url 'javascript-catalog' %}">
</script>
<script type="application/javascript">
let csrftoken = Cookies.get('csrftoken');
Vue.http.headers.common['X-CSRFToken'] = csrftoken;
Vue.component('vue-multiselect', window.VueMultiselect.default)
import { ApiApiFactory } from "@/utils/openapi/api"
let app = new Vue({
components: {
@@ -885,27 +886,27 @@
this.$set(this.$refs.ingredient[index].$data, 'search', this.recipe_data.recipeIngredient[index].ingredient.text)
},
searchKeywords: function (query) {
// this.keywords_loading = true
// this.$http.get("{% url 'dal_keyword' %}" + '?q=' + query).then((response) => {
// this.keywords = response.data.results;
// this.keywords_loading = false
// }).catch((err) => {
// console.log(err)
// this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
// })
let apiFactory = new ApiApiFactory()
this.keywords_loading = true
apiFactory
.listKeywords(query, undefined, undefined, 1, this.options_limit)
.then((response) => {
this.keywords = response.data.results
this.$http.get("{% url 'dal_keyword' %}" + '?q=' + query).then((response) => {
this.keywords = response.data.results;
this.keywords_loading = false
})
.catch((err) => {
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
})
// let apiFactory = new ApiApiFactory()
// this.keywords_loading = true
// apiFactory
// .listKeywords(query, undefined, undefined, 1, this.options_limit)
// .then((response) => {
// this.keywords = response.data.results
// this.keywords_loading = false
// })
// .catch((err) => {
// console.log(err)
// StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
// })
},
searchUnits: function (query) {
let apiFactory = new ApiApiFactory()
@@ -932,46 +933,46 @@
})
},
searchIngredients: function (query) {
// this.ingredients_loading = true
// this.$http.get("{% url 'dal_food' %}" + '?q=' + query).then((response) => {
// this.ingredients = response.data.results
// if (this.recipe_data !== undefined) {
// for (let x of Array.from(this.recipe_data.recipeIngredient)) {
// if (x.ingredient.text !== '') {
// this.ingredients = this.ingredients.filter(item => item.text !== x.ingredient.text)
// this.ingredients.push(x.ingredient)
this.ingredients_loading = true
this.$http.get("{% url 'dal_food' %}" + '?q=' + query).then((response) => {
this.ingredients = response.data.results
if (this.recipe_data !== undefined) {
for (let x of Array.from(this.recipe_data.recipeIngredient)) {
if (x.ingredient.text !== '') {
this.ingredients = this.ingredients.filter(item => item.text !== x.ingredient.text)
this.ingredients.push(x.ingredient)
}
}
}
this.ingredients_loading = false
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
})
// let apiFactory = new ApiApiFactory()
// this.foods_loading = true
// apiFactory
// .listFoods(query, undefined, undefined, 1, this.options_limit)
// .then((response) => {
// this.foods = response.data.results
// if (this.recipe !== undefined) {
// for (let s of this.recipe.steps) {
// for (let i of s.ingredients) {
// if (i.food !== null && i.food.id === undefined) {
// this.foods.push(i.food)
// }
// }
// }
// }
// this.ingredients_loading = false
// }).catch((err) => {
// console.log(err)
// this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
// this.foods_loading = false
// })
// .catch((err) => {
// StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
// })
let apiFactory = new ApiApiFactory()
this.foods_loading = true
apiFactory
.listFoods(query, undefined, undefined, 1, this.options_limit)
.then((response) => {
this.foods = response.data.results
if (this.recipe !== undefined) {
for (let s of this.recipe.steps) {
for (let i of s.ingredients) {
if (i.food !== null && i.food.id === undefined) {
this.foods.push(i.food)
}
}
}
}
this.foods_loading = false
})
.catch((err) => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
})
},
deleteNode: function (node, item, e) {
e.stopPropagation()

View File

@@ -655,7 +655,8 @@ class RecipeViewSet(viewsets.ModelViewSet):
# self.queryset = search_recipes(self.request, self.queryset, self.request.GET)
params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x in list(self.request.GET)}
self.queryset = RecipeSearch(self.request, **params).get_queryset(self.queryset).prefetch_related('cooklog_set')
search = RecipeSearch(self.request, **params)
self.queryset = search.get_queryset(self.queryset).prefetch_related('cooklog_set')
return self.queryset
def list(self, request, *args, **kwargs):

View File

@@ -45,21 +45,15 @@ def hook(request, token):
tb.save()
if tb.chat_id == str(data['message']['chat']['id']):
sl = ShoppingList.objects.filter(Q(created_by=tb.created_by)).filter(finished=False, space=tb.space).order_by('-created_at').first()
if not sl:
sl = ShoppingList.objects.create(created_by=tb.created_by, space=tb.space)
request.space = tb.space # TODO this is likely a bad idea. Verify and test
request.user = tb.created_by
ingredient_parser = IngredientParser(request, False)
amount, unit, ingredient, note = ingredient_parser.parse(data['message']['text'])
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
sl.entries.add(
ShoppingListEntry.objects.create(
food=f, unit=u, amount=amount
)
)
ShoppingListEntry.objects.create(food=f, unit=u, amount=amount, created_by=request.user, space=request.space)
return JsonResponse({'data': data['message']['text']})
except Exception:
pass

View File

@@ -138,6 +138,8 @@ In both cases, also make sure to mount `/media/` in your swag container to point
Please refer to the [appropriate documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup.
For step-by-step instructions to set this up from scratch, see [this example](swag.md).
### Others
If you use none of the above mentioned reverse proxies or want to use an existing one on your host machine (like a local nginx or Caddy), simply use the [PLAIN](https://docs.tandoor.dev/install/docker/#plain) setup above and change the outbound port to one of your liking.

118
docs/install/swag.md Normal file
View File

@@ -0,0 +1,118 @@
!!! danger
Please refer to the [official documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup. This example shows just one setup that may or may not differ from yours in significant ways. This tutorial does not cover security measures, backups, and many other things that you might want to consider.
## Prerequisites
- You have a newly spun-up Ubuntu server with docker (pre-)installed.
- At least one `mydomain.com` and one `mysubdomain.mydomain.com` are pointing to the server's IP. (This tutorial does not cover subfolder installation.)
- You have an ssh terminal session open.
## Installation
### Download and edit Tandoor configuration
```
cd /opt
mkdir recipes
cd recipes
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env
base64 /dev/urandom | head -c50
```
Copy the response from that last command and paste the key into the `.env` file:
```
nano .env
```
You'll also need to enter a Postgres password into the `.env` file. Then, save the file and exit the editor.
### Install and configure Docker Compose
In keeping with [these instructions](https://docs.linuxserver.io/general/docker-compose):
```
cd /opt
curl -L --fail https://raw.githubusercontent.com/linuxserver/docker-docker-compose/master/run.sh -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
```
Next, create and edit the docker compose file.
```
nano docker-compose.yml
```
Paste the following and adjust your domains, subdomains and time zone.
```
---
version: "2.1"
services:
swag:
image: ghcr.io/linuxserver/swag
container_name: swag
cap_add:
- NET_ADMIN
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Berlin # <---- EDIT THIS <---- <----
- URL=mydomain.com # <---- EDIT THIS <---- <----
- SUBDOMAINS=mysubdomain,myothersubdomain # <---- EDIT THIS <---- <----
- EXTRA_DOMAINS=myotherdomain.com # <---- EDIT THIS <---- <----
- VALIDATION=http
volumes:
- ./swag:/config
- ./recipes/media:/media
ports:
- 443:443
- 80:80
restart: unless-stopped
db_recipes:
restart: always
container_name: db_recipes
image: postgres:11-alpine
volumes:
- ./recipes/db:/var/lib/postgresql/data
env_file:
- ./recipes/.env
recipes:
image: vabene1111/recipes
container_name: recipes
restart: unless-stopped
env_file:
- ./recipes/.env
environment:
- UID=1000
- GID=1000
- TZ=Europe/Berlin # <---- EDIT THIS <---- <----
volumes:
- ./recipes/static:/opt/recipes/staticfiles
- ./recipes/media:/opt/recipes/mediafiles
depends_on:
- db_recipes
```
Save and exit.
### Create containers and configure swag reverse proxy
```
docker-compose up -d
```
```
cd /opt/swag/nginx/proxy-confs
cp recipes.subdomain.conf.sample recipes.subdomain.conf
nano recipes.subdomain.conf
```
Change the line `server_name recipes.*;` to `server_name mysubdomain.*;`, save and exit.
### Finalize
```
cd /opt
docker restart swag recipes
```
Go to `https://mysubdomain.mydomain.com`. (If you get a "502 Bad Gateway" error, be patient. It might take a short while until it's functional.)

View File

@@ -586,6 +586,8 @@ export default {
if (this.recipe.working_time === "" || isNaN(this.recipe.working_time)) {
this.recipe.working_time = 0
}
this.recipe.servings = Math.floor(this.recipe.servings) // temporary fix until a proper framework for frontend input validation is established
if (this.recipe.servings === "" || isNaN(this.recipe.servings)) {
this.recipe.servings = 0
}

View File

@@ -1,6 +1,6 @@
<template>
<div id="app" style="margin-bottom: 4vh">
<RecipeSwitcher mode="mealplan" />
<RecipeSwitcher ref="ref_recipe_switcher"/>
<div class="row">
<div class="col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1">
<div class="row">

View File

@@ -5,7 +5,7 @@
</template>
<div v-if="!loading">
<RecipeSwitcher :recipe="rootrecipe.id" :name="rootrecipe.name" mode="recipe" @switch="quickSwitch($event)" />
<RecipeSwitcher ref="ref_recipe_switcher" @switch="quickSwitch($event)" />
<div class="row">
<div class="col-12" style="text-align: center">
<h3>{{ recipe.name }}</h3>

View File

@@ -1,45 +1,75 @@
<template>
<div v-if="recipes !== {}">
<div id="switcher" class="align-center">
<i class="btn btn-outline-dark fas fa-receipt fa-xl fa-fw shadow-none btn-circle"
<i class="btn btn-primary fas fa-receipt fa-xl fa-fw shadow-none btn-circle"
v-b-toggle.related-recipes/>
</div>
<b-sidebar id="related-recipes" title="Quick actions" backdrop right shadow="sm" style="z-index: 10000">
<b-sidebar id="related-recipes" backdrop right bottom no-header shadow="sm" style="z-index: 10000"
@shown="updatePinnedRecipes()">
<template #default="{ hide }">
<nav class="mb-3 ml-3">
<b-nav vertical>
<h5><i class="fas fa-calendar fa-fw"></i> Planned</h5>
<div class="d-flex flex-column justify-content-end h-100 p-3 align-items-end">
<h5>Planned <i class="fas fa-calendar fa-fw"></i></h5>
<div class="text-right">
<template v-if="planned_recipes.length > 0">
<div v-for="r in planned_recipes" :key="`plan${r.id}`">
<b-nav-item variant="link" @click="
navRecipe(r)
hide()
">{{ r.name }}
</b-nav-item>
<div class="pb-1 pt-1">
<a @click=" navRecipe(r); hide()" href="javascript:void(0);">{{ r.name }}</a>
</div>
</div>
</template>
<template v-else>
<span class="text-muted">You have nothing planned for today!</span>
</template>
</div>
<hr/>
<h5><i class="fas fa-thumbtack fa-fw"></i> Pinned</h5>
<h5>Pinned <i class="fas fa-thumbtack fa-fw"></i></h5>
<template v-if="pinned_recipes.length > 0">
<div class="text-right">
<div v-for="r in pinned_recipes" :key="`pin${r.id}`">
<b-nav-item variant="link" @click="
navRecipe(r)
hide()
">{{ r.name }}
</b-nav-item>
</div>
<hr/>
<h5><i class="fas fa-link fa-fw"></i> Related</h5>
<b-row class="pb-1 pt-1">
<b-col cols="2">
<a href="javascript:void(0)" @click="unPinRecipe(r)"
class="text-muted"><i class="fas fa-times"></i></a>
</b-col>
<b-col cols="10">
<a @click="navRecipe(r); hide()" href="javascript:void(0);"
class="align-self-end">{{ r.name }} </a>
</b-col>
</b-row>
<div v-for="r in related_recipes" :key="`related${r.id}`">
<b-nav-item variant="link" @click="
navRecipe(r)
hide()
">{{ r.name }}
</b-nav-item>
</div>
</b-nav>
</nav>
</div>
</template>
<template v-else>
<span class="text-muted">You have no pinned recipes!</span>
</template>
<template v-if="related_recipes.length > 0">
<h5>Related <i class="fas fa-link fa-fw"></i></h5>
<div class="text-right">
<div v-for="r in related_recipes" :key="`related${r.id}`">
<div class="pb-1 pt-1">
<a @click=" navRecipe(r); hide()" href="javascript:void(0);">{{ r.name }}</a>
</div>
</div>
</div>
</template>
</div>
</template>
<template #footer="{ hide }">
<div class="d-flex bg-dark text-light align-items-center px-3 py-2">
<strong class="mr-auto">Quick actions</strong>
<b-button size="sm" @click="hide">Close</b-button>
</div>
</template>
</b-sidebar>
</div>
@@ -60,7 +90,7 @@ export default {
related_recipes: [],
planned_recipes: [],
pinned_recipes: [],
recipes: {}
recipes: {},
}
},
computed: {
@@ -89,14 +119,22 @@ export default {
window.location.href = this.resolveDjangoUrl("view_recipe", recipe.id)
}
},
updatePinnedRecipes: function () {
//TODO clean this up to prevent duplicate API calls
this.loadPinnedRecipes()
this.loadRecipeData()
},
loadRecipeData: function () {
let apiClient = new ApiApiFactory()
let recipe_list = [...this.related_recipes, ...this.planned_recipes, ...this.pinned_recipes]
let recipe_ids = []
recipe_list.forEach((recipe) => {
if (!recipe_ids.includes(recipe.id)) {
recipe_ids.push(recipe.id)
let id = recipe.id
if (!recipe_ids.includes(id)) {
recipe_ids.push(id)
}
})
@@ -111,12 +149,15 @@ export default {
let apiClient = new ApiApiFactory()
// get related recipes and save them for later
return apiClient.relatedRecipe(this.recipe, {query: {levels: 2}}).then((result) => {
this.related_recipes = result.data
if (this.$parent.recipe) {
this.related_recipes = [this.$parent.recipe]
return apiClient.relatedRecipe(this.$parent.recipe.id, {query: {levels: 2, format: 'json'}}).then((result) => {
this.related_recipes = this.related_recipes.concat(result.data)
})
}
},
loadPinnedRecipes: function () {
let pinned_recipe_ids = localStorage.getItem('pinned_recipes') || []
let pinned_recipe_ids = JSON.parse(localStorage.getItem('pinned_recipes')) || []
this.pinned_recipes = pinned_recipe_ids
},
loadMealPlans: function () {
@@ -142,6 +183,13 @@ export default {
return Promise.all(promises)
})
},
unPinRecipe: function (recipe) {
let pinnedRecipes = JSON.parse(localStorage.getItem('pinned_recipes')) || []
pinnedRecipes = pinnedRecipes.filter((r) => r.id !== recipe.id)
console.log('pinned left', pinnedRecipes)
this.pinned_recipes = pinnedRecipes
localStorage.setItem('pinned_recipes', JSON.stringify(pinnedRecipes))
}
},
}
</script>

View File

@@ -8,12 +8,12 @@
:class="{ 'border border-primary': over, shake: isError }"
:style="{ 'cursor:grab': useDrag }"
:draggable="useDrag"
@[useDrag&&`dragover`].prevent
@[useDrag&&`dragenter`].prevent
@[useDrag&&`dragstart`]="handleDragStart($event)"
@[useDrag&&`dragenter`]="handleDragEnter($event)"
@[useDrag&&`dragleave`]="handleDragLeave($event)"
@[useDrag&&`drop`]="handleDragDrop($event)"
@[useDrag&&`dragover`||``].prevent
@[useDrag&&`dragenter`||``].prevent
@[useDrag&&`dragstart`||``]="handleDragStart($event)"
@[useDrag&&`dragenter`||``]="handleDragEnter($event)"
@[useDrag&&`dragleave`||``]="handleDragLeave($event)"
@[useDrag&&`drop`||``]="handleDragDrop($event)"
>
<b-row no-gutters>
<b-col no-gutters class="col-sm-3">
@@ -27,6 +27,7 @@
<div class="m-0 text-truncate small text-muted" v-if="getFullname">{{ getFullname }}</div>
<generic-pill v-for="x in itemTags" :key="x.field" :item_list="itemList(x)" :label="x.label" :color="x.color" />
<generic-ordered-pill
v-for="x in itemOrderedTags"
:key="x.field"
@@ -37,6 +38,7 @@
:item="item"
@finish-action="finishAction"
/>
<div class="mt-auto mb-1" align="right">
<span v-if="item[child_count]" class="mx-2 btn btn-link btn-sm" style="z-index: 800" v-on:click="$emit('item-action', { action: 'get-children', source: item })">
<div v-if="!item.show_children">{{ item[child_count] }} {{ itemName }}</div>

View File

@@ -1,6 +1,6 @@
<template>
<div>
<b-form-group :class="class_list">
<b-form-group class="mb-3">
<template #label v-if="show_label">
{{ form.label }}
</template>
@@ -44,7 +44,6 @@ export default {
return undefined
},
},
class_list: {type: String, default: "mb-3"},
show_label: { type: Boolean, default: true },
clear: { type: Number },
},
@@ -83,7 +82,7 @@ export default {
} else {
arrayValues = [{ id: -1, name: this_value }]
}
if (this.form?.ordered && this.first_run && arrayValues.length > 0) {
if (this.form?.ordered && this.first_run) {
return this.flattenItems(arrayValues)
} else {
return arrayValues

View File

@@ -1,38 +1,60 @@
<template>
<div>
<div class="dropdown d-print-none">
<a class="btn shadow-none" href="javascript:void(0);" role="button" id="dropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<a class="btn shadow-none" href="javascript:void(0);" role="button" id="dropdownMenuLink"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-ellipsis-v fa-lg"></i>
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuLink">
<a class="dropdown-item" :href="resolveDjangoUrl('edit_recipe', recipe.id)"><i class="fas fa-pencil-alt fa-fw"></i> {{ $t("Edit") }}</a>
<a class="dropdown-item" :href="resolveDjangoUrl('edit_recipe', recipe.id)"><i
class="fas fa-pencil-alt fa-fw"></i> {{ $t("Edit") }}</a>
<a class="dropdown-item" :href="resolveDjangoUrl('edit_convert_recipe', recipe.id)" v-if="!recipe.internal"><i class="fas fa-exchange-alt fa-fw"></i> {{ $t("convert_internal") }}</a>
<a class="dropdown-item" :href="resolveDjangoUrl('edit_convert_recipe', recipe.id)"
v-if="!recipe.internal"><i class="fas fa-exchange-alt fa-fw"></i> {{ $t("convert_internal") }}</a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="$bvModal.show(`id_modal_add_book_${modal_id}`)"><i class="fas fa-bookmark fa-fw"></i> {{ $t("Manage_Books") }}</button>
<button class="dropdown-item" @click="$bvModal.show(`id_modal_add_book_${modal_id}`)"><i
class="fas fa-bookmark fa-fw"></i> {{ $t("Manage_Books") }}
</button>
</a>
<a class="dropdown-item" :href="`${resolveDjangoUrl('view_shopping')}?r=[${recipe.id},${servings_value}]`" v-if="recipe.internal" target="_blank" rel="noopener noreferrer">
<a class="dropdown-item"
:href="`${resolveDjangoUrl('view_shopping')}?r=[${recipe.id},${servings_value}]`"
v-if="recipe.internal" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shopping-cart fa-fw"></i> {{ $t("Add_to_Shopping") }}
</a>
<a class="dropdown-item" v-if="recipe.internal" @click="addToShopping" href="#"> <i class="fas fa-shopping-cart fa-fw"></i> {{ $t("create_shopping_new") }} </a>
<a class="dropdown-item" v-if="recipe.internal" @click="addToShopping" href="#"> <i
class="fas fa-shopping-cart fa-fw"></i> {{ $t("create_shopping_new") }} </a>
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i class="fas fa-calendar fa-fw"></i> {{ $t("Add_to_Plan") }} </a>
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i
class="fas fa-calendar fa-fw"></i> {{ $t("Add_to_Plan") }} </a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="$bvModal.show(`id_modal_cook_log_${modal_id}`)"><i class="fas fa-clipboard-list fa-fw"></i> {{ $t("Log_Cooking") }}</button>
<button class="dropdown-item" @click="$bvModal.show(`id_modal_cook_log_${modal_id}`)"><i
class="fas fa-clipboard-list fa-fw"></i> {{ $t("Log_Cooking") }}
</button>
</a>
<a href="javascript:void(0);">
<button class="dropdown-item" onclick="window.print()"><i class="fas fa-print fa-fw"></i> {{ $t("Print") }}</button>
<button class="dropdown-item" onclick="window.print()"><i class="fas fa-print fa-fw"></i>
{{ $t("Print") }}
</button>
</a>
<a class="dropdown-item" :href="resolveDjangoUrl('view_export') + '?r=' + recipe.id" target="_blank" rel="noopener noreferrer"><i class="fas fa-file-export fa-fw"></i> {{ $t("Export") }}</a>
<a class="dropdown-item" :href="resolveDjangoUrl('view_export') + '?r=' + recipe.id" target="_blank"
rel="noopener noreferrer"><i class="fas fa-file-export fa-fw"></i> {{ $t("Export") }}</a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="createShareLink()" v-if="recipe.internal"><i class="fas fa-share-alt fa-fw"></i> {{ $t("Share") }}</button>
<button class="dropdown-item" @click="pinRecipe()"><i class="fas fa-thumbtack fa-fw"></i>
{{ $t("Pin") }}
</button>
</a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="createShareLink()" v-if="recipe.internal"><i
class="fas fa-share-alt fa-fw"></i> {{ $t("Share") }}
</button>
</a>
</div>
</div>
@@ -45,9 +67,16 @@
<div class="col col-md-12">
<label v-if="recipe_share_link !== undefined">{{ $t("Public share link") }}</label>
<input ref="share_link_ref" class="form-control" v-model="recipe_share_link"/>
<b-button class="mt-2 mb-3 d-none d-md-inline" variant="secondary" @click="$bvModal.hide(`modal-share-link_${modal_id}`)">{{ $t("Close") }} </b-button>
<b-button class="mt-2 mb-3 ml-md-2" variant="primary" @click="copyShareLink()">{{ $t("Copy") }}</b-button>
<b-button class="mt-2 mb-3 ml-2 float-right" variant="success" @click="shareIntend()">{{ $t("Share") }} <i class="fa fa-share-alt"></i></b-button>
<b-button class="mt-2 mb-3 d-none d-md-inline" variant="secondary"
@click="$bvModal.hide(`modal-share-link_${modal_id}`)">{{ $t("Close") }}
</b-button>
<b-button class="mt-2 mb-3 ml-md-2" variant="primary" @click="copyShareLink()">{{
$t("Copy")
}}
</b-button>
<b-button class="mt-2 mb-3 ml-2 float-right" variant="success" @click="shareIntend()">{{
$t("Share")
}} <i class="fa fa-share-alt"></i></b-button>
</div>
</div>
</b-modal>
@@ -121,6 +150,11 @@ export default {
this.servings_value = this.servings === -1 ? this.recipe.servings : this.servings
},
methods: {
pinRecipe: function () {
let pinnedRecipes = JSON.parse(localStorage.getItem('pinned_recipes')) || []
pinnedRecipes.push({id: this.recipe.id, name: this.recipe.name})
localStorage.setItem('pinned_recipes', JSON.stringify(pinnedRecipes))
},
saveMealPlan: function (entry) {
entry.date = moment(entry.date).format("YYYY-MM-DD")

View File

@@ -277,14 +277,11 @@
"copy_markdown_table": "Copy as Markdown Table",
"in_shopping": "In Shopping List",
"DelayUntil": "Delay Until",
"Pin": "Pin",
"mark_complete": "Mark Complete",
"QuickEntry": "Quick Entry",
"shopping_add_onhand_desc": "Mark food 'On Hand' when checked off shopping list.",
"shopping_add_onhand": "Auto On Hand",
"related_recipes": "Related Recipes",
"today_recipes": "Today's Recipes",
"mark_complete": "Mark Complete",
"QuickEntry": "Quick Entry",
"shopping_add_onhand_desc": "Mark food 'On Hand' when checked off shopping list.",
"shopping_add_onhand": "Auto On Hand"
"today_recipes": "Today's Recipes"
}