mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-25 11:19:39 -05:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
163c2a53b6 | ||
|
|
aba45657c3 | ||
|
|
6cedde7b2d | ||
|
|
44baa8322c | ||
|
|
0fbb95438a | ||
|
|
c56dd9563c | ||
|
|
0008b7c975 | ||
|
|
524f086cc5 | ||
|
|
8550387e0c | ||
|
|
1618f8df79 | ||
|
|
22dfb2a410 | ||
|
|
6973c65142 | ||
|
|
a01f86a14e | ||
|
|
9704268fdc | ||
|
|
84cc4c1165 | ||
|
|
5cb70becb8 | ||
|
|
5f99abf459 | ||
|
|
4a8ddce391 | ||
|
|
9a14a87c27 | ||
|
|
c01634f9bd | ||
|
|
f055df3b4d | ||
|
|
a83f474d70 | ||
|
|
63d358df36 | ||
|
|
e70548fcc0 | ||
|
|
17b03905e6 | ||
|
|
90403e6a13 | ||
|
|
db400cae25 | ||
|
|
0f8eee4e0f | ||
|
|
1f532f6276 | ||
|
|
b32715e493 | ||
|
|
0d19e12118 | ||
|
|
96e5213fa6 | ||
|
|
44c567d20b | ||
|
|
a71564a424 | ||
|
|
8183e350c9 | ||
|
|
9119d773f1 | ||
|
|
27e5955c78 |
@@ -1,12 +1,7 @@
|
||||
FROM python:3.10-alpine3.18
|
||||
FROM python:3.13-alpine3.22
|
||||
|
||||
#Install all dependencies.
|
||||
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git yarn
|
||||
|
||||
# Fix libxml error from xmlsec https://github.com/xmlsec/python-xmlsec/issues/257#issuecomment-1738620862
|
||||
RUN echo "https://dl-cdn.alpinelinux.org/alpine/v3.15/community/" | tee -a /etc/apk/repositories
|
||||
RUN echo "https://dl-cdn.alpinelinux.org/alpine/v3.15/main" | tee -a /etc/apk/repositories
|
||||
RUN apk add --no-cache libxml2-dev=2.9.14-r2 xmlsec-dev=1.2.33-r0
|
||||
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git yarn libgcc libstdc++ nginx tini envsubst nodejs npm
|
||||
|
||||
#Print all logs without buffering it.
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
@@ -24,8 +19,10 @@ RUN \
|
||||
if [ `apk --print-arch` = "armv7" ]; then \
|
||||
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
|
||||
fi
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev && \
|
||||
echo -n "INPUT ( libldap.so )" > /usr/lib/libldap_r.so && \
|
||||
pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt && \
|
||||
rm -rf /tmp/pip-tmp && \
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev xmlsec-dev xmlsec build-base g++ curl rust && \
|
||||
python -m pip install --upgrade pip && \
|
||||
pip debug -v && \
|
||||
pip install wheel==0.45.1 && \
|
||||
pip install setuptools_rust==1.10.2 && \
|
||||
pip install -r /tmp/pip-tmp/requirements.txt --no-cache-dir &&\
|
||||
apk --purge del .build-deps
|
||||
62
.vscode/tasks.json
vendored
62
.vscode/tasks.json
vendored
@@ -14,28 +14,16 @@
|
||||
},
|
||||
{
|
||||
"label": "Setup Dev Server",
|
||||
"dependsOn": ["Run Migrations", "Yarn Build"]
|
||||
"dependsOn": ["Run Migrations"]
|
||||
},
|
||||
{
|
||||
"label": "Run Dev Server",
|
||||
"type": "shell",
|
||||
"type": "shell",
|
||||
"dependsOn": ["Setup Dev Server"],
|
||||
"command": "python3 manage.py runserver"
|
||||
"command": "DEBUG=1 python3 manage.py runserver"
|
||||
},
|
||||
{
|
||||
"label": "Yarn Install",
|
||||
"dependsOn": ["Yarn Install - Vue", "Yarn Install - Vue3"]
|
||||
},
|
||||
{
|
||||
"label": "Yarn Install - Vue",
|
||||
"type": "shell",
|
||||
"command": "yarn install --force",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Yarn Install - Vue3",
|
||||
"type": "shell",
|
||||
"command": "yarn install --force",
|
||||
"options": {
|
||||
@@ -44,18 +32,6 @@
|
||||
},
|
||||
{
|
||||
"label": "Generate API",
|
||||
"dependsOn": ["Generate API - Vue", "Generate API - Vue3"]
|
||||
},
|
||||
{
|
||||
"label": "Generate API - Vue",
|
||||
"type": "shell",
|
||||
"command": "openapi-generator-cli generate -g typescript-axios -i http://127.0.0.1:8000/openapi/",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue/src/utils/openapi"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Generate API - Vue3",
|
||||
"type": "shell",
|
||||
"command": "openapi-generator-cli generate -g typescript-fetch -i http://127.0.0.1:8000/openapi/",
|
||||
"options": {
|
||||
@@ -63,43 +39,19 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Yarn Serve",
|
||||
"label": "Yarn Dev",
|
||||
"type": "shell",
|
||||
"command": "yarn serve",
|
||||
"dependsOn": ["Yarn Install - Vue"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Vite Serve",
|
||||
"type": "shell",
|
||||
"command": "vite",
|
||||
"dependsOn": ["Yarn Install - Vue3"],
|
||||
"command": "yarn dev",
|
||||
"dependsOn": ["Yarn Install"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Yarn Build",
|
||||
"dependsOn": ["Yarn Build - Vue", "Vite Build - Vue3"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "Yarn Build - Vue",
|
||||
"type": "shell",
|
||||
"command": "yarn build",
|
||||
"dependsOn": ["Yarn Install - Vue"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue"
|
||||
},
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "Vite Build - Vue3",
|
||||
"type": "shell",
|
||||
"command": "vite build",
|
||||
"dependsOn": ["Yarn Install - Vue3"],
|
||||
"dependsOn": ["Yarn Install"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue3"
|
||||
},
|
||||
|
||||
@@ -37,7 +37,7 @@ def get_filetype(name):
|
||||
|
||||
def is_file_type_allowed(filename, image_only=False):
|
||||
is_file_allowed = False
|
||||
allowed_file_types = ['.pdf', '.docx', '.xlsx', '.css']
|
||||
allowed_file_types = ['.pdf', '.docx', '.xlsx', '.css', '.mp4', '.mov']
|
||||
allowed_image_types = ['.png', '.jpg', '.jpeg', '.gif', '.webp']
|
||||
check_list = allowed_image_types
|
||||
if not image_only:
|
||||
@@ -77,6 +77,8 @@ def handle_image(request, image_object, filetype):
|
||||
file_format = 'JPEG'
|
||||
if filetype == '.png':
|
||||
file_format = 'PNG'
|
||||
if filetype == '.webp':
|
||||
file_format = 'WEBP'
|
||||
|
||||
if (image_object.size / 1000) > 500: # if larger than 500 kb compress
|
||||
if filetype == '.jpeg' or filetype == '.jpg':
|
||||
|
||||
34
cookbook/migrations/0223_auto_20250831_1111.py
Normal file
34
cookbook/migrations/0223_auto_20250831_1111.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 4.2.22 on 2025-08-31 09:11
|
||||
|
||||
from django.db import migrations
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def migrate_comments(apps, schema_editor):
|
||||
with scopes_disabled():
|
||||
Comment = apps.get_model('cookbook', 'Comment')
|
||||
CookLog = apps.get_model('cookbook', 'CookLog')
|
||||
|
||||
cook_logs = []
|
||||
|
||||
for c in Comment.objects.all():
|
||||
cook_logs.append(CookLog(
|
||||
recipe=c.recipe,
|
||||
created_by=c.created_by,
|
||||
created_at=c.created_at,
|
||||
comment=c.text,
|
||||
space=c.recipe.space,
|
||||
))
|
||||
|
||||
CookLog.objects.bulk_create(cook_logs, unique_fields=('recipe', 'comment', 'created_at', 'created_by'))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0222_alter_shoppinglistrecipe_created_by_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_comments),
|
||||
]
|
||||
@@ -787,7 +787,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
||||
if plural_name := validated_data.pop('plural_name', None):
|
||||
plural_name = plural_name.strip()
|
||||
|
||||
if food := Food.objects.filter(Q(name=name) | Q(plural_name=name)).first():
|
||||
if food := Food.objects.filter(Q(name__iexact=name) | Q(plural_name__iexact=name)).first():
|
||||
return food
|
||||
|
||||
space = validated_data.pop('space', self.context['request'].space)
|
||||
@@ -1038,7 +1038,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer):
|
||||
fields = (
|
||||
'id', 'name', 'description', 'image', 'keywords', 'working_time',
|
||||
'waiting_time', 'created_by', 'created_at', 'updated_at',
|
||||
'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'new', 'recent'
|
||||
'internal', 'private','servings', 'servings_text', 'rating', 'last_cooked', 'new', 'recent'
|
||||
)
|
||||
# TODO having these readonly fields makes "RecipeOverview.ts" (API Client) not generate the RecipeOverviewToJSON second else block which leads to errors when using the api
|
||||
# TODO find a solution (custom schema?) to have these fields readonly (to save performance) and generate a proper client (two serializers would probably do the trick)
|
||||
@@ -1245,8 +1245,8 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
|
||||
|
||||
class AutoMealPlanSerializer(serializers.Serializer):
|
||||
start_date = serializers.DateField()
|
||||
end_date = serializers.DateField()
|
||||
start_date = serializers.DateTimeField()
|
||||
end_date = serializers.DateTimeField()
|
||||
meal_type_id = serializers.IntegerField()
|
||||
keyword_ids = serializers.ListField()
|
||||
servings = CustomDecimalField()
|
||||
|
||||
@@ -51,11 +51,6 @@
|
||||
{# {% endif %}#}
|
||||
<p class="card-text"><small
|
||||
class="text-muted">{% trans 'Owner' %}: {{ us.space.created_by }}</small>
|
||||
{% if us.space.created_by != us.user %}
|
||||
<p class="card-text"><small
|
||||
class="text-muted"><a
|
||||
href="{% url 'delete_user_space' us.pk %}">{% trans 'Leave Space' %}</a></small>
|
||||
{% endif %}
|
||||
<!--TODO add direct link to management page -->
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -454,13 +454,11 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
|
||||
return self.annotate_recipe(queryset=super().get_queryset(), request=self.request,
|
||||
serializer=self.serializer_class, tree=True)
|
||||
|
||||
|
||||
self.queryset = self.queryset.filter(space=self.request.space)
|
||||
# only order if not root_tree or tree mde because in these modes the sorting is relevant for the client
|
||||
if not root_tree and not tree:
|
||||
self.queryset = self.queryset.order_by(Lower('name').asc())
|
||||
|
||||
|
||||
return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class,
|
||||
tree=True)
|
||||
|
||||
@@ -1451,9 +1449,26 @@ class RecipeViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
|
||||
return Response(serializer.errors, 400)
|
||||
|
||||
@extend_schema(responses=RecipeSerializer(many=False))
|
||||
@decorators.action(detail=True, pagination_class=None, methods=['PATCH'], serializer_class=RecipeSerializer)
|
||||
def delete_external(self, request, pk):
|
||||
obj = self.get_object()
|
||||
if obj.get_space() != request.space and has_group_permission(request.user, ['user']):
|
||||
raise PermissionDenied(detail='You do not have the required permission to perform this action', code=403)
|
||||
|
||||
if obj.storage:
|
||||
get_recipe_provider(obj).delete_file(obj)
|
||||
obj.storage = None
|
||||
obj.file_path = ''
|
||||
obj.file_uid = ''
|
||||
obj.save()
|
||||
|
||||
return Response(self.serializer_class(obj, many=False, context={'request': request}).data)
|
||||
|
||||
|
||||
@extend_schema_view(list=extend_schema(
|
||||
parameters=[OpenApiParameter(name='food_id', description='ID of food to filter for', type=int), ]))
|
||||
parameters=[OpenApiParameter(name='food_id', description='ID of food to filter for', type=int),
|
||||
OpenApiParameter(name='query', description='query that looks into food, base unit or converted unit by name', type=str), ]))
|
||||
class UnitConversionViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
queryset = UnitConversion.objects
|
||||
serializer_class = UnitConversionSerializer
|
||||
@@ -1465,6 +1480,10 @@ class UnitConversionViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
if food_id is not None:
|
||||
self.queryset = self.queryset.filter(food_id=food_id)
|
||||
|
||||
query = self.request.query_params.get('query', None)
|
||||
if query is not None:
|
||||
self.queryset = self.queryset.filter(Q(food__name__icontains=query) | Q(base_unit__name__icontains=query) | Q(converted_unit__name__icontains=query))
|
||||
|
||||
return self.queryset.filter(space=self.request.space)
|
||||
|
||||
|
||||
@@ -2507,7 +2526,7 @@ def meal_plans_to_ical(queryset, filename):
|
||||
request=inline_serializer(name="IngredientStringSerializer", fields={'text': CharField()}),
|
||||
responses=inline_serializer(name="ParsedIngredientSerializer",
|
||||
fields={'amount': IntegerField(), 'unit': CharField(), 'food': CharField(),
|
||||
'note': CharField()})
|
||||
'note': CharField(), 'original_text': CharField()})
|
||||
)
|
||||
@api_view(['POST'])
|
||||
@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope])
|
||||
@@ -2517,13 +2536,20 @@ def ingredient_from_string(request):
|
||||
ingredient_parser = IngredientParser(request, False)
|
||||
amount, unit, food, note = ingredient_parser.parse(text)
|
||||
|
||||
ingredient = {'amount': amount, 'unit': None, 'food': None, 'note': note}
|
||||
ingredient = {'amount': amount, 'unit': None, 'food': None, 'note': note, 'original_text': text}
|
||||
if food:
|
||||
food, created = Food.objects.get_or_create(space=request.space, name=food)
|
||||
ingredient['food'] = {'name': food.name, 'id': food.id}
|
||||
if food_obj := Food.objects.filter(space=request.space).filter(Q(name=food) | Q(plural_name=food)).first():
|
||||
ingredient['food'] = {'name': food_obj.name, 'id': food_obj.id}
|
||||
else:
|
||||
food_obj = Food.objects.create(space=request.space, name=food)
|
||||
ingredient['food'] = {'name': food_obj.name, 'id': food_obj.id}
|
||||
|
||||
if unit:
|
||||
unit, created = Unit.objects.get_or_create(space=request.space, name=unit)
|
||||
if unit_obj := Unit.objects.filter(space=request.space).filter(Q(name=unit) | Q(plural_name=unit)).first():
|
||||
ingredient['food'] = {'name': unit_obj.name, 'id': unit_obj.id}
|
||||
else:
|
||||
unit_obj = Unit.objects.create(space=request.space, name=unit)
|
||||
ingredient['food'] = {'name': unit_obj.name, 'id': unit_obj.id}
|
||||
ingredient['unit'] = {'name': unit.name, 'id': unit.id}
|
||||
|
||||
return JsonResponse(ingredient, status=200)
|
||||
|
||||
@@ -33,17 +33,26 @@ VS Marketplace Link: https://marketplace.visualstudio.com/items?itemName=esbenp.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! note
|
||||
In order to debug vue yarn and vite servers must be started before starting the django server.
|
||||
In order to hot reload vue, the `yarn dev` server must be started before starting the django server.
|
||||
|
||||
There are a number of built in tasks that are available. Here are a few of the key ones:
|
||||
|
||||
- `Setup Dev Server` - Runs all the prerequisite steps so that the dev server can be run inside VSCode.
|
||||
- `Setup Tests` - Runs all prerequisites so tests can be run inside VSCode.
|
||||
|
||||
Once these are run, you should be able to run/debug a django server in VSCode as well as run/debug tests directly through VSCode.
|
||||
There are also a few other tasks specified in case you have specific development needs:
|
||||
Once these are run, there are 2 options. If you want to run a vue3 server in a hot reload mode for quick development of the frontend, you should run a development vue server:
|
||||
|
||||
- `Yarn Dev` - Runs development Vue.js vite server not connected to VSCode. Useful if you want to make Vue changes and see them in realtime.
|
||||
|
||||
If not, you need to build and copy the frontend to the django server. If you make changes to the frontend, you need to re-run this and restart the django server:
|
||||
|
||||
- `Collect Static Files` - Builds and collects the vue3 frontend so that it can be served via the django server.
|
||||
|
||||
Once either of those steps are done, you can start the django server:
|
||||
|
||||
- `Run Dev Server` - Runs a django development server not connected to VSCode.
|
||||
|
||||
There are also a few other tasks specified in case you have specific development needs:
|
||||
|
||||
- `Run all pytests` - Runs all the pytests outside of VSCode.
|
||||
- `Yarn Serve` - Runs development Vue.js server not connected to VSCode. Useful if you want to make Vue changes and see them in realtime.
|
||||
- `Serve Documentation` - Runs a documentation server. Useful if you want to see how changes to documentation show up.
|
||||
|
||||
@@ -4,6 +4,8 @@ import traceback
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
#TODO clean existing links for when plugins are uninstalled or not necessary because it will just be empty links?
|
||||
|
||||
PLUGINS_DIRECTORY = os.path.join(BASE_DIR, 'recipes', 'plugins')
|
||||
if os.path.isdir(PLUGINS_DIRECTORY):
|
||||
for d in os.listdir(PLUGINS_DIRECTORY):
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@vueform/multiselect": "^2.6.11",
|
||||
"@vueuse/core": "^13.6.0",
|
||||
"@vueuse/router": "^13.6.0",
|
||||
@@ -22,7 +23,6 @@
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-simple-calendar": "7.1.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"vuetify": "^3.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -32,20 +32,21 @@
|
||||
"@types/node": "^24.0.8",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"esbuild-register": "^3.6.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "6.3.5",
|
||||
"vite-plugin-pwa": "^1.0.2",
|
||||
"workbox-core": "^7.3.0",
|
||||
"workbox-build": "^7.3.0",
|
||||
"workbox-window": "^7.3.0",
|
||||
"vite-plugin-vuetify": "^2.1.1",
|
||||
"vue-tsc": "^2.2.8",
|
||||
"workbox-background-sync": "^7.3.0",
|
||||
"workbox-build": "^7.3.0",
|
||||
"workbox-core": "^7.3.0",
|
||||
"workbox-expiration": "^7.3.0",
|
||||
"workbox-navigation-preload": "^7.3.0",
|
||||
"workbox-precaching": "^7.3.0",
|
||||
"workbox-routing": "^7.3.0",
|
||||
"workbox-strategies": "^7.3.0",
|
||||
"vite-plugin-vuetify": "^2.1.1",
|
||||
"vue-tsc": "^2.2.8"
|
||||
"workbox-window": "^7.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
114
vue3/src/components/dialogs/AutoPlanDialog.vue
Normal file
114
vue3/src/components/dialogs/AutoPlanDialog.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<v-dialog max-width="600px" :activator="props.activator" v-model="dialog">
|
||||
<v-card :loading="loading">
|
||||
<v-closable-card-title v-model="dialog" :title="$t('Auto_Planner')" icon="fa-solid fa-calendar-plus"></v-closable-card-title>
|
||||
|
||||
<v-card-text>
|
||||
|
||||
<v-form>
|
||||
<model-select model="MealType" v-model="autoMealPlan.mealTypeId" :object="false"></model-select>
|
||||
<model-select model="Keyword" v-model="autoMealPlan.keywordIds" mode="tags" :object="false"></model-select>
|
||||
|
||||
<v-number-input :label="$t('Servings')" v-model="autoMealPlan.servings"></v-number-input>
|
||||
|
||||
<v-date-input :label="$t('Date')"
|
||||
multiple="range"
|
||||
v-model="dateRangeValue"
|
||||
:first-day-of-week="useUserPreferenceStore().deviceSettings.mealplan_startingDayOfWeek"
|
||||
:show-week="useUserPreferenceStore().deviceSettings.mealplan_displayWeekNumbers"
|
||||
prepend-icon=""
|
||||
prepend-inner-icon="$calendar"
|
||||
></v-date-input>
|
||||
|
||||
<model-select model="User" v-model="autoMealPlan.shared" mode="tags"></model-select>
|
||||
<v-checkbox v-model="autoMealPlan.addshopping" :label="$t('AddToShopping')" hide-details></v-checkbox>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn @click="dialog = false">{{ $t('Cancel') }}</v-btn>
|
||||
<v-btn color="create" prepend-icon="fa-solid fa-person-running" @click="doAutoPlan()" :loading="loading">{{ $t('Create') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {useI18n} from "vue-i18n";
|
||||
import ModelSelect from "@/components/inputs/ModelSelect.vue";
|
||||
import {ApiApi, AutoMealPlan} from "@/openapi";
|
||||
import {onMounted, ref} from "vue";
|
||||
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
|
||||
import {VDateInput} from 'vuetify/labs/VDateInput'
|
||||
import {DateTime} from "luxon";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
import {ErrorMessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
import {useMealPlanStore} from "@/stores/MealPlanStore.ts";
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const props = defineProps({
|
||||
activator: {type: String, default: 'parent'},
|
||||
})
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const dialog = defineModel<boolean>({default: false})
|
||||
const loading = ref(false)
|
||||
|
||||
const dateRangeValue = ref([] as Date[])
|
||||
const autoMealPlan = ref({} as AutoMealPlan)
|
||||
|
||||
onMounted(() => {
|
||||
initializeRequest()
|
||||
})
|
||||
|
||||
/**
|
||||
* load default values for auto plan creation
|
||||
*/
|
||||
function initializeRequest() {
|
||||
autoMealPlan.value = {
|
||||
servings: 1,
|
||||
startDate: DateTime.now().toJSDate(),
|
||||
endDate: DateTime.now().plus({day: 7}).toJSDate(),
|
||||
shared: useUserPreferenceStore().userSettings.planShare,
|
||||
addshopping: useUserPreferenceStore().userSettings.mealplanAutoaddShopping,
|
||||
} as AutoMealPlan
|
||||
|
||||
dateRangeValue.value = []
|
||||
let currentDate = DateTime.fromJSDate(autoMealPlan.value.startDate).plus({day: 1}).toJSDate()
|
||||
while (currentDate <= autoMealPlan.value.endDate) {
|
||||
dateRangeValue.value.push(currentDate)
|
||||
currentDate = DateTime.fromJSDate(currentDate).plus({day: 1}).toJSDate()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* perform auto plan creation
|
||||
*/
|
||||
function doAutoPlan() {
|
||||
let api = new ApiApi()
|
||||
loading.value = true
|
||||
|
||||
autoMealPlan.value.startDate = dateRangeValue.value[0]
|
||||
autoMealPlan.value.endDate = dateRangeValue.value[dateRangeValue.value.length - 1]
|
||||
console.log('requesting auto plan from ', autoMealPlan.value.startDate, ' to ', autoMealPlan.value.endDate)
|
||||
|
||||
api.apiAutoPlanCreate({autoMealPlan: autoMealPlan.value}).then(r => {
|
||||
dialog.value = false
|
||||
useMealPlanStore().refreshLastUpdatedPeriod()
|
||||
initializeRequest()
|
||||
useMessageStore().addPreparedMessage(PreparedMessage.CREATE_SUCCESS)
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.CREATE_ERROR, err)
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-textarea :model-value="importLog.msg"></v-textarea>
|
||||
<v-textarea :model-value="importLog.msg" max-rows="25" auto-grow></v-textarea>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
|
||||
@@ -131,9 +131,9 @@ function refreshVisiblePeriod(startDateUnknown: boolean) {
|
||||
|
||||
// load backwards to as on initial
|
||||
if (startDateUnknown) {
|
||||
useMealPlanStore().refreshFromAPI(DateTime.fromJSDate(calendarDate.value).minus({days: days}).toJSDate(), DateTime.now().plus({days: days}).toJSDate())
|
||||
useMealPlanStore().refreshFromAPI(DateTime.fromJSDate(calendarDate.value).minus({days: days}).toJSDate(), DateTime.fromJSDate(calendarDate.value).plus({days: days}).toJSDate())
|
||||
} else {
|
||||
useMealPlanStore().refreshFromAPI(calendarDate.value, DateTime.now().plus({days: days}).toJSDate())
|
||||
useMealPlanStore().refreshFromAPI(calendarDate.value, DateTime.fromJSDate(calendarDate.value).plus({days: days}).toJSDate())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,17 @@
|
||||
|
||||
<template v-if="route.name == 'MealPlanPage'">
|
||||
<v-divider></v-divider>
|
||||
<v-list-item prepend-icon="fa-solid fa-calendar-plus" link>
|
||||
{{$t('Auto_Planner')}}
|
||||
<auto-plan-dialog></auto-plan-dialog>
|
||||
</v-list-item>
|
||||
<v-list-subheader>{{$t('Settings')}}</v-list-subheader>
|
||||
<v-list-item>
|
||||
<meal-plan-device-settings></meal-plan-device-settings>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -27,6 +32,7 @@ import {useRoute} from "vue-router";
|
||||
import {getListModels} from "@/types/Models";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
import MealPlanDeviceSettings from "@/components/settings/MealPlanDeviceSettings.vue";
|
||||
import AutoPlanDialog from "@/components/dialogs/AutoPlanDialog.vue";
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
|
||||
27
vue3/src/components/display/PrivateRecipeBadge.vue
Normal file
27
vue3/src/components/display/PrivateRecipeBadge.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
<span v-if="props.showText" class="ms-1 me-1">{{ $t('Private_Recipe') }}</span>
|
||||
<v-chip class="me-1 mb-1" :color="props.color" :size="props.size" :variant="props.variant" v-for="u in users" :key="u.id" prepend-icon="fa-solid fa-share-nodes"> {{ u.displayName }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {User} from "@/openapi";
|
||||
import {PropType} from "vue";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
|
||||
const props = defineProps({
|
||||
showText: {type: Boolean, default: true},
|
||||
users: {type: [] as PropType<Array<User>>, required: false},
|
||||
|
||||
size: {type: String, default: 'x-small'},
|
||||
color: {type: String, default: ''},
|
||||
variant: {type: String as PropType<NonNullable<"tonal" | "flat" | "text" | "elevated" | "outlined" | "plain"> | undefined>, default: 'tonal'},
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -1,40 +1,11 @@
|
||||
<template>
|
||||
|
||||
<v-card class="mt-1" v-if="cookLogs.length > 0">
|
||||
<v-card-title>{{ $t('Activity') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list>
|
||||
<v-list-item v-for="c in cookLogs.sort((a,b) => a.createdAt! > b.createdAt! ? 1 : -1)" :key="c.id">
|
||||
<template #prepend>
|
||||
<v-avatar color="primary">V</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-bold">{{ c.createdBy.displayName }}
|
||||
<v-rating density="comfortable" size="x-small" color="tandoor" class="float-right" v-model="c.rating" readonly v-if="c.rating != undefined"></v-rating>
|
||||
</v-list-item-title>
|
||||
|
||||
{{ c.comment }}
|
||||
|
||||
<p v-if="c.servings != null && c.servings > 0">
|
||||
{{ c.servings }}
|
||||
<span v-if="recipe.servingsText != ''">{{ recipe.servingsText }}</span>
|
||||
<span v-else-if="c.servings == 1">{{ $t('Serving') }}</span>
|
||||
<span v-else>{{ $t('Servings') }}</span>
|
||||
</p>
|
||||
|
||||
<p class="text-disabled">
|
||||
{{ DateTime.fromJSDate(c.createdAt).toLocaleString(DateTime.DATETIME_SHORT) }}
|
||||
</p>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card class="mt-1 d-print-none" v-if="useUserPreferenceStore().isAuthenticated">
|
||||
<v-card class="mt-1 d-print-none" v-if="useUserPreferenceStore().isAuthenticated" :loading="loading">
|
||||
<v-card-text>
|
||||
<v-textarea :label="$t('Comment')" rows="2" v-model="newCookLog.comment"></v-textarea>
|
||||
<v-row de>
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="4">
|
||||
<v-label>{{$t('Rating')}}</v-label><br/>
|
||||
<v-label>{{ $t('Rating') }}</v-label>
|
||||
<br/>
|
||||
<v-rating v-model="newCookLog.rating" clearable hover density="compact"></v-rating>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
@@ -52,6 +23,48 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<v-card class="mt-1" v-if="cookLogs.length > 0" :loading="loading">
|
||||
<v-card-title>{{ $t('Activity') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list>
|
||||
<v-list-item class="border-t-sm" v-for="c in cookLogs" :key="c.id" :link="c.createdBy.id == useUserPreferenceStore().userSettings?.user.id">
|
||||
<template #prepend>
|
||||
<v-avatar color="primary">{{ c.createdBy.displayName.charAt(0) }}</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-bold">
|
||||
{{ c.createdBy.displayName }}
|
||||
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ c.comment }}</v-list-item-subtitle>
|
||||
|
||||
<v-list-item-subtitle class="font-italic mt-1" v-if="c.servings != null && c.servings > 0">
|
||||
|
||||
{{ c.servings }}
|
||||
<span v-if="recipe.servingsText != ''">{{ recipe.servingsText }}</span>
|
||||
<span v-else-if="c.servings == 1">{{ $t('Serving') }}</span>
|
||||
<span v-else>{{ $t('Servings') }}</span>
|
||||
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template #append>
|
||||
<v-list-item-action class="flex-column align-end">
|
||||
<v-rating density="comfortable" size="x-small" color="tandoor" v-model="c.rating" half-increments readonly
|
||||
v-if="c.rating != undefined"></v-rating>
|
||||
<v-spacer></v-spacer>
|
||||
<v-tooltip location="top" :text="DateTime.fromJSDate(c.createdAt).toLocaleString(DateTime.DATETIME_MED)" v-if="c.createdAt != undefined">
|
||||
<template v-slot:activator="{ props }">
|
||||
<span v-bind="props">{{ DateTime.fromJSDate(c.createdAt).toRelative({style: 'narrow'}) }}</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
</v-list-item-action>
|
||||
</template>
|
||||
<model-edit-dialog model="CookLog" :item="c" v-if="c.createdBy.id == useUserPreferenceStore().userSettings?.user.id" @save="recLoadCookLog(props.recipe.id)" @delete="recLoadCookLog(props.recipe.id)"></model-edit-dialog>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
@@ -63,6 +76,7 @@ import {DateTime} from "luxon";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
|
||||
import {VDateInput} from 'vuetify/labs/VDateInput'
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
|
||||
|
||||
const props = defineProps({
|
||||
recipe: {
|
||||
@@ -74,21 +88,31 @@ const props = defineProps({
|
||||
const newCookLog = ref({} as CookLog);
|
||||
|
||||
const cookLogs = ref([] as CookLog[])
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
refreshActivity()
|
||||
recLoadCookLog(props.recipe.id)
|
||||
resetForm()
|
||||
})
|
||||
|
||||
/**
|
||||
* load cook logs from database for given recipe
|
||||
* recursively load cook logs from database for given recipe
|
||||
*/
|
||||
function refreshActivity() {
|
||||
function recLoadCookLog(recipeId: number, page: number = 1) {
|
||||
const api = new ApiApi()
|
||||
api.apiCookLogList({recipe: props.recipe.id}).then(r => {
|
||||
// TODO pagination
|
||||
loading.value = true
|
||||
if(page == 1){
|
||||
cookLogs.value = []
|
||||
}
|
||||
api.apiCookLogList({recipe: props.recipe.id, page: page}).then(r => {
|
||||
if (r.results) {
|
||||
cookLogs.value = r.results
|
||||
cookLogs.value = cookLogs.value.concat(r.results)
|
||||
if (r.next) {
|
||||
recLoadCookLog(recipeId, page + 1)
|
||||
} else {
|
||||
cookLogs.value = cookLogs.value.sort((a, b) => a.createdAt! > b.createdAt! ? 1 : -1)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
<!-- <p class="text-disabled">{{ props.recipe.createdBy.displayName}}</p>-->
|
||||
<keywords-component variant="outlined" :keywords="props.recipe.keywords" :max-keywords="3" v-if="props.showKeywords">
|
||||
<template #prepend>
|
||||
|
||||
<v-chip class="mb-1 me-1" size="x-small" label variant="outlined" v-if="recipe._private">
|
||||
<private-recipe-badge :show-text="false"></private-recipe-badge>
|
||||
</v-chip>
|
||||
<v-chip class="mb-1 me-1" size="x-small" label variant="outlined" color="info"
|
||||
v-if="props.recipe.internal == false">
|
||||
{{ $t('External') }}
|
||||
@@ -100,6 +104,7 @@ import {Recipe, RecipeOverview} from "@/openapi";
|
||||
import RecipeContextMenu from "@/components/inputs/RecipeContextMenu.vue";
|
||||
import RecipeImage from "@/components/display/RecipeImage.vue";
|
||||
import {useRouter} from "vue-router";
|
||||
import PrivateRecipeBadge from "@/components/display/PrivateRecipeBadge.vue";
|
||||
|
||||
const props = defineProps({
|
||||
recipe: {type: {} as PropType<Recipe | RecipeOverview>, required: true,},
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<recipe-context-menu :recipe="recipe" v-if="useUserPreferenceStore().isAuthenticated"></recipe-context-menu>
|
||||
</v-sheet>
|
||||
<keywords-component variant="flat" class="ms-1" :keywords="recipe.keywords"></keywords-component>
|
||||
<private-recipe-badge :users="recipe.shared" v-if="recipe._private"></private-recipe-badge>
|
||||
<v-rating v-model="recipe.rating" size="x-small" v-if="recipe.rating" half-increments readonly></v-rating>
|
||||
<v-sheet class="ps-2 text-disabled">
|
||||
{{ recipe.description }}
|
||||
@@ -35,8 +36,7 @@
|
||||
</v-card>
|
||||
</v-card>
|
||||
|
||||
<!-- only display values if not all are default (e.g. for external recipes) -->
|
||||
<v-card class="mt-1" v-if="recipe.workingTime != 0 || recipe.waitingTime != 0 || recipe.servings != 1">
|
||||
<v-card class="mt-1">
|
||||
<v-container>
|
||||
<v-row class="text-center text-body-2">
|
||||
<v-col class="pt-1 pb-1">
|
||||
@@ -85,6 +85,8 @@
|
||||
<i>{{ recipe.description }}</i>
|
||||
</p>
|
||||
|
||||
<private-recipe-badge :users="recipe.shared" v-if="recipe._private"></private-recipe-badge>
|
||||
|
||||
<v-rating v-model="recipe.rating" size="x-small" v-if="recipe.rating" readonly></v-rating>
|
||||
|
||||
<keywords-component variant="flat" class="mt-4" :keywords="recipe.keywords"></keywords-component>
|
||||
@@ -147,7 +149,7 @@
|
||||
:title="$t('CreatedBy')"
|
||||
:subtitle="recipe.createdBy.displayName"
|
||||
prepend-icon="fa-solid fa-user"
|
||||
:to="{name: 'SearchPage', query: {createdby: recipe.createdBy.id!}}">
|
||||
:to="(useUserPreferenceStore().isAuthenticated) ? {name: 'SearchPage', query: {createdby: recipe.createdBy.id!}}: undefined">
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
@@ -156,7 +158,7 @@
|
||||
:title="$t('Created')"
|
||||
:subtitle="DateTime.fromJSDate(recipe.createdAt).toLocaleString(DateTime.DATETIME_MED)"
|
||||
prepend-icon="$create"
|
||||
:to="{name: 'SearchPage', query: {createdon: DateTime.fromJSDate(recipe.createdAt).toISODate()}}">
|
||||
:to="(useUserPreferenceStore().isAuthenticated) ? {name: 'SearchPage', query: {createdon: DateTime.fromJSDate(recipe.createdAt).toISODate()}} : undefined">
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
@@ -165,7 +167,7 @@
|
||||
:title="$t('Updated')"
|
||||
:subtitle="DateTime.fromJSDate(recipe.updatedAt).toLocaleString(DateTime.DATETIME_MED)"
|
||||
prepend-icon="$edit"
|
||||
:to="{name: 'SearchPage', query: {updatedon: DateTime.fromJSDate(recipe.updatedAt).toISODate()}}">
|
||||
:to="(useUserPreferenceStore().isAuthenticated) ? {name: 'SearchPage', query: {updatedon: DateTime.fromJSDate(recipe.updatedAt).toISODate()}}: undefined">
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3" v-if="recipe.sourceUrl">
|
||||
@@ -204,6 +206,7 @@ import PropertyView from "@/components/display/PropertyView.vue";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
import {useFileApi} from "@/composables/useFileApi.ts";
|
||||
import PrivateRecipeBadge from "@/components/display/PrivateRecipeBadge.vue";
|
||||
|
||||
const {request, release} = useWakeLock()
|
||||
const {doAiImport, fileApiLoading} = useFileApi()
|
||||
|
||||
@@ -16,16 +16,15 @@
|
||||
<i class="fas fa-clock-rotate-left text-info fa-fw" v-if="a.delayed"></i> <b>
|
||||
<span :class="{'text-disabled': a.checked || a.delayed}" class="text-no-wrap">
|
||||
<span v-if="amounts.length > 1 || (amounts.length == 1 && a.amount != 1)">{{ $n(a.amount) }}</span>
|
||||
<span class="ms-1" v-if="a.unit">{{ a.unit.name }}</span>
|
||||
<span class="ms-1" v-if="a.unit">{{ pluralString(a.unit, a.amount) }}</span>
|
||||
</span>
|
||||
|
||||
</b>
|
||||
</span>
|
||||
<br/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-grow-1 align-self-center">
|
||||
{{ shoppingListFood.food.name }} <br/>
|
||||
{{ pluralString(shoppingListFood.food, (amounts.length > 1 || (amounts.length == 1 && amounts[0].amount > 1) ? 2 : 1)) }} <br/>
|
||||
<span v-if="infoRow"><small class="text-disabled">{{ infoRow }}</small></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,6 +58,7 @@ import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
|
||||
import {IShoppingListFood, ShoppingLineAmount} from "@/types/Shopping";
|
||||
import {isDelayed, isEntryVisible, isShoppingListFoodDelayed, isShoppingListFoodVisible} from "@/utils/logic_utils";
|
||||
import ShoppingLineItemDialog from "@/components/dialogs/ShoppingLineItemDialog.vue";
|
||||
import {pluralString} from "@/utils/model_utils.ts";
|
||||
|
||||
const emit = defineEmits(['clicked'])
|
||||
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
<template>
|
||||
<v-expansion-panels>
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title><i class="far fa-list-alt fa-fw me-2"></i> {{ $t('StepsOverview') }}</v-expansion-panel-title>
|
||||
<v-expansion-panel-title>
|
||||
<i class="far fa-list-alt fa-fw me-2"></i> {{ $t('StepsOverview') }}
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-container>
|
||||
<v-row v-for="(s, i) in props.steps">
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-btn-toggle density="compact" v-model="useUserPreferenceStore().deviceSettings.recipe_mergeStepOverview" border divided>
|
||||
<v-btn :value="false" prepend-icon="fa-solid fa-folder-tree">{{ $t('Structured') }}</v-btn>
|
||||
<v-btn :value="true" prepend-icon="fa-solid fa-arrows-to-circle">{{ $t('Summary') }}</v-btn>
|
||||
</v-btn-toggle>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-for="(s, i) in props.steps" v-if="!useUserPreferenceStore().deviceSettings.recipe_mergeStepOverview">
|
||||
<v-col class="pa-1" cols="12" md="6">
|
||||
<b v-if="s.showAsHeader">{{ i + 1 }}. {{ s.name }} </b>
|
||||
<ingredients-table v-model="s.ingredients" :ingredient-factor="props.ingredientFactor"></ingredients-table>
|
||||
@@ -21,7 +30,13 @@
|
||||
</template>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<v-row v-if="useUserPreferenceStore().deviceSettings.recipe_mergeStepOverview">
|
||||
<v-col class="pa-1" cols="12" md="6">
|
||||
<ingredients-table v-model="mergedIngredients" :ingredient-factor="props.ingredientFactor" :show-checkbox="false"></ingredients-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
|
||||
@@ -30,10 +45,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {PropType} from 'vue'
|
||||
import {Step} from "@/openapi";
|
||||
import {computed, PropType, ref} from 'vue'
|
||||
import {Ingredient, Step} from "@/openapi";
|
||||
import IngredientsTable from "@/components/display/IngredientsTable.vue";
|
||||
import StepView from "@/components/display/StepView.vue";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
|
||||
const props = defineProps({
|
||||
steps: {
|
||||
@@ -46,6 +61,70 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const showMergedIngredients = ref(false)
|
||||
|
||||
const mergedIngredients = computed(() => {
|
||||
// Function to collect all ingredients from recipe steps
|
||||
const getAllIngredients = () => {
|
||||
const ingredients: Array<Ingredient> = [];
|
||||
|
||||
// Add ingredients from steps
|
||||
props.steps.forEach(step => {
|
||||
step.ingredients.forEach(ingredient => {
|
||||
if (ingredient.food && !ingredient.isHeader && !ingredient.noAmount) {
|
||||
ingredients.push(ingredient);
|
||||
}
|
||||
});
|
||||
|
||||
// Add ingredients from step recipes if they exist
|
||||
if (step.stepRecipeData) {
|
||||
step.stepRecipeData.steps?.forEach((subStep: Step) => {
|
||||
subStep.ingredients.forEach((ingredient: Ingredient) => {
|
||||
if (ingredient.food && !ingredient.isHeader && !ingredient.noAmount) {
|
||||
ingredients.push(ingredient);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return ingredients;
|
||||
};
|
||||
|
||||
// Get all ingredients
|
||||
const allIngredients = getAllIngredients();
|
||||
|
||||
// Create a map to group and sum ingredients by food and unit
|
||||
const groupedIngredients = new Map<string, Ingredient>();
|
||||
|
||||
allIngredients.forEach(ingredient => {
|
||||
if (!ingredient.food || !ingredient.unit) return;
|
||||
|
||||
// Create a unique key for food-unit combination
|
||||
const key = `${ingredient.food.id}-${ingredient.unit.id}`;
|
||||
|
||||
if (groupedIngredients.has(key)) {
|
||||
// If this food-unit combination already exists, sum the amounts
|
||||
const existingIngredient = groupedIngredients.get(key)!;
|
||||
existingIngredient.amount += ingredient.amount;
|
||||
} else {
|
||||
// Create a new entry with the adjusted amount
|
||||
const clonedIngredient = {...ingredient};
|
||||
groupedIngredients.set(key, clonedIngredient);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert the map back to an array
|
||||
const result = Array.from(groupedIngredients.values());
|
||||
|
||||
// Sort alphabetically by food name
|
||||
return result.sort((a, b) => {
|
||||
const foodNameA = a.food?.name.toLowerCase() || '';
|
||||
const foodNameB = b.food?.name.toLowerCase() || '';
|
||||
return foodNameA.localeCompare(foodNameB);
|
||||
});
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
<v-list-item :to="{ name: 'ModelEditPage', params: {model: 'recipe', id: recipe.id} }" prepend-icon="$edit">
|
||||
{{ $t('Edit') }}
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="$mealplan" link>
|
||||
<v-list-item prepend-icon="$mealplan" @click="mealPlanDialog = true">
|
||||
{{ $t('Add_to_Plan') }}
|
||||
<model-edit-dialog model="MealPlan" :itemDefaults="{recipe: recipe, servings: recipe.servings}"></model-edit-dialog>
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="$shopping" link>
|
||||
{{ $t('Add_to_Shopping') }}
|
||||
@@ -30,11 +29,12 @@
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
|
||||
<model-edit-dialog model="MealPlan" :itemDefaults="{recipe: recipe, servings: recipe.servings}" :close-after-create="false" :close-after-save="false" v-model="mealPlanDialog"></model-edit-dialog>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {nextTick, PropType} from 'vue'
|
||||
import {nextTick, PropType, ref} from 'vue'
|
||||
import {Recipe, RecipeFlat, RecipeOverview} from "@/openapi";
|
||||
import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
|
||||
import RecipeShareDialog from "@/components/dialogs/RecipeShareDialog.vue";
|
||||
@@ -46,6 +46,8 @@ const props = defineProps({
|
||||
size: {type: String, default: 'medium'},
|
||||
})
|
||||
|
||||
const mealPlanDialog = ref(false)
|
||||
|
||||
function openPrintView() {
|
||||
print()
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<div v-if="!mobile">
|
||||
<vue-draggable v-model="step.ingredients" handle=".drag-handle" :on-sort="sortIngredients" :empty-insert-threshold="25" group="ingredients">
|
||||
<v-row v-for="(ingredient, index) in step.ingredients" :key="ingredient.id" dense>
|
||||
<v-col cols="12" class="pa-0 ma-0 text-center text-disabled">
|
||||
<v-col cols="12" class="pa-0 ma-0 text-center text-disabled" v-if="ingredient.originalText">
|
||||
<v-icon icon="$import" size="x-small"></v-icon>
|
||||
{{ ingredient.originalText }}
|
||||
</v-col>
|
||||
@@ -306,6 +306,7 @@ function parseAndInsertIngredients() {
|
||||
r.forEach(i => {
|
||||
console.log(i)
|
||||
step.value.ingredients.push({
|
||||
originalText: i.value.originalText,
|
||||
amount: i.value.amount,
|
||||
food: i.value.food,
|
||||
unit: i.value.unit,
|
||||
|
||||
81
vue3/src/components/model_editors/CookLogEditor.vue
Normal file
81
vue3/src/components/model_editors/CookLogEditor.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<model-editor-base
|
||||
:loading="loading"
|
||||
:dialog="dialog"
|
||||
@save="saveObject"
|
||||
@delete="deleteObject"
|
||||
@close="emit('close'); editingObjChanged = false"
|
||||
:is-update="isUpdate()"
|
||||
:is-changed="editingObjChanged"
|
||||
:model-class="modelClass"
|
||||
:object-name="editingObjName()">
|
||||
<v-card-text>
|
||||
<v-form :disabled="loading">
|
||||
|
||||
<v-textarea :label="$t('Comment')" rows="2" v-model="editingObj.comment"></v-textarea>
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="4">
|
||||
<v-label>{{ $t('Rating') }}</v-label>
|
||||
<br/>
|
||||
<v-rating v-model="editingObj.rating" clearable hover density="compact"></v-rating>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
|
||||
<v-number-input :label="$t('Servings')" v-model="editingObj.servings" :precision="2"></v-number-input>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-date-input :label="$t('Date')" v-model="editingObj.createdAt"></v-date-input>
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</model-editor-base>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, PropType, watch} from "vue";
|
||||
import {CookLog} from "@/openapi";
|
||||
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
|
||||
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
item: {type: {} as PropType<CookLog>, required: false, default: null},
|
||||
itemId: {type: [Number, String], required: false, default: undefined},
|
||||
itemDefaults: {type: {} as PropType<CookLog>, required: false, default: {} as CookLog},
|
||||
dialog: {type: Boolean, default: false}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
|
||||
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<CookLog>('CookLog', emit)
|
||||
|
||||
/**
|
||||
* watch prop changes and re-initialize editor
|
||||
* required to embed editor directly into pages and be able to change item from the outside
|
||||
*/
|
||||
watch([() => props.item, () => props.itemId], () => {
|
||||
initializeEditor()
|
||||
})
|
||||
|
||||
// object specific data (for selects/display)
|
||||
|
||||
onMounted(() => {
|
||||
initializeEditor()
|
||||
})
|
||||
|
||||
/**
|
||||
* component specific state setup logic
|
||||
*/
|
||||
function initializeEditor() {
|
||||
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -63,8 +63,8 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- <closable-help-alert :text="$t('RecipeStepsHelp')" :action-text="$t('Steps')" @click="tab='steps'"></closable-help-alert>-->
|
||||
<v-btn @click="tab='steps'" class="float-right" variant="tonal" append-icon="fa-solid fa-arrow-right">{{$t('Steps')}} </v-btn>
|
||||
<!-- <closable-help-alert :text="$t('RecipeStepsHelp')" :action-text="$t('Steps')" @click="tab='steps'"></closable-help-alert>-->
|
||||
<v-btn @click="tab='steps'" class="float-right" variant="tonal" append-icon="fa-solid fa-arrow-right">{{ $t('Steps') }}</v-btn>
|
||||
</v-form>
|
||||
|
||||
</v-tabs-window-item>
|
||||
@@ -77,12 +77,19 @@
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col class="text-center">
|
||||
<v-btn-group density="compact">
|
||||
<v-btn-group density="compact" divided border>
|
||||
<v-btn color="success" prepend-icon="fa-solid fa-plus" @click="addStep()">{{ $t('Add_Step') }}</v-btn>
|
||||
<v-btn color="warning" @click="dialogStepManager = true">
|
||||
<v-btn color="warning" @click="dialogStepManager = true" :disabled="editingObj.steps.length < 2">
|
||||
<v-icon icon="fa-solid fa-arrow-down-1-9"></v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn prepend-icon="fa-solid fa-maximize" @click="handleSplitAllSteps" :disabled="editingObj.steps.length < 1"><span
|
||||
v-if="!mobile">{{ $t('Split') }}</span></v-btn>
|
||||
<v-btn prepend-icon="fa-solid fa-minimize" @click="handleMergeAllSteps" :disabled="editingObj.steps.length < 2"><span
|
||||
v-if="!mobile">{{ $t('Merge') }}</span></v-btn>
|
||||
</v-btn-group>
|
||||
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -101,16 +108,25 @@
|
||||
|
||||
<v-text-field :label="$t('Imported_From')" v-model="editingObj.sourceUrl"></v-text-field>
|
||||
<v-checkbox :label="$t('Private_Recipe')" persistent-hint :hint="$t('Private_Recipe_Help')" v-model="editingObj._private"></v-checkbox>
|
||||
<model-select mode="tags" model="User" :label="$t('Share')" persistent-hint v-model="editingObj.shared"
|
||||
<model-select mode="tags" model="User" :label="$t('Share')" persistent-hint v-model="editingObj.shared"
|
||||
append-to-body v-if="editingObj._private"></model-select>
|
||||
|
||||
<div class="mt-2" v-if="editingObj.filePath">
|
||||
{{ $t('ExternalRecipe') }}
|
||||
<v-text-field readonly v-model="editingObj.filePath"></v-text-field>
|
||||
|
||||
<v-btn prepend-icon="$delete" color="error" :loading="loading">{{ $t('delete_title', {type: $t('ExternalRecipe')}) }}
|
||||
<delete-confirm-dialog :object-name="editingObj.filePath" :model-name="$t('ExternalRecipe')" @delete="deleteExternalFile()"></delete-confirm-dialog>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
</v-form>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
</v-card-text>
|
||||
<v-card-text v-if="isSpaceAtRecipeLimit(useUserPreferenceStore().activeSpace)">
|
||||
<v-alert color="warning" icon="fa-solid fa-triangle-exclamation">
|
||||
{{$t('SpaceLimitReached')}}
|
||||
{{ $t('SpaceLimitReached') }}
|
||||
<v-btn color="success" variant="flat" :to="{name: 'SpaceSettings'}">{{ $t('SpaceSettings') }}</v-btn>
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
@@ -138,7 +154,7 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, PropType, ref, shallowRef, watch} from "vue";
|
||||
import {Ingredient, Recipe, Step} from "@/openapi";
|
||||
import {ApiApi, Ingredient, Recipe, Step} from "@/openapi";
|
||||
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
|
||||
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
|
||||
import ModelSelect from "@/components/inputs/ModelSelect.vue";
|
||||
@@ -151,7 +167,9 @@ import ClosableHelpAlert from "@/components/display/ClosableHelpAlert.vue";
|
||||
import {useDisplay} from "vuetify";
|
||||
import {isSpaceAtRecipeLimit} from "@/utils/logic_utils";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
import SpaceSettings from "@/components/settings/SpaceSettings.vue";
|
||||
import {mergeAllSteps, splitAllSteps} from "@/utils/step_utils.ts";
|
||||
import DeleteConfirmDialog from "@/components/dialogs/DeleteConfirmDialog.vue";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
@@ -188,7 +206,7 @@ onMounted(() => {
|
||||
/**
|
||||
* component specific state setup logic
|
||||
*/
|
||||
function initializeEditor(){
|
||||
function initializeEditor() {
|
||||
setupState(props.item, props.itemId, {
|
||||
newItemFunction: () => {
|
||||
editingObj.value.steps = [] as Step[]
|
||||
@@ -249,6 +267,33 @@ function deleteStepAtIndex(index: number) {
|
||||
editingObj.value.steps.splice(index, 1)
|
||||
}
|
||||
|
||||
function handleMergeAllSteps(): void {
|
||||
if (editingObj.value.steps) {
|
||||
mergeAllSteps(editingObj.value.steps)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSplitAllSteps(): void {
|
||||
if (editingObj.value.steps) {
|
||||
splitAllSteps(editingObj.value.steps, '\n')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* deletes the external file for the recipe
|
||||
*/
|
||||
function deleteExternalFile() {
|
||||
let api = new ApiApi()
|
||||
loading.value = true
|
||||
api.apiRecipeDeleteExternalPartialUpdate({id: editingObj.value.id!, patchedRecipe: editingObj.value}).then(r => {
|
||||
editingObj.value = r
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.DELETE_ERROR, err)
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"Log_Recipe_Cooking": "",
|
||||
"Make_Header": "",
|
||||
"Make_Ingredient": "",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "",
|
||||
"Meal_Plan": "",
|
||||
"Meal_Plan_Days": "",
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
"Log_Recipe_Cooking": "Дневник на Рецепта за готвене",
|
||||
"Make_Header": "Направете заглавие",
|
||||
"Make_Ingredient": "Направете съставка",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Управление на Книги",
|
||||
"Meal_Plan": "План на хранене",
|
||||
"Meal_Plan_Days": "Бъдещи планове за хранене",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"Logo": "Logotip",
|
||||
"Make_Header": "Establiu capçalera",
|
||||
"Make_Ingredient": "Establiu ingredient",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Gestioneu els llibres",
|
||||
"Manage_Emails": "Administrar correus",
|
||||
"Meal_Plan": "Pla d'àpats",
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"Logo": "Logo",
|
||||
"Make_Header": "Použij jako nadpis",
|
||||
"Make_Ingredient": "Použij jako ingredienci",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Spravovat kuchařky",
|
||||
"Manage_Emails": "Spravovat emaily",
|
||||
"Meal_Plan": "Jídelníček",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"Logo": "Logo",
|
||||
"Make_Header": "Opret rubrik",
|
||||
"Make_Ingredient": "Opret ingredient",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Administrer bøger",
|
||||
"Manage_Emails": "Håndter Emails",
|
||||
"Meal_Plan": "Madplan",
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"AI": "AI",
|
||||
"AIImportSubtitle": "Verwende AI um Fotos von Rezepten zu importieren.",
|
||||
"API": "API",
|
||||
"APIKey": "API Schlüssel",
|
||||
"Summary": "Zusammenfassung",
|
||||
"Structured": "Strukturiert",
|
||||
"API_Browser": "API Browser",
|
||||
"API_Documentation": "API Dokumentation",
|
||||
"AccessTokenHelp": "Zugriffsschlüssel für die REST Schnittstelle.",
|
||||
@@ -282,7 +285,7 @@
|
||||
"Logout": "Ausloggen",
|
||||
"Make_Header": "In Überschrift wandeln",
|
||||
"Make_Ingredient": "In Zutat umwandeln",
|
||||
"ManageSubscription": "Tarfi verwalten",
|
||||
"ManageSubscription": "Tarif verwalten",
|
||||
"Manage_Books": "Bücher verwalten",
|
||||
"Manage_Emails": "E-Mails verwalten",
|
||||
"MealPlanHelp": "Ein Speiseplan ist ein Eintrag im Kalender zur Planung von Mahlzeiten. Er muss entweder ein Rezept oder einen Titel erhalten und kann mit der Einkaufsliste verknüpft werden. ",
|
||||
@@ -301,6 +304,7 @@
|
||||
"Miscellaneous": "Sonstige",
|
||||
"MissingConversion": "Fehlende Umrechnung",
|
||||
"MissingProperties": "Fehlende Eigenschaften",
|
||||
"Model": "Modell",
|
||||
"ModelSelectResultsHelp": "Für mehr Ergebnisse suchen",
|
||||
"Monday": "Montag",
|
||||
"Month": "Monat",
|
||||
@@ -511,10 +515,12 @@
|
||||
"Storage": "Externer Speicher",
|
||||
"StorageHelp": "Externe Speicherorte an denen Rezepte als Dateien (Foto/PDF) abgelegt und mit Tandor syncronisiert werden können.",
|
||||
"StoragePasswordTokenHelp": "Das hinterlegte Passwort/Token kann nicht angezeigt werden. Es wird nur aktualisiert wenn etwas neues in das Feld eingegeben wird. ",
|
||||
"Structured": "Strukturiert",
|
||||
"SubstituteOnHand": "Du hast eine Alternative vorrätig.",
|
||||
"Substitutes": "Alternativen",
|
||||
"Success": "Erfolgreich",
|
||||
"SuccessClipboard": "Einkaufsliste wurde in die Zwischenablage kopiert",
|
||||
"Summary": "Zusammenfassung",
|
||||
"Sunday": "Sonntag",
|
||||
"Supermarket": "Supermarkt",
|
||||
"SupermarketCategoriesOnly": "Nur Supermarktkategorien",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"Logo": "Λογότυπο",
|
||||
"Make_Header": "Δημιουργία κεφαλίδας",
|
||||
"Make_Ingredient": "Δημιουργία υλικού",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Διαχείριση βιβλίων",
|
||||
"Manage_Emails": "Διαχείριση email",
|
||||
"Meal_Plan": "Πρόγραμμα γευμάτων",
|
||||
|
||||
@@ -299,6 +299,7 @@
|
||||
"Miscellaneous": "Miscellaneous",
|
||||
"MissingConversion": "Missing Conversion",
|
||||
"MissingProperties": "Missing Properties",
|
||||
"Model": "Model",
|
||||
"ModelSelectResultsHelp": "Search for more results",
|
||||
"Monday": "Monday",
|
||||
"Month": "Month",
|
||||
@@ -509,10 +510,12 @@
|
||||
"Storage": "External Storage",
|
||||
"StorageHelp": "External storage locations where recipe files (image/pdf) can be stored and synced with Tandoor.",
|
||||
"StoragePasswordTokenHelp": "The stored password/token will never be displayed. It is only changed if something new is entered into the field. ",
|
||||
"Structured": "Structured",
|
||||
"SubstituteOnHand": "You have a substitute on hand.",
|
||||
"Substitutes": "Substitutes",
|
||||
"Success": "Success",
|
||||
"SuccessClipboard": "Shopping list copied to clipboard",
|
||||
"Summary": "Summary",
|
||||
"Sunday": "Sunday",
|
||||
"Supermarket": "Supermarket",
|
||||
"SupermarketCategoriesOnly": "Supermarket Categories Only",
|
||||
|
||||
@@ -196,6 +196,7 @@
|
||||
"Logo": "Logo",
|
||||
"Make_Header": "Valmista Otsikko",
|
||||
"Make_Ingredient": "Valmista Ainesosa",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Hallinnoi kirjoja",
|
||||
"Manage_Emails": "Hallinnoi sähköposteja",
|
||||
"Meal_Plan": "Ateriasuunnitelma",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"Logo": "לוגו",
|
||||
"Make_Header": "הפוך לכותרת",
|
||||
"Make_Ingredient": "הפוך למרכיב",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "נהל ספרים",
|
||||
"Manage_Emails": "נהל כתובות דואר אלקטרוני",
|
||||
"Meal_Plan": "תוכנית ארוחה",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"Logo": "Logotip",
|
||||
"Make_Header": "Napravi zaglavlje",
|
||||
"Make_Ingredient": "Napravi sastojak",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Upravljaj knjigama",
|
||||
"Manage_Emails": "Upravljanje e-poštom",
|
||||
"Meal_Plan": "Plan obroka",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Log_Recipe_Cooking": "Főzés naplózása",
|
||||
"Make_Header": "Átalakítás címsorra",
|
||||
"Make_Ingredient": "Összetevő létrehozása",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Könyvek kezelése",
|
||||
"Manage_Emails": "Levelezés kezelése",
|
||||
"Meal_Plan": "Menüterv",
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"Load_More": "",
|
||||
"Log_Cooking": "Գրանցել եփելը",
|
||||
"Log_Recipe_Cooking": "Գրանցել բաղադրատոմսի օգտագործում",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Կարգավորել Գրքերը",
|
||||
"Meal_Plan": "Ճաշացուցակ",
|
||||
"Merge": "Միացնել",
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
"Log_Recipe_Cooking": "Log Resep Memasak",
|
||||
"Make_Header": "Buat Header",
|
||||
"Make_Ingredient": "Buat bahan",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Kelola Buku",
|
||||
"Manage_Emails": "",
|
||||
"Meal_Plan": "rencana makan",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"Logo": "",
|
||||
"Make_Header": "",
|
||||
"Make_Ingredient": "",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "",
|
||||
"Manage_Emails": "",
|
||||
"Meal_Plan": "",
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
"Log_Recipe_Cooking": "Užregistruoti recepto pagaminimą",
|
||||
"Make_Header": "Padaryti antraštę",
|
||||
"Make_Ingredient": "Padaryti ingredientą",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Tvarkyti knygas",
|
||||
"Manage_Emails": "",
|
||||
"Meal_Plan": "Maisto planas",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"Logo": "",
|
||||
"Make_Header": "",
|
||||
"Make_Ingredient": "",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "",
|
||||
"Manage_Emails": "",
|
||||
"Meal_Plan": "",
|
||||
|
||||
@@ -192,6 +192,7 @@
|
||||
"Log_Recipe_Cooking": "Logg oppskriftsbruk",
|
||||
"Make_Header": "Bruk som overskrift",
|
||||
"Make_Ingredient": "Bruk som ingrediens",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Administrer bøker",
|
||||
"Manage_Emails": "Administrer e-poster",
|
||||
"Meal_Plan": "Måltidsplan",
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
"Logo": "Logo",
|
||||
"Make_Header": "Utwórz nagłówek",
|
||||
"Make_Ingredient": "Utwórz składnik",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Zarządzaj książkami",
|
||||
"Manage_Emails": "Zarządzaj e-mailami",
|
||||
"Meal_Plan": "Plan posiłków",
|
||||
|
||||
@@ -159,6 +159,7 @@
|
||||
"Log_Recipe_Cooking": "Registrar Receitas de Culinária",
|
||||
"Make_Header": "Tornar cabeçalho",
|
||||
"Make_Ingredient": "Fazer ingrediente",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Gerenciar Livros",
|
||||
"Meal_Plan": "Plano de Refeição",
|
||||
"Meal_Plan_Days": "Planos de alimentação futuros",
|
||||
|
||||
@@ -177,6 +177,7 @@
|
||||
"Log_Recipe_Cooking": "Jurnalul rețetelor de pregătire",
|
||||
"Make_Header": "Creare antet",
|
||||
"Make_Ingredient": "Create ingredient",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Gestionarea cărților",
|
||||
"Manage_Emails": "Gestionarea e-mailurilor",
|
||||
"Meal_Plan": "Plan de alimentare",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"Logo": "Logga",
|
||||
"Make_Header": "Skapa rubrik",
|
||||
"Make_Ingredient": "Skapa ingrediens",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Hantera böcker",
|
||||
"Manage_Emails": "Hantera mejladresser",
|
||||
"Meal_Plan": "Måltidsplanering",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"Logo": "Logo",
|
||||
"Make_Header": "Başlık Oluştur",
|
||||
"Make_Ingredient": "Malzeme Oluştur",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Kitapları Yönet",
|
||||
"Manage_Emails": "E-postaları Yönet",
|
||||
"Meal_Plan": "Yemek Planı",
|
||||
|
||||
@@ -176,6 +176,7 @@
|
||||
"Log_Recipe_Cooking": "Журнал приготування",
|
||||
"Make_Header": "Створити Заголовок",
|
||||
"Make_Ingredient": "Створити Інгрідієнт",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "Управління Книжкою",
|
||||
"Meal_Plan": "План Харчування",
|
||||
"Meal_Plan_Days": "Майбутній план харчування",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"Logo": "徽标",
|
||||
"Make_Header": "显示注意事项",
|
||||
"Make_Ingredient": "制作食材",
|
||||
"ManageSubscription": "",
|
||||
"Manage_Books": "烹饪手册管理",
|
||||
"Manage_Emails": "管理电子邮件",
|
||||
"Meal_Plan": "用餐计划",
|
||||
|
||||
@@ -759,6 +759,11 @@ export interface ApiEnterpriseSocialRecipeCreateRequest {
|
||||
recipe: Omit<Recipe, 'image'|'createdBy'|'createdAt'|'updatedAt'|'foodProperties'|'rating'|'lastCooked'>;
|
||||
}
|
||||
|
||||
export interface ApiEnterpriseSocialRecipeDeleteExternalPartialUpdateRequest {
|
||||
id: number;
|
||||
patchedRecipe?: Omit<PatchedRecipe, 'image'|'createdBy'|'createdAt'|'updatedAt'|'foodProperties'|'rating'|'lastCooked'>;
|
||||
}
|
||||
|
||||
export interface ApiEnterpriseSocialRecipeDestroyRequest {
|
||||
id: number;
|
||||
}
|
||||
@@ -1512,6 +1517,11 @@ export interface ApiRecipeCreateRequest {
|
||||
recipe: Omit<Recipe, 'image'|'createdBy'|'createdAt'|'updatedAt'|'foodProperties'|'rating'|'lastCooked'>;
|
||||
}
|
||||
|
||||
export interface ApiRecipeDeleteExternalPartialUpdateRequest {
|
||||
id: number;
|
||||
patchedRecipe?: Omit<PatchedRecipe, 'image'|'createdBy'|'createdAt'|'updatedAt'|'foodProperties'|'rating'|'lastCooked'>;
|
||||
}
|
||||
|
||||
export interface ApiRecipeDestroyRequest {
|
||||
id: number;
|
||||
}
|
||||
@@ -1941,6 +1951,7 @@ export interface ApiUnitConversionListRequest {
|
||||
foodId?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
export interface ApiUnitConversionPartialUpdateRequest {
|
||||
@@ -4410,6 +4421,46 @@ export class ApiApi extends runtime.BaseAPI {
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* logs request counts to redis cache total/per user/
|
||||
*/
|
||||
async apiEnterpriseSocialRecipeDeleteExternalPartialUpdateRaw(requestParameters: ApiEnterpriseSocialRecipeDeleteExternalPartialUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Recipe>> {
|
||||
if (requestParameters['id'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'id',
|
||||
'Required parameter "id" was null or undefined when calling apiEnterpriseSocialRecipeDeleteExternalPartialUpdate().'
|
||||
);
|
||||
}
|
||||
|
||||
const queryParameters: any = {};
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
headerParameters['Content-Type'] = 'application/json';
|
||||
|
||||
if (this.configuration && this.configuration.apiKey) {
|
||||
headerParameters["Authorization"] = await this.configuration.apiKey("Authorization"); // ApiKeyAuth authentication
|
||||
}
|
||||
|
||||
const response = await this.request({
|
||||
path: `/api/enterprise-social-recipe/{id}/delete_external/`.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters['id']))),
|
||||
method: 'PATCH',
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
body: PatchedRecipeToJSON(requestParameters['patchedRecipe']),
|
||||
}, initOverrides);
|
||||
|
||||
return new runtime.JSONApiResponse(response, (jsonValue) => RecipeFromJSON(jsonValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* logs request counts to redis cache total/per user/
|
||||
*/
|
||||
async apiEnterpriseSocialRecipeDeleteExternalPartialUpdate(requestParameters: ApiEnterpriseSocialRecipeDeleteExternalPartialUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Recipe> {
|
||||
const response = await this.apiEnterpriseSocialRecipeDeleteExternalPartialUpdateRaw(requestParameters, initOverrides);
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* logs request counts to redis cache total/per user/
|
||||
*/
|
||||
@@ -10818,6 +10869,46 @@ export class ApiApi extends runtime.BaseAPI {
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* logs request counts to redis cache total/per user/
|
||||
*/
|
||||
async apiRecipeDeleteExternalPartialUpdateRaw(requestParameters: ApiRecipeDeleteExternalPartialUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Recipe>> {
|
||||
if (requestParameters['id'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'id',
|
||||
'Required parameter "id" was null or undefined when calling apiRecipeDeleteExternalPartialUpdate().'
|
||||
);
|
||||
}
|
||||
|
||||
const queryParameters: any = {};
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
headerParameters['Content-Type'] = 'application/json';
|
||||
|
||||
if (this.configuration && this.configuration.apiKey) {
|
||||
headerParameters["Authorization"] = await this.configuration.apiKey("Authorization"); // ApiKeyAuth authentication
|
||||
}
|
||||
|
||||
const response = await this.request({
|
||||
path: `/api/recipe/{id}/delete_external/`.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters['id']))),
|
||||
method: 'PATCH',
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
body: PatchedRecipeToJSON(requestParameters['patchedRecipe']),
|
||||
}, initOverrides);
|
||||
|
||||
return new runtime.JSONApiResponse(response, (jsonValue) => RecipeFromJSON(jsonValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* logs request counts to redis cache total/per user/
|
||||
*/
|
||||
async apiRecipeDeleteExternalPartialUpdate(requestParameters: ApiRecipeDeleteExternalPartialUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Recipe> {
|
||||
const response = await this.apiRecipeDeleteExternalPartialUpdateRaw(requestParameters, initOverrides);
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* logs request counts to redis cache total/per user/
|
||||
*/
|
||||
@@ -14492,6 +14583,10 @@ export class ApiApi extends runtime.BaseAPI {
|
||||
queryParameters['page_size'] = requestParameters['pageSize'];
|
||||
}
|
||||
|
||||
if (requestParameters['query'] != null) {
|
||||
queryParameters['query'] = requestParameters['query'];
|
||||
}
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
if (this.configuration && this.configuration.apiKey) {
|
||||
|
||||
@@ -109,8 +109,8 @@ export function AutoMealPlanToJSON(value?: AutoMealPlan | null): any {
|
||||
}
|
||||
return {
|
||||
|
||||
'start_date': ((value['startDate']).toISOString().substring(0,10)),
|
||||
'end_date': ((value['endDate']).toISOString().substring(0,10)),
|
||||
'start_date': ((value['startDate']).toISOString()),
|
||||
'end_date': ((value['endDate']).toISOString()),
|
||||
'meal_type_id': value['mealTypeId'],
|
||||
'keyword_ids': value['keywordIds'],
|
||||
'servings': value['servings'],
|
||||
|
||||
@@ -43,6 +43,12 @@ export interface ParsedIngredient {
|
||||
* @memberof ParsedIngredient
|
||||
*/
|
||||
note: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof ParsedIngredient
|
||||
*/
|
||||
originalText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,6 +59,7 @@ export function instanceOfParsedIngredient(value: object): value is ParsedIngred
|
||||
if (!('unit' in value) || value['unit'] === undefined) return false;
|
||||
if (!('food' in value) || value['food'] === undefined) return false;
|
||||
if (!('note' in value) || value['note'] === undefined) return false;
|
||||
if (!('originalText' in value) || value['originalText'] === undefined) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -70,6 +77,7 @@ export function ParsedIngredientFromJSONTyped(json: any, ignoreDiscriminator: bo
|
||||
'unit': json['unit'],
|
||||
'food': json['food'],
|
||||
'note': json['note'],
|
||||
'originalText': json['original_text'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,6 +91,7 @@ export function ParsedIngredientToJSON(value?: ParsedIngredient | null): any {
|
||||
'unit': value['unit'],
|
||||
'food': value['food'],
|
||||
'note': value['note'],
|
||||
'original_text': value['originalText'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,12 @@ export interface RecipeOverview {
|
||||
* @memberof RecipeOverview
|
||||
*/
|
||||
readonly internal: boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof RecipeOverview
|
||||
*/
|
||||
_private?: boolean;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
@@ -179,6 +185,7 @@ export function RecipeOverviewFromJSONTyped(json: any, ignoreDiscriminator: bool
|
||||
'createdAt': (new Date(json['created_at'])),
|
||||
'updatedAt': (new Date(json['updated_at'])),
|
||||
'internal': json['internal'],
|
||||
'_private': json['private'] == null ? undefined : json['private'],
|
||||
'servings': json['servings'],
|
||||
'servingsText': json['servings_text'],
|
||||
'rating': json['rating'],
|
||||
@@ -197,6 +204,7 @@ export function RecipeOverviewToJSON(value?: Omit<RecipeOverview, 'image'|'keywo
|
||||
'id': value['id'],
|
||||
'name': value['name'],
|
||||
'description': value['description'],
|
||||
'private': value['_private'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
<v-btn class="float-right" icon="$create" color="create" v-if="!genericModel.model.disableCreate">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
<model-edit-dialog :close-after-create="false" :model="model"
|
||||
@create="loadItems({page: tablePage, itemsPerPage: useUserPreferenceStore().deviceSettings.general_tableItemsPerPage, search: searchQuery})"
|
||||
@save="loadItems({page: tablePage, itemsPerPage: useUserPreferenceStore().deviceSettings.general_tableItemsPerPage, search: searchQuery})"
|
||||
@delete="loadItems({page: tablePage, itemsPerPage: useUserPreferenceStore().deviceSettings.general_tableItemsPerPage, search: searchQuery})"></model-edit-dialog>
|
||||
@create="loadItems({page: page})"
|
||||
@save="loadItems({page: page })"
|
||||
@delete="loadItems({page: page})"></model-edit-dialog>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card-actions v-if="genericModel.model.name == 'RecipeImport'">
|
||||
@@ -35,7 +35,7 @@
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-text-field prepend-inner-icon="$search" :label="$t('Search')" v-model="searchQuery" clearable></v-text-field>
|
||||
<v-text-field prepend-inner-icon="$search" :label="$t('Search')" v-model="query" clearable></v-text-field>
|
||||
|
||||
<v-data-table-server
|
||||
v-model="selectedItems"
|
||||
@@ -44,12 +44,12 @@
|
||||
:items="items"
|
||||
:items-length="itemCount"
|
||||
:loading="loading"
|
||||
:search="searchQuery"
|
||||
:search="query"
|
||||
:headers="genericModel.getTableHeaders()"
|
||||
:items-per-page-options="itemsPerPageOptions"
|
||||
:show-select="!genericModel.model.disableDelete || genericModel.model.isMerge"
|
||||
:page="tablePage"
|
||||
:items-per-page="useUserPreferenceStore().deviceSettings.general_tableItemsPerPage"
|
||||
:page="page"
|
||||
:items-per-page="pageSize"
|
||||
disable-sort
|
||||
>
|
||||
<template v-slot:header.action v-if="selectedItems.length > 0">
|
||||
@@ -79,7 +79,7 @@
|
||||
<v-list-item prepend-icon="fa-solid fa-arrows-to-dot" v-if="genericModel.model.isMerge" link>
|
||||
{{ $t('Merge') }}
|
||||
<model-merge-dialog :model="model" :source="[item]"
|
||||
@change="loadItems({page: tablePage, itemsPerPage: useUserPreferenceStore().deviceSettings.general_tableItemsPerPage, search: searchQuery})"></model-merge-dialog>
|
||||
@change="loadItems({page: page, itemsPerPage: pageSize, search: query})"></model-merge-dialog>
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="fa-solid fa-table-list" :to="{name: 'IngredientEditorPage', query: {food_id: item.id}}"
|
||||
v-if="genericModel.model.name == 'Food'">
|
||||
@@ -105,10 +105,10 @@
|
||||
</v-row>
|
||||
|
||||
<batch-delete-dialog :items="selectedItems" :model="props.model" v-model="batchDeleteDialog" activator="model"
|
||||
@change="loadItems({page: tablePage, itemsPerPage: useUserPreferenceStore().deviceSettings.general_tableItemsPerPage, search: searchQuery})"></batch-delete-dialog>
|
||||
@change="loadItems({page: page, itemsPerPage: pageSize, search: query})"></batch-delete-dialog>
|
||||
|
||||
<model-merge-dialog :model="model" :source="selectedItems" v-model="batchMergeDialog" activator="model"
|
||||
@change="loadItems({page: tablePage, itemsPerPage: useUserPreferenceStore().deviceSettings.general_tableItemsPerPage, search: searchQuery})"></model-merge-dialog>
|
||||
@change="loadItems({page: page, itemsPerPage: pageSize, search: query})"></model-merge-dialog>
|
||||
|
||||
</v-container>
|
||||
</template>
|
||||
@@ -126,11 +126,12 @@ import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
import ModelMergeDialog from "@/components/dialogs/ModelMergeDialog.vue";
|
||||
import {VDataTableUpdateOptions} from "@/vuetify";
|
||||
import SyncDialog from "@/components/dialogs/SyncDialog.vue";
|
||||
import {ApiApi, RecipeImport} from "@/openapi";
|
||||
import {ApiApi, ApiRecipeListRequest, RecipeImport} from "@/openapi";
|
||||
import {useTitle} from "@vueuse/core";
|
||||
import RecipeShareDialog from "@/components/dialogs/RecipeShareDialog.vue";
|
||||
import AddToShoppingDialog from "@/components/dialogs/AddToShoppingDialog.vue";
|
||||
import BatchDeleteDialog from "@/components/dialogs/BatchDeleteDialog.vue";
|
||||
import {useRouteQuery} from "@vueuse/router";
|
||||
|
||||
const {t} = useI18n()
|
||||
const router = useRouter()
|
||||
@@ -151,7 +152,9 @@ const itemsPerPageOptions = [
|
||||
{value: 50, title: '50'},
|
||||
]
|
||||
|
||||
const tablePage = ref(1)
|
||||
const query = useRouteQuery('query', "")
|
||||
const page = useRouteQuery('page', 1, {transform: Number})
|
||||
const pageSize = useRouteQuery('pageSize', useUserPreferenceStore().deviceSettings.general_tableItemsPerPage, {transform: Number})
|
||||
|
||||
const selectedItems = ref([] as EditorSupportedTypes[])
|
||||
|
||||
@@ -162,25 +165,14 @@ const batchMergeDialog = ref(false)
|
||||
const loading = ref(false);
|
||||
const items = ref([] as Array<any>)
|
||||
const itemCount = ref(0)
|
||||
const searchQuery = ref('')
|
||||
|
||||
const genericModel = ref({} as GenericModel)
|
||||
|
||||
/**
|
||||
* watch route changes (trough navigation) and set table page accordingly
|
||||
*/
|
||||
watch(() => route.query.page, () => {
|
||||
if (!loading.value && typeof route.query.page == "string" && !isNaN(parseInt(route.query.page))) {
|
||||
tablePage.value = parseInt(route.query.page)
|
||||
}
|
||||
})
|
||||
|
||||
// when navigating to ModelListPage from ModelListPage with a different model lifecycle hooks are not called so watch for change here
|
||||
watch(() => props.model, (newValue, oldValue) => {
|
||||
if (newValue != oldValue) {
|
||||
genericModel.value = getGenericModelFromString(props.model, t)
|
||||
tablePage.value = 1
|
||||
loadItems({page: 1, itemsPerPage: useUserPreferenceStore().deviceSettings.general_tableItemsPerPage, search: searchQuery.value})
|
||||
loadItems({page: 1})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -196,10 +188,6 @@ onBeforeMount(() => {
|
||||
}
|
||||
|
||||
title.value = t(genericModel.value.model.localizationKey)
|
||||
|
||||
if (typeof route.query.page == "string" && !isNaN(parseInt(route.query.page))) {
|
||||
tablePage.value = parseInt(route.query.page)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -208,23 +196,14 @@ onBeforeMount(() => {
|
||||
* @param options
|
||||
*/
|
||||
function loadItems(options: VDataTableUpdateOptions) {
|
||||
|
||||
loading.value = true
|
||||
selectedItems.value = []
|
||||
window.scrollTo({top: 0, behavior: 'smooth'})
|
||||
|
||||
if (tablePage.value != options.page) {
|
||||
tablePage.value = options.page
|
||||
}
|
||||
if (route.query.page == undefined) {
|
||||
router.replace({name: 'ModelListPage', params: {model: props.model}, query: {page: options.page}})
|
||||
} else {
|
||||
router.push({name: 'ModelListPage', params: {model: props.model}, query: {page: options.page}})
|
||||
}
|
||||
page.value = options.page
|
||||
pageSize.value = options.itemsPerPage
|
||||
|
||||
useUserPreferenceStore().deviceSettings.general_tableItemsPerPage = options.itemsPerPage
|
||||
|
||||
genericModel.value.list({page: options.page, pageSize: options.itemsPerPage, query: options.search}).then((r: any) => {
|
||||
genericModel.value.list({ query: query.value, page: options.page, pageSize: pageSize.value }).then((r: any) => {
|
||||
items.value = r.results
|
||||
itemCount.value = r.count
|
||||
}).catch((err: any) => {
|
||||
@@ -234,16 +213,6 @@ function loadItems(options: VDataTableUpdateOptions) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* change models and reset page/scroll
|
||||
* @param m
|
||||
*/
|
||||
function changeModel(m: Model) {
|
||||
tablePage.value = 1
|
||||
router.push({name: 'ModelListPage', params: {model: m.name.toLowerCase()}, query: {page: 1}})
|
||||
window.scrollTo({top: 0, behavior: 'smooth'})
|
||||
}
|
||||
|
||||
// model specific functions
|
||||
|
||||
/**
|
||||
@@ -253,7 +222,7 @@ function changeModel(m: Model) {
|
||||
function importRecipe(item: RecipeImport) {
|
||||
let api = new ApiApi()
|
||||
api.apiRecipeImportImportRecipeCreate({id: item.id!, recipeImport: item}).then(r => {
|
||||
loadItems({page: 1, itemsPerPage: useUserPreferenceStore().deviceSettings.general_tableItemsPerPage, search: searchQuery.value})
|
||||
loadItems({page: 1})
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.CREATE_ERROR, err)
|
||||
})
|
||||
@@ -266,7 +235,7 @@ function importAllRecipes() {
|
||||
let api = new ApiApi()
|
||||
|
||||
api.apiRecipeImportImportAllCreate({recipeImport: {} as RecipeImport}).then(r => {
|
||||
loadItems({page: 1, itemsPerPage: useUserPreferenceStore().deviceSettings.general_tableItemsPerPage, search: searchQuery.value})
|
||||
loadItems({page: 1})
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.CREATE_ERROR, err)
|
||||
})
|
||||
|
||||
@@ -272,8 +272,8 @@
|
||||
<v-col class="text-center">
|
||||
<v-btn-group border divided>
|
||||
<v-btn prepend-icon="fa-solid fa-shuffle" @click="autoSortIngredients()"><span v-if="!mobile">{{ $t('Auto_Sort') }}</span></v-btn>
|
||||
<v-btn prepend-icon="fa-solid fa-maximize" @click="splitAllSteps('\n')"><span v-if="!mobile">{{ $t('Split') }}</span></v-btn>
|
||||
<v-btn prepend-icon="fa-solid fa-minimize" @click="mergeAllSteps()"><span v-if="!mobile">{{ $t('Merge') }}</span></v-btn>
|
||||
<v-btn prepend-icon="fa-solid fa-maximize" @click="handleSplitAllSteps()"><span v-if="!mobile">{{ $t('Split') }}</span></v-btn>
|
||||
<v-btn prepend-icon="fa-solid fa-minimize" @click="handleMergeAllSteps()"><span v-if="!mobile">{{ $t('Merge') }}</span></v-btn>
|
||||
</v-btn-group>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -566,6 +566,7 @@ import {DateTime} from "luxon";
|
||||
import {useDjangoUrls} from "@/composables/useDjangoUrls";
|
||||
import bookmarkletJs from '@/assets/bookmarklet_v3?url'
|
||||
import StepIngredientSorterDialog from "@/components/dialogs/StepIngredientSorterDialog.vue";
|
||||
import {mergeAllSteps, splitAllSteps, splitStep} from "@/utils/step_utils.ts";
|
||||
|
||||
function doListImport() {
|
||||
urlList.value = urlListImportInput.value.split('\n')
|
||||
@@ -809,67 +810,15 @@ function deleteStep(step: SourceImportStep) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* utility function used by splitAllSteps and splitStep to split a single step object into multiple step objects
|
||||
* @param step step to split
|
||||
* @param split_character character to use as a delimiter between steps
|
||||
*/
|
||||
function splitStepObject(step: SourceImportStep, split_character: string) {
|
||||
let steps: SourceImportStep[] = []
|
||||
step.instruction.split(split_character).forEach(part => {
|
||||
if (part.trim() !== '') {
|
||||
steps.push({instruction: part, ingredients: [], showIngredientsTable: useUserPreferenceStore().userSettings.showStepIngredients!})
|
||||
}
|
||||
})
|
||||
steps[0].ingredients = step.ingredients // put all ingredients from the original step in the ingredients of the first step of the split step list
|
||||
return steps
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits all steps of a given recipe_json at the split character (e.g. \n or \n\n)
|
||||
* @param split_character character to split steps at
|
||||
*/
|
||||
function splitAllSteps(split_character: string) {
|
||||
let steps: SourceImportStep[] = []
|
||||
if (importResponse.value.recipe) {
|
||||
importResponse.value.recipe.steps.forEach(step => {
|
||||
steps = steps.concat(splitStepObject(step, split_character))
|
||||
})
|
||||
importResponse.value.recipe.steps = steps
|
||||
} else {
|
||||
useMessageStore().addMessage(MessageType.ERROR, "no steps found to split")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the given step at the split character (e.g. \n or \n\n)
|
||||
* @param step step to split
|
||||
* @param split_character character to use as a delimiter between steps
|
||||
*/
|
||||
function splitStep(step: SourceImportStep, split_character: string) {
|
||||
if (importResponse.value.recipe) {
|
||||
let old_index = importResponse.value.recipe.steps.findIndex(x => x === step)
|
||||
let new_steps = splitStepObject(step, split_character)
|
||||
importResponse.value.recipe.steps.splice(old_index, 1, ...new_steps)
|
||||
} else {
|
||||
useMessageStore().addMessage(MessageType.ERROR, "no steps found to split")
|
||||
function handleMergeAllSteps(): void {
|
||||
if (importResponse.value.recipe && importResponse.value.recipe.steps){
|
||||
mergeAllSteps(importResponse.value.recipe.steps)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all steps of a given recipe_json into one
|
||||
*/
|
||||
function mergeAllSteps() {
|
||||
let step = {instruction: '', ingredients: [], showIngredientsTable: useUserPreferenceStore().userSettings.showStepIngredients!} as SourceImportStep
|
||||
if (importResponse.value.recipe) {
|
||||
importResponse.value.recipe.steps.forEach(s => {
|
||||
step.instruction += s.instruction + '\n'
|
||||
step.ingredients = step.ingredients.concat(s.ingredients)
|
||||
})
|
||||
importResponse.value.recipe.steps = [step]
|
||||
} else {
|
||||
useMessageStore().addMessage(MessageType.ERROR, "no steps found to split")
|
||||
function handleSplitAllSteps(): void {
|
||||
if (importResponse.value.recipe && importResponse.value.recipe.steps){
|
||||
splitAllSteps(importResponse.value.recipe.steps, '\n')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ export const useMealPlanStore = defineStore(_STORE_ID, () => {
|
||||
const loading = ref(false)
|
||||
let settings = ref({})
|
||||
|
||||
const lastStartDate = ref(new Date())
|
||||
const lastEndDate = ref(new Date())
|
||||
|
||||
const planList = computed(() => {
|
||||
let plan_list = [] as MealPlan[]
|
||||
|
||||
@@ -49,9 +52,18 @@ export const useMealPlanStore = defineStore(_STORE_ID, () => {
|
||||
// return this.settings
|
||||
// })
|
||||
|
||||
/**
|
||||
* based on the last API refresh period, refresh the meal plan list
|
||||
*/
|
||||
function refreshLastUpdatedPeriod() {
|
||||
refreshFromAPI(lastStartDate.value, lastEndDate.value)
|
||||
}
|
||||
|
||||
function refreshFromAPI(from_date: Date, to_date: Date) {
|
||||
if (currently_updating.value[0] !== from_date || currently_updating.value[1] !== to_date) {
|
||||
lastStartDate.value = from_date
|
||||
lastEndDate.value = to_date
|
||||
|
||||
currently_updating.value = [from_date, to_date] // certainly no perfect check but better than nothing
|
||||
loading.value = true
|
||||
const api = new ApiApi()
|
||||
@@ -147,7 +159,7 @@ export const useMealPlanStore = defineStore(_STORE_ID, () => {
|
||||
// return JSON.parse(s)
|
||||
// }
|
||||
// }
|
||||
return {plans, currently_updating, planList, loading, refreshFromAPI, createObject, updateObject, deleteObject, createOrUpdate}
|
||||
return {plans, currently_updating, planList, loading, refreshFromAPI, createObject, updateObject, deleteObject, refreshLastUpdatedPeriod, createOrUpdate}
|
||||
})
|
||||
|
||||
// enable hot reload for store
|
||||
|
||||
@@ -197,6 +197,8 @@ export const useUserPreferenceStore = defineStore('user_preference_store', () =>
|
||||
mealplan_startingDayOfWeek: 1,
|
||||
mealplan_displayWeekNumbers: true,
|
||||
|
||||
recipe_mergeStepOverview: false,
|
||||
|
||||
search_itemsPerPage: 50,
|
||||
search_viewMode: 'grid',
|
||||
search_visibleFilters: [],
|
||||
|
||||
@@ -581,10 +581,11 @@ export const TCookLog = {
|
||||
localizationKeyDescription: 'CookLogHelp',
|
||||
icon: 'fa-solid fa-table-list',
|
||||
|
||||
isPaginated: true,
|
||||
editorComponent: defineAsyncComponent(() => import(`@/components/model_editors/CookLogEditor.vue`)),
|
||||
|
||||
disableCreate: true,
|
||||
disableUpdate: true,
|
||||
disableDelete: true,
|
||||
|
||||
isPaginated: true,
|
||||
toStringKeys: ['recipe'],
|
||||
|
||||
tableHeaders: [
|
||||
|
||||
@@ -12,11 +12,17 @@ export type TandoorPlugin = {
|
||||
bottomNavigation: any[],
|
||||
userNavigation: any[],
|
||||
|
||||
buildInputs?: string[],
|
||||
|
||||
databasePageComponent?: Component,
|
||||
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export type PluginModule = {
|
||||
plugin: TandoorPlugin
|
||||
}
|
||||
|
||||
const pluginModules = import.meta.glob('@/plugins/*/plugin.ts', { eager: true })
|
||||
export let TANDOOR_PLUGINS = [] as TandoorPlugin[]
|
||||
Object.values(pluginModules).forEach(module => {
|
||||
|
||||
@@ -17,6 +17,8 @@ export type DeviceSettings = {
|
||||
mealplan_startingDayOfWeek: number
|
||||
mealplan_displayWeekNumbers: boolean
|
||||
|
||||
recipe_mergeStepOverview: boolean,
|
||||
|
||||
search_itemsPerPage: number,
|
||||
search_viewMode: 'table'|'grid',
|
||||
search_visibleFilters: String[],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Ingredient, Recipe} from "@/openapi";
|
||||
import {Food, Ingredient, Recipe, Unit} from "@/openapi";
|
||||
|
||||
/**
|
||||
* returns a string representing an ingredient
|
||||
@@ -34,20 +34,29 @@ export function ingredientToString(ingredient: Ingredient) {
|
||||
*/
|
||||
export function ingredientToFoodString(ingredient: Ingredient, ingredientFactor: number) {
|
||||
if (ingredient.food) {
|
||||
if (ingredient.food.pluralName == '' || ingredient.food.pluralName == undefined || ingredient.noAmount) {
|
||||
return ingredient.food.name
|
||||
} else {
|
||||
if (ingredient.alwaysUsePluralFood || ingredient.amount * ingredientFactor > 1) {
|
||||
return ingredient.food.pluralName
|
||||
} else {
|
||||
return ingredient.food.name
|
||||
}
|
||||
}
|
||||
return pluralString(ingredient.food, ingredient.noAmount ? 0 : ingredient.amount * ingredientFactor, ingredient.alwaysUsePluralFood)
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* determines if food or unit should be shown as plural or not
|
||||
* @param object object to show (food or unit)
|
||||
* @param amount amount given in display
|
||||
* @param alwaysUsePlural for printing of ingredients if always plural is enabled
|
||||
*/
|
||||
export function pluralString(object: Food | Unit, amount: number = 1, alwaysUsePlural: boolean = false) {
|
||||
if (object.pluralName == '' || object.pluralName == undefined) {
|
||||
return object.name
|
||||
}
|
||||
if (amount > 1) {
|
||||
return object.pluralName
|
||||
} else {
|
||||
return object.name
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* returns the unit name from an ingredient, pluralizing if necessary
|
||||
* @param ingredient
|
||||
|
||||
73
vue3/src/utils/step_utils.ts
Normal file
73
vue3/src/utils/step_utils.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {MessageType, useMessageStore} from "@/stores/MessageStore";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
import {Step} from "@/openapi";
|
||||
|
||||
interface StepLike {
|
||||
instruction?: string;
|
||||
ingredients?: Array<any>;
|
||||
showIngredientsTable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* utility function used by splitAllSteps and splitStep to split a single step object into multiple step objects
|
||||
* @param step step to split
|
||||
* @param split_character character to use as a delimiter between steps
|
||||
*/
|
||||
function splitStepObject<T extends StepLike>(step: T, split_character: string) {
|
||||
let steps: T[] = []
|
||||
if (step.instruction){
|
||||
step.instruction.split(split_character).forEach(part => {
|
||||
if (part.trim() !== '') {
|
||||
steps.push({instruction: part, ingredients: [], time: 0, showIngredientsTable: useUserPreferenceStore().userSettings.showStepIngredients!})
|
||||
}
|
||||
})
|
||||
steps[0].ingredients = step.ingredients // put all ingredients from the original step in the ingredients of the first step of the split step list
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits all steps of a given recipe_json at the split character (e.g. \n or \n\n)
|
||||
* @param split_character character to split steps at
|
||||
*/
|
||||
export function splitAllSteps<T extends StepLike>(orig_steps: T[], split_character: string) {
|
||||
let steps: T[] = []
|
||||
if (orig_steps) {
|
||||
orig_steps.forEach(step => {
|
||||
steps = steps.concat(splitStepObject(step, split_character))
|
||||
})
|
||||
orig_steps.splice(0, orig_steps.length, ...steps) // replace all steps with the split steps
|
||||
} else {
|
||||
useMessageStore().addMessage(MessageType.ERROR, "no steps found to split")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the given step at the split character (e.g. \n or \n\n)
|
||||
* @param step step to split
|
||||
* @param split_character character to use as a delimiter between steps
|
||||
*/
|
||||
export function splitStep<T extends StepLike>(steps: T[], step: T, split_character: string) {
|
||||
if (steps){
|
||||
let old_index = steps.findIndex(x => x === step)
|
||||
let new_steps = splitStepObject(step, split_character)
|
||||
steps.splice(old_index, 1, ...new_steps)
|
||||
} else {
|
||||
useMessageStore().addMessage(MessageType.ERROR, "no steps found to split")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all steps of a given recipe_json into one
|
||||
*/
|
||||
export function mergeAllSteps<T extends StepLike>(steps: T[]) {
|
||||
let step = {instruction: '', ingredients: [], showIngredientsTable: useUserPreferenceStore().userSettings.showStepIngredients!} as T
|
||||
if (steps) {
|
||||
step.instruction = steps.map(s => s.instruction).join('\n')
|
||||
step.ingredients = steps.flatMap(s => s.ingredients)
|
||||
steps.splice(0, steps.length, step) // replace all steps with the merged step
|
||||
} else {
|
||||
useMessageStore().addMessage(MessageType.ERROR, "no steps found to split")
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
import {fileURLToPath, URL} from 'node:url'
|
||||
import {fileURLToPath, pathToFileURL, URL} from 'node:url'
|
||||
|
||||
import {readdirSync} from "fs"
|
||||
import {resolve, join} from "path"
|
||||
import 'esbuild-register/dist/node'
|
||||
|
||||
import {defineConfig} from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vuetify, {transformAssetUrls} from 'vite-plugin-vuetify'
|
||||
import {VitePWA} from "vite-plugin-pwa";
|
||||
import {PluginModule} from "./src/types/Plugins";
|
||||
import {readFileSync} from "node:fs";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({command, mode, isSsrBuild, isPreview}) => {
|
||||
export default defineConfig(async ({command, mode, isSsrBuild, isPreview}) => {
|
||||
const buildInputs = await collectBuildInputs()
|
||||
|
||||
return {
|
||||
base: mode == 'development' ? '/static/vue3/' : './',
|
||||
plugins: [
|
||||
@@ -40,7 +48,7 @@ export default defineConfig(({command, mode, isSsrBuild, isPreview}) => {
|
||||
// overwrite default .html entry
|
||||
input: [
|
||||
'src/apps/tandoor/main.ts',
|
||||
],
|
||||
].concat(buildInputs),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
@@ -49,3 +57,37 @@ export default defineConfig(({command, mode, isSsrBuild, isPreview}) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* function to load plugin configs and find additional build inputs
|
||||
*/
|
||||
async function collectBuildInputs() {
|
||||
try {
|
||||
const pluginsDir = resolve(__dirname, "src/plugins")
|
||||
const buildInputs: string[] = []
|
||||
|
||||
for (const dir of readdirSync(pluginsDir, {withFileTypes: true})) {
|
||||
if (!dir.isDirectory() && !dir.isSymbolicLink()) continue
|
||||
|
||||
const pluginPath = join(pluginsDir, dir.name, "plugin.ts")
|
||||
try {
|
||||
const code = readFileSync(pluginPath, "utf-8")
|
||||
// Regex to capture buildInputs: [ ... ]
|
||||
const match = code.match(/buildInputs\s*:\s*(\[[^\]]*\])/s)
|
||||
if (match) {
|
||||
const arr = [eval][0](match[1]) as string[]
|
||||
buildInputs.push(...arr)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to parse plugin at ${pluginPath}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Tandoor Plugin build inputs: ', buildInputs)
|
||||
return buildInputs
|
||||
} catch (err) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1904,6 +1904,13 @@ es-to-primitive@^1.3.0:
|
||||
is-date-object "^1.0.5"
|
||||
is-symbol "^1.0.4"
|
||||
|
||||
esbuild-register@^3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-register/-/esbuild-register-3.6.0.tgz#cf270cfa677baebbc0010ac024b823cbf723a36d"
|
||||
integrity sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==
|
||||
dependencies:
|
||||
debug "^4.3.4"
|
||||
|
||||
esbuild@^0.25.0:
|
||||
version "0.25.5"
|
||||
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.5.tgz#71075054993fdfae76c66586f9b9c1f8d7edd430"
|
||||
|
||||
Reference in New Issue
Block a user