mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2026-01-03 21:37:49 -05:00
Merge branch 'develop' of https://github.com/vabene1111/recipes into develop
# Conflicts: # vue/src/components/Modals/LookupInput.vue
This commit is contained in:
@@ -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 /
|
||||
|
||||
@@ -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)
|
||||
@@ -128,7 +169,7 @@ def sort_tree(modeladmin, request, queryset):
|
||||
class KeywordAdmin(TreeAdmin):
|
||||
form = movenodeform_factory(Keyword)
|
||||
ordering = ('space', 'path',)
|
||||
search_fields = ('name', )
|
||||
search_fields = ('name',)
|
||||
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]
|
||||
|
||||
|
||||
@@ -171,13 +212,15 @@ class RecipeAdmin(admin.ModelAdmin):
|
||||
admin.site.register(Recipe, RecipeAdmin)
|
||||
|
||||
admin.site.register(Unit)
|
||||
|
||||
|
||||
# admin.site.register(FoodInheritField)
|
||||
|
||||
|
||||
class FoodAdmin(TreeAdmin):
|
||||
form = movenodeform_factory(Keyword)
|
||||
ordering = ('space', 'path',)
|
||||
search_fields = ('name', )
|
||||
search_fields = ('name',)
|
||||
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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() ]]
|
||||
@@ -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+'"'
|
||||
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
@@ -71,4 +71,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
$('#id_username').focus()
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
118
docs/install/swag.md
Normal 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.)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -25,11 +25,11 @@
|
||||
|
||||
<script>
|
||||
import GenericMultiselect from "@/components/GenericMultiselect"
|
||||
import {StandardToasts, ApiMixin} from "@/utils/utils"
|
||||
import { StandardToasts, ApiMixin } from "@/utils/utils"
|
||||
|
||||
export default {
|
||||
name: "LookupInput",
|
||||
components: {GenericMultiselect},
|
||||
components: { GenericMultiselect },
|
||||
mixins: [ApiMixin],
|
||||
props: {
|
||||
form: {
|
||||
@@ -44,9 +44,8 @@ export default {
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
class_list: {type: String, default: "mb-3"},
|
||||
show_label: {type: Boolean, default: true},
|
||||
clear: {type: Number},
|
||||
show_label: { type: Boolean, default: true },
|
||||
clear: { type: Number },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -81,9 +80,9 @@ export default {
|
||||
} else if (typeof this_value === "object") {
|
||||
arrayValues = [this_value]
|
||||
} else {
|
||||
arrayValues = [{id: -1, name: this_value}]
|
||||
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
|
||||
@@ -111,7 +110,7 @@ export default {
|
||||
addNew: function (e) {
|
||||
// if create a new item requires more than 1 parameter or the field 'name' is insufficient this will need reworked
|
||||
// in a perfect world this would trigger a new modal and allow editing all fields
|
||||
this.genericAPI(this.model, this.Actions.CREATE, {name: e})
|
||||
this.genericAPI(this.model, this.Actions.CREATE, { name: e })
|
||||
.then((result) => {
|
||||
this.new_value = result.data
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
|
||||
@@ -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>
|
||||
@@ -44,10 +66,17 @@
|
||||
<div class="row">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</b-modal>
|
||||
@@ -62,12 +91,12 @@
|
||||
:allow_delete="false"
|
||||
:modal_title="$t('Create_Meal_Plan_Entry')"
|
||||
></meal-plan-edit-modal>
|
||||
<shopping-modal :recipe="recipe" :servings="servings_value" :modal_id="modal_id" />
|
||||
<shopping-modal :recipe="recipe" :servings="servings_value" :modal_id="modal_id"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts } from "@/utils/utils"
|
||||
import {makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts} from "@/utils/utils"
|
||||
import CookLog from "@/components/CookLog"
|
||||
import axios from "axios"
|
||||
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"
|
||||
@@ -75,7 +104,7 @@ import MealPlanEditModal from "@/components/MealPlanEditModal"
|
||||
import ShoppingModal from "@/components/Modals/ShoppingModal"
|
||||
import moment from "moment"
|
||||
import Vue from "vue"
|
||||
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||
import {ApiApiFactory} from "@/utils/openapi/api"
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user