Merge branch 'develop' into docs/docker-installation
This commit is contained in:
commit
075c88e5e8
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -1,4 +1,4 @@
|
||||
name: Continous Integration
|
||||
name: Continuous Integration
|
||||
|
||||
on: [push]
|
||||
|
||||
|
@ -9,7 +9,7 @@
|
||||
<h4 align="center">The recipe manager that allows you to manage your ever growing collection of digital recipes.</h4>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=master" ></a>
|
||||
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continuous%20Integration/badge.svg?branch=master" ></a>
|
||||
<a href="https://github.com/vabene1111/recipes/stargazers" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/stars/vabene1111/recipes" ></a>
|
||||
<a href="https://github.com/vabene1111/recipes/network/members" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/forks/vabene1111/recipes" ></a>
|
||||
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer"><img src="https://badgen.net/badge/icon/discord?icon=discord&label" ></a>
|
||||
|
@ -7,4 +7,4 @@ Since this software is still considered beta/WIP support is always only given fo
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please open a normal public issue if you have any security related concerns. If you feel like the issue should not be discussed in
|
||||
public just open a generic issue and we will discuss further communitcation there (since GitHub does not allow everyone to create a security advisory :/).
|
||||
public just open a generic issue and we will discuss further communication there (since GitHub does not allow everyone to create a security advisory :/).
|
||||
|
@ -442,7 +442,7 @@ class SearchPreferenceForm(forms.ModelForm):
|
||||
|
||||
help_texts = {
|
||||
'search': _(
|
||||
'Select type method of search. Click <a href="/docs/search/">here</a> for full desciption of choices.'),
|
||||
'Select type method of search. Click <a href="/docs/search/">here</a> for full description of choices.'),
|
||||
'lookup': _('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'),
|
||||
'unaccent': _(
|
||||
'Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'),
|
||||
@ -461,7 +461,7 @@ class SearchPreferenceForm(forms.ModelForm):
|
||||
'lookup': _('Fuzzy Lookups'),
|
||||
'unaccent': _('Ignore Accent'),
|
||||
'icontains': _("Partial Match"),
|
||||
'istartswith': _("Starts Wtih"),
|
||||
'istartswith': _("Starts With"),
|
||||
'trigram': _("Fuzzy Search"),
|
||||
'fulltext': _("Full Text")
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ def has_group_permission(user, groups):
|
||||
"""
|
||||
Tests if a given user is member of a certain group (or any higher group)
|
||||
Superusers always bypass permission checks.
|
||||
Unauthenticated users cant be member of any group thus always return false.
|
||||
Unauthenticated users can't be member of any group thus always return false.
|
||||
:param user: django auth user object
|
||||
:param groups: list or tuple of groups the user should be checked for
|
||||
:return: True if user is in allowed groups, false otherwise
|
||||
|
@ -126,7 +126,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
|
||||
add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
|
||||
add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
|
||||
|
||||
# if servings have changed, update the ShoppingListRecipe and existing Entrys
|
||||
# if servings have changed, update the ShoppingListRecipe and existing Entries
|
||||
if servings <= 0:
|
||||
servings = 1
|
||||
|
||||
@ -138,7 +138,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
|
||||
sle.amount = sle.ingredient.amount * Decimal(servings_factor)
|
||||
sle.save()
|
||||
|
||||
# add any missing Entrys
|
||||
# add any missing Entries
|
||||
for i in [x for x in add_ingredients if x.food]:
|
||||
|
||||
ShoppingListEntry.objects.create(
|
||||
|
@ -27,10 +27,10 @@ class RecetteTek(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
|
||||
# Create initial recipe with just a title and a decription
|
||||
# Create initial recipe with just a title and a description
|
||||
recipe = Recipe.objects.create(name=file['title'], created_by=self.request.user, internal=True, space=self.request.space, )
|
||||
|
||||
# set the description as an empty string for later use for the source URL, incase there is no description text.
|
||||
# set the description as an empty string for later use for the source URL, in case there is no description text.
|
||||
recipe.description = ''
|
||||
|
||||
try:
|
||||
|
@ -15,10 +15,10 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2022-01-18 14:52+0100\n"
|
||||
"PO-Revision-Date: 2021-11-12 20:06+0000\n"
|
||||
"Last-Translator: A. L. <richard@anska.de>\n"
|
||||
"Language-Team: German <http://translate.tandoor.dev/projects/tandoor/recipes-"
|
||||
"backend/de/>\n"
|
||||
"PO-Revision-Date: 2022-01-20 22:47+0000\n"
|
||||
"Last-Translator: Sebastian Weber <tandoor@web3r.de>\n"
|
||||
"Language-Team: German <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/de/>\n"
|
||||
"Language: de\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@ -122,10 +122,8 @@ msgstr ""
|
||||
"sollen."
|
||||
|
||||
#: .\cookbook\forms.py:80
|
||||
#, fuzzy
|
||||
#| msgid "Try the new shopping list"
|
||||
msgid "Users with whom to share shopping lists."
|
||||
msgstr "Neue Einkaufsliste ausprobieren"
|
||||
msgstr "Benutzer, mit denen Einkaufslisten geteilt werden sollen."
|
||||
|
||||
#: .\cookbook\forms.py:82
|
||||
msgid "Show recently viewed recipes on search page."
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -15,7 +15,7 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if settings.DATABASES['default']['ENGINE'] not in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
|
||||
self.stdout.write(self.style.WARNING(_('Only Postgress databases use full text search, no index to rebuild')))
|
||||
self.stdout.write(self.style.WARNING(_('Only Postgresql databases use full text search, no index to rebuild')))
|
||||
|
||||
try:
|
||||
language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||
|
@ -0,0 +1,26 @@
|
||||
# Generated by Django 3.2.11 on 2022-01-20 14:39
|
||||
|
||||
from django.db import migrations, models
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def add_default_trigram(apps, schema_editor):
|
||||
with scopes_disabled():
|
||||
UserPreference = apps.get_model('cookbook', 'UserPreference')
|
||||
|
||||
UserPreference.objects.all().update(shopping_add_onhand=False)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0165_remove_step_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userpreference',
|
||||
name='shopping_add_onhand',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.RunPython(add_default_trigram),
|
||||
]
|
18
cookbook/migrations/0167_userpreference_left_handed.py
Normal file
18
cookbook/migrations/0167_userpreference_left_handed.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.11 on 2022-01-20 22:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0166_alter_userpreference_shopping_add_onhand'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='left_handed',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
@ -337,8 +337,9 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
mealplan_autoadd_shopping = models.BooleanField(default=False)
|
||||
mealplan_autoexclude_onhand = models.BooleanField(default=True)
|
||||
mealplan_autoinclude_related = models.BooleanField(default=True)
|
||||
shopping_add_onhand = models.BooleanField(default=True)
|
||||
shopping_add_onhand = models.BooleanField(default=False)
|
||||
filter_to_supermarket = models.BooleanField(default=False)
|
||||
left_handed = models.BooleanField(default=False)
|
||||
default_delay = models.DecimalField(default=4, max_digits=8, decimal_places=4)
|
||||
shopping_recent_days = models.PositiveIntegerField(default=7)
|
||||
csv_delim = models.CharField(max_length=2, default=",")
|
||||
@ -515,7 +516,7 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
|
||||
@staticmethod
|
||||
def reset_inheritance(space=None):
|
||||
# resets inheritted fields to the space defaults and updates all inheritted fields to root object values
|
||||
# resets inherited fields to the space defaults and updates all inherited fields to root object values
|
||||
inherit = space.food_inherit.all()
|
||||
|
||||
# remove all inherited fields from food
|
||||
|
@ -33,7 +33,7 @@ class Nextcloud(Provider):
|
||||
try:
|
||||
files.pop(0) # remove first element because its the folder itself
|
||||
except IndexError:
|
||||
pass # folder is emtpy, no recipes will be imported
|
||||
pass # folder is empty, no recipes will be imported
|
||||
|
||||
import_count = 0
|
||||
for file in files:
|
||||
|
@ -183,7 +183,7 @@ class UserPreferenceSerializer(WritableNestedModelSerializer):
|
||||
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_kj', 'search_style', 'show_recent', 'plan_share',
|
||||
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay',
|
||||
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix',
|
||||
'filter_to_supermarket', 'shopping_add_onhand'
|
||||
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed'
|
||||
)
|
||||
|
||||
|
||||
@ -865,7 +865,7 @@ class AutomationSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
# CORS, REST and Scopes aren't currently working
|
||||
# Scopes are evaluating before REST has authenticated the user assiging a None space
|
||||
# Scopes are evaluating before REST has authenticated the user assigning a None space
|
||||
# I've made the change below to fix the bookmarklet, other serializers likely need a similar/better fix
|
||||
class BookmarkletImportSerializer(serializers.ModelSerializer):
|
||||
def create(self, validated_data):
|
||||
|
@ -72,7 +72,7 @@ def update_food_inheritance(sender, instance=None, created=False, **kwargs):
|
||||
return
|
||||
|
||||
inherit = inherit.values_list('field', flat=True)
|
||||
# apply changes from parent to instance for each inheritted field
|
||||
# apply changes from parent to instance for each inherited field
|
||||
if instance.parent and inherit.count() > 0:
|
||||
parent = instance.get_parent()
|
||||
if 'ignore_shopping' in inherit:
|
||||
|
5
cookbook/static/themes/tandoor.min.css
vendored
5
cookbook/static/themes/tandoor.min.css
vendored
@ -10461,4 +10461,9 @@ textarea, input:not([type="submit"]):not([class="multiselect__input"]):not([clas
|
||||
|
||||
.form-control-search {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 0.5 !important;
|
||||
background: #b98766 !important;
|
||||
}
|
@ -54,7 +54,7 @@
|
||||
<h2>{% trans 'Formatting' %}</h2>
|
||||
<pre class="intro-code code-block"><code>
|
||||
{% trans 'Line breaks are inserted by adding two spaces after the end of a line' %}
|
||||
{% trans 'or by leaving a blank line inbetween.' %}
|
||||
{% trans 'or by leaving a blank line in between.' %}
|
||||
|
||||
**{% trans 'This text is bold' %}**
|
||||
*{% trans 'This text is italic' %}*
|
||||
@ -70,7 +70,7 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% trans 'Line breaks are inserted by adding two spaces after the end of a line' %}<br/>
|
||||
{% trans 'or by leaving a blank line inbetween.' %}<br/><br/>
|
||||
{% trans 'or by leaving a blank line in between.' %}<br/><br/>
|
||||
<b>{% trans 'This text is bold' %}</b><br/>
|
||||
<i>{% trans 'This text is italic' %}</i>
|
||||
<blockquote>
|
||||
@ -82,7 +82,7 @@
|
||||
|
||||
<br/>
|
||||
<h2>{% trans 'Lists' %}</h2>
|
||||
{% trans 'Lists can ordered or unorderd. It is <b>important to leave a blank line before the list!</b>' %}
|
||||
{% trans 'Lists can ordered or unordered. It is <b>important to leave a blank line before the list!</b>' %}
|
||||
<pre class="intro-code code-block"><code>
|
||||
{% trans 'Ordered List' %}
|
||||
|
||||
|
@ -27,7 +27,7 @@
|
||||
{% endblocktrans %}</p>
|
||||
<h4>{% trans 'Simple' %}</h4>
|
||||
<p> {% blocktrans %}
|
||||
Simple searches ignore punctuation and common words such as 'the', 'a', 'and'. And will treat seperate words as required.
|
||||
Simple searches ignore punctuation and common words such as 'the', 'a', 'and'. And will treat separate words as required.
|
||||
Searching for 'apple or flour' will return any recipe that includes both 'apple' and 'flour' anywhere in the fields that have been selected for a full text search.
|
||||
{% endblocktrans %}</p>
|
||||
<h4>{% trans 'Phrase' %}</h4>
|
||||
@ -39,7 +39,7 @@
|
||||
<p> {% blocktrans %}
|
||||
Web searches simulate functionality found on many web search sites supporting special syntax.
|
||||
Placing quotes around several words will convert those words into a phrase.
|
||||
'or' is recongized as searching for the word (or phrase) immediately before 'or' OR the word (or phrase) directly after.
|
||||
'or' is recognized as searching for the word (or phrase) immediately before 'or' OR the word (or phrase) directly after.
|
||||
'-' is recognized as searching for recipes that do not include the word (or phrase) that comes immediately after.
|
||||
For example searching for 'apple pie' or cherry -butter will return any recipe that includes the phrase 'apple pie' or the word 'cherry'
|
||||
in any field included in the full text search but exclude any recipe that has the word 'butter' in any field included.
|
||||
@ -59,7 +59,7 @@
|
||||
{% blocktrans %}
|
||||
Another approach to searching that also requires Postgresql is fuzzy search or trigram similarity. A trigram is a group of three consecutive characters.
|
||||
For example searching for 'apple' will create x trigrams 'app', 'ppl', 'ple' and will create a score of how closely words match the generated trigrams.
|
||||
One benefit of searching trigams is that a search for 'sandwich' will find mispelled words such as 'sandwhich' that would be missed by other methods.
|
||||
One benefit of searching trigams is that a search for 'sandwich' will find misspelled words such as 'sandwhich' that would be missed by other methods.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
|
||||
|
@ -263,7 +263,7 @@
|
||||
{% if request.user.userpreference.shopping_auto_sync > 0 %}
|
||||
<div class="row" v-if="!onLine">
|
||||
<div class="col col-md-12">
|
||||
<div class="alert alert-warning" role="alert">{% trans 'You are offline, shopping list might not syncronize.' %}</div>
|
||||
<div class="alert alert-warning" role="alert">{% trans 'You are offline, shopping list might not synchronize.' %}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -634,7 +634,7 @@
|
||||
console.log('updating recipe', this.shopping_list.recipes[i])
|
||||
recipe_promises.push(this.$http.post("{% url 'api:shoppinglistrecipe-list' %}", this.shopping_list.recipes[i], {}).then((response) => {
|
||||
let old_id = this.shopping_list.recipes[i].id
|
||||
console.log("list recipe create respose ", response.body)
|
||||
console.log("list recipe create response ", response.body)
|
||||
this.$set(this.shopping_list.recipes, i, response.body)
|
||||
for (let e of this.shopping_list.entries.filter(item => item.list_recipe === old_id)) {
|
||||
console.log("found recipe updating ID")
|
||||
|
@ -908,28 +908,22 @@
|
||||
// })
|
||||
},
|
||||
searchUnits: function (query) {
|
||||
let apiFactory = new ApiApiFactory()
|
||||
|
||||
this.units_loading = true
|
||||
apiFactory
|
||||
.listUnits(query, 1, this.options_limit)
|
||||
.then((response) => {
|
||||
this.units = response.data.results
|
||||
|
||||
if (this.recipe !== undefined) {
|
||||
for (let s of this.recipe.steps) {
|
||||
for (let i of s.ingredients) {
|
||||
if (i.unit !== null && i.unit.id === undefined) {
|
||||
this.units.push(i.unit)
|
||||
}
|
||||
}
|
||||
this.$http.get("{% url 'dal_unit' %}" + '?q=' + query).then((response) => {
|
||||
this.units = response.data.results;
|
||||
if (this.recipe_data !== undefined) {
|
||||
for (let x of Array.from(this.recipe_data.recipeIngredient)) {
|
||||
if (x.unit !== null && x.unit.text !== '') {
|
||||
this.units = this.units.filter(item => item.text !== x.unit.text)
|
||||
this.units.push(x.unit)
|
||||
}
|
||||
}
|
||||
this.units_loading = false
|
||||
})
|
||||
.catch((err) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
|
||||
})
|
||||
}
|
||||
this.units_loading = false
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
},
|
||||
searchIngredients: function (query) {
|
||||
this.ingredients_loading = true
|
||||
|
@ -1088,7 +1088,7 @@ def recipe_from_source(request):
|
||||
return JsonResponse(
|
||||
{
|
||||
'error': True,
|
||||
'msg': _('No useable data could be found.')
|
||||
'msg': _('No usable data could be found.')
|
||||
},
|
||||
status=400
|
||||
)
|
||||
|
@ -411,7 +411,7 @@ def user_settings(request):
|
||||
if (api_token := Token.objects.filter(user=request.user).first()) is None:
|
||||
api_token = Token.objects.create(user=request.user)
|
||||
|
||||
# these fields require postgress - just disable them if postgress isn't available
|
||||
# these fields require postgresql - just disable them if postgresql isn't available
|
||||
if not settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
||||
'django.db.backends.postgresql']:
|
||||
search_form.fields['search'].disabled = True
|
||||
|
@ -51,7 +51,7 @@ In technical terms it is a multi tenant system.
|
||||
You can compare a space to something like google drive or dropbox.
|
||||
There is only one installation of the Dropbox system, but it handles multiple users without them noticing each other.
|
||||
For Tandoor that means all people that work together on one recipe collection can be in one space.
|
||||
If you want to host the collection of your friends family or your neighbor you can create a separate space for them (trough the admin interface).
|
||||
If you want to host the collection of your friends family or your neighbor you can create a separate space for them (through the admin interface).
|
||||
|
||||
Sharing between spaces is currently not possible but is planned for future releases.
|
||||
|
||||
|
@ -25,8 +25,8 @@ SOCIAL_PROVIDERS=allauth.socialaccount.providers.github,allauth.socialaccount.pr
|
||||
The exact formatting is important so make sure to follow the steps explained here!
|
||||
|
||||
Depending on your authentication provider you **might need** to configure it.
|
||||
This needs to be done trough the settings system. To make the system flexible (allow multiple providers) and to
|
||||
not require another file to be mounted into the container the configuration ins done trough a single
|
||||
This needs to be done through the settings system. To make the system flexible (allow multiple providers) and to
|
||||
not require another file to be mounted into the container the configuration ins done through a single
|
||||
environment variable. The downside of this approach is that the configuration needs to be put into a single line
|
||||
as environment files loaded by docker compose don't support multiple lines for a single variable.
|
||||
|
||||
|
@ -35,7 +35,7 @@ The basic configuration is the same for all providers.
|
||||
### Local
|
||||
|
||||
!!! info
|
||||
There is currently no way to upload files trough the webinterface. This is a feature that might be added later.
|
||||
There is currently no way to upload files through the webinterface. This is a feature that might be added later.
|
||||
|
||||
The local provider does not need any configuration.
|
||||
For the monitor you will need to define a valid path on your host system.
|
||||
|
@ -9,7 +9,7 @@
|
||||
<h4 align="center">The recipe manager that allows you to manage your ever growing collection of digital recipes.</h4>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=master" ></a>
|
||||
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continuous%20Integration/badge.svg?branch=master" ></a>
|
||||
<a href="https://github.com/vabene1111/recipes/stargazers" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/stars/vabene1111/recipes" ></a>
|
||||
<a href="https://github.com/vabene1111/recipes/network/members" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/forks/vabene1111/recipes" ></a>
|
||||
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer"><img src="https://badgen.net/badge/icon/discord?icon=discord&label" ></a>
|
||||
@ -85,7 +85,7 @@ there are some greater overall goals for the future (in no particular order)
|
||||
|
||||
- Improve the UI! The Design is inconsistent and many pages work but don't look great. This needs to change.
|
||||
- I strongly believe in Open Data and Systems. Thus adding importers and exporters for all relevant other recipe management systems is something i really want to do.
|
||||
- Move all Javascript Libraries to a packet manger and clean up some of the mess I made in the early days
|
||||
- Move all Javascript Libraries to a packet manager and clean up some of the mess I made in the early days
|
||||
- Improve Test coverage and also the individual tests themselves
|
||||
- Improve the documentation for all features and aspects of this project and add some application integrated help
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
|
||||
## K8s Setup
|
||||
|
||||
This is a setup which should be sufficent for production use. Be sure to replace the default secrets!
|
||||
This is a setup which should be sufficient for production use. Be sure to replace the default secrets!
|
||||
|
||||
## Files
|
||||
|
||||
@ -49,7 +49,7 @@ The creation of the persistent volume claims for media and static content. May y
|
||||
|
||||
### 40-sts-postgresql.yaml
|
||||
|
||||
The PostgreSQL stateful set, based on a bitnami image. It runs a init container as root to do the preparations. The postgres container itsef runs as a lower privileged user. The recipes app uses the database super user (postgres) as the recipies app is doing some db migrations on startup, which needs super user privileges.
|
||||
The PostgreSQL stateful set, based on a bitnami image. It runs a init container as root to do the preparations. The postgres container itself runs as a lower privileged user. The recipes app uses the database super user (postgres) as the recipes app is doing some db migrations on startup, which needs super user privileges.
|
||||
|
||||
### 45-service-db.yaml
|
||||
|
||||
@ -57,7 +57,7 @@ Creating the database service.
|
||||
|
||||
### 50-deployment.yaml
|
||||
|
||||
The deployment first fires up a init container to do the database migrations and file modifications. This init container runs as root. The init conainer runs part of the [boot.sh](https://github.com/TandoorRecipes/recipes/blob/develop/boot.sh) script from the `vabene1111/recipes` image.
|
||||
The deployment first fires up a init container to do the database migrations and file modifications. This init container runs as root. The init container runs part of the [boot.sh](https://github.com/TandoorRecipes/recipes/blob/develop/boot.sh) script from the `vabene1111/recipes` image.
|
||||
|
||||
The deployment then runs two containers, the recipes-nginx and the recipes container which runs the gunicorn app. The nginx container gets it's nginx.conf via config map to deliver static content `/static` and `/media`. The guincorn container gets it's secret key and the database password from the secret `recipes`. `gunicorn` runs as user `nobody`.
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
# Manual installation instructions
|
||||
|
||||
These intructions are inspired from a standard django/gunicorn/postgresql instructions ([for example](https://www.digitalocean.com/community/tutorials/how-to-set-up-django-with-postgres-nginx-and-gunicorn-on-ubuntu-16-04))
|
||||
These instructions are inspired from a standard django/gunicorn/postgresql instructions ([for example](https://www.digitalocean.com/community/tutorials/how-to-set-up-django-with-postgres-nginx-and-gunicorn-on-ubuntu-16-04))
|
||||
|
||||
!!! warning
|
||||
Be sure to use python 3.9 and pip related to python 3.9. Depending on your distribution calling `python` or `pip` will use python2 instead of python 3.9.
|
||||
@ -122,7 +122,7 @@ ExecStart=/var/www/recipes/bin/gunicorn --error-logfile /tmp/gunicorn_err.log --
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
*Note*: `-error-logfile /tmp/gunicorn_err.log --log-level debug --capture-output` are usefull for debugging and can be removed later
|
||||
*Note*: `-error-logfile /tmp/gunicorn_err.log --log-level debug --capture-output` are useful for debugging and can be removed later
|
||||
|
||||
*Note2*: Fix the path in the `ExecStart` line to where you gunicorn and recipes are
|
||||
|
||||
|
@ -57,4 +57,4 @@ apache:
|
||||
|
||||
I used two paths `<sub path>` and `<www path>` for simplicity. In my case I have `<sub path> = recipes` and `<www path> = serve/recipes`. One could also change the matching rules of traefik to have everything under one path.
|
||||
|
||||
I left out the TLS config in this example for simplicty.
|
||||
I left out the TLS config in this example for simplicity.
|
||||
|
@ -80,7 +80,7 @@ Easiest way is to do it via Reverse Proxy.
|
||||
- insert name
|
||||
- Source:
|
||||
- Protocol: HTTPS
|
||||
- Hostname: URL if you acces from outside, otherwise ip in network
|
||||
- Hostname: URL if you access from outside, otherwise ip in network
|
||||
- Port: The port you want to access, has to be a different one that the one in the docker-compose file
|
||||
- HSTS can be enabled
|
||||
- Destination:
|
||||
@ -90,14 +90,14 @@ Easiest way is to do it via Reverse Proxy.
|
||||
- Click on Custom Header and press Create -> Websocket
|
||||
- Save
|
||||
- Control Panel -> Security -> Firewall -> Edit Rules -> Create
|
||||
- Ports: Select form a list of build-in applications -> Select -> You find your Reverse Proxy, enable it
|
||||
- Ports: Select form a list of built-in applications -> Select -> You find your Reverse Proxy, enable it
|
||||
- Source IP: Depends, All allows access from outside, i use specific to only connect in my network
|
||||
- Action: Allow
|
||||
- Save and make sure it's above the deny rules
|
||||
|
||||
[Deprecated, Note: ssl Path changed for DSM 7]
|
||||
6.1 Additional SSL Setup
|
||||
- create foler `ssl` inside `nginx` folder
|
||||
- create folder `ssl` inside `nginx` folder
|
||||
- download your ssl certificate from `security` tab in dsm `control panel`
|
||||
- or create a task in `task manager` because Synology will update the certificate every few months
|
||||
- set task to repeat every day
|
||||
|
@ -1,6 +1,6 @@
|
||||
!!! danger
|
||||
Please refer to [the offical documentation](https://doc.traefik.io/traefik/).
|
||||
This example just shows something similar to my setup in case you dont understand the offical documentation.
|
||||
Please refer to [the official documentation](https://doc.traefik.io/traefik/).
|
||||
This example just shows something similar to my setup in case you dont understand the official documentation.
|
||||
|
||||
You need to create a network called `traefik` using `docker network create traefik`.
|
||||
## docker-compose.yml
|
||||
|
@ -2,7 +2,7 @@ There is currently no "good" way of backing up your data implemented in the appl
|
||||
This mean that you will be responsible for backing up your data.
|
||||
|
||||
It is planned to add a "real" backup feature similar to applications like homeassistant where a snapshot can be
|
||||
downloaded and restored trough the web interface.
|
||||
downloaded and restored through the web interface.
|
||||
|
||||
!!! warning
|
||||
When developing a new backup strategy, make sure to also test the restore process!
|
||||
|
@ -21,13 +21,13 @@ The following table roughly defines the capabilities of each role
|
||||
!!! warning
|
||||
Users without groups cannot do anything. Make sure to assign them a group!
|
||||
|
||||
You can either create new users trough the admin interface or by sending them invite links.
|
||||
You can either create new users through the admin interface or by sending them invite links.
|
||||
|
||||
Invite links can be generated on the System page. If you specify a username during the creation of the link
|
||||
the person using it won't be able to change that name.
|
||||
|
||||
## Managing Permissions
|
||||
Management of permissions can currently only be achieved trough the django admin interface.
|
||||
Management of permissions can currently only be achieved through the django admin interface.
|
||||
|
||||
!!! warning
|
||||
Please do not rename the groups as this breaks the permission system.
|
||||
|
@ -407,7 +407,7 @@ TEST_RUNNER = "cookbook.helper.CustomTestRunner.CustomTestRunner"
|
||||
# settings for cross site origin (CORS)
|
||||
# all origins allowed to support bookmarklet
|
||||
# all of this may or may not work with nginx or other web servers
|
||||
# TODO make this user configureable - enable or disable bookmarklets
|
||||
# TODO make this user configurable - enable or disable bookmarklets
|
||||
# TODO since token auth is enabled - this all should be https by default
|
||||
CORS_ORIGIN_ALLOW_ALL = True
|
||||
|
||||
|
@ -79,21 +79,20 @@
|
||||
<div class="col-12 col-md-9 col-lg-6">
|
||||
<h5>{{ $t("Meal_Types") }}</h5>
|
||||
<div>
|
||||
<draggable :list="meal_types" group="meal_types" :empty-insert-threshold="10"
|
||||
handle=".handle" @sort="sortMealTypes()">
|
||||
<b-card no-body class="mt-1" v-for="(meal_type, index) in meal_types" v-hover
|
||||
<draggable :list="meal_types" group="meal_types" :empty-insert-threshold="10" @sort="sortMealTypes()" ghost-class="ghost">
|
||||
<b-card no-body class="mt-1 list-group-item p-2" style="cursor:move" v-for="(meal_type, index) in meal_types" v-hover
|
||||
:key="meal_type.id">
|
||||
<b-card-header class="p-4">
|
||||
<b-card-header class="p-2 border-0">
|
||||
<div class="row">
|
||||
<div class="col-2 handle">
|
||||
<div class="col-2">
|
||||
<button type="button" class="btn btn-lg shadow-none"><i
|
||||
class="fas fa-arrows-alt-v"></i></button>
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<h5>
|
||||
<h5 class="mt-1 mb-1">
|
||||
{{ meal_type.icon }} {{
|
||||
meal_type.name
|
||||
}}<span class="float-right text-primary"
|
||||
}}<span class="float-right text-primary" style="cursor:pointer"
|
||||
><i class="fa"
|
||||
v-bind:class="{ 'fa-pen': !meal_type.editing, 'fa-save': meal_type.editing }"
|
||||
@click="editOrSaveMealType(index)" aria-hidden="true"></i
|
||||
@ -878,4 +877,9 @@ having to override as much.
|
||||
.theme-default .cv-day.draghover {
|
||||
box-shadow: inset 0 0 0.2em 0.2em yellow;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
background: #c8ebfb;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div id="app" style="margin-bottom: 4vh">
|
||||
<RecipeSwitcher ref="ref_recipe_switcher" />
|
||||
<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">
|
||||
@ -8,15 +8,21 @@
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-10 col-xl-8 mt-3 mb-3">
|
||||
<b-input-group>
|
||||
<b-input class="form-control form-control-lg form-control-borderless form-control-search" v-model="search.search_input" v-bind:placeholder="$t('Search')"></b-input>
|
||||
<b-input
|
||||
class="form-control form-control-lg form-control-borderless form-control-search"
|
||||
v-model="search.search_input" v-bind:placeholder="$t('Search')"></b-input>
|
||||
<b-input-group-append>
|
||||
<b-button v-b-tooltip.hover :title="$t('show_sql')" @click="showSQL()" v-if="debug && ui.sql_debug">
|
||||
<b-button v-b-tooltip.hover :title="$t('show_sql')" @click="showSQL()"
|
||||
v-if="debug && ui.sql_debug">
|
||||
<i class="fas fa-bug" style="font-size: 1.5em"></i>
|
||||
</b-button>
|
||||
<b-button variant="light" v-b-tooltip.hover :title="$t('Random Recipes')" @click="openRandom()">
|
||||
<b-button variant="light" v-b-tooltip.hover :title="$t('Random Recipes')"
|
||||
@click="openRandom()">
|
||||
<i class="fas fa-dice-five" style="font-size: 1.5em"></i>
|
||||
</b-button>
|
||||
<b-button v-b-toggle.collapse_advanced_search v-b-tooltip.hover :title="$t('Advanced Settings')" v-bind:variant="!searchFiltered(true) ? 'primary' : 'danger'">
|
||||
<b-button v-b-toggle.collapse_advanced_search v-b-tooltip.hover
|
||||
:title="$t('Advanced Settings')"
|
||||
v-bind:variant="!searchFiltered(true) ? 'primary' : 'danger'">
|
||||
<!-- TODO consider changing this icon to a filter -->
|
||||
<i class="fas fa-caret-down" v-if="!search.advanced_search_visible"></i>
|
||||
<i class="fas fa-caret-up" v-if="search.advanced_search_visible"></i>
|
||||
@ -26,15 +32,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-collapse id="collapse_advanced_search" class="mt-2 shadow-sm" v-model="search.advanced_search_visible">
|
||||
<b-collapse id="collapse_advanced_search" class="mt-2 shadow-sm"
|
||||
v-model="search.advanced_search_visible">
|
||||
<div class="card">
|
||||
<div class="card-body p-4">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<a class="btn btn-primary btn-block text-uppercase" :href="resolveDjangoUrl('new_recipe')">{{ $t("New_Recipe") }}</a>
|
||||
<a class="btn btn-primary btn-block text-uppercase"
|
||||
:href="resolveDjangoUrl('new_recipe')">{{ $t("New_Recipe") }}</a>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<a class="btn btn-primary btn-block text-uppercase" :href="resolveDjangoUrl('data_import_url')">{{ $t("Import") }}</a>
|
||||
<a class="btn btn-primary btn-block text-uppercase"
|
||||
:href="resolveDjangoUrl('data_import_url')">{{ $t("Import") }}</a>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button
|
||||
@ -53,57 +62,92 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<button id="id_settings_button" class="btn btn-primary btn-block text-uppercase"><i class="fas fa-cog fa-lg m-1"></i></button>
|
||||
<button id="id_settings_button"
|
||||
class="btn btn-primary btn-block text-uppercase"><i
|
||||
class="fas fa-cog fa-lg m-1"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-popover target="id_settings_button" triggers="click" placement="bottom">
|
||||
<b-tabs content-class="mt-1" small>
|
||||
<b-tab :title="$t('Settings')" active>
|
||||
<b-form-group v-bind:label="$t('Recently_Viewed')" label-for="popover-input-1" label-cols="6" class="mb-3">
|
||||
<b-form-input type="number" v-model="ui.recently_viewed" id="popover-input-1" size="sm"></b-form-input>
|
||||
<b-form-group v-bind:label="$t('Recently_Viewed')"
|
||||
label-for="popover-input-1" label-cols="6" class="mb-3">
|
||||
<b-form-input type="number" v-model="ui.recently_viewed"
|
||||
id="popover-input-1" size="sm"></b-form-input>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group v-bind:label="$t('Recipes_per_page')" label-for="popover-input-page-count" label-cols="6" class="mb-3">
|
||||
<b-form-input type="number" v-model="ui.page_size" id="popover-input-page-count" size="sm"></b-form-input>
|
||||
<b-form-group v-bind:label="$t('Recipes_per_page')"
|
||||
label-for="popover-input-page-count" label-cols="6"
|
||||
class="mb-3">
|
||||
<b-form-input type="number" v-model="ui.page_size"
|
||||
id="popover-input-page-count"
|
||||
size="sm"></b-form-input>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group v-bind:label="$t('Meal_Plan')" label-for="popover-input-2" label-cols="6" class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.show_meal_plan" id="popover-input-2" size="sm"></b-form-checkbox>
|
||||
<b-form-group v-bind:label="$t('Meal_Plan')" label-for="popover-input-2"
|
||||
label-cols="6" class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.show_meal_plan"
|
||||
id="popover-input-2" size="sm"></b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group v-if="ui.show_meal_plan" v-bind:label="$t('Meal_Plan_Days')" label-for="popover-input-5" label-cols="6" class="mb-3">
|
||||
<b-form-input type="number" v-model="ui.meal_plan_days" id="popover-input-5" size="sm"></b-form-input>
|
||||
<b-form-group v-if="ui.show_meal_plan"
|
||||
v-bind:label="$t('Meal_Plan_Days')"
|
||||
label-for="popover-input-5" label-cols="6" class="mb-3">
|
||||
<b-form-input type="number" v-model="ui.meal_plan_days"
|
||||
id="popover-input-5" size="sm"></b-form-input>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group v-bind:label="$t('Sort_by_new')" label-for="popover-input-3" label-cols="6" class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.sort_by_new" id="popover-input-3" size="sm"></b-form-checkbox>
|
||||
<b-form-group v-bind:label="$t('Sort_by_new')"
|
||||
label-for="popover-input-3" label-cols="6" class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.sort_by_new"
|
||||
id="popover-input-3" size="sm"></b-form-checkbox>
|
||||
</b-form-group>
|
||||
<div class="row" style="margin-top: 1vh">
|
||||
<div class="col-12">
|
||||
<a :href="resolveDjangoUrl('view_settings') + '#search'">{{ $t("Search Settings") }}</a>
|
||||
<a :href="resolveDjangoUrl('view_settings') + '#search'">{{
|
||||
$t("Search Settings")
|
||||
}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</b-tab>
|
||||
<b-tab title="Expert Settings">
|
||||
<b-form-group v-bind:label="$t('remember_search')" label-for="popover-rem-search" label-cols="6" class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.remember_search" id="popover-rem-search" size="sm"></b-form-checkbox>
|
||||
<b-form-group v-bind:label="$t('remember_search')"
|
||||
label-for="popover-rem-search" label-cols="6"
|
||||
class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.remember_search"
|
||||
id="popover-rem-search"
|
||||
size="sm"></b-form-checkbox>
|
||||
</b-form-group>
|
||||
<b-form-group v-if="ui.remember_search" v-bind:label="$t('remember_hours')" label-for="popover-input-rem-hours" label-cols="6" class="mb-3">
|
||||
<b-form-input type="number" v-model="ui.remember_hours" id="popover-rem-hours" size="sm"></b-form-input>
|
||||
<b-form-group v-if="ui.remember_search"
|
||||
v-bind:label="$t('remember_hours')"
|
||||
label-for="popover-input-rem-hours" label-cols="6"
|
||||
class="mb-3">
|
||||
<b-form-input type="number" v-model="ui.remember_hours"
|
||||
id="popover-rem-hours" size="sm"></b-form-input>
|
||||
</b-form-group>
|
||||
<b-form-group v-bind:label="$t('tree_select')" label-for="popover-input-treeselect" label-cols="6" class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.tree_select" id="popover-input-treeselect" size="sm"></b-form-checkbox>
|
||||
<b-form-group v-bind:label="$t('tree_select')"
|
||||
label-for="popover-input-treeselect" label-cols="6"
|
||||
class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.tree_select"
|
||||
id="popover-input-treeselect"
|
||||
size="sm"></b-form-checkbox>
|
||||
</b-form-group>
|
||||
<b-form-group v-if="debug" v-bind:label="$t('sql_debug')" label-for="popover-input-sqldebug" label-cols="6" class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.sql_debug" id="popover-input-sqldebug" size="sm"></b-form-checkbox>
|
||||
<b-form-group v-if="debug" v-bind:label="$t('sql_debug')"
|
||||
label-for="popover-input-sqldebug" label-cols="6"
|
||||
class="mb-3">
|
||||
<b-form-checkbox switch v-model="ui.sql_debug"
|
||||
id="popover-input-sqldebug"
|
||||
size="sm"></b-form-checkbox>
|
||||
</b-form-group>
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
|
||||
<div class="row" style="margin-top: 1vh">
|
||||
<div class="col-12" style="text-align: right">
|
||||
<b-button size="sm" variant="secondary" style="margin-right: 8px" @click="$root.$emit('bv::hide::popover')">{{ $t("Close") }} </b-button>
|
||||
<b-button size="sm" variant="secondary" style="margin-right: 8px"
|
||||
@click="$root.$emit('bv::hide::popover')">{{ $t("Close") }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</b-popover>
|
||||
@ -138,8 +182,12 @@
|
||||
></generic-multiselect>
|
||||
<b-input-group-append>
|
||||
<b-input-group-text>
|
||||
<b-form-checkbox v-model="search.search_keywords_or" name="check-button" @change="refreshData(false)" class="shadow-none" switch>
|
||||
<span class="text-uppercase" v-if="search.search_keywords_or">{{ $t("or") }}</span>
|
||||
<b-form-checkbox v-model="search.search_keywords_or"
|
||||
name="check-button"
|
||||
@change="refreshData(false)"
|
||||
class="shadow-none" switch>
|
||||
<span class="text-uppercase"
|
||||
v-if="search.search_keywords_or">{{ $t("or") }}</span>
|
||||
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
|
||||
</b-form-checkbox>
|
||||
</b-input-group-text>
|
||||
@ -178,8 +226,13 @@
|
||||
></generic-multiselect>
|
||||
<b-input-group-append>
|
||||
<b-input-group-text>
|
||||
<b-form-checkbox v-model="search.search_foods_or" name="check-button" @change="refreshData(false)" class="shadow-none" switch>
|
||||
<span class="text-uppercase" v-if="search.search_foods_or">{{ $t("or") }}</span>
|
||||
<b-form-checkbox v-model="search.search_foods_or"
|
||||
name="check-button"
|
||||
@change="refreshData(false)"
|
||||
class="shadow-none" switch>
|
||||
<span class="text-uppercase" v-if="search.search_foods_or">{{
|
||||
$t("or")
|
||||
}}</span>
|
||||
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
|
||||
</b-form-checkbox>
|
||||
</b-input-group-text>
|
||||
@ -203,8 +256,13 @@
|
||||
></generic-multiselect>
|
||||
<b-input-group-append>
|
||||
<b-input-group-text>
|
||||
<b-form-checkbox v-model="search.search_books_or" name="check-button" @change="refreshData(false)" class="shadow-none" tyle="width: 100%" switch>
|
||||
<span class="text-uppercase" v-if="search.search_books_or">{{ $t("or") }}</span>
|
||||
<b-form-checkbox v-model="search.search_books_or"
|
||||
name="check-button"
|
||||
@change="refreshData(false)"
|
||||
class="shadow-none" tyle="width: 100%" switch>
|
||||
<span class="text-uppercase" v-if="search.search_books_or">{{
|
||||
$t("or")
|
||||
}}</span>
|
||||
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
|
||||
</b-form-checkbox>
|
||||
</b-input-group-text>
|
||||
@ -227,7 +285,7 @@
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
/>
|
||||
<b-input-group-append>
|
||||
<b-input-group-text style="width: 85px"> </b-input-group-text>
|
||||
<b-input-group-text style="width: 85px"></b-input-group-text>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</div>
|
||||
@ -241,7 +299,9 @@
|
||||
<div class="row">
|
||||
<div class="col col-md-12 text-right" style="margin-top: 2vh">
|
||||
<span class="text-muted">
|
||||
{{ $t("Page") }} {{ search.pagination_page }}/{{ Math.ceil(pagination_count / ui.page_size) }}
|
||||
{{ $t("Page") }} {{ search.pagination_page }}/{{
|
||||
Math.ceil(pagination_count / ui.page_size)
|
||||
}}
|
||||
<a href="#" @click="resetSearch"><i class="fas fa-times-circle"></i> {{ $t("Reset") }}</a>
|
||||
</span>
|
||||
</div>
|
||||
@ -249,18 +309,24 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-gap: 0.8rem">
|
||||
<div
|
||||
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-gap: 0.8rem">
|
||||
<template v-if="!searchFiltered()">
|
||||
<recipe-card v-bind:key="`mp_${m.id}`" v-for="m in meal_plans" :recipe="m.recipe" :meal_plan="m" :footer_text="m.meal_type_name" footer_icon="far fa-calendar-alt"></recipe-card>
|
||||
<recipe-card v-bind:key="`mp_${m.id}`" v-for="m in meal_plans" :recipe="m.recipe"
|
||||
:meal_plan="m" :footer_text="m.meal_type_name"
|
||||
footer_icon="far fa-calendar-alt"></recipe-card>
|
||||
</template>
|
||||
<recipe-card v-for="r in recipes" v-bind:key="r.id" :recipe="r" :footer_text="isRecentOrNew(r)[0]" :footer_icon="isRecentOrNew(r)[1]"> </recipe-card>
|
||||
<recipe-card v-for="r in recipes" v-bind:key="r.id" :recipe="r"
|
||||
:footer_text="isRecentOrNew(r)[0]"
|
||||
:footer_icon="isRecentOrNew(r)[1]"></recipe-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 2vh" v-if="!random_search">
|
||||
<div class="col col-md-12">
|
||||
<b-pagination pills v-model="search.pagination_page" :total-rows="pagination_count" :per-page="ui.page_size" @change="pageChange" align="center"> </b-pagination>
|
||||
<b-pagination pills v-model="search.pagination_page" :total-rows="pagination_count"
|
||||
:per-page="ui.page_size" @change="pageChange" align="center"></b-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -271,7 +337,7 @@
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import {BootstrapVue} from "bootstrap-vue"
|
||||
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
import moment from "moment"
|
||||
@ -281,13 +347,13 @@ import VueCookies from "vue-cookies"
|
||||
|
||||
Vue.use(VueCookies)
|
||||
|
||||
import { ApiMixin, ResolveUrlMixin } from "@/utils/utils"
|
||||
import {ApiMixin, ResolveUrlMixin} from "@/utils/utils"
|
||||
|
||||
import LoadingSpinner from "@/components/LoadingSpinner" // TODO: is this deprecated?
|
||||
|
||||
import RecipeCard from "@/components/RecipeCard"
|
||||
import GenericMultiselect from "@/components/GenericMultiselect"
|
||||
import { Treeselect, LOAD_CHILDREN_OPTIONS } from "@riophae/vue-treeselect" //TODO: delete
|
||||
import {Treeselect, LOAD_CHILDREN_OPTIONS} from "@riophae/vue-treeselect" //TODO: delete
|
||||
import "@riophae/vue-treeselect/dist/vue-treeselect.css" //TODO: delete
|
||||
import RecipeSwitcher from "@/components/Buttons/RecipeSwitcher"
|
||||
|
||||
@ -299,7 +365,7 @@ let UI_COOKIE_NAME = "_uisearch_settings"
|
||||
export default {
|
||||
name: "RecipeSearchView",
|
||||
mixins: [ResolveUrlMixin, ApiMixin],
|
||||
components: { GenericMultiselect, RecipeCard, Treeselect, RecipeSwitcher },
|
||||
components: {GenericMultiselect, RecipeCard, Treeselect, RecipeSwitcher},
|
||||
data() {
|
||||
return {
|
||||
// this.Models and this.Actions inherited from ApiMixin
|
||||
@ -350,12 +416,12 @@ export default {
|
||||
}
|
||||
}
|
||||
return [
|
||||
{ id: 5, label: "⭐⭐⭐⭐⭐" + ratingCount(this.facets.Ratings?.["5.0"] ?? 0) },
|
||||
{ id: 4, label: "⭐⭐⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["4.0"] ?? 0) },
|
||||
{ id: 3, label: "⭐⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["3.0"] ?? 0) },
|
||||
{ id: 2, label: "⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["2.0"] ?? 0) },
|
||||
{ id: 1, label: "⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["1.0"] ?? 0) },
|
||||
{ id: 0, label: this.$t("Unrated") + ratingCount(this.facets.Ratings?.["0.0"] ?? 0) },
|
||||
{id: 5, label: "⭐⭐⭐⭐⭐" + ratingCount(this.facets.Ratings?.["5.0"] ?? 0)},
|
||||
{id: 4, label: "⭐⭐⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["4.0"] ?? 0)},
|
||||
{id: 3, label: "⭐⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["3.0"] ?? 0)},
|
||||
{id: 2, label: "⭐⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["2.0"] ?? 0)},
|
||||
{id: 1, label: "⭐ " + this.$t("and_up") + ratingCount(this.facets.Ratings?.["1.0"] ?? 0)},
|
||||
{id: 0, label: this.$t("Unrated") + ratingCount(this.facets.Ratings?.["0.0"] ?? 0)},
|
||||
]
|
||||
},
|
||||
},
|
||||
@ -369,26 +435,44 @@ export default {
|
||||
}
|
||||
let urlParams = new URLSearchParams(window.location.search)
|
||||
|
||||
|
||||
if (urlParams.has("keyword")) {
|
||||
this.search.search_keywords = []
|
||||
this.facets.Keywords = []
|
||||
for (let x of urlParams.getAll("keyword")) {
|
||||
this.search.search_keywords.push(Number.parseInt(x))
|
||||
this.facets.Keywords.push({ id: x, name: "loading..." })
|
||||
let initial_keyword = {id: Number.parseInt(x), name: "loading..."}
|
||||
this.search.search_keywords.push(initial_keyword)
|
||||
|
||||
this.genericAPI(this.Models.KEYWORD, this.Actions.FETCH, {id: initial_keyword.id}).then((response) => {
|
||||
let kw_index = this.search.search_keywords.findIndex((k => k.id === initial_keyword.id))
|
||||
this.$set(this.search.search_keywords, kw_index, response.data)
|
||||
this.$set(this.facets.Keywords, kw_index, response.data)
|
||||
}).catch((err) => {
|
||||
if (err.response.status === 404) {
|
||||
let kw_index = this.search.search_keywords.findIndex((k => k.id === initial_keyword.id))
|
||||
this.search.search_keywords.splice(kw_index, 1)
|
||||
this.facets.Keywords.splice(kw_index, 1)
|
||||
this.refreshData(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.facets.Foods = []
|
||||
for (let x of this.search.search_foods) {
|
||||
this.facets.Foods.push({ id: x, name: "loading..." })
|
||||
this.facets.Foods.push({id: x, name: "loading..."})
|
||||
}
|
||||
|
||||
this.facets.Keywords = []
|
||||
for (let x of this.search.search_keywords) {
|
||||
this.facets.Keywords.push({ id: x, name: "loading..." })
|
||||
this.facets.Keywords.push({id: x, name: "loading..."})
|
||||
}
|
||||
|
||||
this.facets.Books = []
|
||||
for (let x of this.search.search_books) {
|
||||
this.facets.Books.push({ id: x, name: "loading..." })
|
||||
this.facets.Books.push({id: x, name: "loading..."})
|
||||
}
|
||||
|
||||
this.loadMealPlan()
|
||||
this.refreshData(false)
|
||||
})
|
||||
@ -522,27 +606,28 @@ export default {
|
||||
if (!this.ui.tree_select) {
|
||||
return
|
||||
}
|
||||
let params = { hash: hash }
|
||||
let params = {hash: hash}
|
||||
if (facet) {
|
||||
params[facet] = id
|
||||
}
|
||||
return this.genericGetAPI("api_get_facets", params).then((response) => {
|
||||
this.facets = { ...this.facets, ...response.data.facets }
|
||||
this.facets = {...this.facets, ...response.data.facets}
|
||||
})
|
||||
},
|
||||
showSQL: function () {
|
||||
let params = this.buildParams()
|
||||
this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => {})
|
||||
this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => {
|
||||
})
|
||||
},
|
||||
// TODO refactor to combine with load KeywordChildren
|
||||
loadFoodChildren({ action, parentNode, callback }) {
|
||||
loadFoodChildren({action, parentNode, callback}) {
|
||||
if (action === LOAD_CHILDREN_OPTIONS) {
|
||||
if (this.facets?.cache_key) {
|
||||
this.getFacets(this.facets.cache_key, "food", parentNode.id).then(callback())
|
||||
}
|
||||
}
|
||||
},
|
||||
loadKeywordChildren({ action, parentNode, callback }) {
|
||||
loadKeywordChildren({action, parentNode, callback}) {
|
||||
if (action === LOAD_CHILDREN_OPTIONS) {
|
||||
if (this.facets?.cache_key) {
|
||||
this.getFacets(this.facets.cache_key, "keyword", parentNode.id).then(callback())
|
||||
@ -572,7 +657,7 @@ export default {
|
||||
pageSize: this.ui.page_size,
|
||||
}
|
||||
if (!this.searchFiltered()) {
|
||||
params.options = { query: { last_viewed: this.ui.recently_viewed } }
|
||||
params.options = {query: {last_viewed: this.ui.recently_viewed}}
|
||||
}
|
||||
return params
|
||||
},
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -59,7 +59,7 @@ export default {
|
||||
addShopping() {
|
||||
if (this.shopping) {
|
||||
return
|
||||
} // if item already in shopping list, excution handled after confirmation
|
||||
} // if item already in shopping list, execution handled after confirmation
|
||||
let params = {
|
||||
id: this.item.id,
|
||||
amount: 1,
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="cursor:pointer">
|
||||
<a v-if="!button" class="dropdown-item" @click="clipboard"><i :class="icon"></i> {{ label }}</a>
|
||||
<b-button v-if="button" @click="clipboard">{{ label }}</b-button>
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="cursor:pointer">
|
||||
<a v-if="!button" class="dropdown-item" @click="downloadFile"><i :class="icon"></i> {{ label }}</a>
|
||||
<b-button v-if="button" @click="downloadFile">{{ label }}</b-button>
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="cursor:pointer">
|
||||
<a v-if="!button" class="dropdown-item" @click="downloadFile"><i :class="icon"></i> {{ label }}</a>
|
||||
<b-button v-if="button" @click="downloadFile">{{ label }}</b-button>
|
||||
</div>
|
||||
|
@ -116,7 +116,7 @@ export default {
|
||||
},
|
||||
addNew(e) {
|
||||
this.$emit("new", e)
|
||||
// could refactor as Promise - seems unecessary
|
||||
// could refactor as Promise - seems unnecessary
|
||||
setTimeout(() => {
|
||||
this.search("")
|
||||
}, 750)
|
||||
|
@ -7,8 +7,7 @@
|
||||
</div>
|
||||
<div class="col col-md-6 text-right" v-if="header">
|
||||
<h4>
|
||||
<i v-if="show_shopping && ShoppingRecipes.length > 0" class="fas fa-trash text-danger px-2"
|
||||
@click="saveShopping(true)"></i>
|
||||
<i v-if="show_shopping && ShoppingRecipes.length > 0" class="fas fa-trash text-danger px-2" @click="saveShopping(true)"></i>
|
||||
<i v-if="show_shopping" class="fas fa-save text-success px-2" @click="saveShopping()"></i>
|
||||
<i class="fas fa-shopping-cart px-2" @click="getShopping()"></i>
|
||||
</h4>
|
||||
@ -16,17 +15,16 @@
|
||||
</div>
|
||||
<div class="row text-right" v-if="ShoppingRecipes.length > 1">
|
||||
<div class="col col-md-6 offset-md-6 text-right">
|
||||
<b-form-select v-model="selected_shoppingrecipe" :options="ShoppingRecipes"
|
||||
size="sm"></b-form-select>
|
||||
<b-form-select v-model="selected_shoppingrecipe" :options="ShoppingRecipes" size="sm"></b-form-select>
|
||||
</div>
|
||||
</div>
|
||||
<br v-if="header"/>
|
||||
<br v-if="header" />
|
||||
<div class="row no-gutter">
|
||||
<div class="col-md-12">
|
||||
<table class="table table-sm">
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
<template v-for="s in steps" >
|
||||
<tr v-bind:key="s.id" v-if="s.show_as_header && s.name !== ''">
|
||||
<template v-for="s in steps">
|
||||
<tr v-bind:key="s.id" v-if="s.show_as_header && s.name !== '' && !add_shopping_mode">
|
||||
<td colspan="5">
|
||||
<b>{{ s.name }}</b>
|
||||
</td>
|
||||
@ -56,18 +54,18 @@
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import {BootstrapVue} from "bootstrap-vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
|
||||
import IngredientComponent from "@/components/IngredientComponent"
|
||||
import {ApiMixin, StandardToasts} from "@/utils/utils"
|
||||
import { ApiMixin, StandardToasts } from "@/utils/utils"
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: "IngredientCard",
|
||||
mixins: [ApiMixin],
|
||||
components: {IngredientComponent},
|
||||
components: { IngredientComponent },
|
||||
props: {
|
||||
steps: {
|
||||
type: Array,
|
||||
@ -75,12 +73,12 @@ export default {
|
||||
return []
|
||||
},
|
||||
},
|
||||
recipe: {type: Number},
|
||||
ingredient_factor: {type: Number, default: 1},
|
||||
servings: {type: Number, default: 1},
|
||||
detailed: {type: Boolean, default: true},
|
||||
header: {type: Boolean, default: false},
|
||||
add_shopping_mode: {type: Boolean, default: false},
|
||||
recipe: { type: Number },
|
||||
ingredient_factor: { type: Number, default: 1 },
|
||||
servings: { type: Number, default: 1 },
|
||||
detailed: { type: Boolean, default: true },
|
||||
header: { type: Boolean, default: false },
|
||||
add_shopping_mode: { type: Boolean, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -99,7 +97,7 @@ export default {
|
||||
value: x?.list_recipe,
|
||||
text: x?.recipe_mealplan?.name,
|
||||
recipe: x?.recipe_mealplan?.recipe ?? 0,
|
||||
servings: x?.recipe_mealplan?.servings
|
||||
servings: x?.recipe_mealplan?.servings,
|
||||
}
|
||||
})
|
||||
.filter((x) => x?.recipe == this.recipe)
|
||||
|
@ -1,17 +1,22 @@
|
||||
<template>
|
||||
<div v-if="recipe.keywords.length > 0">
|
||||
<span :key="k.id" v-for="k in recipe.keywords" class="pl-1">
|
||||
<b-badge pill variant="light" class="font-weight-normal">{{k.label}}</b-badge>
|
||||
<a :href="`${resolveDjangoUrl('view_search')}?keyword=${k.id}`"><b-badge pill variant="light"
|
||||
class="font-weight-normal">{{ k.label }}</b-badge></a>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {ResolveUrlMixin} from "@/utils/utils";
|
||||
|
||||
export default {
|
||||
name: 'KeywordsComponent',
|
||||
props: {
|
||||
recipe: Object,
|
||||
},
|
||||
name: 'KeywordsComponent',
|
||||
mixins: [ResolveUrlMixin],
|
||||
props: {
|
||||
recipe: Object,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
@ -77,7 +77,7 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.id = Math.random()
|
||||
this.$root.$on("change", this.storeValue) // boostrap modal placed at document so have to listen at root of component
|
||||
this.$root.$on("change", this.storeValue) // bootstrap modal placed at document so have to listen at root of component
|
||||
},
|
||||
computed: {
|
||||
buttonLabel() {
|
||||
|
@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<b-modal :id="`shopping_${this.modal_id}`" hide-footer @show="loadRecipe">
|
||||
<template v-slot:modal-title
|
||||
><h4>{{ $t("Add_Servings_to_Shopping", { servings: servings }) }}</h4></template
|
||||
><h4>{{ $t("Add_Servings_to_Shopping", { servings: recipe_servings }) }}</h4></template
|
||||
>
|
||||
<loading-spinner v-if="loading"></loading-spinner>
|
||||
<div class="accordion" role="tablist" v-if="!loading">
|
||||
@ -15,7 +15,7 @@
|
||||
:steps="steps"
|
||||
:recipe="recipe.id"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:servings="servings"
|
||||
:servings="recipe_servings"
|
||||
:show_shopping="true"
|
||||
:add_shopping_mode="true"
|
||||
:header="false"
|
||||
@ -33,7 +33,7 @@
|
||||
:steps="r.steps"
|
||||
:recipe="r.recipe.id"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:servings="servings"
|
||||
:servings="recipe_servings"
|
||||
:show_shopping="true"
|
||||
:add_shopping_mode="true"
|
||||
:header="false"
|
||||
@ -45,12 +45,19 @@
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
</b-card>
|
||||
</div>
|
||||
<div class="row mt-3 mb-3">
|
||||
<div class="col-12 text-right">
|
||||
<b-button class="mx-2" variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t("Cancel") }} </b-button>
|
||||
<b-button class="mx-2" variant="success" @click="saveShopping">{{ $t("Save") }} </b-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-input-group class="my-3">
|
||||
<b-input-group-prepend is-text>
|
||||
{{ $t("Servings") }}
|
||||
</b-input-group-prepend>
|
||||
|
||||
<b-form-spinbutton min="1" v-model="recipe_servings" inline style="height: 3em"></b-form-spinbutton>
|
||||
|
||||
<b-input-group-append>
|
||||
<b-button variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t("Cancel") }} </b-button>
|
||||
<b-button variant="success" @click="saveShopping">{{ $t("Save") }} </b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
@ -71,25 +78,37 @@ export default {
|
||||
mixins: [],
|
||||
props: {
|
||||
recipe: { required: true, type: Object },
|
||||
servings: { type: Number },
|
||||
servings: { type: Number, default: undefined },
|
||||
modal_id: { required: true, type: Number },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
steps: [],
|
||||
recipe_servings: 0,
|
||||
recipe_servings: undefined,
|
||||
add_shopping: [],
|
||||
related_recipes: [],
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
mounted() {
|
||||
this.recipe_servings = this.servings
|
||||
},
|
||||
computed: {
|
||||
ingredient_factor: function () {
|
||||
return this.servings / this.recipe.servings || this.recipe_servings
|
||||
return this.recipe_servings / this.recipe.servings
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
recipe: {
|
||||
handler() {
|
||||
this.loadRecipe()
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
servings: function (newVal) {
|
||||
this.recipe_servings = parseInt(newVal)
|
||||
},
|
||||
},
|
||||
watch: {},
|
||||
methods: {
|
||||
loadRecipe: function () {
|
||||
this.add_shopping = []
|
||||
@ -109,7 +128,9 @@ export default {
|
||||
.filter((x) => !x?.food?.food_onhand)
|
||||
.map((x) => x.id),
|
||||
]
|
||||
this.recipe_servings = result.data?.servings
|
||||
if (!this.recipe_servings) {
|
||||
this.recipe_servings = result.data?.servings
|
||||
}
|
||||
this.loading = false
|
||||
})
|
||||
.then(() => {
|
||||
@ -159,19 +180,27 @@ export default {
|
||||
let shopping_recipe = {
|
||||
id: this.recipe.id,
|
||||
ingredients: this.add_shopping,
|
||||
servings: this.servings,
|
||||
servings: this.recipe_servings,
|
||||
}
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient
|
||||
.shoppingRecipe(this.recipe.id, shopping_recipe)
|
||||
.then((result) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
this.$emit("finish")
|
||||
})
|
||||
.catch((err) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
})
|
||||
|
||||
this.$bvModal.hide(`shopping_${this.modal_id}`)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.b-form-spinbutton.form-control {
|
||||
background-color: #e9ecef;
|
||||
border: 1px solid #ced4da;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,139 +1,150 @@
|
||||
<template>
|
||||
<div id="shopping_line_item">
|
||||
<b-container fluid class="pr-0 pl-1 pl-md-3">
|
||||
<!-- summary rows -->
|
||||
<b-row align-h="start">
|
||||
<b-col cols="1" class="align-items-center d-flex">
|
||||
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true">
|
||||
<b-row align-h="start">
|
||||
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0"
|
||||
v-if="settings.left_handed">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked"
|
||||
@change="updateChecked"
|
||||
:key="entries[0].id"/>
|
||||
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
|
||||
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i>
|
||||
</div>
|
||||
</b-button>
|
||||
</b-col>
|
||||
<b-col cols="1" class="align-items-center d-flex">
|
||||
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true"
|
||||
@click.stop="$emit('open-context-menu', $event, entries)">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
:class="settings.left_handed ? 'dropdown-spacing' : ''"
|
||||
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret">
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked"
|
||||
@change="updateChecked"
|
||||
:key="entries[0].id"/>
|
||||
</b-col>
|
||||
<b-col cols="8" md="9">
|
||||
<b-row class="d-flex h-100">
|
||||
<b-col cols="5" md="3" class="d-flex align-items-center" v-if="Object.entries(formatAmount).length == 1">
|
||||
<strong class="mr-1">{{ Object.entries(formatAmount)[0][1] }}</strong> {{
|
||||
Object.entries(formatAmount)[0][0]
|
||||
}}
|
||||
</b-col>
|
||||
<b-col cols="5" md="3" class="d-flex flex-column" v-if="Object.entries(formatAmount).length != 1">
|
||||
<div class="small" v-for="(x, i) in Object.entries(formatAmount)" :key="i">{{ x[1] }}  
|
||||
{{ x[0] }}
|
||||
</div>
|
||||
</b-col>
|
||||
|
||||
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
|
||||
{{ formatFood }}
|
||||
</b-col>
|
||||
<b-col cols="3" data-html2canvas-ignore="true"
|
||||
class="align-items-center d-none d-md-flex justify-content-end">
|
||||
<b-button size="sm" @click="showDetails = !showDetails" class="p-0 mr-0 mr-md-2 p-md-2 text-decoration-none"
|
||||
variant="link">
|
||||
<div class="text-nowrap"><i class="fa fa-chevron-right rotate"
|
||||
:class="showDetails ? 'rotated' : ''"></i> <span
|
||||
class="d-none d-md-inline-block">{{ $t('Details') }}</span>
|
||||
</div>
|
||||
</b-button>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none"
|
||||
v-if="!settings.left_handed">
|
||||
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
|
||||
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i>
|
||||
</div>
|
||||
</b-button>
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked"
|
||||
@change="updateChecked"
|
||||
:key="entries[0].id"/>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<b-row align-h="center" class="d-none d-md-flex">
|
||||
<b-col cols="12">
|
||||
<div class="small text-muted text-truncate">{{ formatHint }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<!-- detail rows -->
|
||||
<div class="card no-body mb-1 pt-2 align-content-center shadow-sm" v-if="showDetails">
|
||||
<div v-for="(e, x) in entries" :key="e.id">
|
||||
<b-row class="small justify-content-around">
|
||||
<b-col cols="auto" md="4" class="overflow-hidden text-nowrap">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
|
||||
@click.stop="$emit('open-context-menu', $event, entries)">
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
class="btn btn-link btn-sm m-0 p-0 pl-2"
|
||||
style="text-overflow: ellipsis"
|
||||
@click.stop="openRecipeCard($event, e)"
|
||||
@mouseover="openRecipeCard($event, e)">
|
||||
{{ formatOneRecipe(e) }}
|
||||
</button>
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked"
|
||||
@change="updateChecked"
|
||||
:key="entries[0].id"/>
|
||||
</b-col>
|
||||
<b-col cols="8" md="9">
|
||||
<b-row class="d-flex h-100" @click.stop="$emit('open-context-menu', $event, entries)">
|
||||
<b-col cols="6" md="6" class="d-flex align-items-center" v-if="Object.entries(formatAmount).length == 1">
|
||||
<div><strong>{{ Object.entries(formatAmount)[0][1] }}</strong>  
|
||||
{{ Object.entries(formatAmount)[0][0] }}
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col cols="6" md="6" class="d-flex flex-column" v-if="Object.entries(formatAmount).length != 1">
|
||||
<div class="small" v-for="(x, i) in Object.entries(formatAmount)" :key="i">{{ x[1] }}  
|
||||
{{ x[0] }}
|
||||
</div>
|
||||
</b-col>
|
||||
|
||||
<b-col cols="6" md="3" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
|
||||
{{ formatFood }}
|
||||
</b-col>
|
||||
<b-col cols="3" data-html2canvas-ignore="true" class="align-items-center d-none d-md-flex">
|
||||
<b-button size="sm" @click="showDetails = !showDetails" class="p-0 mr-0 mr-md-2 p-md-2" variant="link">
|
||||
<div class="text-nowrap"><i class="fa fa-chevron-right rotate"
|
||||
:class="showDetails ? 'rotated' : ''"></i> <span
|
||||
class="d-none d-md-inline-block">{{ $t('Details') }}</span>
|
||||
</div>
|
||||
</b-button>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none">
|
||||
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0" variant="link">
|
||||
<div class="text-nowrap"><i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i>
|
||||
</div>
|
||||
</b-button>
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile" :checked="formatChecked"
|
||||
@change="updateChecked"
|
||||
:key="entries[0].id"/>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<b-row align-h="center" class="d-none d-md-flex">
|
||||
<b-col cols="12">
|
||||
<div class="small text-muted text-truncate">{{ formatHint }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-container>
|
||||
<!-- detail rows -->
|
||||
<div class="card no-body mb-1 pt-2 align-content-center ml-2" v-if="showDetails">
|
||||
<b-container fluid>
|
||||
<div v-for="(e, x) in entries" :key="e.id">
|
||||
<b-row class="small justify-content-around">
|
||||
<b-col cols="auto" md="4" class="overflow-hidden text-nowrap">
|
||||
</b-col>
|
||||
<b-col cols="auto" md="4" class="text-muted">{{ formatOneMealPlan(e) }}</b-col>
|
||||
<b-col cols="auto" md="4" class="text-muted text-right overflow-hidden text-nowrap pr-4">
|
||||
{{ formatOneCreatedBy(e) }}
|
||||
<div v-if="formatOneCompletedAt(e)">{{ formatOneCompletedAt(e) }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<b-row align-h="start">
|
||||
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0"
|
||||
v-if="settings.left_handed">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
|
||||
:checked="formatChecked"
|
||||
@change="updateChecked"
|
||||
:key="entries[0].id"/>
|
||||
</b-col>
|
||||
<b-col cols="1" class="align-items-center d-flex">
|
||||
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true"
|
||||
@click.stop="$emit('open-context-menu', $event, e)">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
class="btn btn-link btn-sm m-0 p-0"
|
||||
style="text-overflow: ellipsis"
|
||||
@click.stop="openRecipeCard($event, e)"
|
||||
@mouseover="openRecipeCard($event, e)">
|
||||
{{ formatOneRecipe(e) }}
|
||||
:class="settings.left_handed ? 'dropdown-spacing' : ''"
|
||||
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret">
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
</button>
|
||||
</b-col>
|
||||
<b-col cols="auto" md="4" class="text-muted">{{ formatOneMealPlan(e) }}</b-col>
|
||||
<b-col cols="auto" md="4" class="text-muted text-right overflow-hidden text-nowrap">
|
||||
{{ formatOneCreatedBy(e) }}
|
||||
<div v-if="formatOneCompletedAt(e)">{{ formatOneCompletedAt(e) }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
|
||||
|
||||
|
||||
<b-row align-h="start">
|
||||
<b-col cols="1" class="align-items-center d-flex">
|
||||
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
|
||||
@click.stop="$emit('open-context-menu', $event, e)">
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked"
|
||||
@change="updateChecked"
|
||||
:key="entries[0].id"/>
|
||||
</b-col>
|
||||
<b-col cols="8" md="9">
|
||||
<b-row class="d-flex justify-content-around">
|
||||
<b-col cols="6" md="6" class="d-flex align-items-center">
|
||||
<div>{{ formatOneAmount(e) }}  
|
||||
{{ formatOneUnit(e) }}
|
||||
</div>
|
||||
</b-col>
|
||||
|
||||
<b-col cols="6" md="3" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
|
||||
{{ formatOneFood(e) }}
|
||||
</b-col>
|
||||
<b-col cols="12" class="d-flex d-md-none">
|
||||
<div class="small text-muted text-truncate" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
|
||||
:checked="formatChecked"
|
||||
@change="updateChecked"
|
||||
:key="entries[0].id"/>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<hr class="w-75" v-if="x !== entries.length -1"/>
|
||||
<div class="pb-4" v-if="x === entries.length -1"></div>
|
||||
</div>
|
||||
</b-container>
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control" :checked="formatChecked"
|
||||
@change="updateChecked"
|
||||
:key="entries[0].id"/>
|
||||
</b-col>
|
||||
<b-col cols="8" md="9">
|
||||
<b-row class="d-flex align-items-center h-100">
|
||||
<b-col cols="5" md="3" class="d-flex align-items-center">
|
||||
<strong class="mr-1">{{ formatOneAmount(e) }}</strong> {{ formatOneUnit(e) }}
|
||||
</b-col>
|
||||
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
|
||||
{{ formatOneFood(e) }}
|
||||
</b-col>
|
||||
<b-col cols="12" class="d-flex d-md-none">
|
||||
<div class="small text-muted text-truncate" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none"
|
||||
v-if="!settings.left_handed">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
|
||||
:checked="formatChecked"
|
||||
@change="updateChecked"
|
||||
:key="entries[0].id"/>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<hr class="w-75" v-if="x !== entries.length -1"/>
|
||||
<div class="pb-4" v-if="x === entries.length -1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="m-1" v-if="!showDetails"/>
|
||||
<ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width: 300">
|
||||
@ -177,6 +188,7 @@ export default {
|
||||
entries: {
|
||||
type: Array,
|
||||
},
|
||||
settings: Object,
|
||||
groupby: {type: String},
|
||||
},
|
||||
data() {
|
||||
@ -355,4 +367,11 @@ export default {
|
||||
font-size: 1rem !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dropdown-spacing {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -213,7 +213,7 @@
|
||||
"IgnoreThis": "{food} nicht automatisch zur Einkaufsliste hinzufügen",
|
||||
"shopping_auto_sync": "Automatische Synchronisierung",
|
||||
"shopping_share_desc": "Benutzer sehen all Einträge, die du zur Einkaufsliste hinzufügst. Sie müssen dich hinzufügen, damit du Ihre Einträge sehen kannst.",
|
||||
"IgnoredFood": "{food} beim nächsten Einkauf ignorieren",
|
||||
"IgnoredFood": "{food} beim nächsten Einkauf ignorieren.",
|
||||
"Add_Servings_to_Shopping": "{servings} Portionen zum Einkauf hinzufügen",
|
||||
"Inherit": "Vererben",
|
||||
"InheritFields": "Feldwerte vererben",
|
||||
@ -233,8 +233,8 @@
|
||||
"AddToShopping": "Zur Einkaufsliste hinzufügen",
|
||||
"FoodOnHand": "Sie haben {food} vorrätig.",
|
||||
"DeleteShoppingConfirm": "Möchten Sie wirklich alle {food} von der Einkaufsliste zu entfernen?",
|
||||
"err_moving_resource": "Ein Fehler trat während des Verschiebens einer Ressource auf!",
|
||||
"err_merging_resource": "Beim Zusammenführen einer Relssource trat ein Fehler auf!",
|
||||
"err_moving_resource": "Während des Verschiebens einer Resource ist ein Fehler aufgetreten!",
|
||||
"err_merging_resource": "Beim Zusammenführen einer Ressource ist ein Fehler aufgetreten!",
|
||||
"success_moving_resource": "Ressource wurde erfolgreich verschoben!",
|
||||
"success_merging_resource": "Ressource wurde erfolgreich zusammengeführt!",
|
||||
"Shopping_Categories": "Einkaufskategorien",
|
||||
@ -245,7 +245,7 @@
|
||||
"FoodNotOnHand": "Sie haben kein {food} vorrätig.",
|
||||
"Undefined": "nicht definiert",
|
||||
"AddFoodToShopping": "{food} zur Einkaufsliste hinzufügen",
|
||||
"RemoveFoodFromShopping": "{food} von der Einkaufsliste entfernen",
|
||||
"RemoveFoodFromShopping": "{food} von der Einkaufsliste entfernen",
|
||||
"Search Settings": "Sucheinstellungen",
|
||||
"shopping_auto_sync_desc": "Bei 0 wird Auto-Sync deaktiviert. Beim Betrachten einer Einkaufsliste wird die Liste alle gesetzten Sekunden aktualisiert, um mögliche Änderungen anderer zu zeigen. Nützlich, wenn mehrere Personen einkaufen und mobile Daten nutzen.",
|
||||
"MoveCategory": "Verschieben nach: ",
|
||||
|
@ -287,5 +287,7 @@
|
||||
"sql_debug": "SQL Debug",
|
||||
"remember_search": "Remember Search",
|
||||
"remember_hours": "Hours to Remember",
|
||||
"tree_select": "Use Tree Selection"
|
||||
"tree_select": "Use Tree Selection",
|
||||
"left_handed": "Left-handed mode",
|
||||
"left_handed_help": "Will optimize the UI for use with your left hand."
|
||||
}
|
||||
|
@ -284,5 +284,6 @@
|
||||
"QuickEntry": "Szybki wpis",
|
||||
"related_recipes": "Powiązane przepisy",
|
||||
"today_recipes": "Dzisiejsze przepisy",
|
||||
"Search Settings": "Ustawienia wyszukiwania"
|
||||
"Search Settings": "Ustawienia wyszukiwania",
|
||||
"Pin": "Pin"
|
||||
}
|
||||
|
@ -11,7 +11,7 @@
|
||||
"all_fields_optional": "所有字段都是可选的,可以留空。",
|
||||
"convert_internal": "转换为内部菜谱",
|
||||
"show_only_internal": "仅显示内部菜谱",
|
||||
"Log_Recipe_Cooking": "菜谱烹饪纪录",
|
||||
"Log_Recipe_Cooking": "菜谱烹饪记录",
|
||||
"External_Recipe_Image": "外部菜谱图像",
|
||||
"Add_to_Shopping": "添加到购物",
|
||||
"Add_to_Plan": "添加到计划",
|
||||
@ -54,7 +54,7 @@
|
||||
"Categories": "分类",
|
||||
"Category": "分类",
|
||||
"Selected": "选定",
|
||||
"min": "",
|
||||
"min": "分钟",
|
||||
"Servings": "份量",
|
||||
"Waiting": "等待",
|
||||
"Preparation": "准备",
|
||||
@ -74,7 +74,7 @@
|
||||
"Print": "打印",
|
||||
"Settings": "设置",
|
||||
"or": "或",
|
||||
"and": "与",
|
||||
"and": "和",
|
||||
"Information": "更多信息",
|
||||
"Download": "下载",
|
||||
"Create": "创建",
|
||||
@ -165,5 +165,115 @@
|
||||
"Create_New_Shopping Category": "创建新的购物类别",
|
||||
"Automate": "自动化",
|
||||
"Empty": "空的",
|
||||
"SupermarketName": "超市名"
|
||||
"SupermarketName": "超市名",
|
||||
"NotInShopping": "购物清单中没有 {food}。",
|
||||
"Drag_Here_To_Delete": "拖动此处可删除",
|
||||
"DeleteShoppingConfirm": "确定要移除购物清单中所有 {food} 吗?",
|
||||
"ShowDelayed": "显示延迟的项目",
|
||||
"mealplan_autoexclude_onhand": "排除入手的食物",
|
||||
"shopping_share_desc": "用户将看到您添加到购物清单中的所有商品。他们必须添加你才能看到他们清单上的内容。",
|
||||
"shopping_auto_sync_desc": "设置为0将禁用自动同步。当查看购物列表时,该列表每隔一秒更新一次,以同步其他人可能做出的更改。在多人购物时很有用,但会使用移动数据。",
|
||||
"err_merge_self": "无法将项目与自身合并",
|
||||
"CategoryInstruction": "拖动类别可更改出现在购物清单中的订单类别。",
|
||||
"csv_prefix_help": "将清单复制到剪贴板时要添加的前缀。",
|
||||
"remember_search": "记住搜索",
|
||||
"Root": "根",
|
||||
"Instructions": "说明",
|
||||
"Period": "周期",
|
||||
"Plan_Period_To_Show": "显示星期、月或年",
|
||||
"Periods": "周期",
|
||||
"Plan_Show_How_Many_Periods": "要显示多少个周期",
|
||||
"Starting_Day": "一周中的第一天",
|
||||
"Meal_Types": "用餐类型",
|
||||
"Make_header": "显示注意事项",
|
||||
"Color": "颜色",
|
||||
"New_Meal_Type": "新用餐类型",
|
||||
"Pin": "固定",
|
||||
"Planner_Settings": "计划者设置",
|
||||
"Meal_Type": "用餐类型",
|
||||
"Clone": "复制",
|
||||
"Title_or_Recipe_Required": "需要选择标题或菜谱",
|
||||
"Export_As_ICal": "将当前周期导出为 iCal 格式",
|
||||
"Week_Numbers": "周数",
|
||||
"Show_Week_Numbers": "显示周数?",
|
||||
"Coming_Soon": "即将到来",
|
||||
"New_Cookbook": "新烹饪书",
|
||||
"Hide_Keyword": "隐藏关键词",
|
||||
"Export_To_ICal": "导出 .ics",
|
||||
"Added_To_Shopping_List": "添加到购物清单",
|
||||
"Cannot_Add_Notes_To_Shopping": "无法将笔记添加到购物清单",
|
||||
"Shopping_List_Empty": "您的购物列表当前为空,您可以通过用餐计划条目的上下文菜单添加项目(右键单击卡片或左键单击菜单图标)",
|
||||
"Next_Period": "下期",
|
||||
"Current_Period": "本期",
|
||||
"Next_Day": "第二天",
|
||||
"Previous_Period": "上期",
|
||||
"Previous_Day": "前一天",
|
||||
"remember_hours": "需要记住的时间",
|
||||
"tree_select": "使用树形选择",
|
||||
"Make_Ingredient": "显示材料",
|
||||
"Note": "笔记",
|
||||
"Added_on": "添加到",
|
||||
"AddToShopping": "添加到购物清单",
|
||||
"IngredientInShopping": "此材料已在购物清单。",
|
||||
"OnHand": "目前",
|
||||
"FoodOnHand": "你手上有 {food}。",
|
||||
"FoodNotOnHand": "你还没有 {food}。",
|
||||
"Undefined": "未定义的",
|
||||
"Create_Meal_Plan_Entry": "创建用餐计划条目",
|
||||
"Edit_Meal_Plan_Entry": "编辑用餐计划条目",
|
||||
"Title": "标题",
|
||||
"Week": "星期",
|
||||
"Month": "月份",
|
||||
"Year": "年",
|
||||
"Planner": "计划者",
|
||||
"Meal_Type_Required": "用餐类型是必需的",
|
||||
"AddFoodToShopping": "添加 {food} 到购物清单",
|
||||
"RemoveFoodFromShopping": "从购物清单中移除 {food}",
|
||||
"IgnoredFood": "已忽略购买 {food}。",
|
||||
"Add_Servings_to_Shopping": "添加 {servings} 份到购物",
|
||||
"Inherit": "继承",
|
||||
"InheritFields": "继承字段值",
|
||||
"FoodInherit": "食物可继承的字段",
|
||||
"ShowUncategorizedFood": "显示未定义",
|
||||
"GroupBy": "分组",
|
||||
"MoveCategory": "移动到: ",
|
||||
"IgnoreThis": "永不自动添加 {food} 到购物",
|
||||
"DelayFor": "延迟 {hours} 小时",
|
||||
"Warning": "警告",
|
||||
"NoCategory": "未选择分类。",
|
||||
"Completed": "完成",
|
||||
"OfflineAlert": "您处于离线状态,购物清单可能无法同步。",
|
||||
"shopping_share": "分享购物清单",
|
||||
"shopping_auto_sync": "自动同步",
|
||||
"mealplan_autoadd_shopping": "自动添加用餐计划",
|
||||
"mealplan_autoinclude_related": "添加相关的菜谱",
|
||||
"default_delay": "默认延迟时间",
|
||||
"mealplan_autoadd_shopping_desc": "自动将用餐计划配料添加到购物清单中。",
|
||||
"mealplan_autoexclude_onhand_desc": "将用餐计划添加到购物清单时(手动或自动),排除当前手头上的配料。",
|
||||
"mealplan_autoinclude_related_desc": "将用餐计划(手动或自动)添加到购物清单时,包括所有相关菜谱。",
|
||||
"default_delay_desc": "延迟购物清单条目的默认小时数。",
|
||||
"err_move_self": "无法将项目移动到自身",
|
||||
"nothing": "无事可做",
|
||||
"show_sql": "显示 SQL",
|
||||
"filter_to_supermarket_desc": "默认情况下,过滤购物清单只包括所选超市的类别。",
|
||||
"shopping_recent_days_desc": "显示最近几天的购物清单条目。",
|
||||
"shopping_recent_days": "最近几天",
|
||||
"create_shopping_new": "添加到新的购物清单",
|
||||
"download_pdf": "下载 PDF",
|
||||
"download_csv": "下载 CSV",
|
||||
"csv_delim_help": "用于 CSV 导出的分隔符。",
|
||||
"csv_delim_label": "CSV 分隔符",
|
||||
"SuccessClipboard": "购物清单已复制到剪贴板",
|
||||
"copy_to_clipboard": "复制到剪贴板",
|
||||
"csv_prefix_label": "清单前缀",
|
||||
"copy_markdown_table": "复制为 Markdown 表格",
|
||||
"in_shopping": "在购物清单上",
|
||||
"DelayUntil": "推迟到",
|
||||
"mark_complete": "标记完成",
|
||||
"QuickEntry": "快速入口",
|
||||
"shopping_add_onhand_desc": "在核对购物清单时,将食物标记为“入手”。",
|
||||
"shopping_add_onhand": "自动入手",
|
||||
"related_recipes": "相关的菜谱",
|
||||
"today_recipes": "今日菜谱",
|
||||
"sql_debug": "调试 SQL"
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user