mirror of
https://github.com/hay-kot/mealie.git
synced 2025-08-21 22:13:31 -07:00
Merge branch 'mealie-next' into mealie-next
This commit is contained in:
commit
76ff81f508
287 changed files with 16340 additions and 17006 deletions
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
@ -17,7 +17,7 @@ jobs:
|
||||||
name: Build Package
|
name: Build Package
|
||||||
uses: ./.github/workflows/build-package.yml
|
uses: ./.github/workflows/build-package.yml
|
||||||
with:
|
with:
|
||||||
tag: release
|
tag: ${{ github.event.release.tag_name }}
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
permissions:
|
permissions:
|
||||||
|
|
|
@ -12,7 +12,7 @@ repos:
|
||||||
exclude: ^tests/data/
|
exclude: ^tests/data/
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
# Ruff version.
|
# Ruff version.
|
||||||
rev: v0.12.2
|
rev: v0.12.7
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
|
|
@ -35,7 +35,7 @@ conventional_commits = true
|
||||||
filter_unconventional = true
|
filter_unconventional = true
|
||||||
# regex for preprocessing the commit messages
|
# regex for preprocessing the commit messages
|
||||||
commit_preprocessors = [
|
commit_preprocessors = [
|
||||||
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/hay-kot/mealie/issues/${2}))"},
|
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/mealie-recipes/mealie/issues/${2}))"},
|
||||||
]
|
]
|
||||||
# regex for parsing and grouping commits
|
# regex for parsing and grouping commits
|
||||||
commit_parsers = [
|
commit_parsers = [
|
||||||
|
|
|
@ -179,9 +179,15 @@ def inject_nuxt_values():
|
||||||
|
|
||||||
all_langs = []
|
all_langs = []
|
||||||
for match in locales_dir.glob("*.json"):
|
for match in locales_dir.glob("*.json"):
|
||||||
lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}" }},'
|
match_data = LOCALE_DATA.get(match.stem)
|
||||||
|
match_dir = match_data.dir if match_data else "ltr"
|
||||||
|
|
||||||
|
lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}", dir: "{match_dir}" }},'
|
||||||
all_langs.append(lang_string)
|
all_langs.append(lang_string)
|
||||||
|
|
||||||
|
all_langs.sort()
|
||||||
|
all_date_locales.sort()
|
||||||
|
|
||||||
log.debug(f"injecting locales into nuxt config -> {nuxt_config}")
|
log.debug(f"injecting locales into nuxt config -> {nuxt_config}")
|
||||||
inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs)
|
inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs)
|
||||||
inject_inline(i18n_config, CodeKeys.nuxt_local_dates, all_date_locales)
|
inject_inline(i18n_config, CodeKeys.nuxt_local_dates, all_date_locales)
|
||||||
|
|
|
@ -44,7 +44,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
|
||||||
"note": "1 cup unsalted butter, cut into cubes",
|
"note": "1 cup unsalted butter, cut into cubes",
|
||||||
"unit": None,
|
"unit": None,
|
||||||
"food": None,
|
"food": None,
|
||||||
"disableAmount": True,
|
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"originalText": None,
|
"originalText": None,
|
||||||
"referenceId": "ea3b6702-9532-4fbc-a40b-f99917831c26",
|
"referenceId": "ea3b6702-9532-4fbc-a40b-f99917831c26",
|
||||||
|
@ -54,7 +53,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
|
||||||
"note": "1 cup light brown sugar",
|
"note": "1 cup light brown sugar",
|
||||||
"unit": None,
|
"unit": None,
|
||||||
"food": None,
|
"food": None,
|
||||||
"disableAmount": True,
|
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"originalText": None,
|
"originalText": None,
|
||||||
"referenceId": "c5bbfefb-1e23-4ffd-af88-c0363a0fae82",
|
"referenceId": "c5bbfefb-1e23-4ffd-af88-c0363a0fae82",
|
||||||
|
@ -64,7 +62,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
|
||||||
"note": "1/2 cup granulated white sugar",
|
"note": "1/2 cup granulated white sugar",
|
||||||
"unit": None,
|
"unit": None,
|
||||||
"food": None,
|
"food": None,
|
||||||
"disableAmount": True,
|
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"originalText": None,
|
"originalText": None,
|
||||||
"referenceId": "034f481b-c426-4a17-b983-5aea9be4974b",
|
"referenceId": "034f481b-c426-4a17-b983-5aea9be4974b",
|
||||||
|
@ -74,7 +71,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
|
||||||
"note": "2 large eggs",
|
"note": "2 large eggs",
|
||||||
"unit": None,
|
"unit": None,
|
||||||
"food": None,
|
"food": None,
|
||||||
"disableAmount": True,
|
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"originalText": None,
|
"originalText": None,
|
||||||
"referenceId": "37c1f796-3bdb-4856-859f-dbec90bc27e4",
|
"referenceId": "37c1f796-3bdb-4856-859f-dbec90bc27e4",
|
||||||
|
@ -84,7 +80,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
|
||||||
"note": "2 tsp vanilla extract",
|
"note": "2 tsp vanilla extract",
|
||||||
"unit": None,
|
"unit": None,
|
||||||
"food": None,
|
"food": None,
|
||||||
"disableAmount": True,
|
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"originalText": None,
|
"originalText": None,
|
||||||
"referenceId": "85561ace-f249-401d-834c-e600a2f6280e",
|
"referenceId": "85561ace-f249-401d-834c-e600a2f6280e",
|
||||||
|
@ -94,7 +89,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
|
||||||
"note": "1/2 cup creamy peanut butter",
|
"note": "1/2 cup creamy peanut butter",
|
||||||
"unit": None,
|
"unit": None,
|
||||||
"food": None,
|
"food": None,
|
||||||
"disableAmount": True,
|
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"originalText": None,
|
"originalText": None,
|
||||||
"referenceId": "ac91bda0-e8a8-491a-976a-ae4e72418cfd",
|
"referenceId": "ac91bda0-e8a8-491a-976a-ae4e72418cfd",
|
||||||
|
@ -104,7 +98,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
|
||||||
"note": "1 tsp cornstarch",
|
"note": "1 tsp cornstarch",
|
||||||
"unit": None,
|
"unit": None,
|
||||||
"food": None,
|
"food": None,
|
||||||
"disableAmount": True,
|
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"originalText": None,
|
"originalText": None,
|
||||||
"referenceId": "4d1256b3-115e-4475-83cd-464fbc304cb0",
|
"referenceId": "4d1256b3-115e-4475-83cd-464fbc304cb0",
|
||||||
|
@ -114,7 +107,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
|
||||||
"note": "1 tsp baking soda",
|
"note": "1 tsp baking soda",
|
||||||
"unit": None,
|
"unit": None,
|
||||||
"food": None,
|
"food": None,
|
||||||
"disableAmount": True,
|
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"originalText": None,
|
"originalText": None,
|
||||||
"referenceId": "64627441-39f9-4ee3-8494-bafe36451d12",
|
"referenceId": "64627441-39f9-4ee3-8494-bafe36451d12",
|
||||||
|
@ -124,7 +116,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
|
||||||
"note": "1/2 tsp salt",
|
"note": "1/2 tsp salt",
|
||||||
"unit": None,
|
"unit": None,
|
||||||
"food": None,
|
"food": None,
|
||||||
"disableAmount": True,
|
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"originalText": None,
|
"originalText": None,
|
||||||
"referenceId": "7ae212d0-3cd1-44b0-899e-ec5bd91fd384",
|
"referenceId": "7ae212d0-3cd1-44b0-899e-ec5bd91fd384",
|
||||||
|
@ -134,7 +125,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
|
||||||
"note": "1 cup cake flour",
|
"note": "1 cup cake flour",
|
||||||
"unit": None,
|
"unit": None,
|
||||||
"food": None,
|
"food": None,
|
||||||
"disableAmount": True,
|
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"originalText": None,
|
"originalText": None,
|
||||||
"referenceId": "06967994-8548-4952-a8cc-16e8db228ebd",
|
"referenceId": "06967994-8548-4952-a8cc-16e8db228ebd",
|
||||||
|
@ -144,7 +134,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
|
||||||
"note": "2 cups all-purpose flour",
|
"note": "2 cups all-purpose flour",
|
||||||
"unit": None,
|
"unit": None,
|
||||||
"food": None,
|
"food": None,
|
||||||
"disableAmount": True,
|
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"originalText": None,
|
"originalText": None,
|
||||||
"referenceId": "bdb33b23-c767-4465-acf8-3b8e79eb5691",
|
"referenceId": "bdb33b23-c767-4465-acf8-3b8e79eb5691",
|
||||||
|
@ -154,7 +143,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
|
||||||
"note": "2 cups peanut butter chips",
|
"note": "2 cups peanut butter chips",
|
||||||
"unit": None,
|
"unit": None,
|
||||||
"food": None,
|
"food": None,
|
||||||
"disableAmount": True,
|
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"originalText": None,
|
"originalText": None,
|
||||||
"referenceId": "12ba0af8-affd-4fb2-9cca-6f1b3e8d3aef",
|
"referenceId": "12ba0af8-affd-4fb2-9cca-6f1b3e8d3aef",
|
||||||
|
@ -164,7 +152,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
|
||||||
"note": "1½ cups Reese's Pieces candies",
|
"note": "1½ cups Reese's Pieces candies",
|
||||||
"unit": None,
|
"unit": None,
|
||||||
"food": None,
|
"food": None,
|
||||||
"disableAmount": True,
|
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"originalText": None,
|
"originalText": None,
|
||||||
"referenceId": "4bdc0598-a3eb-41ee-8af0-4da9348fbfe2",
|
"referenceId": "4bdc0598-a3eb-41ee-8af0-4da9348fbfe2",
|
||||||
|
@ -221,7 +208,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
|
||||||
"showAssets": False,
|
"showAssets": False,
|
||||||
"landscapeView": False,
|
"landscapeView": False,
|
||||||
"disableComments": False,
|
"disableComments": False,
|
||||||
"disableAmount": True,
|
|
||||||
"locked": False,
|
"locked": False,
|
||||||
},
|
},
|
||||||
"assets": [],
|
"assets": [],
|
||||||
|
|
|
@ -20,7 +20,7 @@ RUN yarn generate
|
||||||
###############################################
|
###############################################
|
||||||
# Base Image - Python
|
# Base Image - Python
|
||||||
###############################################
|
###############################################
|
||||||
FROM python:3.12-slim as python-base
|
FROM python:3.12-slim AS python-base
|
||||||
|
|
||||||
ENV MEALIE_HOME="/app"
|
ENV MEALIE_HOME="/app"
|
||||||
|
|
||||||
|
@ -119,7 +119,7 @@ RUN . $VENV_PATH/bin/activate \
|
||||||
###############################################
|
###############################################
|
||||||
# Production Image
|
# Production Image
|
||||||
###############################################
|
###############################################
|
||||||
FROM python-base as production
|
FROM python-base AS production
|
||||||
LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie"
|
LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie"
|
||||||
ENV PRODUCTION=true
|
ENV PRODUCTION=true
|
||||||
ENV TESTING=false
|
ENV TESTING=false
|
||||||
|
|
|
@ -13,14 +13,14 @@ Steps:
|
||||||
|
|
||||||
#### 1. Get your API Token
|
#### 1. Get your API Token
|
||||||
|
|
||||||
Create an API token from Mealie's User Settings page (https://hay-kot.github.io/mealie/documentation/users-groups/user-settings/#api-key-generation)
|
Create an API token from Mealie's User Settings page (https://docs.mealie.io/documentation/getting-started/api-usage/#getting-a-token)
|
||||||
|
|
||||||
#### 2. Create Home Assistant Sensors
|
#### 2. Create Home Assistant Sensors
|
||||||
|
|
||||||
Create REST sensors in home assistant to get the details of today's meal.
|
Create REST sensors in home assistant to get the details of today's meal.
|
||||||
We will create sensors to get the name and ID of the first meal in today's meal plan (note that this may not be what is wanted if there is more than one meal planned for the day). We need the ID as well as the name to be able to retrieve the image for the meal.
|
We will create sensors to get the name and ID of the first meal in today's meal plan (note that this may not be what is wanted if there is more than one meal planned for the day). We need the ID as well as the name to be able to retrieve the image for the meal.
|
||||||
|
|
||||||
Make sure the url and port (`http://mealie:9000` ) matches your installation's address and _API_ port.
|
Make sure the url and port (`http://mealie:9000`) matches your installation's address and _API_ port.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
rest:
|
rest:
|
||||||
|
|
|
@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
|
||||||
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
||||||
|
|
||||||
1. Take a backup just in case!
|
1. Take a backup just in case!
|
||||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v2.8.0`
|
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.0.2`
|
||||||
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
||||||
4. Restart the container
|
4. Restart the container
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
# Installing with PostgreSQL
|
# Installing with PostgreSQL
|
||||||
|
|
||||||
|
!!! Warning
|
||||||
|
When upgrading postgresql major versions, manual steps are required [Postgres#37](https://github.com/docker-library/postgres/issues/37).
|
||||||
|
|
||||||
PostgreSQL might be considered if you need to support many concurrent users. In addition, some features are only enabled on PostgreSQL, such as fuzzy search.
|
PostgreSQL might be considered if you need to support many concurrent users. In addition, some features are only enabled on PostgreSQL, such as fuzzy search.
|
||||||
|
|
||||||
**For Environment Variable Configuration, see** [Backend Configuration](./backend-config.md)
|
**For Environment Variable Configuration, see** [Backend Configuration](./backend-config.md)
|
||||||
|
@ -7,7 +10,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
mealie:
|
mealie:
|
||||||
image: ghcr.io/mealie-recipes/mealie:v2.8.0 # (3)
|
image: ghcr.io/mealie-recipes/mealie:v3.0.2 # (3)
|
||||||
container_name: mealie
|
container_name: mealie
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
@ -38,7 +41,7 @@ services:
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
container_name: postgres
|
container_name: postgres
|
||||||
image: postgres:15
|
image: postgres:17
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- mealie-pgdata:/var/lib/postgresql/data
|
- mealie-pgdata:/var/lib/postgresql/data
|
||||||
|
@ -46,6 +49,7 @@ services:
|
||||||
POSTGRES_PASSWORD: mealie
|
POSTGRES_PASSWORD: mealie
|
||||||
POSTGRES_USER: mealie
|
POSTGRES_USER: mealie
|
||||||
PGUSER: mealie
|
PGUSER: mealie
|
||||||
|
POSTGRES_DB: mealie
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "pg_isready"]
|
test: ["CMD", "pg_isready"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|
|
@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
mealie:
|
mealie:
|
||||||
image: ghcr.io/mealie-recipes/mealie:v2.8.0 # (3)
|
image: ghcr.io/mealie-recipes/mealie:v3.0.2 # (3)
|
||||||
container_name: mealie
|
container_name: mealie
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
|
|
|
@ -2,6 +2,3 @@
|
||||||
|
|
||||||
## Feature Requests
|
## Feature Requests
|
||||||
[Please request new features on Github](https://github.com/mealie-recipes/mealie/discussions/new?category=feature-request)
|
[Please request new features on Github](https://github.com/mealie-recipes/mealie/discussions/new?category=feature-request)
|
||||||
|
|
||||||
## Progress
|
|
||||||
See the [Github Projects page](https://github.com/users/hay-kot/projects/2) to see what is currently being worked on
|
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -351,7 +351,7 @@
|
||||||
<!-- Custom narrow footer -->
|
<!-- Custom narrow footer -->
|
||||||
<div class="md-footer-meta__inner md-grid">
|
<div class="md-footer-meta__inner md-grid">
|
||||||
<div class="md-footer-social">
|
<div class="md-footer-social">
|
||||||
<a class="md-footer-social__link" href="https://github.com/hay-kot/mealie" rel="noopener" target="_blank"
|
<a class="md-footer-social__link" href="https://github.com/mealie-recipes/mealie" rel="noopener" target="_blank"
|
||||||
title="github.com">
|
title="github.com">
|
||||||
<svg style="width: 32px; height: 32px" viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg">
|
<svg style="width: 32px; height: 32px" viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
|
|
|
@ -1,390 +0,0 @@
|
||||||
/* cyrillic-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-100-cyrillic-ext1.woff2") format("woff2");
|
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
|
||||||
}
|
|
||||||
/* cyrillic */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-100-cyrillic2.woff2") format("woff2");
|
|
||||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
|
||||||
}
|
|
||||||
/* greek-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-100-greek-ext3.woff2") format("woff2");
|
|
||||||
unicode-range: U+1F00-1FFF;
|
|
||||||
}
|
|
||||||
/* greek */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-100-greek4.woff2") format("woff2");
|
|
||||||
unicode-range: U+0370-03FF;
|
|
||||||
}
|
|
||||||
/* vietnamese */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-100-vietnamese5.woff2") format("woff2");
|
|
||||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
|
||||||
}
|
|
||||||
/* latin-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-100-latin-ext6.woff2") format("woff2");
|
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
||||||
}
|
|
||||||
/* latin */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-100-latin7.woff2") format("woff2");
|
|
||||||
unicode-range:
|
|
||||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
|
||||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
/* cyrillic-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-300-cyrillic-ext8.woff2") format("woff2");
|
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
|
||||||
}
|
|
||||||
/* cyrillic */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-300-cyrillic9.woff2") format("woff2");
|
|
||||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
|
||||||
}
|
|
||||||
/* greek-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-300-greek-ext10.woff2") format("woff2");
|
|
||||||
unicode-range: U+1F00-1FFF;
|
|
||||||
}
|
|
||||||
/* greek */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-300-greek11.woff2") format("woff2");
|
|
||||||
unicode-range: U+0370-03FF;
|
|
||||||
}
|
|
||||||
/* vietnamese */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-300-vietnamese12.woff2") format("woff2");
|
|
||||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
|
||||||
}
|
|
||||||
/* latin-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-300-latin-ext13.woff2") format("woff2");
|
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
||||||
}
|
|
||||||
/* latin */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-300-latin14.woff2") format("woff2");
|
|
||||||
unicode-range:
|
|
||||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
|
||||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
/* cyrillic-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-400-cyrillic-ext15.woff2") format("woff2");
|
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
|
||||||
}
|
|
||||||
/* cyrillic */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-400-cyrillic16.woff2") format("woff2");
|
|
||||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
|
||||||
}
|
|
||||||
/* greek-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-400-greek-ext17.woff2") format("woff2");
|
|
||||||
unicode-range: U+1F00-1FFF;
|
|
||||||
}
|
|
||||||
/* greek */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-400-greek18.woff2") format("woff2");
|
|
||||||
unicode-range: U+0370-03FF;
|
|
||||||
}
|
|
||||||
/* vietnamese */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-400-vietnamese19.woff2") format("woff2");
|
|
||||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
|
||||||
}
|
|
||||||
/* latin-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-400-latin-ext20.woff2") format("woff2");
|
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
||||||
}
|
|
||||||
/* latin */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-400-latin21.woff2") format("woff2");
|
|
||||||
unicode-range:
|
|
||||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
|
||||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
/* cyrillic-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-500-cyrillic-ext22.woff2") format("woff2");
|
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
|
||||||
}
|
|
||||||
/* cyrillic */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-500-cyrillic23.woff2") format("woff2");
|
|
||||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
|
||||||
}
|
|
||||||
/* greek-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-500-greek-ext24.woff2") format("woff2");
|
|
||||||
unicode-range: U+1F00-1FFF;
|
|
||||||
}
|
|
||||||
/* greek */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-500-greek25.woff2") format("woff2");
|
|
||||||
unicode-range: U+0370-03FF;
|
|
||||||
}
|
|
||||||
/* vietnamese */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-500-vietnamese26.woff2") format("woff2");
|
|
||||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
|
||||||
}
|
|
||||||
/* latin-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-500-latin-ext27.woff2") format("woff2");
|
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
||||||
}
|
|
||||||
/* latin */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-500-latin28.woff2") format("woff2");
|
|
||||||
unicode-range:
|
|
||||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
|
||||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
/* cyrillic-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-700-cyrillic-ext29.woff2") format("woff2");
|
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
|
||||||
}
|
|
||||||
/* cyrillic */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-700-cyrillic30.woff2") format("woff2");
|
|
||||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
|
||||||
}
|
|
||||||
/* greek-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-700-greek-ext31.woff2") format("woff2");
|
|
||||||
unicode-range: U+1F00-1FFF;
|
|
||||||
}
|
|
||||||
/* greek */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-700-greek32.woff2") format("woff2");
|
|
||||||
unicode-range: U+0370-03FF;
|
|
||||||
}
|
|
||||||
/* vietnamese */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-700-vietnamese33.woff2") format("woff2");
|
|
||||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
|
||||||
}
|
|
||||||
/* latin-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-700-latin-ext34.woff2") format("woff2");
|
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
||||||
}
|
|
||||||
/* latin */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-700-latin35.woff2") format("woff2");
|
|
||||||
unicode-range:
|
|
||||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
|
||||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
/* cyrillic-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-900-cyrillic-ext36.woff2") format("woff2");
|
|
||||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
|
||||||
}
|
|
||||||
/* cyrillic */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-900-cyrillic37.woff2") format("woff2");
|
|
||||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
|
||||||
}
|
|
||||||
/* greek-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-900-greek-ext38.woff2") format("woff2");
|
|
||||||
unicode-range: U+1F00-1FFF;
|
|
||||||
}
|
|
||||||
/* greek */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-900-greek39.woff2") format("woff2");
|
|
||||||
unicode-range: U+0370-03FF;
|
|
||||||
}
|
|
||||||
/* vietnamese */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-900-vietnamese40.woff2") format("woff2");
|
|
||||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
|
||||||
}
|
|
||||||
/* latin-ext */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-900-latin-ext41.woff2") format("woff2");
|
|
||||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
||||||
}
|
|
||||||
/* latin */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Roboto";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("~assets/fonts/Roboto-900-latin42.woff2") format("woff2");
|
|
||||||
unicode-range:
|
|
||||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
|
|
||||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -44,78 +44,54 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
|
||||||
import { Organizer } from "~/lib/api/types/non-generated";
|
import { Organizer } from "~/lib/api/types/non-generated";
|
||||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||||
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
|
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||||
|
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
const modelValue = defineModel<ReadCookBook>({ required: true });
|
||||||
components: { QueryFilterBuilder },
|
const i18n = useI18n();
|
||||||
props: {
|
const cookbook = toRef(modelValue);
|
||||||
modelValue: {
|
function handleInput(value: string | undefined) {
|
||||||
type: Object as () => ReadCookBook,
|
cookbook.value.queryFilterString = value || "";
|
||||||
required: true,
|
}
|
||||||
},
|
|
||||||
actions: {
|
const fieldDefs: FieldDefinition[] = [
|
||||||
type: Object as () => any,
|
{
|
||||||
required: true,
|
name: "recipe_category.id",
|
||||||
},
|
label: i18n.t("category.categories"),
|
||||||
|
type: Organizer.Category,
|
||||||
},
|
},
|
||||||
emits: ["update:modelValue"],
|
{
|
||||||
setup(props, { emit }) {
|
name: "tags.id",
|
||||||
const i18n = useI18n();
|
label: i18n.t("tag.tags"),
|
||||||
|
type: Organizer.Tag,
|
||||||
const cookbook = toRef(() => props.modelValue);
|
|
||||||
|
|
||||||
function handleInput(value: string | undefined) {
|
|
||||||
cookbook.value.queryFilterString = value || "";
|
|
||||||
emit("update:modelValue", cookbook.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldDefs: FieldDefinition[] = [
|
|
||||||
{
|
|
||||||
name: "recipe_category.id",
|
|
||||||
label: i18n.t("category.categories"),
|
|
||||||
type: Organizer.Category,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tags.id",
|
|
||||||
label: i18n.t("tag.tags"),
|
|
||||||
type: Organizer.Tag,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "recipe_ingredient.food.id",
|
|
||||||
label: i18n.t("recipe.ingredients"),
|
|
||||||
type: Organizer.Food,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tools.id",
|
|
||||||
label: i18n.t("tool.tools"),
|
|
||||||
type: Organizer.Tool,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "household_id",
|
|
||||||
label: i18n.t("household.households"),
|
|
||||||
type: Organizer.Household,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "created_at",
|
|
||||||
label: i18n.t("general.date-created"),
|
|
||||||
type: "date",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "updated_at",
|
|
||||||
label: i18n.t("general.date-updated"),
|
|
||||||
type: "date",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
cookbook,
|
|
||||||
handleInput,
|
|
||||||
fieldDefs,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
|
name: "recipe_ingredient.food.id",
|
||||||
|
label: i18n.t("recipe.ingredients"),
|
||||||
|
type: Organizer.Food,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tools.id",
|
||||||
|
label: i18n.t("tool.tools"),
|
||||||
|
type: Organizer.Tool,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "household_id",
|
||||||
|
label: i18n.t("household.households"),
|
||||||
|
type: Organizer.Household,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "created_at",
|
||||||
|
label: i18n.t("general.date-created"),
|
||||||
|
type: "date",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "updated_at",
|
||||||
|
label: i18n.t("general.date-updated"),
|
||||||
|
type: "date",
|
||||||
|
},
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -17,7 +17,6 @@
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<CookbookEditor
|
<CookbookEditor
|
||||||
v-model="editTarget"
|
v-model="editTarget"
|
||||||
:actions="actions"
|
|
||||||
/>
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
@ -65,7 +64,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { useLazyRecipes } from "~/composables/recipes";
|
import { useLazyRecipes } from "~/composables/recipes";
|
||||||
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
|
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
|
||||||
import { useCookbookStore } from "~/composables/store/use-cookbook-store";
|
import { useCookbookStore } from "~/composables/store/use-cookbook-store";
|
||||||
|
@ -74,81 +73,58 @@ import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import type { RecipeCookBook } from "~/lib/api/types/cookbook";
|
import type { RecipeCookBook } from "~/lib/api/types/cookbook";
|
||||||
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
const $auth = useMealieAuth();
|
||||||
components: { RecipeCardSection, CookbookEditor },
|
const { isOwnGroup } = useLoggedInState();
|
||||||
setup() {
|
|
||||||
const $auth = useMealieAuth();
|
|
||||||
const { isOwnGroup } = useLoggedInState();
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||||
const slug = route.params.slug as string;
|
const slug = route.params.slug as string;
|
||||||
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
|
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
|
||||||
const { actions } = useCookbookStore();
|
const { actions } = useCookbookStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const tab = ref(null);
|
const book = getOne(slug);
|
||||||
const book = getOne(slug);
|
|
||||||
|
|
||||||
const isOwnHousehold = computed(() => {
|
const isOwnHousehold = computed(() => {
|
||||||
if (!($auth.user.value && book.value?.householdId)) {
|
if (!($auth.user.value && book.value?.householdId)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $auth.user.value.householdId === book.value.householdId;
|
return $auth.user.value.householdId === book.value.householdId;
|
||||||
});
|
});
|
||||||
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
|
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
|
||||||
|
|
||||||
const dialogStates = reactive({
|
const dialogStates = reactive({
|
||||||
edit: false,
|
edit: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const editTarget = ref<RecipeCookBook | null>(null);
|
const editTarget = ref<RecipeCookBook | null>(null);
|
||||||
function handleEditCookbook() {
|
function handleEditCookbook() {
|
||||||
dialogStates.edit = true;
|
dialogStates.edit = true;
|
||||||
editTarget.value = book.value;
|
editTarget.value = book.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function editCookbook() {
|
async function editCookbook() {
|
||||||
if (!editTarget.value) {
|
if (!editTarget.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const response = await actions.updateOne(editTarget.value);
|
const response = await actions.updateOne(editTarget.value);
|
||||||
|
|
||||||
if (response?.slug && book.value?.slug !== response?.slug) {
|
if (response?.slug && book.value?.slug !== response?.slug) {
|
||||||
// if name changed, redirect to new slug
|
// if name changed, redirect to new slug
|
||||||
router.push(`/g/${route.params.groupSlug}/cookbooks/${response?.slug}`);
|
router.push(`/g/${route.params.groupSlug}/cookbooks/${response?.slug}`);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// otherwise reload the page, since the recipe criteria changed
|
// otherwise reload the page, since the recipe criteria changed
|
||||||
router.go(0);
|
router.go(0);
|
||||||
}
|
}
|
||||||
dialogStates.edit = false;
|
dialogStates.edit = false;
|
||||||
editTarget.value = null;
|
editTarget.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: book?.value?.name || "Cookbook",
|
title: book?.value?.name || "Cookbook",
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
book,
|
|
||||||
slug,
|
|
||||||
tab,
|
|
||||||
appendRecipes,
|
|
||||||
assignSorted,
|
|
||||||
recipes,
|
|
||||||
removeRecipe,
|
|
||||||
replaceRecipes,
|
|
||||||
canEdit,
|
|
||||||
dialogStates,
|
|
||||||
editTarget,
|
|
||||||
handleEditCookbook,
|
|
||||||
editCookbook,
|
|
||||||
actions,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -20,45 +20,33 @@
|
||||||
</v-data-table>
|
</v-data-table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { parseISO, formatDistanceToNow } from "date-fns";
|
import { parseISO, formatDistanceToNow } from "date-fns";
|
||||||
import type { GroupDataExport } from "~/lib/api/types/group";
|
import type { GroupDataExport } from "~/lib/api/types/group";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
defineProps<{
|
||||||
props: {
|
exports: GroupDataExport[];
|
||||||
exports: {
|
}>();
|
||||||
type: Array as () => GroupDataExport[],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const i18n = useI18n();
|
|
||||||
|
|
||||||
const headers = [
|
const i18n = useI18n();
|
||||||
{ title: i18n.t("export.export"), value: "name" },
|
|
||||||
{ title: i18n.t("export.file-name"), value: "filename" },
|
|
||||||
{ title: i18n.t("export.size"), value: "size" },
|
|
||||||
{ title: i18n.t("export.link-expires"), value: "expires" },
|
|
||||||
{ title: "", value: "actions" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function getTimeToExpire(timeString: string) {
|
const headers = [
|
||||||
const expiresAt = parseISO(timeString);
|
{ title: i18n.t("export.export"), value: "name" },
|
||||||
|
{ title: i18n.t("export.file-name"), value: "filename" },
|
||||||
|
{ title: i18n.t("export.size"), value: "size" },
|
||||||
|
{ title: i18n.t("export.link-expires"), value: "expires" },
|
||||||
|
{ title: "", value: "actions" },
|
||||||
|
];
|
||||||
|
|
||||||
return formatDistanceToNow(expiresAt, {
|
function getTimeToExpire(timeString: string) {
|
||||||
addSuffix: false,
|
const expiresAt = parseISO(timeString);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadData(_: any) {
|
return formatDistanceToNow(expiresAt, {
|
||||||
console.log("Downloading data...");
|
addSuffix: false,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
function downloadData(_: any) {
|
||||||
downloadData,
|
console.log("Downloading data...");
|
||||||
headers,
|
}
|
||||||
getTimeToExpire,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -9,30 +9,10 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
export default defineNuxtComponent({
|
import type { ReadGroupPreferences } from "~/lib/api/types/user";
|
||||||
props: {
|
|
||||||
modelValue: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["update:modelValue"],
|
|
||||||
setup(props, context) {
|
|
||||||
const preferences = computed({
|
|
||||||
get() {
|
|
||||||
return props.modelValue;
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
context.emit("update:modelValue", val);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
const preferences = defineModel<ReadGroupPreferences>({ required: true });
|
||||||
preferences,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
<style lang="scss" scoped></style>
|
||||||
|
|
|
@ -1,91 +0,0 @@
|
||||||
<template>
|
|
||||||
<v-select
|
|
||||||
v-model="selected"
|
|
||||||
:items="households"
|
|
||||||
:label="label"
|
|
||||||
:hint="description"
|
|
||||||
:persistent-hint="!!description"
|
|
||||||
item-title="name"
|
|
||||||
:multiple="multiselect"
|
|
||||||
:prepend-inner-icon="$globals.icons.household"
|
|
||||||
return-object
|
|
||||||
>
|
|
||||||
<template #chip="data">
|
|
||||||
<v-chip
|
|
||||||
:key="data.index"
|
|
||||||
class="ma-1"
|
|
||||||
:input-value="data.item"
|
|
||||||
size="small"
|
|
||||||
closable
|
|
||||||
label
|
|
||||||
color="accent"
|
|
||||||
dark
|
|
||||||
@click:close="removeByIndex(data.index)"
|
|
||||||
>
|
|
||||||
{{ data.item.raw.name || data.item }}
|
|
||||||
</v-chip>
|
|
||||||
</template>
|
|
||||||
</v-select>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { useHouseholdStore } from "~/composables/store/use-household-store";
|
|
||||||
|
|
||||||
interface HouseholdLike {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
|
||||||
props: {
|
|
||||||
modelValue: {
|
|
||||||
type: Array as () => HouseholdLike[],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
multiselect: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["update:modelValue"],
|
|
||||||
setup(props, context) {
|
|
||||||
const selected = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (val) => {
|
|
||||||
context.emit("update:modelValue", val);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (selected.value === undefined) {
|
|
||||||
selected.value = [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const i18n = useI18n();
|
|
||||||
const label = computed(
|
|
||||||
() => props.multiselect ? i18n.t("household.households") : i18n.t("household.household"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { store: households } = useHouseholdStore();
|
|
||||||
function removeByIndex(index: number) {
|
|
||||||
if (selected.value === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newSelected = selected.value.filter((_, i) => i !== index);
|
|
||||||
selected.value = [...newSelected];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
selected,
|
|
||||||
label,
|
|
||||||
households,
|
|
||||||
removeByIndex,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
|
@ -18,7 +18,7 @@
|
||||||
:open-on-hover="mdAndUp"
|
:open-on-hover="mdAndUp"
|
||||||
content-class="d-print-none"
|
content-class="d-print-none"
|
||||||
>
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
:class="{ 'rounded-circle': fab }"
|
:class="{ 'rounded-circle': fab }"
|
||||||
:size="fab ? 'small' : undefined"
|
:size="fab ? 'small' : undefined"
|
||||||
|
@ -26,7 +26,7 @@
|
||||||
:icon="!fab"
|
:icon="!fab"
|
||||||
variant="text"
|
variant="text"
|
||||||
dark
|
dark
|
||||||
v-bind="props"
|
v-bind="activatorProps"
|
||||||
@click.prevent
|
@click.prevent
|
||||||
>
|
>
|
||||||
<v-icon>{{ icon }}</v-icon>
|
<v-icon>{{ icon }}</v-icon>
|
||||||
|
@ -50,7 +50,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue";
|
import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue";
|
||||||
import type { ShoppingListSummary } from "~/lib/api/types/household";
|
import type { ShoppingListSummary } from "~/lib/api/types/household";
|
||||||
|
@ -64,101 +64,84 @@ export interface ContextMenuItem {
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
components: {
|
recipes?: Recipe[];
|
||||||
RecipeDialogAddToShoppingList,
|
menuTop?: boolean;
|
||||||
},
|
fab?: boolean;
|
||||||
props: {
|
color?: string;
|
||||||
recipes: {
|
menuIcon?: string | null;
|
||||||
type: Array as () => Recipe[],
|
}
|
||||||
default: () => [],
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
},
|
recipes: () => [],
|
||||||
menuTop: {
|
menuTop: true,
|
||||||
type: Boolean,
|
fab: false,
|
||||||
default: true,
|
color: "primary",
|
||||||
},
|
menuIcon: null,
|
||||||
fab: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
color: {
|
|
||||||
type: String,
|
|
||||||
default: "primary",
|
|
||||||
},
|
|
||||||
menuIcon: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props, context) {
|
|
||||||
const { mdAndUp } = useDisplay();
|
|
||||||
|
|
||||||
const i18n = useI18n();
|
|
||||||
const { $globals } = useNuxtApp();
|
|
||||||
const api = useUserApi();
|
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
loading: false,
|
|
||||||
shoppingListDialog: false,
|
|
||||||
menuItems: [
|
|
||||||
{
|
|
||||||
title: i18n.t("recipe.add-to-list"),
|
|
||||||
icon: $globals.icons.cartCheck,
|
|
||||||
color: undefined,
|
|
||||||
event: "shoppingList",
|
|
||||||
isPublic: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const icon = props.menuIcon || $globals.icons.dotsVertical;
|
|
||||||
|
|
||||||
const shoppingLists = ref<ShoppingListSummary[]>();
|
|
||||||
const recipesWithScales = computed(() => {
|
|
||||||
return props.recipes.map((recipe) => {
|
|
||||||
return {
|
|
||||||
scale: 1,
|
|
||||||
...recipe,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function getShoppingLists() {
|
|
||||||
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
|
|
||||||
if (data) {
|
|
||||||
shoppingLists.value = data.items as ShoppingListSummary[] ?? [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
|
||||||
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
|
||||||
shoppingList: () => {
|
|
||||||
getShoppingLists();
|
|
||||||
state.shoppingListDialog = true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function contextMenuEventHandler(eventKey: string) {
|
|
||||||
const handler = eventHandlers[eventKey];
|
|
||||||
|
|
||||||
if (handler && typeof handler === "function") {
|
|
||||||
handler();
|
|
||||||
state.loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.emit(eventKey);
|
|
||||||
state.loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...toRefs(state),
|
|
||||||
contextMenuEventHandler,
|
|
||||||
icon,
|
|
||||||
recipesWithScales,
|
|
||||||
shoppingLists,
|
|
||||||
mdAndUp,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
[key: string]: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { mdAndUp } = useDisplay();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
|
const api = useUserApi();
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
loading: false,
|
||||||
|
shoppingListDialog: false,
|
||||||
|
menuItems: [
|
||||||
|
{
|
||||||
|
title: i18n.t("recipe.add-to-list"),
|
||||||
|
icon: $globals.icons.cartCheck,
|
||||||
|
color: undefined,
|
||||||
|
event: "shoppingList",
|
||||||
|
isPublic: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { shoppingListDialog, menuItems } = toRefs(state);
|
||||||
|
|
||||||
|
const icon = props.menuIcon || $globals.icons.dotsVertical;
|
||||||
|
|
||||||
|
const shoppingLists = ref<ShoppingListSummary[]>();
|
||||||
|
const recipesWithScales = computed(() => {
|
||||||
|
return props.recipes.map((recipe) => {
|
||||||
|
return {
|
||||||
|
scale: 1,
|
||||||
|
...recipe,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getShoppingLists() {
|
||||||
|
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
|
||||||
|
if (data) {
|
||||||
|
shoppingLists.value = data.items as ShoppingListSummary[] ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||||
|
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
||||||
|
shoppingList: () => {
|
||||||
|
getShoppingLists();
|
||||||
|
state.shoppingListDialog = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function contextMenuEventHandler(eventKey: string) {
|
||||||
|
const handler = eventHandlers[eventKey];
|
||||||
|
|
||||||
|
if (handler && typeof handler === "function") {
|
||||||
|
handler();
|
||||||
|
state.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(eventKey);
|
||||||
|
state.loading = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -5,12 +5,12 @@
|
||||||
style="gap: 10px"
|
style="gap: 10px"
|
||||||
>
|
>
|
||||||
<v-select
|
<v-select
|
||||||
v-model="inputDay"
|
v-model="day"
|
||||||
:items="MEAL_DAY_OPTIONS"
|
:items="MEAL_DAY_OPTIONS"
|
||||||
:label="$t('meal-plan.rule-day')"
|
:label="$t('meal-plan.rule-day')"
|
||||||
/>
|
/>
|
||||||
<v-select
|
<v-select
|
||||||
v-model="inputEntryType"
|
v-model="entryType"
|
||||||
:items="MEAL_TYPE_OPTIONS"
|
:items="MEAL_TYPE_OPTIONS"
|
||||||
:label="$t('meal-plan.meal-type')"
|
:label="$t('meal-plan.meal-type')"
|
||||||
/>
|
/>
|
||||||
|
@ -19,157 +19,104 @@
|
||||||
<div class="mb-5">
|
<div class="mb-5">
|
||||||
<QueryFilterBuilder
|
<QueryFilterBuilder
|
||||||
:field-defs="fieldDefs"
|
:field-defs="fieldDefs"
|
||||||
:initial-query-filter="queryFilter"
|
:initial-query-filter="props.queryFilter"
|
||||||
@input="handleQueryFilterInput"
|
@input="handleQueryFilterInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TODO: proper pluralization of inputDay -->
|
<!-- TODO: proper pluralization of inputDay -->
|
||||||
{{ $t('meal-plan.this-rule-will-apply', {
|
{{ $t('meal-plan.this-rule-will-apply', {
|
||||||
dayCriteria: inputDay === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [inputDay]),
|
dayCriteria: day === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [day]),
|
||||||
mealTypeCriteria: inputEntryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [inputEntryType]),
|
mealTypeCriteria: entryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [entryType]),
|
||||||
}) }}
|
}) }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||||
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
|
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||||
import { Organizer } from "~/lib/api/types/non-generated";
|
import { Organizer } from "~/lib/api/types/non-generated";
|
||||||
import type { QueryFilterJSON } from "~/lib/api/types/response";
|
import type { QueryFilterJSON } from "~/lib/api/types/response";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
components: {
|
queryFilter?: QueryFilterJSON | null;
|
||||||
QueryFilterBuilder,
|
showHelp?: boolean;
|
||||||
},
|
}
|
||||||
props: {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
day: {
|
queryFilter: null,
|
||||||
type: String,
|
showHelp: false,
|
||||||
default: "unset",
|
|
||||||
},
|
|
||||||
entryType: {
|
|
||||||
type: String,
|
|
||||||
default: "unset",
|
|
||||||
},
|
|
||||||
queryFilterString: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
queryFilter: {
|
|
||||||
type: Object as () => QueryFilterJSON,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
showHelp: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["update:day", "update:entry-type", "update:query-filter-string"],
|
|
||||||
setup(props, context) {
|
|
||||||
const i18n = useI18n();
|
|
||||||
|
|
||||||
const MEAL_TYPE_OPTIONS = [
|
|
||||||
{ title: i18n.t("meal-plan.breakfast"), value: "breakfast" },
|
|
||||||
{ title: i18n.t("meal-plan.lunch"), value: "lunch" },
|
|
||||||
{ title: i18n.t("meal-plan.dinner"), value: "dinner" },
|
|
||||||
{ title: i18n.t("meal-plan.side"), value: "side" },
|
|
||||||
{ title: i18n.t("meal-plan.type-any"), value: "unset" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const MEAL_DAY_OPTIONS = [
|
|
||||||
{ title: i18n.t("general.monday"), value: "monday" },
|
|
||||||
{ title: i18n.t("general.tuesday"), value: "tuesday" },
|
|
||||||
{ title: i18n.t("general.wednesday"), value: "wednesday" },
|
|
||||||
{ title: i18n.t("general.thursday"), value: "thursday" },
|
|
||||||
{ title: i18n.t("general.friday"), value: "friday" },
|
|
||||||
{ title: i18n.t("general.saturday"), value: "saturday" },
|
|
||||||
{ title: i18n.t("general.sunday"), value: "sunday" },
|
|
||||||
{ title: i18n.t("meal-plan.day-any"), value: "unset" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const inputDay = computed({
|
|
||||||
get: () => {
|
|
||||||
return props.day;
|
|
||||||
},
|
|
||||||
set: (val) => {
|
|
||||||
context.emit("update:day", val);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const inputEntryType = computed({
|
|
||||||
get: () => {
|
|
||||||
return props.entryType;
|
|
||||||
},
|
|
||||||
set: (val) => {
|
|
||||||
context.emit("update:entry-type", val);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const inputQueryFilterString = computed({
|
|
||||||
get: () => {
|
|
||||||
return props.queryFilterString;
|
|
||||||
},
|
|
||||||
set: (val) => {
|
|
||||||
context.emit("update:query-filter-string", val);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleQueryFilterInput(value: string | undefined) {
|
|
||||||
inputQueryFilterString.value = value || "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const fieldDefs: FieldDefinition[] = [
|
|
||||||
{
|
|
||||||
name: "recipe_category.id",
|
|
||||||
label: i18n.t("category.categories"),
|
|
||||||
type: Organizer.Category,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tags.id",
|
|
||||||
label: i18n.t("tag.tags"),
|
|
||||||
type: Organizer.Tag,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "recipe_ingredient.food.id",
|
|
||||||
label: i18n.t("recipe.ingredients"),
|
|
||||||
type: Organizer.Food,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tools.id",
|
|
||||||
label: i18n.t("tool.tools"),
|
|
||||||
type: Organizer.Tool,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "household_id",
|
|
||||||
label: i18n.t("household.households"),
|
|
||||||
type: Organizer.Household,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "last_made",
|
|
||||||
label: i18n.t("general.last-made"),
|
|
||||||
type: "date",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "created_at",
|
|
||||||
label: i18n.t("general.date-created"),
|
|
||||||
type: "date",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "updated_at",
|
|
||||||
label: i18n.t("general.date-updated"),
|
|
||||||
type: "date",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
MEAL_TYPE_OPTIONS,
|
|
||||||
MEAL_DAY_OPTIONS,
|
|
||||||
inputDay,
|
|
||||||
inputEntryType,
|
|
||||||
inputQueryFilterString,
|
|
||||||
handleQueryFilterInput,
|
|
||||||
fieldDefs,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const day = defineModel<string>("day", { default: "unset" });
|
||||||
|
const entryType = defineModel<string>("entryType", { default: "unset" });
|
||||||
|
const queryFilterString = defineModel<string>("queryFilterString", { default: "" });
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const MEAL_TYPE_OPTIONS = [
|
||||||
|
{ title: i18n.t("meal-plan.breakfast"), value: "breakfast" },
|
||||||
|
{ title: i18n.t("meal-plan.lunch"), value: "lunch" },
|
||||||
|
{ title: i18n.t("meal-plan.dinner"), value: "dinner" },
|
||||||
|
{ title: i18n.t("meal-plan.side"), value: "side" },
|
||||||
|
{ title: i18n.t("meal-plan.type-any"), value: "unset" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MEAL_DAY_OPTIONS = [
|
||||||
|
{ title: i18n.t("general.monday"), value: "monday" },
|
||||||
|
{ title: i18n.t("general.tuesday"), value: "tuesday" },
|
||||||
|
{ title: i18n.t("general.wednesday"), value: "wednesday" },
|
||||||
|
{ title: i18n.t("general.thursday"), value: "thursday" },
|
||||||
|
{ title: i18n.t("general.friday"), value: "friday" },
|
||||||
|
{ title: i18n.t("general.saturday"), value: "saturday" },
|
||||||
|
{ title: i18n.t("general.sunday"), value: "sunday" },
|
||||||
|
{ title: i18n.t("meal-plan.day-any"), value: "unset" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function handleQueryFilterInput(value: string | undefined) {
|
||||||
|
console.warn("handleQueryFilterInput called with value:", value);
|
||||||
|
queryFilterString.value = value || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldDefs: FieldDefinition[] = [
|
||||||
|
{
|
||||||
|
name: "recipe_category.id",
|
||||||
|
label: i18n.t("category.categories"),
|
||||||
|
type: Organizer.Category,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tags.id",
|
||||||
|
label: i18n.t("tag.tags"),
|
||||||
|
type: Organizer.Tag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "recipe_ingredient.food.id",
|
||||||
|
label: i18n.t("recipe.ingredients"),
|
||||||
|
type: Organizer.Food,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tools.id",
|
||||||
|
label: i18n.t("tool.tools"),
|
||||||
|
type: Organizer.Tool,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "household_id",
|
||||||
|
label: i18n.t("household.households"),
|
||||||
|
type: Organizer.Household,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "last_made",
|
||||||
|
label: i18n.t("general.last-made"),
|
||||||
|
type: "date",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "created_at",
|
||||||
|
label: i18n.t("general.date-created"),
|
||||||
|
type: "date",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "updated_at",
|
||||||
|
label: i18n.t("general.date-updated"),
|
||||||
|
type: "date",
|
||||||
|
},
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -16,11 +16,11 @@
|
||||||
:label="$t('settings.webhooks.webhook-url')"
|
:label="$t('settings.webhooks.webhook-url')"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
/>
|
/>
|
||||||
<v-time-picker
|
<v-text-field
|
||||||
v-model="scheduledTime"
|
v-model="scheduledTime"
|
||||||
class="elevation-2"
|
type="time"
|
||||||
ampm-in-title
|
clearable
|
||||||
format="ampm"
|
variant="underlined"
|
||||||
/>
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions class="py-0 justify-end">
|
<v-card-actions class="py-0 justify-end">
|
||||||
|
@ -50,52 +50,43 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ReadWebhook } from "~/lib/api/types/household";
|
import type { ReadWebhook } from "~/lib/api/types/household";
|
||||||
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
|
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
const props = defineProps<{
|
||||||
props: {
|
webhook: ReadWebhook;
|
||||||
webhook: {
|
}>();
|
||||||
type: Object as () => ReadWebhook,
|
|
||||||
required: true,
|
const emit = defineEmits<{
|
||||||
},
|
delete: [id: string];
|
||||||
|
save: [webhook: ReadWebhook];
|
||||||
|
test: [id: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const itemUTC = ref<string>(props.webhook.scheduledTime);
|
||||||
|
const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime));
|
||||||
|
|
||||||
|
const scheduledTime = computed({
|
||||||
|
get() {
|
||||||
|
return itemLocal.value;
|
||||||
},
|
},
|
||||||
emits: ["delete", "save", "test"],
|
set(v: string) {
|
||||||
setup(props, { emit }) {
|
itemUTC.value = timeLocalToUTC(v);
|
||||||
const i18n = useI18n();
|
itemLocal.value = v;
|
||||||
const itemUTC = ref<string>(props.webhook.scheduledTime);
|
|
||||||
const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime));
|
|
||||||
|
|
||||||
const scheduledTime = computed({
|
|
||||||
get() {
|
|
||||||
return itemLocal.value;
|
|
||||||
},
|
|
||||||
set(v: string) {
|
|
||||||
itemUTC.value = timeLocalToUTC(v);
|
|
||||||
itemLocal.value = v;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const webhookCopy = ref({ ...props.webhook });
|
|
||||||
|
|
||||||
function handleSave() {
|
|
||||||
webhookCopy.value.scheduledTime = itemLocal.value;
|
|
||||||
emit("save", webhookCopy.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set page title using useSeoMeta
|
|
||||||
useSeoMeta({
|
|
||||||
title: i18n.t("settings.webhooks.webhooks"),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
webhookCopy,
|
|
||||||
scheduledTime,
|
|
||||||
handleSave,
|
|
||||||
itemUTC,
|
|
||||||
itemLocal,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const webhookCopy = ref({ ...props.webhook });
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
webhookCopy.value.scheduledTime = itemLocal.value;
|
||||||
|
emit("save", webhookCopy.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set page title using useSeoMeta
|
||||||
|
useSeoMeta({
|
||||||
|
title: i18n.t("settings.webhooks.webhooks"),
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -41,106 +41,76 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ReadHouseholdPreferences } from "~/lib/api/types/household";
|
import type { ReadHouseholdPreferences } from "~/lib/api/types/household";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
const preferences = defineModel<ReadHouseholdPreferences>({ required: true });
|
||||||
props: {
|
const i18n = useI18n();
|
||||||
modelValue: {
|
|
||||||
type: Object,
|
type Preference = {
|
||||||
required: true,
|
key: keyof ReadHouseholdPreferences;
|
||||||
},
|
label: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const recipePreferences: Preference[] = [
|
||||||
|
{
|
||||||
|
key: "recipePublic",
|
||||||
|
label: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes"),
|
||||||
|
description: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes-description"),
|
||||||
},
|
},
|
||||||
emits: ["update:modelValue"],
|
{
|
||||||
setup(props, context) {
|
key: "recipeShowNutrition",
|
||||||
const i18n = useI18n();
|
label: i18n.t("group.show-nutrition-information"),
|
||||||
|
description: i18n.t("group.show-nutrition-information-description"),
|
||||||
type Preference = {
|
|
||||||
key: keyof ReadHouseholdPreferences;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const recipePreferences: Preference[] = [
|
|
||||||
{
|
|
||||||
key: "recipePublic",
|
|
||||||
label: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes"),
|
|
||||||
description: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes-description"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "recipeShowNutrition",
|
|
||||||
label: i18n.t("group.show-nutrition-information"),
|
|
||||||
description: i18n.t("group.show-nutrition-information-description"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "recipeShowAssets",
|
|
||||||
label: i18n.t("group.show-recipe-assets"),
|
|
||||||
description: i18n.t("group.show-recipe-assets-description"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "recipeLandscapeView",
|
|
||||||
label: i18n.t("group.default-to-landscape-view"),
|
|
||||||
description: i18n.t("group.default-to-landscape-view-description"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "recipeDisableComments",
|
|
||||||
label: i18n.t("group.disable-users-from-commenting-on-recipes"),
|
|
||||||
description: i18n.t("group.disable-users-from-commenting-on-recipes-description"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "recipeDisableAmount",
|
|
||||||
label: i18n.t("group.disable-organizing-recipe-ingredients-by-units-and-food"),
|
|
||||||
description: i18n.t("group.disable-organizing-recipe-ingredients-by-units-and-food-description"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const allDays = [
|
|
||||||
{
|
|
||||||
name: i18n.t("general.sunday"),
|
|
||||||
value: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n.t("general.monday"),
|
|
||||||
value: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n.t("general.tuesday"),
|
|
||||||
value: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n.t("general.wednesday"),
|
|
||||||
value: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n.t("general.thursday"),
|
|
||||||
value: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n.t("general.friday"),
|
|
||||||
value: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n.t("general.saturday"),
|
|
||||||
value: 6,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const preferences = computed({
|
|
||||||
get() {
|
|
||||||
return props.modelValue;
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
context.emit("update:modelValue", val);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
allDays,
|
|
||||||
preferences,
|
|
||||||
recipePreferences,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
|
key: "recipeShowAssets",
|
||||||
|
label: i18n.t("group.show-recipe-assets"),
|
||||||
|
description: i18n.t("group.show-recipe-assets-description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "recipeLandscapeView",
|
||||||
|
label: i18n.t("group.default-to-landscape-view"),
|
||||||
|
description: i18n.t("group.default-to-landscape-view-description"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "recipeDisableComments",
|
||||||
|
label: i18n.t("group.disable-users-from-commenting-on-recipes"),
|
||||||
|
description: i18n.t("group.disable-users-from-commenting-on-recipes-description"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const allDays = [
|
||||||
|
{
|
||||||
|
name: i18n.t("general.sunday"),
|
||||||
|
value: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n.t("general.monday"),
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n.t("general.tuesday"),
|
||||||
|
value: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n.t("general.wednesday"),
|
||||||
|
value: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n.t("general.thursday"),
|
||||||
|
value: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n.t("general.friday"),
|
||||||
|
value: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n.t("general.saturday"),
|
||||||
|
value: 6,
|
||||||
|
},
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="css">
|
<style lang="css">
|
||||||
|
|
|
@ -147,7 +147,7 @@
|
||||||
:model-value="field.value"
|
:model-value="field.value"
|
||||||
type="number"
|
type="number"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
@:model-value="setFieldValue(field, index, $event)"
|
@update:model-value="setFieldValue(field, index, $event)"
|
||||||
/>
|
/>
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-else-if="field.type === 'boolean'"
|
v-else-if="field.type === 'boolean'"
|
||||||
|
@ -163,14 +163,14 @@
|
||||||
max-width="290px"
|
max-width="290px"
|
||||||
min-width="auto"
|
min-width="auto"
|
||||||
>
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
:prepend-icon="$globals.icons.calendar"
|
:prepend-icon="$globals.icons.calendar"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
v-bind="props"
|
v-bind="activatorProps"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
@ -184,53 +184,53 @@
|
||||||
</v-menu>
|
</v-menu>
|
||||||
<RecipeOrganizerSelector
|
<RecipeOrganizerSelector
|
||||||
v-else-if="field.type === Organizer.Category"
|
v-else-if="field.type === Organizer.Category"
|
||||||
:model-value="field.organizers"
|
v-model="field.organizers"
|
||||||
:selector-type="Organizer.Category"
|
:selector-type="Organizer.Category"
|
||||||
:show-add="false"
|
:show-add="false"
|
||||||
:show-label="false"
|
:show-label="false"
|
||||||
:show-icon="false"
|
:show-icon="false"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
@update:model-value="setOrganizerValues(field, index, $event)"
|
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||||
/>
|
/>
|
||||||
<RecipeOrganizerSelector
|
<RecipeOrganizerSelector
|
||||||
v-else-if="field.type === Organizer.Tag"
|
v-else-if="field.type === Organizer.Tag"
|
||||||
:model-value="field.organizers"
|
v-model="field.organizers"
|
||||||
:selector-type="Organizer.Tag"
|
:selector-type="Organizer.Tag"
|
||||||
:show-add="false"
|
:show-add="false"
|
||||||
:show-label="false"
|
:show-label="false"
|
||||||
:show-icon="false"
|
:show-icon="false"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
@update:model-value="setOrganizerValues(field, index, $event)"
|
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||||
/>
|
/>
|
||||||
<RecipeOrganizerSelector
|
<RecipeOrganizerSelector
|
||||||
v-else-if="field.type === Organizer.Tool"
|
v-else-if="field.type === Organizer.Tool"
|
||||||
:model-value="field.organizers"
|
v-model="field.organizers"
|
||||||
:selector-type="Organizer.Tool"
|
:selector-type="Organizer.Tool"
|
||||||
:show-add="false"
|
:show-add="false"
|
||||||
:show-label="false"
|
:show-label="false"
|
||||||
:show-icon="false"
|
:show-icon="false"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
@update:model-value="setOrganizerValues(field, index, $event)"
|
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||||
/>
|
/>
|
||||||
<RecipeOrganizerSelector
|
<RecipeOrganizerSelector
|
||||||
v-else-if="field.type === Organizer.Food"
|
v-else-if="field.type === Organizer.Food"
|
||||||
:model-value="field.organizers"
|
v-model="field.organizers"
|
||||||
:selector-type="Organizer.Food"
|
:selector-type="Organizer.Food"
|
||||||
:show-add="false"
|
:show-add="false"
|
||||||
:show-label="false"
|
:show-label="false"
|
||||||
:show-icon="false"
|
:show-icon="false"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
@update:model-value="setOrganizerValues(field, index, $event)"
|
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||||
/>
|
/>
|
||||||
<RecipeOrganizerSelector
|
<RecipeOrganizerSelector
|
||||||
v-else-if="field.type === Organizer.Household"
|
v-else-if="field.type === Organizer.Household"
|
||||||
:model-value="field.organizers"
|
v-model="field.organizers"
|
||||||
:selector-type="Organizer.Household"
|
:selector-type="Organizer.Household"
|
||||||
:show-add="false"
|
:show-add="false"
|
||||||
:show-label="false"
|
:show-label="false"
|
||||||
:show-icon="false"
|
:show-icon="false"
|
||||||
variant="underlined"
|
variant="underlined"
|
||||||
@update:model-value="setOrganizerValues(field, index, $event)"
|
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<!-- right parenthesis -->
|
<!-- right parenthesis -->
|
||||||
|
@ -297,7 +297,7 @@
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { VueDraggable } from "vue-draggable-plus";
|
import { VueDraggable } from "vue-draggable-plus";
|
||||||
import { useDebounceFn } from "@vueuse/core";
|
import { useDebounceFn } from "@vueuse/core";
|
||||||
import { useHouseholdSelf } from "~/composables/use-households";
|
import { useHouseholdSelf } from "~/composables/use-households";
|
||||||
|
@ -307,365 +307,344 @@ import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalK
|
||||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||||
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
const props = defineProps({
|
||||||
components: {
|
fieldDefs: {
|
||||||
VueDraggable,
|
type: Array as () => FieldDefinition[],
|
||||||
RecipeOrganizerSelector,
|
required: true,
|
||||||
},
|
},
|
||||||
props: {
|
initialQueryFilter: {
|
||||||
fieldDefs: {
|
type: Object as () => QueryFilterJSON | null,
|
||||||
type: Array as () => FieldDefinition[],
|
default: null,
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
initialQueryFilter: {
|
|
||||||
type: Object as () => QueryFilterJSON | null,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
emits: ["input", "inputJSON"],
|
});
|
||||||
setup(props, context) {
|
|
||||||
const { household } = useHouseholdSelf();
|
|
||||||
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
|
|
||||||
|
|
||||||
const firstDayOfWeek = computed(() => {
|
const emit = defineEmits<{
|
||||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
(event: "input", value: string | undefined): void;
|
||||||
});
|
(event: "inputJSON", value: QueryFilterJSON | undefined): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
const state = reactive({
|
const { household } = useHouseholdSelf();
|
||||||
showAdvanced: false,
|
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
|
||||||
qfValid: false,
|
|
||||||
datePickers: [] as boolean[],
|
|
||||||
drag: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const storeMap = {
|
const firstDayOfWeek = computed(() => {
|
||||||
[Organizer.Category]: useCategoryStore(),
|
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||||
[Organizer.Tag]: useTagStore(),
|
});
|
||||||
[Organizer.Tool]: useToolStore(),
|
|
||||||
[Organizer.Food]: useFoodStore(),
|
|
||||||
[Organizer.Household]: useHouseholdStore(),
|
|
||||||
};
|
|
||||||
|
|
||||||
function onDragEnd(event: any) {
|
const state = reactive({
|
||||||
state.drag = false;
|
showAdvanced: false,
|
||||||
|
qfValid: false,
|
||||||
|
datePickers: [] as boolean[],
|
||||||
|
drag: false,
|
||||||
|
});
|
||||||
|
const { showAdvanced, datePickers, drag } = toRefs(state);
|
||||||
|
|
||||||
const oldIndex: number = event.oldIndex;
|
const storeMap = {
|
||||||
const newIndex: number = event.newIndex;
|
[Organizer.Category]: useCategoryStore(),
|
||||||
state.datePickers[oldIndex] = false;
|
[Organizer.Tag]: useTagStore(),
|
||||||
state.datePickers[newIndex] = false;
|
[Organizer.Tool]: useToolStore(),
|
||||||
|
[Organizer.Food]: useFoodStore(),
|
||||||
|
[Organizer.Household]: useHouseholdStore(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function onDragEnd(event: any) {
|
||||||
|
state.drag = false;
|
||||||
|
|
||||||
|
const oldIndex: number = event.oldIndex;
|
||||||
|
const newIndex: number = event.newIndex;
|
||||||
|
state.datePickers[oldIndex] = false;
|
||||||
|
state.datePickers[newIndex] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// add id to fields to prevent reactivity issues
|
||||||
|
type FieldWithId = Field & { id: number };
|
||||||
|
const fields = ref<FieldWithId[]>([]);
|
||||||
|
|
||||||
|
const uid = ref(1); // init uid to pass to fields
|
||||||
|
function useUid() {
|
||||||
|
return uid.value++;
|
||||||
|
}
|
||||||
|
function addField(field: FieldDefinition) {
|
||||||
|
fields.value.push({
|
||||||
|
...getFieldFromFieldDef(field),
|
||||||
|
id: useUid(),
|
||||||
|
});
|
||||||
|
state.datePickers.push(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setField(index: number, fieldLabel: string) {
|
||||||
|
state.datePickers[index] = false;
|
||||||
|
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.label === fieldLabel);
|
||||||
|
if (!fieldDef) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions);
|
||||||
|
const updatedField = { ...fields.value[index], ...fieldDef };
|
||||||
|
|
||||||
|
// we have to set this explicitly since it might be undefined
|
||||||
|
updatedField.fieldOptions = fieldDef.fieldOptions;
|
||||||
|
|
||||||
|
fields.value[index] = {
|
||||||
|
...getFieldFromFieldDef(updatedField, resetValue),
|
||||||
|
id: fields.value[index].id, // keep the id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
|
||||||
|
fields.value[index].leftParenthesis = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRightParenthesisValue(field: FieldWithId, index: number, value: string) {
|
||||||
|
fields.value[index].rightParenthesis = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLogicalOperatorValue(field: FieldWithId, index: number, value: LogicalOperator | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
value = logOps.value.AND.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.value[index].logicalOperator = value ? logOps.value[value] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
|
||||||
|
fields.value[index].relationalOperatorValue = relOps.value[value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
|
||||||
|
state.datePickers[index] = false;
|
||||||
|
fields.value[index].value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
|
||||||
|
fields.value[index].values = values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFieldOrganizers(field: FieldWithId, index: number, organizers: OrganizerBase[]) {
|
||||||
|
fields.value[index].organizers = organizers;
|
||||||
|
// Sync the values array with the organizers array
|
||||||
|
fields.value[index].values = organizers.map(org => org.id?.toString() || "").filter(id => id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeField(index: number) {
|
||||||
|
fields.value.splice(index, 1);
|
||||||
|
state.datePickers.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
|
||||||
|
/* newFields.forEach((field, index) => {
|
||||||
|
const updatedField = getFieldFromFieldDef(field);
|
||||||
|
fields.value[index] = updatedField; // recursive!!!
|
||||||
|
}); */
|
||||||
|
|
||||||
|
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
||||||
|
if (qf) {
|
||||||
|
console.debug(`Set query filter: ${qf}`);
|
||||||
|
}
|
||||||
|
state.qfValid = !!qf;
|
||||||
|
|
||||||
|
emit("input", qf || undefined);
|
||||||
|
emit("inputJSON", qf ? buildQueryFilterJSON() : undefined);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
watch(fields, fieldsUpdater, { deep: true });
|
||||||
|
|
||||||
|
async function hydrateOrganizers(field: FieldWithId, _index: number) {
|
||||||
|
if (!field.values?.length || !isOrganizerType(field.type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { store, actions } = storeMap[field.type];
|
||||||
|
if (!store.value.length) {
|
||||||
|
await actions.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizers = field.values.map((value) => {
|
||||||
|
const organizer = store.value.find(item => item?.id?.toString() === value);
|
||||||
|
if (!organizer) {
|
||||||
|
console.error(`Could not find organizer with id ${value}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return organizer;
|
||||||
|
});
|
||||||
|
|
||||||
|
field.organizers = organizers.filter(organizer => organizer !== undefined) as OrganizerBase[];
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFieldsError(error = "") {
|
||||||
|
if (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.value = [];
|
||||||
|
if (props.fieldDefs.length) {
|
||||||
|
addField(props.fieldDefs[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeFields() {
|
||||||
|
if (!props.initialQueryFilter?.parts?.length) {
|
||||||
|
return initFieldsError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const initFields: FieldWithId[] = [];
|
||||||
|
let error = false;
|
||||||
|
|
||||||
|
for (const [index, part] of props.initialQueryFilter.parts.entries()) {
|
||||||
|
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.name === part.attributeName);
|
||||||
|
if (!fieldDef) {
|
||||||
|
error = true;
|
||||||
|
return initFieldsError(`Invalid query filter; unknown attribute name "${part.attributeName || ""}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// add id to fields to prevent reactivity issues
|
const field: FieldWithId = {
|
||||||
type FieldWithId = Field & { id: number };
|
...getFieldFromFieldDef(fieldDef),
|
||||||
const fields = ref<FieldWithId[]>([]);
|
id: useUid(),
|
||||||
|
};
|
||||||
|
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
|
||||||
|
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
|
||||||
|
field.logicalOperator = part.logicalOperator
|
||||||
|
? logOps.value[part.logicalOperator]
|
||||||
|
: field.logicalOperator;
|
||||||
|
field.relationalOperatorValue = part.relationalOperator
|
||||||
|
? relOps.value[part.relationalOperator]
|
||||||
|
: field.relationalOperatorValue;
|
||||||
|
|
||||||
const uid = ref(1); // init uid to pass to fields
|
if (field.leftParenthesis || field.rightParenthesis) {
|
||||||
function useUid() {
|
state.showAdvanced = true;
|
||||||
return uid.value++;
|
}
|
||||||
}
|
|
||||||
function addField(field: FieldDefinition) {
|
|
||||||
fields.value.push({
|
|
||||||
...getFieldFromFieldDef(field),
|
|
||||||
id: useUid(),
|
|
||||||
});
|
|
||||||
state.datePickers.push(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
function setField(index: number, fieldLabel: string) {
|
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||||
state.datePickers[index] = false;
|
if (typeof part.value === "string") {
|
||||||
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.label === fieldLabel);
|
field.values = part.value ? [part.value] : [];
|
||||||
if (!fieldDef) {
|
}
|
||||||
return;
|
else {
|
||||||
}
|
field.values = part.value || [];
|
||||||
|
}
|
||||||
|
|
||||||
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions);
|
if (isOrganizerType(field.type)) {
|
||||||
const updatedField = { ...fields.value[index], ...fieldDef };
|
await hydrateOrganizers(field, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (field.type === "boolean") {
|
||||||
|
const boolString = part.value || "false";
|
||||||
|
field.value = (
|
||||||
|
boolString[0].toLowerCase() === "t"
|
||||||
|
|| boolString[0].toLowerCase() === "y"
|
||||||
|
|| boolString[0] === "1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else if (field.type === "number") {
|
||||||
|
field.value = Number(part.value as string || "0");
|
||||||
|
if (isNaN(field.value)) {
|
||||||
|
error = true;
|
||||||
|
return initFieldsError(`Invalid query filter; invalid number value "${(part.value || "").toString()}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (field.type === "date") {
|
||||||
|
field.value = part.value as string || "";
|
||||||
|
const date = new Date(field.value);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
error = true;
|
||||||
|
return initFieldsError(`Invalid query filter; invalid date value "${(part.value || "").toString()}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
field.value = part.value as string || "";
|
||||||
|
}
|
||||||
|
|
||||||
// we have to set this explicitly since it might be undefined
|
initFields.push(field);
|
||||||
updatedField.fieldOptions = fieldDef.fieldOptions;
|
}
|
||||||
|
|
||||||
fields.value[index] = {
|
if (initFields.length && !error) {
|
||||||
...getFieldFromFieldDef(updatedField, resetValue),
|
fields.value = initFields;
|
||||||
id: fields.value[index].id, // keep the id
|
}
|
||||||
};
|
else {
|
||||||
}
|
initFieldsError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
|
onMounted(async () => {
|
||||||
fields.value[index].leftParenthesis = value;
|
try {
|
||||||
}
|
await initializeFields();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function setRightParenthesisValue(field: FieldWithId, index: number, value: string) {
|
function buildQueryFilterJSON(): QueryFilterJSON {
|
||||||
fields.value[index].rightParenthesis = value;
|
const parts = fields.value.map((field) => {
|
||||||
}
|
const part: QueryFilterJSONPart = {
|
||||||
|
attributeName: field.name,
|
||||||
|
leftParenthesis: field.leftParenthesis,
|
||||||
|
rightParenthesis: field.rightParenthesis,
|
||||||
|
logicalOperator: field.logicalOperator?.value,
|
||||||
|
relationalOperator: field.relationalOperatorValue?.value,
|
||||||
|
};
|
||||||
|
|
||||||
function setLogicalOperatorValue(field: FieldWithId, index: number, value: LogicalOperator | undefined) {
|
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||||
if (!value) {
|
part.value = field.values.map(value => value.toString());
|
||||||
value = logOps.value.AND.value;
|
}
|
||||||
}
|
else if (field.type === "boolean") {
|
||||||
|
part.value = field.value ? "true" : "false";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
part.value = (field.value || "").toString();
|
||||||
|
}
|
||||||
|
|
||||||
fields.value[index].logicalOperator = value ? logOps.value[value] : undefined;
|
return part;
|
||||||
}
|
});
|
||||||
|
|
||||||
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
|
const qfJSON = { parts } as QueryFilterJSON;
|
||||||
fields.value[index].relationalOperatorValue = relOps.value[value];
|
console.debug(`Built query filter JSON: ${JSON.stringify(qfJSON)}`);
|
||||||
}
|
return qfJSON;
|
||||||
|
}
|
||||||
|
|
||||||
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
|
const config = computed(() => {
|
||||||
state.datePickers[index] = false;
|
const baseColMaxWidth = 55;
|
||||||
fields.value[index].value = value;
|
return {
|
||||||
}
|
col: {
|
||||||
|
class: "d-flex justify-center align-end field-col pa-1",
|
||||||
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
|
},
|
||||||
fields.value[index].values = values;
|
select: {
|
||||||
}
|
textClass: "d-flex justify-center text-center",
|
||||||
|
},
|
||||||
function setOrganizerValues(field: FieldWithId, index: number, values: OrganizerBase[]) {
|
items: {
|
||||||
setFieldValues(field, index, values.map(value => value.id.toString()));
|
icon: {
|
||||||
fields.value[index].organizers = values;
|
cols: 1,
|
||||||
}
|
style: "width: fit-content;",
|
||||||
|
},
|
||||||
function removeField(index: number) {
|
leftParens: {
|
||||||
fields.value.splice(index, 1);
|
cols: state.showAdvanced ? 1 : 0,
|
||||||
state.datePickers.splice(index, 1);
|
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
||||||
};
|
},
|
||||||
|
logicalOperator: {
|
||||||
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
|
cols: 1,
|
||||||
/* newFields.forEach((field, index) => {
|
style: `min-width: ${baseColMaxWidth}px;`,
|
||||||
const updatedField = getFieldFromFieldDef(field);
|
},
|
||||||
fields.value[index] = updatedField; // recursive!!!
|
fieldName: {
|
||||||
}); */
|
cols: state.showAdvanced ? 2 : 3,
|
||||||
|
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
||||||
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
},
|
||||||
if (qf) {
|
relationalOperator: {
|
||||||
console.debug(`Set query filter: ${qf}`);
|
cols: 2,
|
||||||
}
|
style: `min-width: ${baseColMaxWidth * 2}px;`,
|
||||||
state.qfValid = !!qf;
|
},
|
||||||
|
fieldValue: {
|
||||||
context.emit("input", qf || undefined);
|
cols: state.showAdvanced ? 3 : 4,
|
||||||
context.emit("inputJSON", qf ? buildQueryFilterJSON() : undefined);
|
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
||||||
}, 500);
|
},
|
||||||
|
rightParens: {
|
||||||
watch(fields, fieldsUpdater, { deep: true });
|
cols: state.showAdvanced ? 1 : 0,
|
||||||
|
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
||||||
async function hydrateOrganizers(field: FieldWithId, index: number) {
|
},
|
||||||
if (!field.values?.length || !isOrganizerType(field.type)) {
|
fieldActions: {
|
||||||
return;
|
cols: 1,
|
||||||
}
|
style: `min-width: ${baseColMaxWidth}px;`,
|
||||||
|
},
|
||||||
field.organizers = [];
|
},
|
||||||
|
};
|
||||||
const { store, actions } = storeMap[field.type];
|
|
||||||
if (!store.value.length) {
|
|
||||||
await actions.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
const organizers = field.values.map((value) => {
|
|
||||||
const organizer = store.value.find(item => item?.id?.toString() === value);
|
|
||||||
if (!organizer) {
|
|
||||||
console.error(`Could not find organizer with id ${value}`);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return organizer;
|
|
||||||
});
|
|
||||||
field.organizers = organizers.filter(organizer => organizer !== undefined) as OrganizerBase[];
|
|
||||||
setOrganizerValues(field, index, field.organizers);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initFieldsError(error = "") {
|
|
||||||
if (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
fields.value = [];
|
|
||||||
if (props.fieldDefs.length) {
|
|
||||||
addField(props.fieldDefs[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initializeFields() {
|
|
||||||
if (!props.initialQueryFilter?.parts?.length) {
|
|
||||||
return initFieldsError();
|
|
||||||
};
|
|
||||||
|
|
||||||
const initFields: FieldWithId[] = [];
|
|
||||||
let error = false;
|
|
||||||
props.initialQueryFilter.parts.forEach((part: QueryFilterJSONPart, index: number) => {
|
|
||||||
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.name === part.attributeName);
|
|
||||||
if (!fieldDef) {
|
|
||||||
error = true;
|
|
||||||
return initFieldsError(`Invalid query filter; unknown attribute name "${part.attributeName || ""}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const field: FieldWithId = {
|
|
||||||
...getFieldFromFieldDef(fieldDef),
|
|
||||||
id: useUid(),
|
|
||||||
};
|
|
||||||
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
|
|
||||||
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
|
|
||||||
field.logicalOperator = part.logicalOperator
|
|
||||||
? logOps.value[part.logicalOperator]
|
|
||||||
: field.logicalOperator;
|
|
||||||
field.relationalOperatorValue = part.relationalOperator
|
|
||||||
? relOps.value[part.relationalOperator]
|
|
||||||
: field.relationalOperatorValue;
|
|
||||||
|
|
||||||
if (field.leftParenthesis || field.rightParenthesis) {
|
|
||||||
state.showAdvanced = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
|
||||||
if (typeof part.value === "string") {
|
|
||||||
field.values = part.value ? [part.value] : [];
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
field.values = part.value || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOrganizerType(field.type)) {
|
|
||||||
hydrateOrganizers(field, index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (field.type === "boolean") {
|
|
||||||
const boolString = part.value || "false";
|
|
||||||
field.value = (
|
|
||||||
boolString[0].toLowerCase() === "t"
|
|
||||||
|| boolString[0].toLowerCase() === "y"
|
|
||||||
|| boolString[0] === "1"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
else if (field.type === "number") {
|
|
||||||
field.value = Number(part.value as string || "0");
|
|
||||||
if (isNaN(field.value)) {
|
|
||||||
error = true;
|
|
||||||
return initFieldsError(`Invalid query filter; invalid number value "${(part.value || "").toString()}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (field.type === "date") {
|
|
||||||
field.value = part.value as string || "";
|
|
||||||
const date = new Date(field.value);
|
|
||||||
if (isNaN(date.getTime())) {
|
|
||||||
error = true;
|
|
||||||
return initFieldsError(`Invalid query filter; invalid date value "${(part.value || "").toString()}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
field.value = part.value as string || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
initFields.push(field);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (initFields.length && !error) {
|
|
||||||
fields.value = initFields;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
initFieldsError();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
initializeFields();
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildQueryFilterJSON(): QueryFilterJSON {
|
|
||||||
const parts = fields.value.map((field) => {
|
|
||||||
const part: QueryFilterJSONPart = {
|
|
||||||
attributeName: field.name,
|
|
||||||
leftParenthesis: field.leftParenthesis,
|
|
||||||
rightParenthesis: field.rightParenthesis,
|
|
||||||
logicalOperator: field.logicalOperator?.value,
|
|
||||||
relationalOperator: field.relationalOperatorValue?.value,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
|
||||||
part.value = field.values.map(value => value.toString());
|
|
||||||
}
|
|
||||||
else if (field.type === "boolean") {
|
|
||||||
part.value = field.value ? "true" : "false";
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
part.value = (field.value || "").toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return part;
|
|
||||||
});
|
|
||||||
|
|
||||||
const qfJSON = { parts } as QueryFilterJSON;
|
|
||||||
console.debug(`Built query filter JSON: ${JSON.stringify(qfJSON)}`);
|
|
||||||
return qfJSON;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = computed(() => {
|
|
||||||
const baseColMaxWidth = 55;
|
|
||||||
return {
|
|
||||||
col: {
|
|
||||||
class: "d-flex justify-center align-end field-col pa-1",
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
textClass: "d-flex justify-center text-center",
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
icon: {
|
|
||||||
cols: 1,
|
|
||||||
style: "width: fit-content;",
|
|
||||||
},
|
|
||||||
leftParens: {
|
|
||||||
cols: state.showAdvanced ? 1 : 0,
|
|
||||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
|
||||||
},
|
|
||||||
logicalOperator: {
|
|
||||||
cols: 1,
|
|
||||||
style: `min-width: ${baseColMaxWidth}px;`,
|
|
||||||
},
|
|
||||||
fieldName: {
|
|
||||||
cols: state.showAdvanced ? 2 : 3,
|
|
||||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
|
||||||
},
|
|
||||||
relationalOperator: {
|
|
||||||
cols: 2,
|
|
||||||
style: `min-width: ${baseColMaxWidth * 2}px;`,
|
|
||||||
},
|
|
||||||
fieldValue: {
|
|
||||||
cols: state.showAdvanced ? 3 : 4,
|
|
||||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
|
||||||
},
|
|
||||||
rightParens: {
|
|
||||||
cols: state.showAdvanced ? 1 : 0,
|
|
||||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
|
||||||
},
|
|
||||||
fieldActions: {
|
|
||||||
cols: 1,
|
|
||||||
style: `min-width: ${baseColMaxWidth}px;`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
Organizer,
|
|
||||||
...toRefs(state),
|
|
||||||
logOps,
|
|
||||||
relOps,
|
|
||||||
config,
|
|
||||||
firstDayOfWeek,
|
|
||||||
onDragEnd,
|
|
||||||
// Fields
|
|
||||||
fields,
|
|
||||||
addField,
|
|
||||||
setField,
|
|
||||||
setLeftParenthesisValue,
|
|
||||||
setRightParenthesisValue,
|
|
||||||
setLogicalOperatorValue,
|
|
||||||
setRelationalOperatorValue,
|
|
||||||
setFieldValue,
|
|
||||||
setFieldValues,
|
|
||||||
setOrganizerValues,
|
|
||||||
removeField,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -17,8 +17,8 @@
|
||||||
<RecipeFavoriteBadge v-if="loggedIn" color="info" button-style :recipe-id="recipe.id!" show-always />
|
<RecipeFavoriteBadge v-if="loggedIn" color="info" button-style :recipe-id="recipe.id!" show-always />
|
||||||
<RecipeTimelineBadge v-if="loggedIn" class="ml-1" color="info" button-style :slug="recipe.slug" :recipe-name="recipe.name!" />
|
<RecipeTimelineBadge v-if="loggedIn" class="ml-1" color="info" button-style :slug="recipe.slug" :recipe-name="recipe.name!" />
|
||||||
<div v-if="loggedIn">
|
<div v-if="loggedIn">
|
||||||
<v-tooltip v-if="canEdit" bottom color="info">
|
<v-tooltip v-if="canEdit" location="bottom" color="info">
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props: tooltipProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
icon
|
icon
|
||||||
variant="flat"
|
variant="flat"
|
||||||
|
@ -26,7 +26,7 @@
|
||||||
size="small"
|
size="small"
|
||||||
color="info"
|
color="info"
|
||||||
class="ml-1"
|
class="ml-1"
|
||||||
v-bind="props"
|
v-bind="tooltipProps"
|
||||||
@click="$emit('edit', true)"
|
@click="$emit('edit', true)"
|
||||||
>
|
>
|
||||||
<v-icon size="x-large">
|
<v-icon size="x-large">
|
||||||
|
@ -86,7 +86,7 @@
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||||
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
|
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
|
||||||
|
@ -97,103 +97,75 @@ const DELETE_EVENT = "delete";
|
||||||
const CLOSE_EVENT = "close";
|
const CLOSE_EVENT = "close";
|
||||||
const JSON_EVENT = "json";
|
const JSON_EVENT = "json";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge },
|
recipe: Recipe;
|
||||||
props: {
|
slug: string;
|
||||||
recipe: {
|
recipeScale?: number;
|
||||||
required: true,
|
open: boolean;
|
||||||
type: Object as () => Recipe,
|
name: string;
|
||||||
},
|
loggedIn?: boolean;
|
||||||
slug: {
|
recipeId: string;
|
||||||
required: true,
|
canEdit?: boolean;
|
||||||
type: String,
|
}
|
||||||
},
|
withDefaults(defineProps<Props>(), {
|
||||||
recipeScale: {
|
recipeScale: 1,
|
||||||
type: Number,
|
loggedIn: false,
|
||||||
default: 1,
|
canEdit: false,
|
||||||
},
|
|
||||||
open: {
|
|
||||||
required: true,
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
required: true,
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
loggedIn: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
recipeId: {
|
|
||||||
required: true,
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
canEdit: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["print", "input", "delete", "close", "edit"],
|
|
||||||
setup(_, context) {
|
|
||||||
const deleteDialog = ref(false);
|
|
||||||
|
|
||||||
const i18n = useI18n();
|
|
||||||
const { $globals } = useNuxtApp();
|
|
||||||
const editorButtons = [
|
|
||||||
{
|
|
||||||
text: i18n.t("general.delete"),
|
|
||||||
icon: $globals.icons.delete,
|
|
||||||
event: DELETE_EVENT,
|
|
||||||
color: "error",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: i18n.t("general.json"),
|
|
||||||
icon: $globals.icons.codeBraces,
|
|
||||||
event: JSON_EVENT,
|
|
||||||
color: "accent",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: i18n.t("general.close"),
|
|
||||||
icon: $globals.icons.close,
|
|
||||||
event: CLOSE_EVENT,
|
|
||||||
color: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: i18n.t("general.save"),
|
|
||||||
icon: $globals.icons.save,
|
|
||||||
event: SAVE_EVENT,
|
|
||||||
color: "success",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function emitHandler(event: string) {
|
|
||||||
switch (event) {
|
|
||||||
case CLOSE_EVENT:
|
|
||||||
context.emit(CLOSE_EVENT);
|
|
||||||
context.emit("input", false);
|
|
||||||
break;
|
|
||||||
case DELETE_EVENT:
|
|
||||||
deleteDialog.value = true;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
context.emit(event);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitDelete() {
|
|
||||||
context.emit(DELETE_EVENT);
|
|
||||||
context.emit("input", false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
deleteDialog,
|
|
||||||
editorButtons,
|
|
||||||
emitHandler,
|
|
||||||
emitDelete,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["print", "input", "delete", "close", "edit"]);
|
||||||
|
|
||||||
|
const deleteDialog = ref(false);
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
|
|
||||||
|
const editorButtons = [
|
||||||
|
{
|
||||||
|
text: i18n.t("general.delete"),
|
||||||
|
icon: $globals.icons.delete,
|
||||||
|
event: DELETE_EVENT,
|
||||||
|
color: "error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: i18n.t("general.json"),
|
||||||
|
icon: $globals.icons.codeBraces,
|
||||||
|
event: JSON_EVENT,
|
||||||
|
color: "accent",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: i18n.t("general.close"),
|
||||||
|
icon: $globals.icons.close,
|
||||||
|
event: CLOSE_EVENT,
|
||||||
|
color: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: i18n.t("general.save"),
|
||||||
|
icon: $globals.icons.save,
|
||||||
|
event: SAVE_EVENT,
|
||||||
|
color: "success",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function emitHandler(event: string) {
|
||||||
|
switch (event) {
|
||||||
|
case CLOSE_EVENT:
|
||||||
|
emit("close");
|
||||||
|
emit("input", false);
|
||||||
|
break;
|
||||||
|
case DELETE_EVENT:
|
||||||
|
deleteDialog.value = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
emit(event as any);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitDelete() {
|
||||||
|
emit("delete");
|
||||||
|
emit("input", false);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<div class="ma-auto">
|
<div class="ma-auto">
|
||||||
<v-tooltip bottom>
|
<v-tooltip location="bottom">
|
||||||
<template #activator="{ props: tooltipProps }">
|
<template #activator="{ props: tooltipProps }">
|
||||||
<v-icon v-bind="tooltipProps">
|
<v-icon v-bind="tooltipProps">
|
||||||
{{ getIconDefinition(item.icon).icon }}
|
{{ getIconDefinition(item.icon).icon }}
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<!-- Wrap v-hover with a div to provide a proper DOM element for the transition -->
|
<!-- Wrap v-hover with a div to provide a proper DOM element for the transition -->
|
||||||
<v-lazy>
|
|
||||||
<div>
|
<div>
|
||||||
<v-hover
|
<v-hover
|
||||||
v-slot="{ isHovering, props }"
|
v-slot="{ isHovering, props: hoverProps }"
|
||||||
:open-delay="50"
|
:open-delay="50"
|
||||||
>
|
>
|
||||||
<v-card
|
<v-card
|
||||||
v-bind="props"
|
v-bind="hoverProps"
|
||||||
:class="{ 'on-hover': isHovering }"
|
:class="{ 'on-hover': isHovering }"
|
||||||
:style="{ cursor }"
|
:style="{ cursor }"
|
||||||
:elevation="isHovering ? 12 : 2"
|
:elevation="isHovering ? 12 : 2"
|
||||||
|
@ -99,10 +98,9 @@
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-hover>
|
</v-hover>
|
||||||
</div>
|
</div>
|
||||||
</v-lazy>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||||
import RecipeChips from "./RecipeChips.vue";
|
import RecipeChips from "./RecipeChips.vue";
|
||||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||||
|
@ -110,69 +108,41 @@ import RecipeCardImage from "./RecipeCardImage.vue";
|
||||||
import RecipeRating from "./RecipeRating.vue";
|
import RecipeRating from "./RecipeRating.vue";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage },
|
name: string;
|
||||||
props: {
|
slug: string;
|
||||||
name: {
|
description?: string | null;
|
||||||
type: String,
|
rating?: number;
|
||||||
required: true,
|
ratingColor?: string;
|
||||||
},
|
image?: string;
|
||||||
slug: {
|
tags?: Array<any>;
|
||||||
type: String,
|
recipeId: string;
|
||||||
required: true,
|
imageHeight?: number;
|
||||||
},
|
}
|
||||||
description: {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
type: String,
|
description: null,
|
||||||
default: null,
|
rating: 0,
|
||||||
},
|
ratingColor: "secondary",
|
||||||
rating: {
|
image: "abc123",
|
||||||
type: Number,
|
tags: () => [],
|
||||||
required: false,
|
imageHeight: 200,
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
ratingColor: {
|
|
||||||
type: String,
|
|
||||||
default: "secondary",
|
|
||||||
},
|
|
||||||
image: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
default: "abc123",
|
|
||||||
},
|
|
||||||
tags: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
recipeId: {
|
|
||||||
required: true,
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
imageHeight: {
|
|
||||||
type: Number,
|
|
||||||
default: 200,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["click", "delete"],
|
|
||||||
setup(props) {
|
|
||||||
const $auth = useMealieAuth();
|
|
||||||
const { isOwnGroup } = useLoggedInState();
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
|
||||||
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
|
||||||
const recipeRoute = computed<string>(() => {
|
|
||||||
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
|
||||||
});
|
|
||||||
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
|
|
||||||
|
|
||||||
return {
|
|
||||||
isOwnGroup,
|
|
||||||
recipeRoute,
|
|
||||||
showRecipeContent,
|
|
||||||
cursor,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
click: [];
|
||||||
|
delete: [slug: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||||
|
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
||||||
|
const recipeRoute = computed<string>(() => {
|
||||||
|
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||||
|
});
|
||||||
|
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
@ -197,6 +167,7 @@ export default defineNuxtComponent({
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 8;
|
-webkit-line-clamp: 8;
|
||||||
|
line-clamp: 8;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -28,84 +28,60 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
import { useStaticRoutes } from "~/composables/api";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
props: {
|
tiny?: boolean | null;
|
||||||
tiny: {
|
small?: boolean | null;
|
||||||
type: Boolean,
|
large?: boolean | null;
|
||||||
default: null,
|
iconSize?: number | string;
|
||||||
},
|
slug?: string | null;
|
||||||
small: {
|
recipeId: string;
|
||||||
type: Boolean,
|
imageVersion?: string | null;
|
||||||
default: null,
|
height?: number | string;
|
||||||
},
|
}
|
||||||
large: {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
type: Boolean,
|
tiny: null,
|
||||||
default: null,
|
small: null,
|
||||||
},
|
large: null,
|
||||||
iconSize: {
|
iconSize: 100,
|
||||||
type: [Number, String],
|
slug: null,
|
||||||
default: 100,
|
imageVersion: null,
|
||||||
},
|
height: "100%",
|
||||||
slug: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
recipeId: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
imageVersion: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
height: {
|
|
||||||
type: [Number, String],
|
|
||||||
default: "100%",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["click"],
|
|
||||||
setup(props) {
|
|
||||||
const api = useUserApi();
|
|
||||||
|
|
||||||
const { recipeImage, recipeSmallImage, recipeTinyImage } = useStaticRoutes();
|
|
||||||
|
|
||||||
const fallBackImage = ref(false);
|
|
||||||
const imageSize = computed(() => {
|
|
||||||
if (props.tiny) return "tiny";
|
|
||||||
if (props.small) return "small";
|
|
||||||
if (props.large) return "large";
|
|
||||||
return "large";
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.recipeId,
|
|
||||||
() => {
|
|
||||||
fallBackImage.value = false;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
function getImage(recipeId: string) {
|
|
||||||
switch (imageSize.value) {
|
|
||||||
case "tiny":
|
|
||||||
return recipeTinyImage(recipeId, props.imageVersion);
|
|
||||||
case "small":
|
|
||||||
return recipeSmallImage(recipeId, props.imageVersion);
|
|
||||||
case "large":
|
|
||||||
return recipeImage(recipeId, props.imageVersion);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
api,
|
|
||||||
fallBackImage,
|
|
||||||
imageSize,
|
|
||||||
getImage,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
click: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { recipeImage, recipeSmallImage, recipeTinyImage } = useStaticRoutes();
|
||||||
|
|
||||||
|
const fallBackImage = ref(false);
|
||||||
|
const imageSize = computed(() => {
|
||||||
|
if (props.tiny) return "tiny";
|
||||||
|
if (props.small) return "small";
|
||||||
|
if (props.large) return "large";
|
||||||
|
return "large";
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.recipeId,
|
||||||
|
() => {
|
||||||
|
fallBackImage.value = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function getImage(recipeId: string) {
|
||||||
|
switch (imageSize.value) {
|
||||||
|
case "tiny":
|
||||||
|
return recipeTinyImage(recipeId, props.imageVersion);
|
||||||
|
case "small":
|
||||||
|
return recipeSmallImage(recipeId, props.imageVersion);
|
||||||
|
case "large":
|
||||||
|
return recipeImage(recipeId, props.imageVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -3,7 +3,10 @@
|
||||||
<v-expand-transition>
|
<v-expand-transition>
|
||||||
<v-card
|
<v-card
|
||||||
:ripple="false"
|
:ripple="false"
|
||||||
:class="isFlat ? 'mx-auto flat' : 'mx-auto'"
|
:class="[
|
||||||
|
isFlat ? 'mx-auto flat' : 'mx-auto',
|
||||||
|
{ 'disable-highlight': disableHighlight },
|
||||||
|
]"
|
||||||
:style="{ cursor }"
|
:style="{ cursor }"
|
||||||
hover
|
hover
|
||||||
height="100%"
|
height="100%"
|
||||||
|
@ -123,7 +126,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||||
import RecipeCardImage from "./RecipeCardImage.vue";
|
import RecipeCardImage from "./RecipeCardImage.vue";
|
||||||
|
@ -131,78 +134,44 @@ import RecipeRating from "./RecipeRating.vue";
|
||||||
import RecipeChips from "./RecipeChips.vue";
|
import RecipeChips from "./RecipeChips.vue";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
components: {
|
name: string;
|
||||||
RecipeFavoriteBadge,
|
slug: string;
|
||||||
RecipeContextMenu,
|
description: string;
|
||||||
RecipeRating,
|
rating?: number;
|
||||||
RecipeCardImage,
|
image?: string;
|
||||||
RecipeChips,
|
tags?: Array<any>;
|
||||||
},
|
recipeId: string;
|
||||||
props: {
|
vertical?: boolean;
|
||||||
name: {
|
isFlat?: boolean;
|
||||||
type: String,
|
height?: number;
|
||||||
required: true,
|
disableHighlight?: boolean;
|
||||||
},
|
}
|
||||||
slug: {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
type: String,
|
rating: 0,
|
||||||
required: true,
|
image: "abc123",
|
||||||
},
|
tags: () => [],
|
||||||
description: {
|
vertical: false,
|
||||||
type: String,
|
isFlat: false,
|
||||||
required: true,
|
height: 150,
|
||||||
},
|
disableHighlight: false,
|
||||||
rating: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
image: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
default: "abc123",
|
|
||||||
},
|
|
||||||
tags: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
recipeId: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
vertical: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
isFlat: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
height: {
|
|
||||||
type: [Number],
|
|
||||||
default: 150,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["selected", "delete"],
|
|
||||||
setup(props) {
|
|
||||||
const $auth = useMealieAuth();
|
|
||||||
const { isOwnGroup } = useLoggedInState();
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
|
||||||
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
|
||||||
const recipeRoute = computed<string>(() => {
|
|
||||||
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
|
||||||
});
|
|
||||||
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
|
|
||||||
|
|
||||||
return {
|
|
||||||
isOwnGroup,
|
|
||||||
recipeRoute,
|
|
||||||
showRecipeContent,
|
|
||||||
cursor,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
selected: [];
|
||||||
|
delete: [slug: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||||
|
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
||||||
|
const recipeRoute = computed<string>(() => {
|
||||||
|
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||||
|
});
|
||||||
|
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@ -241,4 +210,8 @@ export default defineNuxtComponent({
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.disable-highlight :deep(.v-card__overlay) {
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -36,11 +36,11 @@
|
||||||
offset-y
|
offset-y
|
||||||
start
|
start
|
||||||
>
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
variant="text"
|
variant="text"
|
||||||
:icon="$vuetify.display.xs"
|
:icon="$vuetify.display.xs"
|
||||||
v-bind="props"
|
v-bind="activatorProps"
|
||||||
:loading="sortLoading"
|
:loading="sortLoading"
|
||||||
>
|
>
|
||||||
<v-icon :start="!$vuetify.display.xs">
|
<v-icon :start="!$vuetify.display.xs">
|
||||||
|
@ -162,7 +162,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { useThrottleFn } from "@vueuse/core";
|
import { useThrottleFn } from "@vueuse/core";
|
||||||
import RecipeCard from "./RecipeCard.vue";
|
import RecipeCard from "./RecipeCard.vue";
|
||||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||||
|
@ -175,273 +175,243 @@ import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
||||||
const REPLACE_RECIPES_EVENT = "replaceRecipes";
|
const REPLACE_RECIPES_EVENT = "replaceRecipes";
|
||||||
const APPEND_RECIPES_EVENT = "appendRecipes";
|
const APPEND_RECIPES_EVENT = "appendRecipes";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
components: {
|
disableToolbar?: boolean;
|
||||||
RecipeCard,
|
disableSort?: boolean;
|
||||||
RecipeCardMobile,
|
icon?: string | null;
|
||||||
},
|
title?: string | null;
|
||||||
props: {
|
singleColumn?: boolean;
|
||||||
disableToolbar: {
|
recipes?: Recipe[];
|
||||||
type: Boolean,
|
query?: RecipeSearchQuery | null;
|
||||||
default: false,
|
}
|
||||||
},
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
disableSort: {
|
disableToolbar: false,
|
||||||
type: Boolean,
|
disableSort: false,
|
||||||
default: false,
|
icon: null,
|
||||||
},
|
title: null,
|
||||||
icon: {
|
singleColumn: false,
|
||||||
type: String,
|
recipes: () => [],
|
||||||
default: null,
|
query: null,
|
||||||
},
|
});
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
singleColumn: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
recipes: {
|
|
||||||
type: Array as () => Recipe[],
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
type: Object as () => RecipeSearchQuery,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props, context) {
|
|
||||||
const { $vuetify } = useNuxtApp();
|
|
||||||
const preferences = useUserSortPreferences();
|
|
||||||
|
|
||||||
const EVENTS = {
|
const emit = defineEmits<{
|
||||||
az: "az",
|
replaceRecipes: [recipes: Recipe[]];
|
||||||
rating: "rating",
|
appendRecipes: [recipes: Recipe[]];
|
||||||
created: "created",
|
}>();
|
||||||
updated: "updated",
|
|
||||||
lastMade: "lastMade",
|
|
||||||
shuffle: "shuffle",
|
|
||||||
};
|
|
||||||
|
|
||||||
const $auth = useMealieAuth();
|
const { $vuetify } = useNuxtApp();
|
||||||
const { $globals } = useNuxtApp();
|
const preferences = useUserSortPreferences();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
|
||||||
const useMobileCards = computed(() => {
|
|
||||||
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayTitleIcon = computed(() => {
|
const EVENTS = {
|
||||||
return props.icon || $globals.icons.tags;
|
az: "az",
|
||||||
});
|
rating: "rating",
|
||||||
|
created: "created",
|
||||||
|
updated: "updated",
|
||||||
|
lastMade: "lastMade",
|
||||||
|
shuffle: "shuffle",
|
||||||
|
};
|
||||||
|
|
||||||
const state = reactive({
|
const $auth = useMealieAuth();
|
||||||
sortLoading: false,
|
const { $globals } = useNuxtApp();
|
||||||
});
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
const useMobileCards = computed(() => {
|
||||||
|
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
|
||||||
|
});
|
||||||
|
|
||||||
const route = useRoute();
|
const displayTitleIcon = computed(() => {
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
return props.icon || $globals.icons.tags;
|
||||||
|
});
|
||||||
|
|
||||||
const page = ref(1);
|
const sortLoading = ref(false);
|
||||||
const perPage = 32;
|
|
||||||
const hasMore = ref(true);
|
|
||||||
const ready = ref(false);
|
|
||||||
const loading = ref(false);
|
|
||||||
|
|
||||||
const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
const queryFilter = computed(() => {
|
const page = ref(1);
|
||||||
return props.query.queryFilter || null;
|
const perPage = 32;
|
||||||
|
const hasMore = ref(true);
|
||||||
|
const ready = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
// TODO: allow user to filter out null values when ordering by a value that may be null (such as lastMade)
|
const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// const orderBy = props.query?.orderBy || preferences.value.orderBy;
|
const queryFilter = computed(() => {
|
||||||
// const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
|
return props.query?.queryFilter || null;
|
||||||
|
|
||||||
// if (props.query.queryFilter && orderByFilter) {
|
// TODO: allow user to filter out null values when ordering by a value that may be null (such as lastMade)
|
||||||
// return `(${props.query.queryFilter}) AND ${orderByFilter}`;
|
|
||||||
// } else if (props.query.queryFilter) {
|
|
||||||
// return props.query.queryFilter;
|
|
||||||
// } else {
|
|
||||||
// return orderByFilter;
|
|
||||||
// }
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchRecipes(pageCount = 1) {
|
// const orderBy = props.query?.orderBy || preferences.value.orderBy;
|
||||||
const orderDir = props.query?.orderDirection || preferences.value.orderDirection;
|
// const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
|
||||||
const orderByNullPosition = props.query?.orderByNullPosition || orderDir === "asc" ? "first" : "last";
|
|
||||||
return await fetchMore(
|
|
||||||
page.value,
|
|
||||||
perPage * pageCount,
|
|
||||||
props.query?.orderBy || preferences.value.orderBy,
|
|
||||||
orderDir,
|
|
||||||
orderByNullPosition,
|
|
||||||
props.query,
|
|
||||||
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
|
|
||||||
queryFilter.value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
// if (props.query.queryFilter && orderByFilter) {
|
||||||
|
// return `(${props.query.queryFilter}) AND ${orderByFilter}`;
|
||||||
|
// } else if (props.query.queryFilter) {
|
||||||
|
// return props.query.queryFilter;
|
||||||
|
// } else {
|
||||||
|
// return orderByFilter;
|
||||||
|
// }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchRecipes(pageCount = 1) {
|
||||||
|
const orderDir = props.query?.orderDirection || preferences.value.orderDirection;
|
||||||
|
const orderByNullPosition = props.query?.orderByNullPosition || orderDir === "asc" ? "first" : "last";
|
||||||
|
return await fetchMore(
|
||||||
|
page.value,
|
||||||
|
perPage * pageCount,
|
||||||
|
props.query?.orderBy || preferences.value.orderBy,
|
||||||
|
orderDir,
|
||||||
|
orderByNullPosition,
|
||||||
|
props.query,
|
||||||
|
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
|
||||||
|
queryFilter.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await initRecipes();
|
||||||
|
ready.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastQuery: string | undefined = JSON.stringify(props.query);
|
||||||
|
watch(
|
||||||
|
() => props.query,
|
||||||
|
async (newValue: RecipeSearchQuery | undefined | null) => {
|
||||||
|
const newValueString = JSON.stringify(newValue);
|
||||||
|
if (lastQuery !== newValueString) {
|
||||||
|
lastQuery = newValueString;
|
||||||
|
ready.value = false;
|
||||||
await initRecipes();
|
await initRecipes();
|
||||||
ready.value = true;
|
ready.value = true;
|
||||||
});
|
|
||||||
|
|
||||||
let lastQuery: string | undefined = JSON.stringify(props.query);
|
|
||||||
watch(
|
|
||||||
() => props.query,
|
|
||||||
async (newValue: RecipeSearchQuery | undefined) => {
|
|
||||||
const newValueString = JSON.stringify(newValue);
|
|
||||||
if (lastQuery !== newValueString) {
|
|
||||||
lastQuery = newValueString;
|
|
||||||
ready.value = false;
|
|
||||||
await initRecipes();
|
|
||||||
ready.value = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
async function initRecipes() {
|
|
||||||
page.value = 1;
|
|
||||||
hasMore.value = true;
|
|
||||||
|
|
||||||
// we double-up the first call to avoid a bug with large screens that render
|
|
||||||
// the entire first page without scrolling, preventing additional loading
|
|
||||||
const newRecipes = await fetchRecipes(page.value + 1);
|
|
||||||
if (newRecipes.length < perPage) {
|
|
||||||
hasMore.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// since we doubled the first call, we also need to advance the page
|
|
||||||
page.value = page.value + 1;
|
|
||||||
|
|
||||||
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const infiniteScroll = useThrottleFn(async () => {
|
|
||||||
if (!hasMore.value || loading.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true;
|
|
||||||
page.value = page.value + 1;
|
|
||||||
|
|
||||||
const newRecipes = await fetchRecipes();
|
|
||||||
if (newRecipes.length < perPage) {
|
|
||||||
hasMore.value = false;
|
|
||||||
}
|
|
||||||
if (newRecipes.length) {
|
|
||||||
context.emit(APPEND_RECIPES_EVENT, newRecipes);
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = false;
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
async function sortRecipes(sortType: string) {
|
|
||||||
if (state.sortLoading || loading.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setter(
|
|
||||||
orderBy: string,
|
|
||||||
ascIcon: string,
|
|
||||||
descIcon: string,
|
|
||||||
defaultOrderDirection = "asc",
|
|
||||||
filterNull = false,
|
|
||||||
) {
|
|
||||||
if (preferences.value.orderBy !== orderBy) {
|
|
||||||
preferences.value.orderBy = orderBy;
|
|
||||||
preferences.value.orderDirection = defaultOrderDirection;
|
|
||||||
preferences.value.filterNull = filterNull;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
|
||||||
}
|
|
||||||
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (sortType) {
|
|
||||||
case EVENTS.az:
|
|
||||||
setter(
|
|
||||||
"name",
|
|
||||||
$globals.icons.sortAlphabeticalAscending,
|
|
||||||
$globals.icons.sortAlphabeticalDescending,
|
|
||||||
"asc",
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case EVENTS.rating:
|
|
||||||
setter("rating", $globals.icons.sortAscending, $globals.icons.sortDescending, "desc", true);
|
|
||||||
break;
|
|
||||||
case EVENTS.created:
|
|
||||||
setter(
|
|
||||||
"created_at",
|
|
||||||
$globals.icons.sortCalendarAscending,
|
|
||||||
$globals.icons.sortCalendarDescending,
|
|
||||||
"desc",
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case EVENTS.updated:
|
|
||||||
setter("updated_at", $globals.icons.sortClockAscending, $globals.icons.sortClockDescending, "desc", false);
|
|
||||||
break;
|
|
||||||
case EVENTS.lastMade:
|
|
||||||
setter(
|
|
||||||
"last_made",
|
|
||||||
$globals.icons.sortCalendarAscending,
|
|
||||||
$globals.icons.sortCalendarDescending,
|
|
||||||
"desc",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.log("Unknown Event", sortType);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// reset pagination
|
|
||||||
page.value = 1;
|
|
||||||
hasMore.value = true;
|
|
||||||
|
|
||||||
state.sortLoading = true;
|
|
||||||
loading.value = true;
|
|
||||||
|
|
||||||
// fetch new recipes
|
|
||||||
const newRecipes = await fetchRecipes();
|
|
||||||
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
|
||||||
|
|
||||||
state.sortLoading = false;
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function navigateRandom() {
|
|
||||||
const recipe = await getRandom(props.query, queryFilter.value);
|
|
||||||
if (!recipe?.slug) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push(`/g/${groupSlug.value}/r/${recipe.slug}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMobileCards() {
|
|
||||||
preferences.value.useMobileCards = !preferences.value.useMobileCards;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...toRefs(state),
|
|
||||||
displayTitleIcon,
|
|
||||||
EVENTS,
|
|
||||||
infiniteScroll,
|
|
||||||
ready,
|
|
||||||
loading,
|
|
||||||
navigateRandom,
|
|
||||||
preferences,
|
|
||||||
sortRecipes,
|
|
||||||
toggleMobileCards,
|
|
||||||
useMobileCards,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
|
async function initRecipes() {
|
||||||
|
page.value = 1;
|
||||||
|
hasMore.value = true;
|
||||||
|
|
||||||
|
// we double-up the first call to avoid a bug with large screens that render
|
||||||
|
// the entire first page without scrolling, preventing additional loading
|
||||||
|
const newRecipes = await fetchRecipes(page.value + 1);
|
||||||
|
if (newRecipes.length < perPage) {
|
||||||
|
hasMore.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// since we doubled the first call, we also need to advance the page
|
||||||
|
page.value = page.value + 1;
|
||||||
|
|
||||||
|
emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const infiniteScroll = useThrottleFn(async () => {
|
||||||
|
if (!hasMore.value || loading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
page.value = page.value + 1;
|
||||||
|
|
||||||
|
const newRecipes = await fetchRecipes();
|
||||||
|
if (newRecipes.length < perPage) {
|
||||||
|
hasMore.value = false;
|
||||||
|
}
|
||||||
|
if (newRecipes.length) {
|
||||||
|
emit(APPEND_RECIPES_EVENT, newRecipes);
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
async function sortRecipes(sortType: string) {
|
||||||
|
if (sortLoading.value || loading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setter(
|
||||||
|
orderBy: string,
|
||||||
|
ascIcon: string,
|
||||||
|
descIcon: string,
|
||||||
|
defaultOrderDirection = "asc",
|
||||||
|
filterNull = false,
|
||||||
|
) {
|
||||||
|
if (preferences.value.orderBy !== orderBy) {
|
||||||
|
preferences.value.orderBy = orderBy;
|
||||||
|
preferences.value.orderDirection = defaultOrderDirection;
|
||||||
|
preferences.value.filterNull = filterNull;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||||
|
}
|
||||||
|
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (sortType) {
|
||||||
|
case EVENTS.az:
|
||||||
|
setter(
|
||||||
|
"name",
|
||||||
|
$globals.icons.sortAlphabeticalAscending,
|
||||||
|
$globals.icons.sortAlphabeticalDescending,
|
||||||
|
"asc",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case EVENTS.rating:
|
||||||
|
setter("rating", $globals.icons.sortAscending, $globals.icons.sortDescending, "desc", true);
|
||||||
|
break;
|
||||||
|
case EVENTS.created:
|
||||||
|
setter(
|
||||||
|
"created_at",
|
||||||
|
$globals.icons.sortCalendarAscending,
|
||||||
|
$globals.icons.sortCalendarDescending,
|
||||||
|
"desc",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case EVENTS.updated:
|
||||||
|
setter("updated_at", $globals.icons.sortClockAscending, $globals.icons.sortClockDescending, "desc", false);
|
||||||
|
break;
|
||||||
|
case EVENTS.lastMade:
|
||||||
|
setter(
|
||||||
|
"last_made",
|
||||||
|
$globals.icons.sortCalendarAscending,
|
||||||
|
$globals.icons.sortCalendarDescending,
|
||||||
|
"desc",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log("Unknown Event", sortType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset pagination
|
||||||
|
page.value = 1;
|
||||||
|
hasMore.value = true;
|
||||||
|
|
||||||
|
sortLoading.value = true;
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
// fetch new recipes
|
||||||
|
const newRecipes = await fetchRecipes();
|
||||||
|
emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||||
|
|
||||||
|
sortLoading.value = false;
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateRandom() {
|
||||||
|
const recipe = await getRandom(props.query, queryFilter.value);
|
||||||
|
if (!recipe?.slug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/g/${groupSlug.value}/r/${recipe.slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMobileCards() {
|
||||||
|
preferences.value.useMobileCards = !preferences.value.useMobileCards;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
@ -23,66 +23,38 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export type UrlPrefixParam = "tags" | "categories" | "tools";
|
export type UrlPrefixParam = "tags" | "categories" | "tools";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
props: {
|
truncate?: boolean;
|
||||||
truncate: {
|
items?: RecipeCategory[] | RecipeTag[] | RecipeTool[];
|
||||||
type: Boolean,
|
title?: boolean;
|
||||||
default: false,
|
urlPrefix?: UrlPrefixParam;
|
||||||
},
|
limit?: number;
|
||||||
items: {
|
small?: boolean;
|
||||||
type: Array as () => RecipeCategory[] | RecipeTag[] | RecipeTool[],
|
maxWidth?: string | null;
|
||||||
default: () => [],
|
}
|
||||||
},
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
title: {
|
truncate: false,
|
||||||
type: Boolean,
|
items: () => [],
|
||||||
default: false,
|
title: false,
|
||||||
},
|
urlPrefix: "categories",
|
||||||
urlPrefix: {
|
limit: 999,
|
||||||
type: String as () => UrlPrefixParam,
|
small: false,
|
||||||
default: "categories",
|
maxWidth: null,
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
type: Number,
|
|
||||||
default: 999,
|
|
||||||
},
|
|
||||||
small: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
maxWidth: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["item-selected"],
|
|
||||||
setup(props) {
|
|
||||||
const $auth = useMealieAuth();
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
|
||||||
const baseRecipeRoute = computed<string>(() => {
|
|
||||||
return `/g/${groupSlug.value}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
function truncateText(text: string, length = 20, clamp = "...") {
|
|
||||||
if (!props.truncate) return text;
|
|
||||||
const node = document.createElement("div");
|
|
||||||
node.innerHTML = text;
|
|
||||||
const content = node.textContent || "";
|
|
||||||
return content.length > length ? content.slice(0, length) + clamp : content;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
baseRecipeRoute,
|
|
||||||
truncateText,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
defineEmits(["item-selected"]);
|
||||||
|
function truncateText(text: string, length = 20, clamp = "...") {
|
||||||
|
if (!props.truncate) return text;
|
||||||
|
const node = document.createElement("div");
|
||||||
|
node.innerHTML = text;
|
||||||
|
const content = node.textContent || "";
|
||||||
|
return content.length > length ? content.slice(0, length) + clamp : content;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style></style>
|
<style></style>
|
||||||
|
|
|
@ -12,7 +12,12 @@
|
||||||
@confirm="deleteRecipe()"
|
@confirm="deleteRecipe()"
|
||||||
>
|
>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
{{ $t("recipe.delete-confirmation") }}
|
<template v-if="isAdminAndNotOwner">
|
||||||
|
{{ $t("recipe.admin-delete-confirmation") }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ $t("recipe.delete-confirmation") }}
|
||||||
|
</template>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
|
@ -50,12 +55,12 @@
|
||||||
max-width="290px"
|
max-width="290px"
|
||||||
min-width="auto"
|
min-width="auto"
|
||||||
>
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="newMealdateString"
|
v-model="newMealdateString"
|
||||||
:label="$t('general.date')"
|
:label="$t('general.date')"
|
||||||
:prepend-icon="$globals.icons.calendar"
|
:prepend-icon="$globals.icons.calendar"
|
||||||
v-bind="props"
|
v-bind="activatorProps"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
@ -95,7 +100,7 @@
|
||||||
:open-on-hover="$vuetify.display.mdAndUp"
|
:open-on-hover="$vuetify.display.mdAndUp"
|
||||||
content-class="d-print-none"
|
content-class="d-print-none"
|
||||||
>
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
icon
|
icon
|
||||||
:variant="fab ? 'flat' : undefined"
|
:variant="fab ? 'flat' : undefined"
|
||||||
|
@ -103,7 +108,7 @@
|
||||||
:size="fab ? 'small' : undefined"
|
:size="fab ? 'small' : undefined"
|
||||||
:color="fab ? 'info' : 'secondary'"
|
:color="fab ? 'info' : 'secondary'"
|
||||||
:fab="fab"
|
:fab="fab"
|
||||||
v-bind="props"
|
v-bind="activatorProps"
|
||||||
@click.prevent
|
@click.prevent
|
||||||
>
|
>
|
||||||
<v-icon
|
<v-icon
|
||||||
|
@ -125,32 +130,27 @@
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
|
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
|
||||||
<v-divider />
|
<v-divider />
|
||||||
<v-list-group @click.stop>
|
<v-list-item
|
||||||
<template #activator="{ props }">
|
v-for="(action, index) in recipeActions"
|
||||||
<v-list-item-title v-bind="props">
|
:key="index"
|
||||||
{{ $t("recipe.recipe-actions") }}
|
@click="executeRecipeAction(action)"
|
||||||
</v-list-item-title>
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon color="undefined">
|
||||||
|
{{ $globals.icons.linkVariantPlus }}
|
||||||
|
</v-icon>
|
||||||
</template>
|
</template>
|
||||||
<v-list density="compact" class="ma-0 pa-0">
|
<v-list-item-title>
|
||||||
<v-list-item
|
{{ action.title }}
|
||||||
v-for="(action, index) in recipeActions"
|
</v-list-item-title>
|
||||||
:key="index"
|
</v-list-item>
|
||||||
class="pl-6"
|
|
||||||
@click="executeRecipeAction(action)"
|
|
||||||
>
|
|
||||||
<v-list-item-title>
|
|
||||||
{{ action.title }}
|
|
||||||
</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</v-list-group>
|
|
||||||
</div>
|
</div>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue";
|
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue";
|
||||||
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
|
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
|
||||||
import RecipeDialogShare from "./RecipeDialogShare.vue";
|
import RecipeDialogShare from "./RecipeDialogShare.vue";
|
||||||
|
@ -186,343 +186,312 @@ export interface ContextMenuItem {
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
components: {
|
useItems?: ContextMenuIncludes;
|
||||||
RecipeDialogAddToShoppingList,
|
appendItems?: ContextMenuItem[];
|
||||||
RecipeDialogPrintPreferences,
|
leadingItems?: ContextMenuItem[];
|
||||||
RecipeDialogShare,
|
menuTop?: boolean;
|
||||||
},
|
fab?: boolean;
|
||||||
props: {
|
color?: string;
|
||||||
useItems: {
|
slug: string;
|
||||||
type: Object as () => ContextMenuIncludes,
|
menuIcon?: string | null;
|
||||||
default: () => ({
|
name: string;
|
||||||
delete: true,
|
recipe?: Recipe;
|
||||||
edit: true,
|
recipeId: string;
|
||||||
download: true,
|
recipeScale?: number;
|
||||||
duplicate: false,
|
}
|
||||||
mealplanner: true,
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
shoppingList: true,
|
useItems: () => ({
|
||||||
print: true,
|
delete: true,
|
||||||
printPreferences: true,
|
edit: true,
|
||||||
share: true,
|
download: true,
|
||||||
recipeActions: true,
|
duplicate: false,
|
||||||
}),
|
mealplanner: true,
|
||||||
},
|
shoppingList: true,
|
||||||
// Append items are added at the end of the useItems list
|
print: true,
|
||||||
appendItems: {
|
printPreferences: true,
|
||||||
type: Array as () => ContextMenuItem[],
|
share: true,
|
||||||
default: () => [],
|
recipeActions: true,
|
||||||
},
|
}),
|
||||||
// Append items are added at the beginning of the useItems list
|
appendItems: () => [],
|
||||||
leadingItems: {
|
leadingItems: () => [],
|
||||||
type: Array as () => ContextMenuItem[],
|
menuTop: true,
|
||||||
default: () => [],
|
fab: false,
|
||||||
},
|
color: "primary",
|
||||||
menuTop: {
|
menuIcon: null,
|
||||||
type: Boolean,
|
recipe: undefined,
|
||||||
default: true,
|
recipeScale: 1,
|
||||||
},
|
|
||||||
fab: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
color: {
|
|
||||||
type: String,
|
|
||||||
default: "primary",
|
|
||||||
},
|
|
||||||
slug: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
menuIcon: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
required: true,
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
recipe: {
|
|
||||||
type: Object as () => Recipe,
|
|
||||||
default: undefined,
|
|
||||||
},
|
|
||||||
recipeId: {
|
|
||||||
required: true,
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
recipeScale: {
|
|
||||||
type: Number,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["delete"],
|
|
||||||
setup(props, context) {
|
|
||||||
const api = useUserApi();
|
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
printPreferencesDialog: false,
|
|
||||||
shareDialog: false,
|
|
||||||
recipeDeleteDialog: false,
|
|
||||||
mealplannerDialog: false,
|
|
||||||
shoppingListDialog: false,
|
|
||||||
recipeDuplicateDialog: false,
|
|
||||||
recipeName: props.name,
|
|
||||||
loading: false,
|
|
||||||
menuItems: [] as ContextMenuItem[],
|
|
||||||
newMealdate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
|
|
||||||
newMealType: "dinner" as PlanEntryType,
|
|
||||||
pickerMenu: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newMealdateString = computed(() => {
|
|
||||||
return state.newMealdate.toISOString().substring(0, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
const i18n = useI18n();
|
|
||||||
const $auth = useMealieAuth();
|
|
||||||
const { $globals } = useNuxtApp();
|
|
||||||
const { household } = useHouseholdSelf();
|
|
||||||
const { isOwnGroup } = useLoggedInState();
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
|
||||||
|
|
||||||
const firstDayOfWeek = computed(() => {
|
|
||||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Context Menu Setup
|
|
||||||
|
|
||||||
const defaultItems: { [key: string]: ContextMenuItem } = {
|
|
||||||
edit: {
|
|
||||||
title: i18n.t("general.edit"),
|
|
||||||
icon: $globals.icons.edit,
|
|
||||||
color: undefined,
|
|
||||||
event: "edit",
|
|
||||||
isPublic: false,
|
|
||||||
},
|
|
||||||
delete: {
|
|
||||||
title: i18n.t("general.delete"),
|
|
||||||
icon: $globals.icons.delete,
|
|
||||||
color: undefined,
|
|
||||||
event: "delete",
|
|
||||||
isPublic: false,
|
|
||||||
},
|
|
||||||
download: {
|
|
||||||
title: i18n.t("general.download"),
|
|
||||||
icon: $globals.icons.download,
|
|
||||||
color: undefined,
|
|
||||||
event: "download",
|
|
||||||
isPublic: false,
|
|
||||||
},
|
|
||||||
duplicate: {
|
|
||||||
title: i18n.t("general.duplicate"),
|
|
||||||
icon: $globals.icons.duplicate,
|
|
||||||
color: undefined,
|
|
||||||
event: "duplicate",
|
|
||||||
isPublic: false,
|
|
||||||
},
|
|
||||||
mealplanner: {
|
|
||||||
title: i18n.t("recipe.add-to-plan"),
|
|
||||||
icon: $globals.icons.calendar,
|
|
||||||
color: undefined,
|
|
||||||
event: "mealplanner",
|
|
||||||
isPublic: false,
|
|
||||||
},
|
|
||||||
shoppingList: {
|
|
||||||
title: i18n.t("recipe.add-to-list"),
|
|
||||||
icon: $globals.icons.cartCheck,
|
|
||||||
color: undefined,
|
|
||||||
event: "shoppingList",
|
|
||||||
isPublic: false,
|
|
||||||
},
|
|
||||||
print: {
|
|
||||||
title: i18n.t("general.print"),
|
|
||||||
icon: $globals.icons.printer,
|
|
||||||
color: undefined,
|
|
||||||
event: "print",
|
|
||||||
isPublic: true,
|
|
||||||
},
|
|
||||||
printPreferences: {
|
|
||||||
title: i18n.t("general.print-preferences"),
|
|
||||||
icon: $globals.icons.printerSettings,
|
|
||||||
color: undefined,
|
|
||||||
event: "printPreferences",
|
|
||||||
isPublic: true,
|
|
||||||
},
|
|
||||||
share: {
|
|
||||||
title: i18n.t("general.share"),
|
|
||||||
icon: $globals.icons.shareVariant,
|
|
||||||
color: undefined,
|
|
||||||
event: "share",
|
|
||||||
isPublic: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get Default Menu Items Specified in Props
|
|
||||||
for (const [key, value] of Object.entries(props.useItems)) {
|
|
||||||
if (value) {
|
|
||||||
const item = defaultItems[key];
|
|
||||||
if (item && (item.isPublic || isOwnGroup.value)) {
|
|
||||||
state.menuItems.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add leading and Appending Items
|
|
||||||
state.menuItems = [...state.menuItems, ...props.leadingItems, ...props.appendItems];
|
|
||||||
|
|
||||||
const icon = props.menuIcon || $globals.icons.dotsVertical;
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Context Menu Event Handler
|
|
||||||
|
|
||||||
const shoppingLists = ref<ShoppingListSummary[]>();
|
|
||||||
const recipeRef = ref<Recipe | undefined>(props.recipe);
|
|
||||||
const recipeRefWithScale = computed(() =>
|
|
||||||
recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
async function getShoppingLists() {
|
|
||||||
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
|
|
||||||
if (data) {
|
|
||||||
shoppingLists.value = data.items ?? [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshRecipe() {
|
|
||||||
const { data } = await api.recipes.getOne(props.slug);
|
|
||||||
if (data) {
|
|
||||||
recipeRef.value = data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const groupRecipeActionsStore = useGroupRecipeActions();
|
|
||||||
|
|
||||||
async function executeRecipeAction(action: GroupRecipeActionOut) {
|
|
||||||
if (!props.recipe) return;
|
|
||||||
const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale);
|
|
||||||
|
|
||||||
if (action.actionType === "post") {
|
|
||||||
if (!response?.error) {
|
|
||||||
alert.success(i18n.t("events.message-sent"));
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
alert.error(i18n.t("events.something-went-wrong"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteRecipe() {
|
|
||||||
const { data } = await api.recipes.deleteOne(props.slug);
|
|
||||||
if (data?.slug) {
|
|
||||||
router.push(`/g/${groupSlug.value}`);
|
|
||||||
}
|
|
||||||
context.emit("delete", props.slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
const download = useDownloader();
|
|
||||||
|
|
||||||
async function handleDownloadEvent() {
|
|
||||||
const { data } = await api.recipes.getZipToken(props.slug);
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addRecipeToPlan() {
|
|
||||||
const { response } = await api.mealplans.createOne({
|
|
||||||
date: newMealdateString.value,
|
|
||||||
entryType: state.newMealType,
|
|
||||||
title: "",
|
|
||||||
text: "",
|
|
||||||
recipeId: props.recipeId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response?.status === 201) {
|
|
||||||
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function duplicateRecipe() {
|
|
||||||
const { data } = await api.recipes.duplicateOne(props.slug, state.recipeName);
|
|
||||||
if (data && data.slug) {
|
|
||||||
router.push(`/g/${groupSlug.value}/r/${data.slug}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: Print is handled as an event in the parent component
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
|
||||||
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
|
||||||
delete: () => {
|
|
||||||
state.recipeDeleteDialog = true;
|
|
||||||
},
|
|
||||||
edit: () => router.push(`/g/${groupSlug.value}/r/${props.slug}` + "?edit=true"),
|
|
||||||
download: handleDownloadEvent,
|
|
||||||
duplicate: () => {
|
|
||||||
state.recipeDuplicateDialog = true;
|
|
||||||
},
|
|
||||||
mealplanner: () => {
|
|
||||||
state.mealplannerDialog = true;
|
|
||||||
},
|
|
||||||
printPreferences: async () => {
|
|
||||||
if (!recipeRef.value) {
|
|
||||||
await refreshRecipe();
|
|
||||||
}
|
|
||||||
state.printPreferencesDialog = true;
|
|
||||||
},
|
|
||||||
shoppingList: () => {
|
|
||||||
const promises: Promise<void>[] = [getShoppingLists()];
|
|
||||||
if (!recipeRef.value) {
|
|
||||||
promises.push(refreshRecipe());
|
|
||||||
}
|
|
||||||
|
|
||||||
Promise.allSettled(promises).then(() => {
|
|
||||||
state.shoppingListDialog = true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
share: () => {
|
|
||||||
state.shareDialog = true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function contextMenuEventHandler(eventKey: string) {
|
|
||||||
const handler = eventHandlers[eventKey];
|
|
||||||
|
|
||||||
if (handler && typeof handler === "function") {
|
|
||||||
handler();
|
|
||||||
state.loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.emit(eventKey);
|
|
||||||
state.loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const planTypeOptions = usePlanTypeOptions();
|
|
||||||
|
|
||||||
return {
|
|
||||||
...toRefs(state),
|
|
||||||
newMealdateString,
|
|
||||||
recipeRef,
|
|
||||||
recipeRefWithScale,
|
|
||||||
executeRecipeAction,
|
|
||||||
recipeActions: groupRecipeActionsStore.recipeActions,
|
|
||||||
shoppingLists,
|
|
||||||
duplicateRecipe,
|
|
||||||
contextMenuEventHandler,
|
|
||||||
deleteRecipe,
|
|
||||||
addRecipeToPlan,
|
|
||||||
icon,
|
|
||||||
planTypeOptions,
|
|
||||||
firstDayOfWeek,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
[key: string]: any;
|
||||||
|
delete: [slug: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const api = useUserApi();
|
||||||
|
|
||||||
|
const printPreferencesDialog = ref(false);
|
||||||
|
const shareDialog = ref(false);
|
||||||
|
const recipeDeleteDialog = ref(false);
|
||||||
|
const mealplannerDialog = ref(false);
|
||||||
|
const shoppingListDialog = ref(false);
|
||||||
|
const recipeDuplicateDialog = ref(false);
|
||||||
|
const recipeName = ref(props.name);
|
||||||
|
const loading = ref(false);
|
||||||
|
const menuItems = ref<ContextMenuItem[]>([]);
|
||||||
|
const newMealdate = ref(new Date());
|
||||||
|
const newMealType = ref<PlanEntryType>("dinner");
|
||||||
|
const pickerMenu = ref(false);
|
||||||
|
|
||||||
|
const newMealdateString = computed(() => {
|
||||||
|
// Format the date to YYYY-MM-DD in the same timezone as newMealdate
|
||||||
|
const year = newMealdate.value.getFullYear();
|
||||||
|
const month = String(newMealdate.value.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(newMealdate.value.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
|
const { household } = useHouseholdSelf();
|
||||||
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
|
const firstDayOfWeek = computed(() => {
|
||||||
|
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Context Menu Setup
|
||||||
|
|
||||||
|
const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||||
|
edit: {
|
||||||
|
title: i18n.t("general.edit"),
|
||||||
|
icon: $globals.icons.edit,
|
||||||
|
color: undefined,
|
||||||
|
event: "edit",
|
||||||
|
isPublic: false,
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
title: i18n.t("general.delete"),
|
||||||
|
icon: $globals.icons.delete,
|
||||||
|
color: undefined,
|
||||||
|
event: "delete",
|
||||||
|
isPublic: false,
|
||||||
|
},
|
||||||
|
download: {
|
||||||
|
title: i18n.t("general.download"),
|
||||||
|
icon: $globals.icons.download,
|
||||||
|
color: undefined,
|
||||||
|
event: "download",
|
||||||
|
isPublic: false,
|
||||||
|
},
|
||||||
|
duplicate: {
|
||||||
|
title: i18n.t("general.duplicate"),
|
||||||
|
icon: $globals.icons.duplicate,
|
||||||
|
color: undefined,
|
||||||
|
event: "duplicate",
|
||||||
|
isPublic: false,
|
||||||
|
},
|
||||||
|
mealplanner: {
|
||||||
|
title: i18n.t("recipe.add-to-plan"),
|
||||||
|
icon: $globals.icons.calendar,
|
||||||
|
color: undefined,
|
||||||
|
event: "mealplanner",
|
||||||
|
isPublic: false,
|
||||||
|
},
|
||||||
|
shoppingList: {
|
||||||
|
title: i18n.t("recipe.add-to-list"),
|
||||||
|
icon: $globals.icons.cartCheck,
|
||||||
|
color: undefined,
|
||||||
|
event: "shoppingList",
|
||||||
|
isPublic: false,
|
||||||
|
},
|
||||||
|
print: {
|
||||||
|
title: i18n.t("general.print"),
|
||||||
|
icon: $globals.icons.printer,
|
||||||
|
color: undefined,
|
||||||
|
event: "print",
|
||||||
|
isPublic: true,
|
||||||
|
},
|
||||||
|
printPreferences: {
|
||||||
|
title: i18n.t("general.print-preferences"),
|
||||||
|
icon: $globals.icons.printerSettings,
|
||||||
|
color: undefined,
|
||||||
|
event: "printPreferences",
|
||||||
|
isPublic: true,
|
||||||
|
},
|
||||||
|
share: {
|
||||||
|
title: i18n.t("general.share"),
|
||||||
|
icon: $globals.icons.shareVariant,
|
||||||
|
color: undefined,
|
||||||
|
event: "share",
|
||||||
|
isPublic: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add leading and Appending Items
|
||||||
|
menuItems.value = [...menuItems.value, ...props.leadingItems, ...props.appendItems];
|
||||||
|
|
||||||
|
const icon = props.menuIcon || $globals.icons.dotsVertical;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Context Menu Event Handler
|
||||||
|
|
||||||
|
const shoppingLists = ref<ShoppingListSummary[]>();
|
||||||
|
const recipeRef = ref<Recipe | undefined>(props.recipe);
|
||||||
|
const recipeRefWithScale = computed(() =>
|
||||||
|
recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined,
|
||||||
|
);
|
||||||
|
const isAdminAndNotOwner = computed(() => {
|
||||||
|
return (
|
||||||
|
$auth.user.value?.admin
|
||||||
|
&& $auth.user.value?.id !== recipeRef.value?.userId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const canDelete = computed(() => {
|
||||||
|
const user = $auth.user.value;
|
||||||
|
const recipe = recipeRef.value;
|
||||||
|
return user && recipe && (user.admin || user.id === recipe.userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Default Menu Items Specified in Props
|
||||||
|
for (const [key, value] of Object.entries(props.useItems)) {
|
||||||
|
if (!value) continue;
|
||||||
|
|
||||||
|
// Skip delete if not allowed
|
||||||
|
if (key === "delete" && !canDelete.value) continue;
|
||||||
|
|
||||||
|
const item = defaultItems[key];
|
||||||
|
if (item && (item.isPublic || isOwnGroup.value)) {
|
||||||
|
menuItems.value.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getShoppingLists() {
|
||||||
|
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
|
||||||
|
if (data) {
|
||||||
|
shoppingLists.value = data.items ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRecipe() {
|
||||||
|
const { data } = await api.recipes.getOne(props.slug);
|
||||||
|
if (data) {
|
||||||
|
recipeRef.value = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const groupRecipeActionsStore = useGroupRecipeActions();
|
||||||
|
|
||||||
|
async function executeRecipeAction(action: GroupRecipeActionOut) {
|
||||||
|
if (!props.recipe) return;
|
||||||
|
const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale);
|
||||||
|
|
||||||
|
if (action.actionType === "post") {
|
||||||
|
if (!response?.error) {
|
||||||
|
alert.success(i18n.t("events.message-sent"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert.error(i18n.t("events.something-went-wrong"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRecipe() {
|
||||||
|
const { data } = await api.recipes.deleteOne(props.slug);
|
||||||
|
if (data?.slug) {
|
||||||
|
router.push(`/g/${groupSlug.value}`);
|
||||||
|
}
|
||||||
|
emit("delete", props.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
const download = useDownloader();
|
||||||
|
|
||||||
|
async function handleDownloadEvent() {
|
||||||
|
const { data } = await api.recipes.getZipToken(props.slug);
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRecipeToPlan() {
|
||||||
|
const { response } = await api.mealplans.createOne({
|
||||||
|
date: newMealdateString.value,
|
||||||
|
entryType: newMealType.value,
|
||||||
|
title: "",
|
||||||
|
text: "",
|
||||||
|
recipeId: props.recipeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.status === 201) {
|
||||||
|
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function duplicateRecipe() {
|
||||||
|
const { data } = await api.recipes.duplicateOne(props.slug, recipeName.value);
|
||||||
|
if (data && data.slug) {
|
||||||
|
router.push(`/g/${groupSlug.value}/r/${data.slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Print is handled as an event in the parent component
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||||
|
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
||||||
|
delete: () => {
|
||||||
|
recipeDeleteDialog.value = true;
|
||||||
|
},
|
||||||
|
edit: () => router.push(`/g/${groupSlug.value}/r/${props.slug}` + "?edit=true"),
|
||||||
|
download: handleDownloadEvent,
|
||||||
|
duplicate: () => {
|
||||||
|
recipeDuplicateDialog.value = true;
|
||||||
|
},
|
||||||
|
mealplanner: () => {
|
||||||
|
mealplannerDialog.value = true;
|
||||||
|
},
|
||||||
|
printPreferences: async () => {
|
||||||
|
if (!recipeRef.value) {
|
||||||
|
await refreshRecipe();
|
||||||
|
}
|
||||||
|
printPreferencesDialog.value = true;
|
||||||
|
},
|
||||||
|
shoppingList: () => {
|
||||||
|
const promises: Promise<void>[] = [getShoppingLists()];
|
||||||
|
if (!recipeRef.value) {
|
||||||
|
promises.push(refreshRecipe());
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.allSettled(promises).then(() => {
|
||||||
|
shoppingListDialog.value = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
share: () => {
|
||||||
|
shareDialog.value = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function contextMenuEventHandler(eventKey: string) {
|
||||||
|
const handler = eventHandlers[eventKey];
|
||||||
|
|
||||||
|
if (handler && typeof handler === "function") {
|
||||||
|
handler();
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(eventKey);
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const planTypeOptions = usePlanTypeOptions();
|
||||||
|
const recipeActions = groupRecipeActionsStore.recipeActions;
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -33,7 +33,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { whenever } from "@vueuse/core";
|
import { whenever } from "@vueuse/core";
|
||||||
import { validators } from "~/composables/use-validators";
|
import { validators } from "~/composables/use-validators";
|
||||||
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||||
|
@ -42,86 +42,66 @@ export interface GenericAlias {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
props: {
|
data: IngredientFood | IngredientUnit;
|
||||||
modelValue: {
|
}
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
type: Object as () => IngredientFood | IngredientUnit,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["submit", "update:modelValue", "cancel"],
|
|
||||||
setup(props, context) {
|
|
||||||
// V-Model Support
|
|
||||||
const dialog = computed({
|
|
||||||
get: () => {
|
|
||||||
return props.modelValue;
|
|
||||||
},
|
|
||||||
set: (val) => {
|
|
||||||
context.emit("update:modelValue", val);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function createAlias() {
|
const props = defineProps<Props>();
|
||||||
aliases.value.push({
|
|
||||||
name: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteAlias(index: number) {
|
const emit = defineEmits<{
|
||||||
aliases.value.splice(index, 1);
|
submit: [aliases: GenericAlias[]];
|
||||||
}
|
cancel: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
const aliases = ref<GenericAlias[]>(props.data.aliases || []);
|
// V-Model Support
|
||||||
function initAliases() {
|
const dialog = defineModel<boolean>({ default: false });
|
||||||
aliases.value = [...props.data.aliases || []];
|
|
||||||
if (!aliases.value.length) {
|
|
||||||
createAlias();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function createAlias() {
|
||||||
|
aliases.value.push({
|
||||||
|
name: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteAlias(index: number) {
|
||||||
|
aliases.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aliases = ref<GenericAlias[]>(props.data.aliases || []);
|
||||||
|
function initAliases() {
|
||||||
|
aliases.value = [...props.data.aliases || []];
|
||||||
|
if (!aliases.value.length) {
|
||||||
|
createAlias();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initAliases();
|
||||||
|
whenever(
|
||||||
|
() => dialog.value,
|
||||||
|
() => {
|
||||||
initAliases();
|
initAliases();
|
||||||
whenever(
|
},
|
||||||
() => props.modelValue,
|
);
|
||||||
() => {
|
|
||||||
initAliases();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
function saveAliases() {
|
function saveAliases() {
|
||||||
const seenAliasNames: string[] = [];
|
const seenAliasNames: string[] = [];
|
||||||
const keepAliases: GenericAlias[] = [];
|
const keepAliases: GenericAlias[] = [];
|
||||||
aliases.value.forEach((alias) => {
|
aliases.value.forEach((alias) => {
|
||||||
if (
|
if (
|
||||||
!alias.name
|
!alias.name
|
||||||
|| alias.name === props.data.name
|
|| alias.name === props.data.name
|
||||||
|| alias.name === props.data.pluralName
|
|| alias.name === props.data.pluralName
|
||||||
|| alias.name === props.data.abbreviation
|
|| alias.name === props.data.abbreviation
|
||||||
|| alias.name === props.data.pluralAbbreviation
|
|| alias.name === props.data.pluralAbbreviation
|
||||||
|| seenAliasNames.includes(alias.name)
|
|| seenAliasNames.includes(alias.name)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
keepAliases.push(alias);
|
|
||||||
seenAliasNames.push(alias.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
aliases.value = keepAliases;
|
|
||||||
context.emit("submit", keepAliases);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
keepAliases.push(alias);
|
||||||
aliases,
|
seenAliasNames.push(alias.name);
|
||||||
createAlias,
|
});
|
||||||
dialog,
|
|
||||||
deleteAlias,
|
aliases.value = keepAliases;
|
||||||
saveAliases,
|
emit("submit", keepAliases);
|
||||||
validators,
|
}
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -3,12 +3,13 @@
|
||||||
v-model="selected"
|
v-model="selected"
|
||||||
item-key="id"
|
item-key="id"
|
||||||
show-select
|
show-select
|
||||||
:sort-by="[{ key: 'dateAdded', order: 'desc' }]"
|
:sort-by="sortBy"
|
||||||
:headers="headers"
|
:headers="headers"
|
||||||
:items="recipes"
|
:items="recipes"
|
||||||
:items-per-page="15"
|
:items-per-page="15"
|
||||||
class="elevation-0"
|
class="elevation-0"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
return-object
|
||||||
>
|
>
|
||||||
<template #[`item.name`]="{ item }">
|
<template #[`item.name`]="{ item }">
|
||||||
<a
|
<a
|
||||||
|
@ -61,7 +62,7 @@
|
||||||
</v-data-table>
|
</v-data-table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import UserAvatar from "../User/UserAvatar.vue";
|
import UserAvatar from "../User/UserAvatar.vue";
|
||||||
import RecipeChip from "./RecipeChips.vue";
|
import RecipeChip from "./RecipeChips.vue";
|
||||||
import type { Recipe, RecipeCategory, RecipeTool } from "~/lib/api/types/recipe";
|
import type { Recipe, RecipeCategory, RecipeTool } from "~/lib/api/types/recipe";
|
||||||
|
@ -69,8 +70,6 @@ import { useUserApi } from "~/composables/api";
|
||||||
import type { UserSummary } from "~/lib/api/types/user";
|
import type { UserSummary } from "~/lib/api/types/user";
|
||||||
import type { RecipeTag } from "~/lib/api/types/household";
|
import type { RecipeTag } from "~/lib/api/types/household";
|
||||||
|
|
||||||
const INPUT_EVENT = "update:modelValue";
|
|
||||||
|
|
||||||
interface ShowHeaders {
|
interface ShowHeaders {
|
||||||
id: boolean;
|
id: boolean;
|
||||||
owner: boolean;
|
owner: boolean;
|
||||||
|
@ -83,136 +82,114 @@ interface ShowHeaders {
|
||||||
dateAdded: boolean;
|
dateAdded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
components: { RecipeChip, UserAvatar },
|
loading?: boolean;
|
||||||
props: {
|
recipes?: Recipe[];
|
||||||
modelValue: {
|
showHeaders?: ShowHeaders;
|
||||||
type: Array as PropType<Recipe[]>,
|
}
|
||||||
required: false,
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
default: () => [],
|
loading: false,
|
||||||
},
|
recipes: () => [],
|
||||||
loading: {
|
showHeaders: () => ({
|
||||||
type: Boolean,
|
id: true,
|
||||||
required: false,
|
owner: false,
|
||||||
default: false,
|
tags: true,
|
||||||
},
|
categories: true,
|
||||||
recipes: {
|
tools: true,
|
||||||
type: Array as () => Recipe[],
|
recipeServings: true,
|
||||||
default: () => [],
|
recipeYieldQuantity: true,
|
||||||
},
|
recipeYield: true,
|
||||||
showHeaders: {
|
dateAdded: true,
|
||||||
type: Object as () => ShowHeaders,
|
}),
|
||||||
required: false,
|
|
||||||
default: () => {
|
|
||||||
return {
|
|
||||||
id: true,
|
|
||||||
owner: false,
|
|
||||||
tags: true,
|
|
||||||
categories: true,
|
|
||||||
recipeServings: true,
|
|
||||||
recipeYieldQuantity: true,
|
|
||||||
recipeYield: true,
|
|
||||||
dateAdded: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["click"],
|
|
||||||
setup(props, context) {
|
|
||||||
const i18n = useI18n();
|
|
||||||
const $auth = useMealieAuth();
|
|
||||||
const groupSlug = $auth.user.value?.groupSlug;
|
|
||||||
const router = useRouter();
|
|
||||||
const selected = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: value => context.emit(INPUT_EVENT, value),
|
|
||||||
});
|
|
||||||
|
|
||||||
const headers = computed(() => {
|
|
||||||
const hdrs: Array<{ title: string; value: string; align?: string; sortable?: boolean }> = [];
|
|
||||||
|
|
||||||
if (props.showHeaders.id) {
|
|
||||||
hdrs.push({ title: i18n.t("general.id"), value: "id" });
|
|
||||||
}
|
|
||||||
if (props.showHeaders.owner) {
|
|
||||||
hdrs.push({ title: i18n.t("general.owner"), value: "userId", align: "center", sortable: true });
|
|
||||||
}
|
|
||||||
hdrs.push({ title: i18n.t("general.name"), value: "name", sortable: true });
|
|
||||||
if (props.showHeaders.categories) {
|
|
||||||
hdrs.push({ title: i18n.t("recipe.categories"), value: "recipeCategory", sortable: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.showHeaders.tags) {
|
|
||||||
hdrs.push({ title: i18n.t("tag.tags"), value: "tags", sortable: true });
|
|
||||||
}
|
|
||||||
if (props.showHeaders.tools) {
|
|
||||||
hdrs.push({ title: i18n.t("tool.tools"), value: "tools", sortable: true });
|
|
||||||
}
|
|
||||||
if (props.showHeaders.recipeServings) {
|
|
||||||
hdrs.push({ title: i18n.t("recipe.servings"), value: "recipeServings", sortable: true });
|
|
||||||
}
|
|
||||||
if (props.showHeaders.recipeYieldQuantity) {
|
|
||||||
hdrs.push({ title: i18n.t("recipe.yield"), value: "recipeYieldQuantity", sortable: true });
|
|
||||||
}
|
|
||||||
if (props.showHeaders.recipeYield) {
|
|
||||||
hdrs.push({ title: i18n.t("recipe.yield-text"), value: "recipeYield", sortable: true });
|
|
||||||
}
|
|
||||||
if (props.showHeaders.dateAdded) {
|
|
||||||
hdrs.push({ title: i18n.t("general.date-added"), value: "dateAdded", sortable: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return hdrs;
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatDate(date: string) {
|
|
||||||
try {
|
|
||||||
return i18n.d(Date.parse(date), "medium");
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// Group Members
|
|
||||||
const api = useUserApi();
|
|
||||||
const members = ref<UserSummary[]>([]);
|
|
||||||
|
|
||||||
async function refreshMembers() {
|
|
||||||
const { data } = await api.groups.fetchMembers();
|
|
||||||
if (data) {
|
|
||||||
members.value = data.items;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterItems(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
|
|
||||||
if (!groupSlug || !item.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(`/g/${groupSlug}?${itemType}=${item.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
refreshMembers();
|
|
||||||
});
|
|
||||||
|
|
||||||
function getMember(id: string) {
|
|
||||||
if (members.value[0]) {
|
|
||||||
return members.value.find(m => m.id === id)?.fullName;
|
|
||||||
}
|
|
||||||
|
|
||||||
return i18n.t("general.none");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
selected,
|
|
||||||
groupSlug,
|
|
||||||
headers,
|
|
||||||
formatDate,
|
|
||||||
members,
|
|
||||||
getMember,
|
|
||||||
filterItems,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
click: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const selected = defineModel<Recipe[]>({ default: () => [] });
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const groupSlug = $auth.user.value?.groupSlug;
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Initialize sort state with default sorting by dateAdded descending
|
||||||
|
const sortBy = ref([{ key: "dateAdded", order: "desc" as const }]);
|
||||||
|
|
||||||
|
const headers = computed(() => {
|
||||||
|
const hdrs: Array<{ title: string; value: string; align?: "center" | "start" | "end"; sortable?: boolean }> = [];
|
||||||
|
|
||||||
|
if (props.showHeaders.id) {
|
||||||
|
hdrs.push({ title: i18n.t("general.id"), value: "id" });
|
||||||
|
}
|
||||||
|
if (props.showHeaders.owner) {
|
||||||
|
hdrs.push({ title: i18n.t("general.owner"), value: "userId", align: "center", sortable: true });
|
||||||
|
}
|
||||||
|
hdrs.push({ title: i18n.t("general.name"), value: "name", sortable: true });
|
||||||
|
if (props.showHeaders.categories) {
|
||||||
|
hdrs.push({ title: i18n.t("recipe.categories"), value: "recipeCategory", sortable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.showHeaders.tags) {
|
||||||
|
hdrs.push({ title: i18n.t("tag.tags"), value: "tags", sortable: true });
|
||||||
|
}
|
||||||
|
if (props.showHeaders.tools) {
|
||||||
|
hdrs.push({ title: i18n.t("tool.tools"), value: "tools", sortable: true });
|
||||||
|
}
|
||||||
|
if (props.showHeaders.recipeServings) {
|
||||||
|
hdrs.push({ title: i18n.t("recipe.servings"), value: "recipeServings", sortable: true });
|
||||||
|
}
|
||||||
|
if (props.showHeaders.recipeYieldQuantity) {
|
||||||
|
hdrs.push({ title: i18n.t("recipe.yield"), value: "recipeYieldQuantity", sortable: true });
|
||||||
|
}
|
||||||
|
if (props.showHeaders.recipeYield) {
|
||||||
|
hdrs.push({ title: i18n.t("recipe.yield-text"), value: "recipeYield", sortable: true });
|
||||||
|
}
|
||||||
|
if (props.showHeaders.dateAdded) {
|
||||||
|
hdrs.push({ title: i18n.t("general.date-added"), value: "dateAdded", sortable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return hdrs;
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDate(date: string) {
|
||||||
|
try {
|
||||||
|
return i18n.d(Date.parse(date), "medium");
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Group Members
|
||||||
|
const api = useUserApi();
|
||||||
|
const members = ref<UserSummary[]>([]);
|
||||||
|
|
||||||
|
async function refreshMembers() {
|
||||||
|
const { data } = await api.groups.fetchMembers();
|
||||||
|
if (data) {
|
||||||
|
members.value = data.items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterItems(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
|
||||||
|
if (!groupSlug || !item.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/g/${groupSlug}?${itemType}=${item.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refreshMembers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function getMember(id: string) {
|
||||||
|
if (members.value[0]) {
|
||||||
|
return members.value.find(m => m.id === id)?.fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return i18n.t("general.none");
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -51,7 +51,7 @@
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
v-if="shoppingListIngredientDialog"
|
v-if="shoppingListIngredientDialog"
|
||||||
v-model="dialog"
|
v-model="dialog"
|
||||||
:title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')"
|
:title="selectedShoppingList?.name || $t('recipe.add-to-list')"
|
||||||
:icon="$globals.icons.cartCheck"
|
:icon="$globals.icons.cartCheck"
|
||||||
width="70%"
|
width="70%"
|
||||||
:submit-text="$t('recipe.add-to-list')"
|
:submit-text="$t('recipe.add-to-list')"
|
||||||
|
@ -130,20 +130,23 @@
|
||||||
.ingredients[i]
|
.ingredients[i]
|
||||||
.checked"
|
.checked"
|
||||||
>
|
>
|
||||||
<v-checkbox
|
<v-container class="pa-0 ma-0">
|
||||||
hide-details
|
<v-row no-gutters>
|
||||||
:model-value="ingredientData.checked"
|
<v-checkbox
|
||||||
class="pt-0 my-auto py-auto"
|
hide-details
|
||||||
color="secondary"
|
:model-value="ingredientData.checked"
|
||||||
density="compact"
|
class="pt-0 my-auto py-auto mr-2"
|
||||||
/>
|
color="secondary"
|
||||||
<div :key="ingredientData.ingredient.quantity">
|
density="compact"
|
||||||
<RecipeIngredientListItem
|
/>
|
||||||
:ingredient="ingredientData.ingredient"
|
<div :key="`${ingredientData.ingredient.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
|
||||||
:disable-amount="ingredientData.disableAmount"
|
<RecipeIngredientListItem
|
||||||
:scale="recipeSection.recipeScale"
|
:ingredient="ingredientData.ingredient"
|
||||||
/>
|
:scale="recipeSection.recipeScale"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -172,7 +175,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { toRefs } from "@vueuse/core";
|
import { toRefs } from "@vueuse/core";
|
||||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
|
@ -188,7 +191,6 @@ export interface RecipeWithScale extends Recipe {
|
||||||
export interface ShoppingListIngredient {
|
export interface ShoppingListIngredient {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
ingredient: RecipeIngredient;
|
ingredient: RecipeIngredient;
|
||||||
disableAmount: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShoppingListIngredientSection {
|
export interface ShoppingListIngredientSection {
|
||||||
|
@ -203,240 +205,214 @@ export interface ShoppingListRecipeIngredientSection {
|
||||||
ingredientSections: ShoppingListIngredientSection[];
|
ingredientSections: ShoppingListIngredientSection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
components: {
|
recipes?: RecipeWithScale[];
|
||||||
RecipeIngredientListItem,
|
shoppingLists?: ShoppingListSummary[];
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
recipes: undefined,
|
||||||
|
shoppingLists: () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialog = defineModel<boolean>({ default: false });
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const api = useUserApi();
|
||||||
|
const preferences = useShoppingListPreferences();
|
||||||
|
const ready = ref(false);
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
shoppingListDialog: true,
|
||||||
|
shoppingListIngredientDialog: false,
|
||||||
|
shoppingListShowAllToggled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { shoppingListDialog, shoppingListIngredientDialog, shoppingListShowAllToggled: _shoppingListShowAllToggled } = toRefs(state);
|
||||||
|
|
||||||
|
const userHousehold = computed(() => {
|
||||||
|
return $auth.user.value?.householdSlug || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const shoppingListChoices = computed(() => {
|
||||||
|
return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
|
||||||
|
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
|
||||||
|
|
||||||
|
watchEffect(
|
||||||
|
() => {
|
||||||
|
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||||
|
selectedShoppingList.value = shoppingListChoices.value[0];
|
||||||
|
openShoppingListIngredientDialog(selectedShoppingList.value);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
ready.value = true;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
props: {
|
);
|
||||||
modelValue: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
recipes: {
|
|
||||||
type: Array as () => RecipeWithScale[],
|
|
||||||
default: undefined,
|
|
||||||
},
|
|
||||||
shoppingLists: {
|
|
||||||
type: Array as () => ShoppingListSummary[],
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["update:modelValue"],
|
|
||||||
setup(props, context) {
|
|
||||||
const i18n = useI18n();
|
|
||||||
const $auth = useMealieAuth();
|
|
||||||
const api = useUserApi();
|
|
||||||
const preferences = useShoppingListPreferences();
|
|
||||||
const ready = ref(false);
|
|
||||||
|
|
||||||
// v-model support
|
watch(dialog, (val) => {
|
||||||
const dialog = computed({
|
if (!val) {
|
||||||
get: () => {
|
initState();
|
||||||
return props.modelValue;
|
}
|
||||||
},
|
});
|
||||||
set: (val) => {
|
|
||||||
context.emit("update:modelValue", val);
|
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||||
initState();
|
const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>();
|
||||||
},
|
for (const recipe of recipes) {
|
||||||
|
if (!recipe.slug) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipeSectionMap.has(recipe.slug)) {
|
||||||
|
const existingSection = recipeSectionMap.get(recipe.slug);
|
||||||
|
if (existingSection) {
|
||||||
|
existingSection.recipeScale += recipe.scale;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(recipe.id && recipe.name && recipe.recipeIngredient)) {
|
||||||
|
const { data } = await api.recipes.getOne(recipe.slug);
|
||||||
|
if (!data?.recipeIngredient?.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
recipe.id = data.id || "";
|
||||||
|
recipe.name = data.name || "";
|
||||||
|
recipe.recipeIngredient = data.recipeIngredient;
|
||||||
|
}
|
||||||
|
else if (!recipe.recipeIngredient.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
|
||||||
|
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
|
||||||
|
return {
|
||||||
|
checked: !householdsWithFood.includes(userHousehold.value),
|
||||||
|
ingredient: ing,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = reactive({
|
let currentTitle = "";
|
||||||
shoppingListDialog: true,
|
const onHandIngs: ShoppingListIngredient[] = [];
|
||||||
shoppingListIngredientDialog: false,
|
const shoppingListIngredientSections = shoppingListIngredients.reduce((sections, ing) => {
|
||||||
shoppingListShowAllToggled: false,
|
if (ing.ingredient.title) {
|
||||||
});
|
currentTitle = ing.ingredient.title;
|
||||||
|
}
|
||||||
|
|
||||||
const userHousehold = computed(() => {
|
// If this is the first item in the section, create a new section
|
||||||
return $auth.user.value?.householdSlug || "";
|
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
|
||||||
});
|
if (sections.length) {
|
||||||
|
// Add the on-hand ingredients to the previous section
|
||||||
const shoppingListChoices = computed(() => {
|
sections[sections.length - 1].ingredients.push(...onHandIngs);
|
||||||
return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
|
onHandIngs.length = 0;
|
||||||
});
|
|
||||||
|
|
||||||
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
|
|
||||||
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
|
|
||||||
|
|
||||||
watchEffect(
|
|
||||||
() => {
|
|
||||||
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
|
|
||||||
selectedShoppingList.value = shoppingListChoices.value[0];
|
|
||||||
openShoppingListIngredientDialog(selectedShoppingList.value);
|
|
||||||
}
|
}
|
||||||
else {
|
sections.push({
|
||||||
ready.value = true;
|
sectionName: currentTitle,
|
||||||
|
ingredients: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the on-hand ingredients for later
|
||||||
|
const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
|
||||||
|
if (householdsWithFood.includes(userHousehold.value)) {
|
||||||
|
onHandIngs.push(ing);
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the ingredient to previous section
|
||||||
|
sections[sections.length - 1].ingredients.push(ing);
|
||||||
|
return sections;
|
||||||
|
}, [] as ShoppingListIngredientSection[]);
|
||||||
|
|
||||||
|
// Add remaining on-hand ingredients to the previous section
|
||||||
|
shoppingListIngredientSections[shoppingListIngredientSections.length - 1].ingredients.push(...onHandIngs);
|
||||||
|
|
||||||
|
recipeSectionMap.set(recipe.slug, {
|
||||||
|
recipeId: recipe.id,
|
||||||
|
recipeName: recipe.name,
|
||||||
|
recipeScale: recipe.scale,
|
||||||
|
ingredientSections: shoppingListIngredientSections,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function initState() {
|
||||||
|
state.shoppingListDialog = true;
|
||||||
|
state.shoppingListIngredientDialog = false;
|
||||||
|
state.shoppingListShowAllToggled = false;
|
||||||
|
recipeIngredientSections.value = [];
|
||||||
|
selectedShoppingList.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
initState();
|
||||||
|
|
||||||
|
async function openShoppingListIngredientDialog(list: ShoppingListSummary) {
|
||||||
|
if (!props.recipes?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedShoppingList.value = list;
|
||||||
|
await consolidateRecipesIntoSections(props.recipes);
|
||||||
|
state.shoppingListDialog = false;
|
||||||
|
state.shoppingListIngredientDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setShowAllToggled() {
|
||||||
|
state.shoppingListShowAllToggled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bulkCheckIngredients(value = true) {
|
||||||
|
recipeIngredientSections.value.forEach((recipeSection) => {
|
||||||
|
recipeSection.ingredientSections.forEach((ingSection) => {
|
||||||
|
ingSection.ingredients.forEach((ing) => {
|
||||||
|
ing.checked = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRecipesToList() {
|
||||||
|
if (!selectedShoppingList.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipeData: ShoppingListAddRecipeParamsBulk[] = [];
|
||||||
|
recipeIngredientSections.value.forEach((section) => {
|
||||||
|
const ingredients: RecipeIngredient[] = [];
|
||||||
|
section.ingredientSections.forEach((ingSection) => {
|
||||||
|
ingSection.ingredients.forEach((ing) => {
|
||||||
|
if (ing.checked) {
|
||||||
|
ingredients.push(ing.ingredient);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ingredients.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recipeData.push(
|
||||||
|
{
|
||||||
|
recipeId: section.recipeId,
|
||||||
|
recipeIncrementQuantity: section.recipeScale,
|
||||||
|
recipeIngredients: ingredients,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData);
|
||||||
const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>();
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
for (const recipe of recipes) {
|
error ? alert.error(i18n.t("recipe.failed-to-add-recipes-to-list")) : alert.success(i18n.t("recipe.successfully-added-to-list"));
|
||||||
if (!recipe.slug) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipeSectionMap.has(recipe.slug)) {
|
state.shoppingListDialog = false;
|
||||||
recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale;
|
state.shoppingListIngredientDialog = false;
|
||||||
continue;
|
dialog.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(recipe.id && recipe.name && recipe.recipeIngredient)) {
|
|
||||||
const { data } = await api.recipes.getOne(recipe.slug);
|
|
||||||
if (!data?.recipeIngredient?.length) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
recipe.id = data.id || "";
|
|
||||||
recipe.name = data.name || "";
|
|
||||||
recipe.recipeIngredient = data.recipeIngredient;
|
|
||||||
}
|
|
||||||
else if (!recipe.recipeIngredient.length) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
|
|
||||||
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
|
|
||||||
return {
|
|
||||||
checked: !householdsWithFood.includes(userHousehold.value),
|
|
||||||
ingredient: ing,
|
|
||||||
disableAmount: recipe.settings?.disableAmount || false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
let currentTitle = "";
|
|
||||||
const onHandIngs: ShoppingListIngredient[] = [];
|
|
||||||
const shoppingListIngredientSections = shoppingListIngredients.reduce((sections, ing) => {
|
|
||||||
if (ing.ingredient.title) {
|
|
||||||
currentTitle = ing.ingredient.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this is the first item in the section, create a new section
|
|
||||||
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
|
|
||||||
if (sections.length) {
|
|
||||||
// Add the on-hand ingredients to the previous section
|
|
||||||
sections[sections.length - 1].ingredients.push(...onHandIngs);
|
|
||||||
onHandIngs.length = 0;
|
|
||||||
}
|
|
||||||
sections.push({
|
|
||||||
sectionName: currentTitle,
|
|
||||||
ingredients: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the on-hand ingredients for later
|
|
||||||
const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
|
|
||||||
if (householdsWithFood.includes(userHousehold.value)) {
|
|
||||||
onHandIngs.push(ing);
|
|
||||||
return sections;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the ingredient to previous section
|
|
||||||
sections[sections.length - 1].ingredients.push(ing);
|
|
||||||
return sections;
|
|
||||||
}, [] as ShoppingListIngredientSection[]);
|
|
||||||
|
|
||||||
// Add remaining on-hand ingredients to the previous section
|
|
||||||
shoppingListIngredientSections[shoppingListIngredientSections.length - 1].ingredients.push(...onHandIngs);
|
|
||||||
|
|
||||||
recipeSectionMap.set(recipe.slug, {
|
|
||||||
recipeId: recipe.id,
|
|
||||||
recipeName: recipe.name,
|
|
||||||
recipeScale: recipe.scale,
|
|
||||||
ingredientSections: shoppingListIngredientSections,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
function initState() {
|
|
||||||
state.shoppingListDialog = true;
|
|
||||||
state.shoppingListIngredientDialog = false;
|
|
||||||
state.shoppingListShowAllToggled = false;
|
|
||||||
recipeIngredientSections.value = [];
|
|
||||||
selectedShoppingList.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
initState();
|
|
||||||
|
|
||||||
async function openShoppingListIngredientDialog(list: ShoppingListSummary) {
|
|
||||||
if (!props.recipes?.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedShoppingList.value = list;
|
|
||||||
await consolidateRecipesIntoSections(props.recipes);
|
|
||||||
state.shoppingListDialog = false;
|
|
||||||
state.shoppingListIngredientDialog = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setShowAllToggled() {
|
|
||||||
state.shoppingListShowAllToggled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bulkCheckIngredients(value = true) {
|
|
||||||
recipeIngredientSections.value.forEach((recipeSection) => {
|
|
||||||
recipeSection.ingredientSections.forEach((ingSection) => {
|
|
||||||
ingSection.ingredients.forEach((ing) => {
|
|
||||||
ing.checked = value;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addRecipesToList() {
|
|
||||||
if (!selectedShoppingList.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipeData: ShoppingListAddRecipeParamsBulk[] = [];
|
|
||||||
recipeIngredientSections.value.forEach((section) => {
|
|
||||||
const ingredients: RecipeIngredient[] = [];
|
|
||||||
section.ingredientSections.forEach((ingSection) => {
|
|
||||||
ingSection.ingredients.forEach((ing) => {
|
|
||||||
if (ing.checked) {
|
|
||||||
ingredients.push(ing.ingredient);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ingredients.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
recipeData.push(
|
|
||||||
{
|
|
||||||
recipeId: section.recipeId,
|
|
||||||
recipeIncrementQuantity: section.recipeScale,
|
|
||||||
recipeIngredients: ingredients,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
||||||
error ? alert.error(i18n.t("recipe.failed-to-add-recipes-to-list")) : alert.success(i18n.t("recipe.successfully-added-to-list"));
|
|
||||||
|
|
||||||
state.shoppingListDialog = false;
|
|
||||||
state.shoppingListIngredientDialog = false;
|
|
||||||
dialog.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
dialog,
|
|
||||||
preferences,
|
|
||||||
ready,
|
|
||||||
shoppingListChoices,
|
|
||||||
...toRefs(state),
|
|
||||||
addRecipesToList,
|
|
||||||
bulkCheckIngredients,
|
|
||||||
openShoppingListIngredientDialog,
|
|
||||||
setShowAllToggled,
|
|
||||||
recipeIngredientSections,
|
|
||||||
selectedShoppingList,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="css">
|
<style scoped lang="css">
|
||||||
|
|
|
@ -4,9 +4,9 @@
|
||||||
v-model="dialog"
|
v-model="dialog"
|
||||||
width="800"
|
width="800"
|
||||||
>
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-bind="props"
|
v-bind="activatorProps"
|
||||||
@click="inputText = inputTextProp"
|
@click="inputText = inputTextProp"
|
||||||
>
|
>
|
||||||
{{ $t("new-recipe.bulk-add") }}
|
{{ $t("new-recipe.bulk-add") }}
|
||||||
|
@ -89,88 +89,75 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
props: {
|
inputTextProp?: string;
|
||||||
inputTextProp: {
|
}
|
||||||
type: String,
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
required: false,
|
inputTextProp: "",
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["bulk-data"],
|
|
||||||
setup(props, context) {
|
|
||||||
const state = reactive({
|
|
||||||
dialog: false,
|
|
||||||
inputText: props.inputTextProp,
|
|
||||||
});
|
|
||||||
|
|
||||||
function splitText() {
|
|
||||||
return state.inputText.split("\n").filter(line => !(line === "\n" || !line));
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFirstCharacter() {
|
|
||||||
state.inputText = splitText()
|
|
||||||
.map(line => line.substring(1))
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
const numberedLineRegex = /\d+[.):] /gm;
|
|
||||||
|
|
||||||
function splitByNumberedLine() {
|
|
||||||
// Split inputText by numberedLineRegex
|
|
||||||
const matches = state.inputText.match(numberedLineRegex);
|
|
||||||
|
|
||||||
matches?.forEach((match, idx) => {
|
|
||||||
const replaceText = idx === 0 ? "" : "\n";
|
|
||||||
state.inputText = state.inputText.replace(match, replaceText);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimAllLines() {
|
|
||||||
const splitLines = splitText();
|
|
||||||
|
|
||||||
splitLines.forEach((element: string, index: number) => {
|
|
||||||
splitLines[index] = element.trim();
|
|
||||||
});
|
|
||||||
|
|
||||||
state.inputText = splitLines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function save() {
|
|
||||||
context.emit("bulk-data", splitText());
|
|
||||||
state.dialog = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const i18n = useI18n();
|
|
||||||
|
|
||||||
const utilities = [
|
|
||||||
{
|
|
||||||
id: "trim-whitespace",
|
|
||||||
description: i18n.t("new-recipe.trim-whitespace-description"),
|
|
||||||
action: trimAllLines,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trim-prefix",
|
|
||||||
description: i18n.t("new-recipe.trim-prefix-description"),
|
|
||||||
action: removeFirstCharacter,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "split-by-numbered-line",
|
|
||||||
description: i18n.t("new-recipe.split-by-numbered-line-description"),
|
|
||||||
action: splitByNumberedLine,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
utilities,
|
|
||||||
splitText,
|
|
||||||
trimAllLines,
|
|
||||||
removeFirstCharacter,
|
|
||||||
splitByNumberedLine,
|
|
||||||
save,
|
|
||||||
...toRefs(state),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"bulk-data": [data: string[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dialog = ref(false);
|
||||||
|
const inputText = ref(props.inputTextProp);
|
||||||
|
|
||||||
|
function splitText() {
|
||||||
|
return inputText.value.split("\n").filter(line => !(line === "\n" || !line));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFirstCharacter() {
|
||||||
|
inputText.value = splitText()
|
||||||
|
.map(line => line.substring(1))
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberedLineRegex = /\d+[.):] /gm;
|
||||||
|
|
||||||
|
function splitByNumberedLine() {
|
||||||
|
// Split inputText by numberedLineRegex
|
||||||
|
const matches = inputText.value.match(numberedLineRegex);
|
||||||
|
|
||||||
|
matches?.forEach((match, idx) => {
|
||||||
|
const replaceText = idx === 0 ? "" : "\n";
|
||||||
|
inputText.value = inputText.value.replace(match, replaceText);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimAllLines() {
|
||||||
|
const splitLines = splitText();
|
||||||
|
|
||||||
|
splitLines.forEach((element: string, index: number) => {
|
||||||
|
splitLines[index] = element.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
inputText.value = splitLines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
emit("bulk-data", splitText());
|
||||||
|
dialog.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const utilities = [
|
||||||
|
{
|
||||||
|
id: "trim-whitespace",
|
||||||
|
description: i18n.t("new-recipe.trim-whitespace-description"),
|
||||||
|
action: trimAllLines,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trim-prefix",
|
||||||
|
description: i18n.t("new-recipe.trim-prefix-description"),
|
||||||
|
action: removeFirstCharacter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "split-by-numbered-line",
|
||||||
|
description: i18n.t("new-recipe.split-by-numbered-line-description"),
|
||||||
|
action: splitByNumberedLine,
|
||||||
|
},
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -44,6 +44,7 @@
|
||||||
<v-switch
|
<v-switch
|
||||||
v-model="preferences.showDescription"
|
v-model="preferences.showDescription"
|
||||||
hide-details
|
hide-details
|
||||||
|
color="primary"
|
||||||
:label="$t('recipe.description')"
|
:label="$t('recipe.description')"
|
||||||
/>
|
/>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
@ -51,6 +52,7 @@
|
||||||
<v-switch
|
<v-switch
|
||||||
v-model="preferences.showNotes"
|
v-model="preferences.showNotes"
|
||||||
hide-details
|
hide-details
|
||||||
|
color="primary"
|
||||||
:label="$t('recipe.notes')"
|
:label="$t('recipe.notes')"
|
||||||
/>
|
/>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
@ -63,6 +65,7 @@
|
||||||
<v-switch
|
<v-switch
|
||||||
v-model="preferences.showNutrition"
|
v-model="preferences.showNutrition"
|
||||||
hide-details
|
hide-details
|
||||||
|
color="primary"
|
||||||
:label="$t('recipe.nutrition')"
|
:label="$t('recipe.nutrition')"
|
||||||
/>
|
/>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
@ -83,45 +86,19 @@
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
||||||
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
|
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
|
||||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
components: {
|
recipe?: NoUndefinedField<Recipe>;
|
||||||
RecipePrintView,
|
}
|
||||||
},
|
withDefaults(defineProps<Props>(), {
|
||||||
props: {
|
recipe: undefined,
|
||||||
modelValue: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
recipe: {
|
|
||||||
type: Object as () => NoUndefinedField<Recipe>,
|
|
||||||
default: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["update:modelValue"],
|
|
||||||
setup(props, context) {
|
|
||||||
const preferences = useUserPrintPreferences();
|
|
||||||
|
|
||||||
// V-Model Support
|
|
||||||
const dialog = computed({
|
|
||||||
get: () => {
|
|
||||||
return props.modelValue;
|
|
||||||
},
|
|
||||||
set: (val) => {
|
|
||||||
context.emit("update:modelValue", val);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
dialog,
|
|
||||||
ImagePosition,
|
|
||||||
preferences,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dialog = defineModel<boolean>({ default: false });
|
||||||
|
const preferences = useUserPrintPreferences();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -52,10 +52,6 @@
|
||||||
<div class="mr-auto">
|
<div class="mr-auto">
|
||||||
{{ $t("search.results") }}
|
{{ $t("search.results") }}
|
||||||
</div>
|
</div>
|
||||||
<!-- <router-link
|
|
||||||
:to="advancedSearchUrl"
|
|
||||||
class="text-primary"
|
|
||||||
> {{ $t("search.advanced-search") }} </router-link> -->
|
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
|
|
||||||
<RecipeCardMobile
|
<RecipeCardMobile
|
||||||
|
@ -76,7 +72,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import type { RecipeSummary } from "~/lib/api/types/recipe";
|
import type { RecipeSummary } from "~/lib/api/types/recipe";
|
||||||
|
@ -85,114 +81,104 @@ import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
|
||||||
import { usePublicExploreApi } from "~/composables/api/api-client";
|
import { usePublicExploreApi } from "~/composables/api/api-client";
|
||||||
|
|
||||||
const SELECTED_EVENT = "selected";
|
const SELECTED_EVENT = "selected";
|
||||||
export default defineNuxtComponent({
|
|
||||||
components: {
|
|
||||||
RecipeCardMobile,
|
|
||||||
},
|
|
||||||
|
|
||||||
setup(_, context) {
|
// Define emits
|
||||||
const $auth = useMealieAuth();
|
const emit = defineEmits<{
|
||||||
const state = reactive({
|
selected: [recipe: RecipeSummary];
|
||||||
loading: false,
|
}>();
|
||||||
selectedIndex: -1,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===========================================================================
|
const $auth = useMealieAuth();
|
||||||
// Dialog State Management
|
const loading = ref(false);
|
||||||
const dialog = ref(false);
|
const selectedIndex = ref(-1);
|
||||||
|
|
||||||
// Reset or Grab Recipes on Change
|
// ===========================================================================
|
||||||
watch(dialog, (val) => {
|
// Dialog State Management
|
||||||
if (!val) {
|
const dialog = ref(false);
|
||||||
search.query.value = "";
|
|
||||||
state.selectedIndex = -1;
|
|
||||||
search.data.value = [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===========================================================================
|
// Reset or Grab Recipes on Change
|
||||||
// Event Handlers
|
watch(dialog, (val) => {
|
||||||
|
if (!val) {
|
||||||
|
search.query.value = "";
|
||||||
|
selectedIndex.value = -1;
|
||||||
|
search.data.value = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function selectRecipe() {
|
// ===========================================================================
|
||||||
const recipeCards = document.getElementsByClassName("arrow-nav");
|
// Event Handlers
|
||||||
if (recipeCards) {
|
|
||||||
if (state.selectedIndex < 0) {
|
|
||||||
state.selectedIndex = -1;
|
|
||||||
document.getElementById("arrow-search")?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.selectedIndex >= recipeCards.length) {
|
function selectRecipe() {
|
||||||
state.selectedIndex = recipeCards.length - 1;
|
const recipeCards = document.getElementsByClassName("arrow-nav");
|
||||||
}
|
if (recipeCards) {
|
||||||
|
if (selectedIndex.value < 0) {
|
||||||
(recipeCards[state.selectedIndex] as HTMLElement).focus();
|
selectedIndex.value = -1;
|
||||||
}
|
document.getElementById("arrow-search")?.focus();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUpDown(e: KeyboardEvent) {
|
if (selectedIndex.value >= recipeCards.length) {
|
||||||
if (e.key === "Enter") {
|
selectedIndex.value = recipeCards.length - 1;
|
||||||
console.log(document.activeElement);
|
|
||||||
// (document.activeElement as HTMLElement).click();
|
|
||||||
}
|
|
||||||
else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
state.selectedIndex--;
|
|
||||||
}
|
|
||||||
else if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
state.selectedIndex++;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selectRecipe();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(dialog, (val) => {
|
(recipeCards[selectedIndex.value] as HTMLElement).focus();
|
||||||
if (!val) {
|
}
|
||||||
document.removeEventListener("keyup", onUpDown);
|
}
|
||||||
}
|
|
||||||
else {
|
|
||||||
document.addEventListener("keyup", onUpDown);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
function onUpDown(e: KeyboardEvent) {
|
||||||
const route = useRoute();
|
if (e.key === "Enter") {
|
||||||
const advancedSearchUrl = computed(() => `/g/${groupSlug.value}`);
|
console.log(document.activeElement);
|
||||||
watch(route, close);
|
// (document.activeElement as HTMLElement).click();
|
||||||
|
}
|
||||||
|
else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex.value--;
|
||||||
|
}
|
||||||
|
else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex.value++;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectRecipe();
|
||||||
|
}
|
||||||
|
|
||||||
function open() {
|
watch(dialog, (val) => {
|
||||||
dialog.value = true;
|
if (!val) {
|
||||||
}
|
document.removeEventListener("keyup", onUpDown);
|
||||||
function close() {
|
}
|
||||||
dialog.value = false;
|
else {
|
||||||
}
|
document.addEventListener("keyup", onUpDown);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ===========================================================================
|
const route = useRoute();
|
||||||
// Basic Search
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
const { isOwnGroup } = useLoggedInState();
|
watch(route, close);
|
||||||
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
|
|
||||||
const search = useRecipeSearch(api);
|
|
||||||
|
|
||||||
// Select Handler
|
function open() {
|
||||||
|
dialog.value = true;
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
dialog.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
function handleSelect(recipe: RecipeSummary) {
|
// ===========================================================================
|
||||||
close();
|
// Basic Search
|
||||||
context.emit(SELECTED_EVENT, recipe);
|
const { isOwnGroup } = useLoggedInState();
|
||||||
}
|
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
|
||||||
|
const search = useRecipeSearch(api);
|
||||||
|
|
||||||
return {
|
// Select Handler
|
||||||
...toRefs(state),
|
function handleSelect(recipe: RecipeSummary) {
|
||||||
advancedSearchUrl,
|
close();
|
||||||
dialog,
|
emit(SELECTED_EVENT, recipe);
|
||||||
open,
|
}
|
||||||
close,
|
|
||||||
handleSelect,
|
// Expose functions to parent components
|
||||||
search,
|
defineExpose({
|
||||||
};
|
open,
|
||||||
},
|
close,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -14,14 +14,14 @@
|
||||||
max-width="290px"
|
max-width="290px"
|
||||||
min-width="auto"
|
min-width="auto"
|
||||||
>
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="expirationDateString"
|
v-model="expirationDateString"
|
||||||
:label="$t('recipe-share.expiration-date')"
|
:label="$t('recipe-share.expiration-date')"
|
||||||
:hint="$t('recipe-share.default-30-days')"
|
:hint="$t('recipe-share.default-30-days')"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
:prepend-icon="$globals.icons.calendar"
|
:prepend-icon="$globals.icons.calendar"
|
||||||
v-bind="props"
|
v-bind="activatorProps"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
@ -92,150 +92,116 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { useClipboard, useShare, whenever } from "@vueuse/core";
|
import { useClipboard, useShare, whenever } from "@vueuse/core";
|
||||||
import type { RecipeShareToken } from "~/lib/api/types/recipe";
|
import type { RecipeShareToken } from "~/lib/api/types/recipe";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { useHouseholdSelf } from "~/composables/use-households";
|
import { useHouseholdSelf } from "~/composables/use-households";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
props: {
|
recipeId: string;
|
||||||
modelValue: {
|
name: string;
|
||||||
type: Boolean,
|
}
|
||||||
default: false,
|
const props = defineProps<Props>();
|
||||||
},
|
|
||||||
recipeId: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["update:modelValue"],
|
|
||||||
setup(props, context) {
|
|
||||||
// V-Model Support
|
|
||||||
const dialog = computed({
|
|
||||||
get: () => {
|
|
||||||
return props.modelValue;
|
|
||||||
},
|
|
||||||
set: (val) => {
|
|
||||||
context.emit("update:modelValue", val);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const state = reactive({
|
const dialog = defineModel<boolean>({ default: false });
|
||||||
datePickerMenu: false,
|
|
||||||
expirationDate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
|
|
||||||
tokens: [] as RecipeShareToken[],
|
|
||||||
});
|
|
||||||
|
|
||||||
const expirationDateString = computed(() => {
|
const datePickerMenu = ref(false);
|
||||||
return state.expirationDate.toISOString().substring(0, 10);
|
const expirationDate = ref(new Date(Date.now() - new Date().getTimezoneOffset() * 60000));
|
||||||
});
|
const tokens = ref<RecipeShareToken[]>([]);
|
||||||
|
|
||||||
whenever(
|
const expirationDateString = computed(() => {
|
||||||
() => props.modelValue,
|
return expirationDate.value.toISOString().substring(0, 10);
|
||||||
() => {
|
|
||||||
// Set expiration date to today + 30 Days
|
|
||||||
const today = new Date();
|
|
||||||
state.expirationDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
|
|
||||||
refreshTokens();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const i18n = useI18n();
|
|
||||||
const $auth = useMealieAuth();
|
|
||||||
const { household } = useHouseholdSelf();
|
|
||||||
const route = useRoute();
|
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
|
||||||
|
|
||||||
const firstDayOfWeek = computed(() => {
|
|
||||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Token Actions
|
|
||||||
|
|
||||||
const userApi = useUserApi();
|
|
||||||
|
|
||||||
async function createNewToken() {
|
|
||||||
// Convert expiration date to timestamp
|
|
||||||
const { data } = await userApi.recipes.share.createOne({
|
|
||||||
recipeId: props.recipeId,
|
|
||||||
expiresAt: state.expirationDate.toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
state.tokens.push(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteToken(id: string) {
|
|
||||||
await userApi.recipes.share.deleteOne(id);
|
|
||||||
state.tokens = state.tokens.filter(token => token.id !== id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshTokens() {
|
|
||||||
const { data } = await userApi.recipes.share.getAll(1, -1, { recipe_id: props.recipeId });
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
// @ts-expect-error - TODO: This routes doesn't have pagination, but the type are mismatched.
|
|
||||||
state.tokens = data ?? [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { share, isSupported: shareIsSupported } = useShare();
|
|
||||||
const { copy, copied, isSupported } = useClipboard();
|
|
||||||
|
|
||||||
function getRecipeText() {
|
|
||||||
return i18n.t("recipe.share-recipe-message", [props.name]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTokenLink(token: string) {
|
|
||||||
return `${window.location.origin}/g/${groupSlug.value}/shared/r/${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyTokenLink(token: string) {
|
|
||||||
if (isSupported.value) {
|
|
||||||
await copy(getTokenLink(token));
|
|
||||||
if (copied.value) {
|
|
||||||
alert.success(i18n.t("recipe-share.recipe-link-copied-message") as string);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
alert.error(i18n.t("general.clipboard-copy-failure") as string);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
alert.error(i18n.t("general.clipboard-not-supported") as string);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function shareRecipe(token: string) {
|
|
||||||
if (shareIsSupported) {
|
|
||||||
share({
|
|
||||||
title: props.name,
|
|
||||||
url: getTokenLink(token),
|
|
||||||
text: getRecipeText() as string,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
await copyTokenLink(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...toRefs(state),
|
|
||||||
expirationDateString,
|
|
||||||
dialog,
|
|
||||||
createNewToken,
|
|
||||||
deleteToken,
|
|
||||||
firstDayOfWeek,
|
|
||||||
shareRecipe,
|
|
||||||
copyTokenLink,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
whenever(
|
||||||
|
() => dialog.value,
|
||||||
|
() => {
|
||||||
|
// Set expiration date to today + 30 Days
|
||||||
|
const today = new Date();
|
||||||
|
expirationDate.value = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||||
|
refreshTokens();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const { household } = useHouseholdSelf();
|
||||||
|
const route = useRoute();
|
||||||
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
|
|
||||||
|
const firstDayOfWeek = computed(() => {
|
||||||
|
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Token Actions
|
||||||
|
|
||||||
|
const userApi = useUserApi();
|
||||||
|
|
||||||
|
async function createNewToken() {
|
||||||
|
// Convert expiration date to timestamp
|
||||||
|
const { data } = await userApi.recipes.share.createOne({
|
||||||
|
recipeId: props.recipeId,
|
||||||
|
expiresAt: expirationDate.value.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
tokens.value.push(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteToken(id: string) {
|
||||||
|
await userApi.recipes.share.deleteOne(id);
|
||||||
|
tokens.value = tokens.value.filter(token => token.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTokens() {
|
||||||
|
const { data } = await userApi.recipes.share.getAll(1, -1, { recipe_id: props.recipeId });
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
// @ts-expect-error - TODO: This routes doesn't have pagination, but the type are mismatched.
|
||||||
|
tokens.value = data ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { share, isSupported: shareIsSupported } = useShare();
|
||||||
|
const { copy, copied, isSupported } = useClipboard();
|
||||||
|
|
||||||
|
function getRecipeText() {
|
||||||
|
return i18n.t("recipe.share-recipe-message", [props.name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenLink(token: string) {
|
||||||
|
return `${window.location.origin}/g/${groupSlug.value}/shared/r/${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTokenLink(token: string) {
|
||||||
|
if (isSupported.value) {
|
||||||
|
await copy(getTokenLink(token));
|
||||||
|
if (copied.value) {
|
||||||
|
alert.success(i18n.t("recipe-share.recipe-link-copied-message") as string);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert.error(i18n.t("general.clipboard-copy-failure") as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert.error(i18n.t("general.clipboard-not-supported") as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shareRecipe(token: string) {
|
||||||
|
if (shareIsSupported) {
|
||||||
|
share({
|
||||||
|
title: props.name,
|
||||||
|
url: getTokenLink(token),
|
||||||
|
text: getRecipeText() as string,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await copyTokenLink(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
nudge-right="50"
|
nudge-right="50"
|
||||||
:color="buttonStyle ? 'info' : 'secondary'"
|
:color="buttonStyle ? 'info' : 'secondary'"
|
||||||
>
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props: tooltipProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="isFavorite || showAlways"
|
v-if="isFavorite || showAlways"
|
||||||
icon
|
icon
|
||||||
|
@ -13,7 +13,7 @@
|
||||||
size="small"
|
size="small"
|
||||||
:color="buttonStyle ? 'info' : 'secondary'"
|
:color="buttonStyle ? 'info' : 'secondary'"
|
||||||
:fab="buttonStyle"
|
:fab="buttonStyle"
|
||||||
v-bind="{ ...props, ...$attrs }"
|
v-bind="{ ...tooltipProps, ...$attrs }"
|
||||||
@click.prevent="toggleFavorite"
|
@click.prevent="toggleFavorite"
|
||||||
>
|
>
|
||||||
<v-icon
|
<v-icon
|
||||||
|
@ -28,47 +28,38 @@
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUserSelfRatings } from "~/composables/use-users";
|
import { useUserSelfRatings } from "~/composables/use-users";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
props: {
|
recipeId?: string;
|
||||||
recipeId: {
|
showAlways?: boolean;
|
||||||
type: String,
|
buttonStyle?: boolean;
|
||||||
default: "",
|
}
|
||||||
},
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
showAlways: {
|
recipeId: "",
|
||||||
type: Boolean,
|
showAlways: false,
|
||||||
default: false,
|
buttonStyle: false,
|
||||||
},
|
|
||||||
buttonStyle: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const api = useUserApi();
|
|
||||||
const $auth = useMealieAuth();
|
|
||||||
const { userRatings, refreshUserRatings } = useUserSelfRatings();
|
|
||||||
|
|
||||||
const isFavorite = computed(() => {
|
|
||||||
const rating = userRatings.value.find(r => r.recipeId === props.recipeId);
|
|
||||||
return rating?.isFavorite || false;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function toggleFavorite() {
|
|
||||||
if (!$auth.user.value) return;
|
|
||||||
if (!isFavorite.value) {
|
|
||||||
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
|
|
||||||
}
|
|
||||||
await refreshUserRatings();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isFavorite, toggleFavorite };
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const api = useUserApi();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const { userRatings, refreshUserRatings } = useUserSelfRatings();
|
||||||
|
|
||||||
|
const isFavorite = computed(() => {
|
||||||
|
const rating = userRatings.value.find(r => r.recipeId === props.recipeId);
|
||||||
|
return rating?.isFavorite || false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function toggleFavorite() {
|
||||||
|
if (!$auth.user.value) return;
|
||||||
|
if (!isFavorite.value) {
|
||||||
|
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
|
||||||
|
}
|
||||||
|
await refreshUserRatings();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -7,11 +7,11 @@
|
||||||
nudge-top="6"
|
nudge-top="6"
|
||||||
:close-on-content-click="false"
|
:close-on-content-click="false"
|
||||||
>
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
color="accent"
|
color="accent"
|
||||||
dark
|
dark
|
||||||
v-bind="props"
|
v-bind="activatorProps"
|
||||||
>
|
>
|
||||||
<v-icon start>
|
<v-icon start>
|
||||||
{{ $globals.icons.fileImage }}
|
{{ $globals.icons.fileImage }}
|
||||||
|
@ -61,52 +61,42 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
|
|
||||||
const REFRESH_EVENT = "refresh";
|
const REFRESH_EVENT = "refresh";
|
||||||
const UPLOAD_EVENT = "upload";
|
const UPLOAD_EVENT = "upload";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
const props = defineProps<{ slug: string }>();
|
||||||
props: {
|
|
||||||
slug: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props, context) {
|
|
||||||
const state = reactive({
|
|
||||||
url: "",
|
|
||||||
loading: false,
|
|
||||||
menu: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
function uploadImage(fileObject: File) {
|
const emit = defineEmits<{
|
||||||
context.emit(UPLOAD_EVENT, fileObject);
|
refresh: [];
|
||||||
state.menu = false;
|
upload: [fileObject: File];
|
||||||
}
|
}>();
|
||||||
|
|
||||||
const api = useUserApi();
|
const url = ref("");
|
||||||
async function getImageFromURL() {
|
const loading = ref(false);
|
||||||
state.loading = true;
|
const menu = ref(false);
|
||||||
if (await api.recipes.updateImagebyURL(props.slug, state.url)) {
|
|
||||||
context.emit(REFRESH_EVENT);
|
|
||||||
}
|
|
||||||
state.loading = false;
|
|
||||||
state.menu = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const i18n = useI18n();
|
function uploadImage(fileObject: File) {
|
||||||
const messages = props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")];
|
emit(UPLOAD_EVENT, fileObject);
|
||||||
|
menu.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
const api = useUserApi();
|
||||||
...toRefs(state),
|
async function getImageFromURL() {
|
||||||
uploadImage,
|
loading.value = true;
|
||||||
getImageFromURL,
|
if (await api.recipes.updateImagebyURL(props.slug, url.value)) {
|
||||||
messages,
|
emit(REFRESH_EVENT);
|
||||||
};
|
}
|
||||||
},
|
loading.value = false;
|
||||||
});
|
menu.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const messages = computed(() =>
|
||||||
|
props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")],
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
<style lang="scss" scoped></style>
|
||||||
|
|
|
@ -17,7 +17,6 @@
|
||||||
class="d-flex flex-wrap my-1"
|
class="d-flex flex-wrap my-1"
|
||||||
>
|
>
|
||||||
<v-col
|
<v-col
|
||||||
v-if="!disableAmount"
|
|
||||||
sm="12"
|
sm="12"
|
||||||
md="2"
|
md="2"
|
||||||
cols="12"
|
cols="12"
|
||||||
|
@ -42,7 +41,6 @@
|
||||||
</v-text-field>
|
</v-text-field>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col
|
<v-col
|
||||||
v-if="!disableAmount"
|
|
||||||
sm="12"
|
sm="12"
|
||||||
md="3"
|
md="3"
|
||||||
cols="12"
|
cols="12"
|
||||||
|
@ -63,6 +61,22 @@
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="handleUnitEnter"
|
@keyup.enter="handleUnitEnter"
|
||||||
>
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-tooltip v-if="unitError" location="bottom">
|
||||||
|
<template #activator="{ props: unitTooltipProps }">
|
||||||
|
<v-icon
|
||||||
|
v-bind="unitTooltipProps"
|
||||||
|
class="ml-2 mr-n3 opacity-100"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{{ $globals.icons.alert }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
<span v-if="unitErrorTooltip">
|
||||||
|
{{ unitErrorTooltip }}
|
||||||
|
</span>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
<template #no-data>
|
<template #no-data>
|
||||||
<div class="caption text-center pb-2">
|
<div class="caption text-center pb-2">
|
||||||
{{ $t("recipe.press-enter-to-create") }}
|
{{ $t("recipe.press-enter-to-create") }}
|
||||||
|
@ -82,7 +96,6 @@
|
||||||
|
|
||||||
<!-- Foods Input -->
|
<!-- Foods Input -->
|
||||||
<v-col
|
<v-col
|
||||||
v-if="!disableAmount"
|
|
||||||
m="12"
|
m="12"
|
||||||
md="3"
|
md="3"
|
||||||
cols="12"
|
cols="12"
|
||||||
|
@ -104,6 +117,22 @@
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="handleFoodEnter"
|
@keyup.enter="handleFoodEnter"
|
||||||
>
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-tooltip v-if="foodError" location="bottom">
|
||||||
|
<template #activator="{ props: foodTooltipProps }">
|
||||||
|
<v-icon
|
||||||
|
v-bind="foodTooltipProps"
|
||||||
|
class="ml-2 mr-n3 opacity-100"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{{ $globals.icons.alert }}
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
<span v-if="foodErrorTooltip">
|
||||||
|
{{ foodErrorTooltip }}
|
||||||
|
</span>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
<template #no-data>
|
<template #no-data>
|
||||||
<div class="caption text-center pb-2">
|
<div class="caption text-center pb-2">
|
||||||
{{ $t("recipe.press-enter-to-create") }}
|
{{ $t("recipe.press-enter-to-create") }}
|
||||||
|
@ -134,16 +163,7 @@
|
||||||
:placeholder="$t('recipe.notes')"
|
:placeholder="$t('recipe.notes')"
|
||||||
class="mb-auto"
|
class="mb-auto"
|
||||||
@click="$emit('clickIngredientField', 'note')"
|
@click="$emit('clickIngredientField', 'note')"
|
||||||
>
|
/>
|
||||||
<template #prepend>
|
|
||||||
<v-icon
|
|
||||||
v-if="disableAmount && $attrs && $attrs.delete"
|
|
||||||
class="mr-n1 handle"
|
|
||||||
>
|
|
||||||
{{ $globals.icons.arrowUpDown }}
|
|
||||||
</v-icon>
|
|
||||||
</template>
|
|
||||||
</v-text-field>
|
|
||||||
<BaseButtonGroup
|
<BaseButtonGroup
|
||||||
hover
|
hover
|
||||||
:large="false"
|
:large="false"
|
||||||
|
@ -153,7 +173,6 @@
|
||||||
@toggle-original="toggleOriginalText"
|
@toggle-original="toggleOriginalText"
|
||||||
@insert-above="$emit('insert-above')"
|
@insert-above="$emit('insert-above')"
|
||||||
@insert-below="$emit('insert-below')"
|
@insert-below="$emit('insert-below')"
|
||||||
@insert-ingredient="$emit('insert-ingredient')"
|
|
||||||
@delete="$emit('delete')"
|
@delete="$emit('delete')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -184,22 +203,29 @@ import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
// defineModel replaces modelValue prop
|
// defineModel replaces modelValue prop
|
||||||
const model = defineModel<RecipeIngredient>({ required: true });
|
const model = defineModel<RecipeIngredient>({ required: true });
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
disableAmount: {
|
unitError: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
allowInsertIngredient: {
|
unitErrorTooltip: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
foodError: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
foodErrorTooltip: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits([
|
defineEmits([
|
||||||
"clickIngredientField",
|
"clickIngredientField",
|
||||||
"insert-above",
|
"insert-above",
|
||||||
"insert-below",
|
"insert-below",
|
||||||
"insert-ingredient",
|
|
||||||
"delete",
|
"delete",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@ -228,13 +254,6 @@ const contextMenuOptions = computed(() => {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (props.allowInsertIngredient) {
|
|
||||||
options.push({
|
|
||||||
text: i18n.t("recipe.insert-ingredient"),
|
|
||||||
event: "insert-ingredient",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (model.value.originalText) {
|
if (model.value.originalText) {
|
||||||
options.push({
|
options.push({
|
||||||
text: i18n.t("recipe.see-original-text"),
|
text: i18n.t("recipe.see-original-text"),
|
||||||
|
|
|
@ -3,21 +3,13 @@
|
||||||
<div v-html="safeMarkup" />
|
<div v-html="safeMarkup" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients";
|
import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
props: {
|
markup: string;
|
||||||
markup: {
|
}
|
||||||
type: String,
|
const props = defineProps<Props>();
|
||||||
required: true,
|
|
||||||
},
|
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
|
|
||||||
return {
|
|
||||||
safeMarkup,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -28,34 +28,20 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import type { RecipeIngredient } from "~/lib/api/types/household";
|
import type { RecipeIngredient } from "~/lib/api/types/household";
|
||||||
import { useParsedIngredientText } from "~/composables/recipes";
|
import { useParsedIngredientText } from "~/composables/recipes";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
props: {
|
ingredient: RecipeIngredient;
|
||||||
ingredient: {
|
scale?: number;
|
||||||
type: Object as () => RecipeIngredient,
|
}
|
||||||
required: true,
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
},
|
scale: 1,
|
||||||
disableAmount: {
|
});
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
scale: {
|
|
||||||
type: Number,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const parsedIng = computed(() => {
|
|
||||||
return useParsedIngredientText(props.ingredient, props.disableAmount, props.scale);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
const parsedIng = computed(() => {
|
||||||
parsedIng,
|
return useParsedIngredientText(props.ingredient, props.scale);
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -43,7 +43,6 @@
|
||||||
<v-list-item-title>
|
<v-list-item-title>
|
||||||
<RecipeIngredientListItem
|
<RecipeIngredientListItem
|
||||||
:ingredient="ingredient"
|
:ingredient="ingredient"
|
||||||
:disable-amount="disableAmount"
|
|
||||||
:scale="scale"
|
:scale="scale"
|
||||||
/>
|
/>
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
|
@ -53,71 +52,51 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||||
import { parseIngredientText } from "~/composables/recipes";
|
import { parseIngredientText } from "~/composables/recipes";
|
||||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
components: { RecipeIngredientListItem },
|
value?: RecipeIngredient[];
|
||||||
props: {
|
scale?: number;
|
||||||
value: {
|
isCookMode?: boolean;
|
||||||
type: Array as () => RecipeIngredient[],
|
}
|
||||||
default: () => [],
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
},
|
value: () => [],
|
||||||
disableAmount: {
|
scale: 1,
|
||||||
type: Boolean,
|
isCookMode: false,
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
scale: {
|
|
||||||
type: Number,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
isCookMode: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
function validateTitle(title?: string) {
|
|
||||||
return !(title === undefined || title === "" || title === null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
checked: props.value.map(() => false),
|
|
||||||
showTitleEditor: computed(() => props.value.map(x => validateTitle(x.title))),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ingredientCopyText = computed(() => {
|
|
||||||
const components: string[] = [];
|
|
||||||
props.value.forEach((ingredient) => {
|
|
||||||
if (ingredient.title) {
|
|
||||||
if (components.length) {
|
|
||||||
components.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
components.push(`[${ingredient.title}]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
components.push(parseIngredientText(ingredient, props.disableAmount, props.scale, false));
|
|
||||||
});
|
|
||||||
|
|
||||||
return components.join("\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
function toggleChecked(index: number) {
|
|
||||||
// TODO Find a better way to do this - $set is not available, and
|
|
||||||
// direct array modifications are not propagated for some reason
|
|
||||||
state.checked.splice(index, 1, !state.checked[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...toRefs(state),
|
|
||||||
ingredientCopyText,
|
|
||||||
toggleChecked,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function validateTitle(title?: string | null) {
|
||||||
|
return !(title === undefined || title === "" || title === null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const checked = ref(props.value.map(() => false));
|
||||||
|
const showTitleEditor = computed(() => props.value.map(x => validateTitle(x.title)));
|
||||||
|
|
||||||
|
const ingredientCopyText = computed(() => {
|
||||||
|
const components: string[] = [];
|
||||||
|
props.value.forEach((ingredient) => {
|
||||||
|
if (ingredient.title) {
|
||||||
|
if (components.length) {
|
||||||
|
components.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
components.push(`[${ingredient.title}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
components.push(parseIngredientText(ingredient, props.scale, false));
|
||||||
|
});
|
||||||
|
|
||||||
|
return components.join("\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleChecked(index: number) {
|
||||||
|
// TODO Find a better way to do this - $set is not available, and
|
||||||
|
// direct array modifications are not propagated for some reason
|
||||||
|
checked.value.splice(index, 1, !checked.value[index]);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
<div>
|
<div>
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
v-model="madeThisDialog"
|
v-model="madeThisDialog"
|
||||||
|
:loading="madeThisFormLoading"
|
||||||
:icon="$globals.icons.chefHat"
|
:icon="$globals.icons.chefHat"
|
||||||
:title="$t('recipe.made-this')"
|
:title="$t('recipe.made-this')"
|
||||||
:submit-text="$t('recipe.add-to-timeline')"
|
:submit-text="$t('recipe.add-to-timeline')"
|
||||||
|
@ -29,11 +30,11 @@
|
||||||
offset-y
|
offset-y
|
||||||
max-width="290px"
|
max-width="290px"
|
||||||
>
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="newTimelineEventTimestampString"
|
v-model="newTimelineEventTimestampString"
|
||||||
:prepend-icon="$globals.icons.calendar"
|
:prepend-icon="$globals.icons.calendar"
|
||||||
v-bind="props"
|
v-bind="activatorProps"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
@ -85,13 +86,13 @@
|
||||||
<div>
|
<div>
|
||||||
<div v-if="lastMadeReady" class="d-flex justify-center flex-wrap">
|
<div v-if="lastMadeReady" class="d-flex justify-center flex-wrap">
|
||||||
<v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger">
|
<v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger">
|
||||||
<v-tooltip bottom>
|
<v-tooltip location="bottom">
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props: tooltipProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
rounded
|
rounded
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="x-large"
|
size="x-large"
|
||||||
v-bind="props"
|
v-bind="tooltipProps"
|
||||||
style="border-color: rgb(var(--v-theme-primary));"
|
style="border-color: rgb(var(--v-theme-primary));"
|
||||||
@click="madeThisDialog = true"
|
@click="madeThisDialog = true"
|
||||||
>
|
>
|
||||||
|
@ -116,148 +117,165 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { whenever } from "@vueuse/core";
|
import { whenever } from "@vueuse/core";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
|
import { alert } from "~/composables/use-toast";
|
||||||
import { useHouseholdSelf } from "~/composables/use-households";
|
import { useHouseholdSelf } from "~/composables/use-households";
|
||||||
import type { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe";
|
import type { Recipe, RecipeTimelineEventIn, RecipeTimelineEventOut } from "~/lib/api/types/recipe";
|
||||||
import type { VForm } from "~/types/auto-forms";
|
import type { VForm } from "~/types/auto-forms";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
const props = defineProps<{ recipe: Recipe }>();
|
||||||
props: {
|
const emit = defineEmits<{
|
||||||
recipe: {
|
eventCreated: [event: RecipeTimelineEventOut];
|
||||||
type: Object as () => Recipe,
|
}>();
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["eventCreated"],
|
|
||||||
setup(props, context) {
|
|
||||||
const madeThisDialog = ref(false);
|
|
||||||
const userApi = useUserApi();
|
|
||||||
const { household } = useHouseholdSelf();
|
|
||||||
const i18n = useI18n();
|
|
||||||
const $auth = useMealieAuth();
|
|
||||||
const domMadeThisForm = ref<VForm>();
|
|
||||||
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
|
||||||
subject: "",
|
|
||||||
eventType: "comment",
|
|
||||||
eventMessage: "",
|
|
||||||
timestamp: undefined,
|
|
||||||
recipeId: props.recipe?.id || "",
|
|
||||||
});
|
|
||||||
const newTimelineEventImage = ref<Blob | File>();
|
|
||||||
const newTimelineEventImageName = ref<string>("");
|
|
||||||
const newTimelineEventImagePreviewUrl = ref<string>();
|
|
||||||
const newTimelineEventTimestamp = ref<Date>(new Date());
|
|
||||||
const newTimelineEventTimestampString = computed(() => {
|
|
||||||
return newTimelineEventTimestamp.value.toISOString().substring(0, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
const lastMade = ref(props.recipe.lastMade);
|
const madeThisDialog = ref(false);
|
||||||
const lastMadeReady = ref(false);
|
const userApi = useUserApi();
|
||||||
onMounted(async () => {
|
const { household } = useHouseholdSelf();
|
||||||
if (!$auth.user?.value?.householdSlug) {
|
const i18n = useI18n();
|
||||||
lastMade.value = props.recipe.lastMade;
|
const $auth = useMealieAuth();
|
||||||
}
|
const domMadeThisForm = ref<VForm>();
|
||||||
else {
|
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
||||||
const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || "");
|
subject: "",
|
||||||
lastMade.value = data?.lastMade;
|
eventType: "comment",
|
||||||
}
|
eventMessage: "",
|
||||||
|
timestamp: undefined,
|
||||||
lastMadeReady.value = true;
|
recipeId: props.recipe?.id || "",
|
||||||
});
|
|
||||||
|
|
||||||
whenever(
|
|
||||||
() => madeThisDialog.value,
|
|
||||||
() => {
|
|
||||||
// Set timestamp to now
|
|
||||||
newTimelineEventTimestamp.value = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const firstDayOfWeek = computed(() => {
|
|
||||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
function clearImage() {
|
|
||||||
newTimelineEventImage.value = undefined;
|
|
||||||
newTimelineEventImageName.value = "";
|
|
||||||
newTimelineEventImagePreviewUrl.value = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadImage(fileObject: File) {
|
|
||||||
newTimelineEventImage.value = fileObject;
|
|
||||||
newTimelineEventImageName.value = fileObject.name;
|
|
||||||
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUploadedImage(fileObject: Blob) {
|
|
||||||
newTimelineEventImage.value = fileObject;
|
|
||||||
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = reactive({ datePickerMenu: false });
|
|
||||||
async function createTimelineEvent() {
|
|
||||||
if (!(newTimelineEventTimestampString.value && props.recipe?.id && props.recipe?.slug)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
newTimelineEvent.value.recipeId = props.recipe.id;
|
|
||||||
// Note: $auth.user is now a ref
|
|
||||||
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.value?.fullName });
|
|
||||||
|
|
||||||
// the user only selects the date, so we set the time to end of day local time
|
|
||||||
// we choose the end of day so it always comes after "new recipe" events
|
|
||||||
newTimelineEvent.value.timestamp = new Date(newTimelineEventTimestampString.value + "T23:59:59").toISOString();
|
|
||||||
|
|
||||||
const eventResponse = await userApi.recipes.createTimelineEvent(newTimelineEvent.value);
|
|
||||||
const newEvent = eventResponse.data;
|
|
||||||
|
|
||||||
// we also update the recipe's last made value
|
|
||||||
if (!lastMade.value || newTimelineEvent.value.timestamp > lastMade.value) {
|
|
||||||
lastMade.value = newTimelineEvent.value.timestamp;
|
|
||||||
await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
// update the image, if provided
|
|
||||||
if (newTimelineEventImage.value && newEvent) {
|
|
||||||
const imageResponse = await userApi.recipes.updateTimelineEventImage(
|
|
||||||
newEvent.id,
|
|
||||||
newTimelineEventImage.value,
|
|
||||||
newTimelineEventImageName.value,
|
|
||||||
);
|
|
||||||
if (imageResponse.data) {
|
|
||||||
newEvent.image = imageResponse.data.image;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// reset form
|
|
||||||
newTimelineEvent.value.eventMessage = "";
|
|
||||||
newTimelineEvent.value.timestamp = undefined;
|
|
||||||
clearImage();
|
|
||||||
madeThisDialog.value = false;
|
|
||||||
domMadeThisForm.value?.reset();
|
|
||||||
|
|
||||||
context.emit("eventCreated", newEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...toRefs(state),
|
|
||||||
domMadeThisForm,
|
|
||||||
madeThisDialog,
|
|
||||||
firstDayOfWeek,
|
|
||||||
newTimelineEvent,
|
|
||||||
newTimelineEventImage,
|
|
||||||
newTimelineEventImagePreviewUrl,
|
|
||||||
newTimelineEventTimestamp,
|
|
||||||
newTimelineEventTimestampString,
|
|
||||||
lastMade,
|
|
||||||
lastMadeReady,
|
|
||||||
createTimelineEvent,
|
|
||||||
clearImage,
|
|
||||||
uploadImage,
|
|
||||||
updateUploadedImage,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
const newTimelineEventImage = ref<Blob | File>();
|
||||||
|
const newTimelineEventImageName = ref<string>("");
|
||||||
|
const newTimelineEventImagePreviewUrl = ref<string>();
|
||||||
|
const newTimelineEventTimestamp = ref<Date>(new Date());
|
||||||
|
const newTimelineEventTimestampString = computed(() => {
|
||||||
|
return newTimelineEventTimestamp.value.toISOString().substring(0, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastMade = ref(props.recipe.lastMade);
|
||||||
|
const lastMadeReady = ref(false);
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!$auth.user?.value?.householdSlug) {
|
||||||
|
lastMade.value = props.recipe.lastMade;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || "");
|
||||||
|
lastMade.value = data?.lastMade;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastMadeReady.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
whenever(
|
||||||
|
() => madeThisDialog.value,
|
||||||
|
() => {
|
||||||
|
// Set timestamp to now
|
||||||
|
newTimelineEventTimestamp.value = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstDayOfWeek = computed(() => {
|
||||||
|
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearImage() {
|
||||||
|
newTimelineEventImage.value = undefined;
|
||||||
|
newTimelineEventImageName.value = "";
|
||||||
|
newTimelineEventImagePreviewUrl.value = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadImage(fileObject: File) {
|
||||||
|
newTimelineEventImage.value = fileObject;
|
||||||
|
newTimelineEventImageName.value = fileObject.name;
|
||||||
|
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUploadedImage(fileObject: Blob) {
|
||||||
|
newTimelineEventImage.value = fileObject;
|
||||||
|
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
const datePickerMenu = ref(false);
|
||||||
|
const madeThisFormLoading = ref(false);
|
||||||
|
|
||||||
|
function resetMadeThisForm() {
|
||||||
|
madeThisFormLoading.value = false;
|
||||||
|
|
||||||
|
newTimelineEvent.value.eventMessage = "";
|
||||||
|
newTimelineEvent.value.timestamp = undefined;
|
||||||
|
clearImage();
|
||||||
|
madeThisDialog.value = false;
|
||||||
|
domMadeThisForm.value?.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTimelineEvent() {
|
||||||
|
if (!(newTimelineEventTimestampString.value && props.recipe?.id && props.recipe?.slug)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
madeThisFormLoading.value = true;
|
||||||
|
|
||||||
|
newTimelineEvent.value.recipeId = props.recipe.id;
|
||||||
|
// Note: $auth.user is now a ref
|
||||||
|
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.value?.fullName });
|
||||||
|
|
||||||
|
// the user only selects the date, so we set the time to end of day local time
|
||||||
|
// we choose the end of day so it always comes after "new recipe" events
|
||||||
|
newTimelineEvent.value.timestamp = new Date(newTimelineEventTimestampString.value + "T23:59:59").toISOString();
|
||||||
|
|
||||||
|
let newEvent: RecipeTimelineEventOut | null = null;
|
||||||
|
try {
|
||||||
|
const eventResponse = await userApi.recipes.createTimelineEvent(newTimelineEvent.value);
|
||||||
|
newEvent = eventResponse.data;
|
||||||
|
if (!newEvent) {
|
||||||
|
throw new Error("No event created");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to create timeline event:", error);
|
||||||
|
alert.error(i18n.t("recipe.failed-to-add-to-timeline"));
|
||||||
|
resetMadeThisForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// we also update the recipe's last made value
|
||||||
|
if (!lastMade.value || newTimelineEvent.value.timestamp > lastMade.value) {
|
||||||
|
try {
|
||||||
|
lastMade.value = newTimelineEvent.value.timestamp;
|
||||||
|
await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to update last made date:", error);
|
||||||
|
alert.error(i18n.t("recipe.failed-to-update-recipe"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the image, if provided
|
||||||
|
let imageError = false;
|
||||||
|
if (newTimelineEventImage.value) {
|
||||||
|
try {
|
||||||
|
const imageResponse = await userApi.recipes.updateTimelineEventImage(
|
||||||
|
newEvent.id,
|
||||||
|
newTimelineEventImage.value,
|
||||||
|
newTimelineEventImageName.value,
|
||||||
|
);
|
||||||
|
if (imageResponse.data) {
|
||||||
|
newEvent.image = imageResponse.data.image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
imageError = true;
|
||||||
|
console.error("Failed to upload image for timeline event:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageError) {
|
||||||
|
alert.error(i18n.t("recipe.added-to-timeline-but-failed-to-add-image"));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
alert.success(i18n.t("recipe.added-to-timeline"));
|
||||||
|
}
|
||||||
|
|
||||||
|
resetMadeThisForm();
|
||||||
|
emit("eventCreated", newEvent);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -51,141 +51,121 @@
|
||||||
</v-list>
|
</v-list>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { useFraction } from "~/composables/recipes/use-fraction";
|
import { useFraction } from "~/composables/recipes/use-fraction";
|
||||||
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||||
import type { RecipeSummary } from "~/lib/api/types/recipe";
|
import type { RecipeSummary } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
props: {
|
recipes: RecipeSummary[];
|
||||||
recipes: {
|
listItem?: ShoppingListItemOut;
|
||||||
type: Array as () => RecipeSummary[],
|
small?: boolean;
|
||||||
required: true,
|
tile?: boolean;
|
||||||
},
|
showDescription?: boolean;
|
||||||
listItem: {
|
disabled?: boolean;
|
||||||
type: Object as () => ShoppingListItemOut | undefined,
|
}
|
||||||
default: undefined,
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
},
|
listItem: undefined,
|
||||||
small: {
|
small: false,
|
||||||
type: Boolean,
|
tile: false,
|
||||||
default: false,
|
showDescription: false,
|
||||||
},
|
disabled: false,
|
||||||
tile: {
|
});
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
showDescription: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const $auth = useMealieAuth();
|
|
||||||
const { frac } = useFraction();
|
|
||||||
const route = useRoute();
|
|
||||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
|
||||||
|
|
||||||
const attrs = computed(() => {
|
const $auth = useMealieAuth();
|
||||||
return props.small
|
const { frac } = useFraction();
|
||||||
? {
|
const route = useRoute();
|
||||||
class: {
|
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||||
sheet: props.tile ? "mb-1 me-1 justify-center align-center" : "mb-1 justify-center align-center",
|
|
||||||
listItem: "px-0",
|
|
||||||
avatar: "ma-0",
|
|
||||||
icon: "ma-0 pa-0 primary",
|
|
||||||
text: "pa-0",
|
|
||||||
},
|
|
||||||
style: {
|
|
||||||
text: {
|
|
||||||
title: "font-size: small;",
|
|
||||||
subTitle: "font-size: x-small;",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
class: {
|
|
||||||
sheet: props.tile ? "mx-1 justify-center align-center" : "mb-1 justify-center align-center",
|
|
||||||
listItem: "px-4",
|
|
||||||
avatar: "",
|
|
||||||
icon: "pa-1 primary",
|
|
||||||
text: "",
|
|
||||||
},
|
|
||||||
style: {
|
|
||||||
text: {
|
|
||||||
title: "",
|
|
||||||
subTitle: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function sanitizeHTML(rawHtml: string) {
|
const attrs = computed(() => {
|
||||||
return DOMPurify.sanitize(rawHtml, {
|
return props.small
|
||||||
USE_PROFILES: { html: true },
|
? {
|
||||||
ALLOWED_TAGS: ["strong", "sup"],
|
class: {
|
||||||
});
|
sheet: props.tile ? "mb-1 me-1 justify-center align-center" : "mb-1 justify-center align-center",
|
||||||
|
listItem: "px-0",
|
||||||
|
avatar: "ma-0",
|
||||||
|
icon: "ma-0 pa-0 primary",
|
||||||
|
text: "pa-0",
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
text: {
|
||||||
|
title: "font-size: small;",
|
||||||
|
subTitle: "font-size: x-small;",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
class: {
|
||||||
|
sheet: props.tile ? "mx-1 justify-center align-center" : "mb-1 justify-center align-center",
|
||||||
|
listItem: "px-4",
|
||||||
|
avatar: "",
|
||||||
|
icon: "pa-1 primary",
|
||||||
|
text: "",
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
text: {
|
||||||
|
title: "",
|
||||||
|
subTitle: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function sanitizeHTML(rawHtml: string) {
|
||||||
|
return DOMPurify.sanitize(rawHtml, {
|
||||||
|
USE_PROFILES: { html: true },
|
||||||
|
ALLOWED_TAGS: ["strong", "sup"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const listItemDescriptions = computed<string[]>(() => {
|
||||||
|
if (
|
||||||
|
props.recipes.length === 1 // we don't need to specify details if there's only one recipe ref
|
||||||
|
|| !props.listItem?.recipeReferences
|
||||||
|
|| props.listItem.recipeReferences.length !== props.recipes.length
|
||||||
|
) {
|
||||||
|
return props.recipes.map(_ => "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const listItemDescriptions: string[] = [];
|
||||||
|
for (let i = 0; i < props.recipes.length; i++) {
|
||||||
|
const itemRef = props.listItem?.recipeReferences[i];
|
||||||
|
const quantity = (itemRef.recipeQuantity || 1) * (itemRef.recipeScale || 1);
|
||||||
|
|
||||||
|
let listItemDescription = "";
|
||||||
|
if (props.listItem.unit?.fraction) {
|
||||||
|
const fraction = frac(quantity, 10, true);
|
||||||
|
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||||
|
listItemDescription += fraction[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fraction[1] > 0) {
|
||||||
|
listItemDescription += ` <sup>${fraction[1]}</sup>⁄<sub>${fraction[2]}</sub>`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
listItemDescription = (quantity).toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
listItemDescription = (Math.round(quantity * 100) / 100).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const listItemDescriptions = computed<string[]>(() => {
|
if (props.listItem.unit) {
|
||||||
if (
|
const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation
|
||||||
props.recipes.length === 1 // we don't need to specify details if there's only one recipe ref
|
? props.listItem.unit.abbreviation
|
||||||
|| !props.listItem?.recipeReferences
|
: props.listItem.unit.name;
|
||||||
|| props.listItem.recipeReferences.length !== props.recipes.length
|
|
||||||
) {
|
|
||||||
return props.recipes.map(_ => "");
|
|
||||||
}
|
|
||||||
|
|
||||||
const listItemDescriptions: string[] = [];
|
listItemDescription += ` ${unitDisplay}`;
|
||||||
for (let i = 0; i < props.recipes.length; i++) {
|
}
|
||||||
const itemRef = props.listItem?.recipeReferences[i];
|
|
||||||
const quantity = (itemRef.recipeQuantity || 1) * (itemRef.recipeScale || 1);
|
|
||||||
|
|
||||||
let listItemDescription = "";
|
if (itemRef.recipeNote) {
|
||||||
if (props.listItem.unit?.fraction) {
|
listItemDescription += `, ${itemRef.recipeNote}`;
|
||||||
const fraction = frac(quantity, 10, true);
|
}
|
||||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
|
||||||
listItemDescription += fraction[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fraction[1] > 0) {
|
listItemDescriptions.push(sanitizeHTML(listItemDescription));
|
||||||
listItemDescription += ` <sup>${fraction[1]}</sup>⁄<sub>${fraction[2]}</sub>`;
|
}
|
||||||
}
|
|
||||||
else {
|
|
||||||
listItemDescription = (quantity).toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
listItemDescription = (Math.round(quantity * 100) / 100).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.listItem.unit) {
|
return listItemDescriptions;
|
||||||
const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation
|
|
||||||
? props.listItem.unit.abbreviation
|
|
||||||
: props.listItem.unit.name;
|
|
||||||
|
|
||||||
listItemDescription += ` ${unitDisplay}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemRef.recipeNote) {
|
|
||||||
listItemDescription += `, ${itemRef.recipeNote}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
listItemDescriptions.push(sanitizeHTML(listItemDescription));
|
|
||||||
}
|
|
||||||
|
|
||||||
return listItemDescriptions;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
attrs,
|
|
||||||
groupSlug,
|
|
||||||
listItemDescriptions,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
:suffix="labels[key].suffix"
|
:suffix="labels[key].suffix"
|
||||||
type="number"
|
type="number"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
variant="underlined"
|
||||||
@update:model-value="updateValue(key, $event)"
|
@update:model-value="updateValue(key, $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -31,77 +32,61 @@
|
||||||
:key="index"
|
:key="index"
|
||||||
style="min-height: 25px"
|
style="min-height: 25px"
|
||||||
>
|
>
|
||||||
<div>
|
<v-list-item-title class="pl-2 d-flex">
|
||||||
<v-list-item-title class="pl-4 caption flex row">
|
<div>{{ item.label }}</div>
|
||||||
<div>{{ item.label }}</div>
|
<div class="ml-auto mr-1">
|
||||||
<div class="ml-auto mr-1">
|
{{ item.value }}
|
||||||
{{ item.value }}
|
</div>
|
||||||
</div>
|
<div>{{ item.suffix }}</div>
|
||||||
<div>{{ item.suffix }}</div>
|
</v-list-item-title>
|
||||||
</v-list-item-title>
|
|
||||||
</div>
|
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-card>
|
</v-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { useNutritionLabels } from "~/composables/recipes";
|
import { useNutritionLabels } from "~/composables/recipes";
|
||||||
import type { Nutrition } from "~/lib/api/types/recipe";
|
import type { Nutrition } from "~/lib/api/types/recipe";
|
||||||
import type { NutritionLabelType } from "~/composables/recipes/use-recipe-nutrition";
|
import type { NutritionLabelType } from "~/composables/recipes/use-recipe-nutrition";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
props: {
|
edit?: boolean;
|
||||||
modelValue: {
|
}
|
||||||
type: Object as () => Nutrition,
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
required: true,
|
edit: true,
|
||||||
},
|
});
|
||||||
edit: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["update:modelValue"],
|
|
||||||
setup(props, context) {
|
|
||||||
const { labels } = useNutritionLabels();
|
|
||||||
const valueNotNull = computed(() => {
|
|
||||||
let key: keyof Nutrition;
|
|
||||||
for (key in props.modelValue) {
|
|
||||||
if (props.modelValue[key] !== null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
const showViewer = computed(() => !props.edit && valueNotNull.value);
|
const modelValue = defineModel<Nutrition>({ required: true });
|
||||||
|
|
||||||
function updateValue(key: number | string, event: Event) {
|
const { labels } = useNutritionLabels();
|
||||||
context.emit("update:modelValue", { ...props.modelValue, [key]: event });
|
const valueNotNull = computed(() => {
|
||||||
|
let key: keyof Nutrition;
|
||||||
|
for (key in modelValue.value) {
|
||||||
|
if (modelValue.value[key] !== null) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
// Build a new list that only contains nutritional information that has a value
|
const showViewer = computed(() => !props.edit && valueNotNull.value);
|
||||||
const renderedList = computed(() => {
|
|
||||||
return Object.entries(labels).reduce((item: NutritionLabelType, [key, label]) => {
|
|
||||||
if (props.modelValue[key]?.trim()) {
|
|
||||||
item[key] = {
|
|
||||||
...label,
|
|
||||||
value: props.modelValue[key],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
}, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
function updateValue(key: number | string, event: Event) {
|
||||||
labels,
|
modelValue.value = { ...modelValue.value, [key]: event };
|
||||||
valueNotNull,
|
}
|
||||||
showViewer,
|
|
||||||
updateValue,
|
// Build a new list that only contains nutritional information that has a value
|
||||||
renderedList,
|
const renderedList = computed(() => {
|
||||||
};
|
return Object.entries(labels).reduce((item: NutritionLabelType, [key, label]) => {
|
||||||
},
|
if (modelValue.value[key]?.trim()) {
|
||||||
|
item[key] = {
|
||||||
|
...label,
|
||||||
|
value: modelValue.value[key],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}, {});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -60,119 +60,93 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store";
|
import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store";
|
||||||
import { type RecipeOrganizer, Organizer } from "~/lib/api/types/non-generated";
|
import { type RecipeOrganizer, Organizer } from "~/lib/api/types/non-generated";
|
||||||
|
|
||||||
const CREATED_ITEM_EVENT = "created-item";
|
const CREATED_ITEM_EVENT = "created-item";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
props: {
|
color?: string | null;
|
||||||
modelValue: {
|
tagDialog?: boolean;
|
||||||
type: Boolean,
|
itemType?: RecipeOrganizer;
|
||||||
default: false,
|
}
|
||||||
},
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
color: {
|
color: null,
|
||||||
type: String,
|
tagDialog: true,
|
||||||
default: null,
|
itemType: "category" as RecipeOrganizer,
|
||||||
},
|
|
||||||
tagDialog: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
itemType: {
|
|
||||||
type: String as () => RecipeOrganizer,
|
|
||||||
default: "category",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["update:modelValue"],
|
|
||||||
setup(props, context) {
|
|
||||||
const i18n = useI18n();
|
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
name: "",
|
|
||||||
onHand: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dialog = computed({
|
|
||||||
get() {
|
|
||||||
return props.modelValue;
|
|
||||||
},
|
|
||||||
set(value) {
|
|
||||||
context.emit("update:modelValue", value);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(val: boolean) => {
|
|
||||||
if (!val) state.name = "";
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const userApi = useUserApi();
|
|
||||||
|
|
||||||
const store = (() => {
|
|
||||||
switch (props.itemType) {
|
|
||||||
case Organizer.Tag:
|
|
||||||
return useTagStore();
|
|
||||||
case Organizer.Tool:
|
|
||||||
return useToolStore();
|
|
||||||
default:
|
|
||||||
return useCategoryStore();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const properties = computed(() => {
|
|
||||||
switch (props.itemType) {
|
|
||||||
case Organizer.Tag:
|
|
||||||
return {
|
|
||||||
title: i18n.t("tag.create-a-tag"),
|
|
||||||
label: i18n.t("tag.tag-name"),
|
|
||||||
api: userApi.tags,
|
|
||||||
};
|
|
||||||
case Organizer.Tool:
|
|
||||||
return {
|
|
||||||
title: i18n.t("tool.create-a-tool"),
|
|
||||||
label: i18n.t("tool.tool-name"),
|
|
||||||
api: userApi.tools,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
title: i18n.t("category.create-a-category"),
|
|
||||||
label: i18n.t("category.category-name"),
|
|
||||||
api: userApi.categories,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const rules = {
|
|
||||||
required: (val: string) => !!val || (i18n.t("general.a-name-is-required") as string),
|
|
||||||
};
|
|
||||||
|
|
||||||
async function select() {
|
|
||||||
if (store) {
|
|
||||||
// @ts-expect-error the same state is used for different organizer types, which have different requirements
|
|
||||||
await store.actions.createOne({ ...state });
|
|
||||||
}
|
|
||||||
|
|
||||||
const newItem = store.store.value.find(item => item.name === state.name);
|
|
||||||
|
|
||||||
context.emit(CREATED_ITEM_EVENT, newItem);
|
|
||||||
dialog.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
Organizer,
|
|
||||||
...toRefs(state),
|
|
||||||
dialog,
|
|
||||||
properties,
|
|
||||||
rules,
|
|
||||||
select,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"created-item": [item: any];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dialog = defineModel<boolean>({ default: false });
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const name = ref("");
|
||||||
|
const onHand = ref(false);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
dialog,
|
||||||
|
(val: boolean) => {
|
||||||
|
if (!val) name.value = "";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const userApi = useUserApi();
|
||||||
|
|
||||||
|
const store = (() => {
|
||||||
|
switch (props.itemType) {
|
||||||
|
case Organizer.Tag:
|
||||||
|
return useTagStore();
|
||||||
|
case Organizer.Tool:
|
||||||
|
return useToolStore();
|
||||||
|
default:
|
||||||
|
return useCategoryStore();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const properties = computed(() => {
|
||||||
|
switch (props.itemType) {
|
||||||
|
case Organizer.Tag:
|
||||||
|
return {
|
||||||
|
title: i18n.t("tag.create-a-tag"),
|
||||||
|
label: i18n.t("tag.tag-name"),
|
||||||
|
api: userApi.tags,
|
||||||
|
};
|
||||||
|
case Organizer.Tool:
|
||||||
|
return {
|
||||||
|
title: i18n.t("tool.create-a-tool"),
|
||||||
|
label: i18n.t("tool.tool-name"),
|
||||||
|
api: userApi.tools,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: i18n.t("category.create-a-category"),
|
||||||
|
label: i18n.t("category.category-name"),
|
||||||
|
api: userApi.categories,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
required: (val: string) => !!val || (i18n.t("general.a-name-is-required") as string),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function select() {
|
||||||
|
if (store) {
|
||||||
|
// @ts-expect-error the same state is used for different organizer types, which have different requirements
|
||||||
|
await store.actions.createOne({ name: name.value, onHand: onHand.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newItem = store.store.value.find(item => item.name === name.value);
|
||||||
|
|
||||||
|
emit(CREATED_ITEM_EVENT, newItem);
|
||||||
|
dialog.value = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style></style>
|
<style></style>
|
||||||
|
|
|
@ -122,9 +122,8 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
|
|
||||||
import { useContextPresets } from "~/composables/use-context-presents";
|
import { useContextPresets } from "~/composables/use-context-presents";
|
||||||
import RecipeOrganizerDialog from "~/components/Domain/Recipe/RecipeOrganizerDialog.vue";
|
import RecipeOrganizerDialog from "~/components/Domain/Recipe/RecipeOrganizerDialog.vue";
|
||||||
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||||
|
@ -138,156 +137,128 @@ interface GenericItem {
|
||||||
onHand: boolean;
|
onHand: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
const props = defineProps<{
|
||||||
components: {
|
items: GenericItem[];
|
||||||
RecipeOrganizerDialog,
|
icon: string;
|
||||||
},
|
itemType: RecipeOrganizer;
|
||||||
props: {
|
}>();
|
||||||
items: {
|
|
||||||
type: Array as () => GenericItem[],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
itemType: {
|
|
||||||
type: String as () => RecipeOrganizer,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["update", "delete"],
|
|
||||||
setup(props, { emit }) {
|
|
||||||
const state = reactive({
|
|
||||||
// Search Options
|
|
||||||
options: {
|
|
||||||
ignoreLocation: true,
|
|
||||||
shouldSort: true,
|
|
||||||
threshold: 0.2,
|
|
||||||
location: 0,
|
|
||||||
distance: 20,
|
|
||||||
findAllMatches: true,
|
|
||||||
maxPatternLength: 32,
|
|
||||||
minMatchCharLength: 1,
|
|
||||||
keys: ["name"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const $auth = useMealieAuth();
|
const emit = defineEmits<{
|
||||||
const route = useRoute();
|
update: [item: GenericItem];
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
|
delete: [id: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
// =================================================================
|
const state = reactive({
|
||||||
// Context Menu
|
// Search Options
|
||||||
|
options: {
|
||||||
const dialogs = ref({
|
ignoreLocation: true,
|
||||||
organizer: false,
|
shouldSort: true,
|
||||||
update: false,
|
threshold: 0.2,
|
||||||
delete: false,
|
location: 0,
|
||||||
});
|
distance: 20,
|
||||||
|
findAllMatches: true,
|
||||||
const presets = useContextPresets();
|
maxPatternLength: 32,
|
||||||
|
minMatchCharLength: 1,
|
||||||
const translationKey = computed<string>(() => {
|
keys: ["name"],
|
||||||
const typeMap = {
|
|
||||||
categories: "category.category",
|
|
||||||
tags: "tag.tag",
|
|
||||||
tools: "tool.tool",
|
|
||||||
foods: "shopping-list.food",
|
|
||||||
households: "household.household",
|
|
||||||
};
|
|
||||||
return typeMap[props.itemType] || "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteTarget = ref<GenericItem | null>(null);
|
|
||||||
const updateTarget = ref<GenericItem | null>(null);
|
|
||||||
|
|
||||||
function confirmDelete(item: GenericItem) {
|
|
||||||
deleteTarget.value = item;
|
|
||||||
dialogs.value.delete = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteOne() {
|
|
||||||
if (!deleteTarget.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit("delete", deleteTarget.value.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openUpdateDialog(item: GenericItem) {
|
|
||||||
updateTarget.value = deepCopy(item);
|
|
||||||
dialogs.value.update = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateOne() {
|
|
||||||
if (!updateTarget.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit("update", updateTarget.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// Search Functions
|
|
||||||
|
|
||||||
const searchString = useRouteQuery("q", "");
|
|
||||||
|
|
||||||
const fuse = computed(() => {
|
|
||||||
return new Fuse(props.items, state.options);
|
|
||||||
});
|
|
||||||
|
|
||||||
const fuzzyItems = computed<GenericItem[]>(() => {
|
|
||||||
if (searchString.value.trim() === "") {
|
|
||||||
return props.items;
|
|
||||||
}
|
|
||||||
const result = fuse.value.search(searchString.value.trim() as string);
|
|
||||||
return result.map(x => x.item);
|
|
||||||
});
|
|
||||||
|
|
||||||
// =================================================================
|
|
||||||
// Sorted Items
|
|
||||||
|
|
||||||
const itemsSorted = computed(() => {
|
|
||||||
const byLetter: { [key: string]: Array<GenericItem> } = {};
|
|
||||||
|
|
||||||
if (!fuzzyItems.value) {
|
|
||||||
return byLetter;
|
|
||||||
}
|
|
||||||
|
|
||||||
[...fuzzyItems.value]
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.forEach((item) => {
|
|
||||||
const letter = item.name[0].toUpperCase();
|
|
||||||
if (!byLetter[letter]) {
|
|
||||||
byLetter[letter] = [];
|
|
||||||
}
|
|
||||||
byLetter[letter].push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
return byLetter;
|
|
||||||
});
|
|
||||||
|
|
||||||
function isTitle(str: number | string) {
|
|
||||||
return typeof str === "string" && str.length === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
groupSlug,
|
|
||||||
isTitle,
|
|
||||||
dialogs,
|
|
||||||
confirmDelete,
|
|
||||||
openUpdateDialog,
|
|
||||||
updateOne,
|
|
||||||
updateTarget,
|
|
||||||
deleteOne,
|
|
||||||
deleteTarget,
|
|
||||||
Organizer,
|
|
||||||
presets,
|
|
||||||
itemsSorted,
|
|
||||||
searchString,
|
|
||||||
translationKey,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
const route = useRoute();
|
||||||
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Context Menu
|
||||||
|
|
||||||
|
const dialogs = ref({
|
||||||
|
organizer: false,
|
||||||
|
update: false,
|
||||||
|
delete: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const presets = useContextPresets();
|
||||||
|
|
||||||
|
const translationKey = computed<string>(() => {
|
||||||
|
const typeMap = {
|
||||||
|
categories: "category.category",
|
||||||
|
tags: "tag.tag",
|
||||||
|
tools: "tool.tool",
|
||||||
|
foods: "shopping-list.food",
|
||||||
|
households: "household.household",
|
||||||
|
};
|
||||||
|
return typeMap[props.itemType] || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteTarget = ref<GenericItem | null>(null);
|
||||||
|
const updateTarget = ref<GenericItem | null>(null);
|
||||||
|
|
||||||
|
function confirmDelete(item: GenericItem) {
|
||||||
|
deleteTarget.value = item;
|
||||||
|
dialogs.value.delete = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteOne() {
|
||||||
|
if (!deleteTarget.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("delete", deleteTarget.value.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUpdateDialog(item: GenericItem) {
|
||||||
|
updateTarget.value = deepCopy(item);
|
||||||
|
dialogs.value.update = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOne() {
|
||||||
|
if (!updateTarget.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("update", updateTarget.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// Search Functions
|
||||||
|
|
||||||
|
const searchString = useRouteQuery("q", "");
|
||||||
|
|
||||||
|
const fuse = computed(() => {
|
||||||
|
return new Fuse(props.items, state.options);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fuzzyItems = computed<GenericItem[]>(() => {
|
||||||
|
if (searchString.value.trim() === "") {
|
||||||
|
return props.items;
|
||||||
|
}
|
||||||
|
const result = fuse.value.search(searchString.value.trim() as string);
|
||||||
|
return result.map(x => x.item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Sorted Items
|
||||||
|
|
||||||
|
const itemsSorted = computed(() => {
|
||||||
|
const byLetter: { [key: string]: Array<GenericItem> } = {};
|
||||||
|
|
||||||
|
if (!fuzzyItems.value) {
|
||||||
|
return byLetter;
|
||||||
|
}
|
||||||
|
|
||||||
|
[...fuzzyItems.value]
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.forEach((item) => {
|
||||||
|
const letter = item.name[0].toUpperCase();
|
||||||
|
if (!byLetter[letter]) {
|
||||||
|
byLetter[letter] = [];
|
||||||
|
}
|
||||||
|
byLetter[letter].push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return byLetter;
|
||||||
|
});
|
||||||
|
|
||||||
|
function isTitle(str: number | string) {
|
||||||
|
return typeof str === "string" && str.length === 1;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
v-model="selected"
|
v-model="selected"
|
||||||
v-bind="inputAttrs"
|
v-bind="inputAttrs"
|
||||||
v-model:search="searchInput"
|
v-model:search="searchInput"
|
||||||
:items="storeItem"
|
:items="items"
|
||||||
:label="label"
|
:label="label"
|
||||||
chips
|
chips
|
||||||
closable-chips
|
closable-chips
|
||||||
|
@ -46,180 +46,138 @@
|
||||||
</v-autocomplete>
|
</v-autocomplete>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import type { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
|
import type { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
|
||||||
import type { RecipeTool } from "~/lib/api/types/admin";
|
import type { RecipeTool } from "~/lib/api/types/admin";
|
||||||
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
props: {
|
selectorType: RecipeOrganizer;
|
||||||
modelValue: {
|
inputAttrs?: Record<string, any>;
|
||||||
type: Array as () => (
|
returnObject?: boolean;
|
||||||
| HouseholdSummary
|
showAdd?: boolean;
|
||||||
| RecipeTag
|
showLabel?: boolean;
|
||||||
| RecipeCategory
|
showIcon?: boolean;
|
||||||
| RecipeTool
|
variant?: "filled" | "underlined" | "outlined" | "plain" | "solo" | "solo-inverted" | "solo-filled";
|
||||||
| IngredientFood
|
}
|
||||||
| string
|
|
||||||
)[] | undefined,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* The type of organizer to use.
|
|
||||||
*/
|
|
||||||
selectorType: {
|
|
||||||
type: String as () => RecipeOrganizer,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
inputAttrs: {
|
|
||||||
type: Object as () => Record<string, any>,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
returnObject: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
showAdd: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
showLabel: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
showIcon: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
variant: {
|
|
||||||
type: String as () => "filled" | "underlined" | "outlined" | "plain" | "solo" | "solo-inverted" | "solo-filled",
|
|
||||||
default: "outlined",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["update:modelValue"],
|
|
||||||
|
|
||||||
setup(props, context) {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
const selected = computed({
|
inputAttrs: () => ({}),
|
||||||
get: () => props.modelValue,
|
returnObject: true,
|
||||||
set: (val) => {
|
showAdd: true,
|
||||||
context.emit("update:modelValue", val);
|
showLabel: true,
|
||||||
},
|
showIcon: true,
|
||||||
});
|
variant: "outlined",
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (selected.value === undefined) {
|
|
||||||
selected.value = [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const i18n = useI18n();
|
|
||||||
const { $globals } = useNuxtApp();
|
|
||||||
|
|
||||||
const label = computed(() => {
|
|
||||||
if (!props.showLabel) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (props.selectorType) {
|
|
||||||
case Organizer.Tag:
|
|
||||||
return i18n.t("tag.tags");
|
|
||||||
case Organizer.Category:
|
|
||||||
return i18n.t("category.categories");
|
|
||||||
case Organizer.Tool:
|
|
||||||
return i18n.t("tool.tools");
|
|
||||||
case Organizer.Food:
|
|
||||||
return i18n.t("general.foods");
|
|
||||||
case Organizer.Household:
|
|
||||||
return i18n.t("household.households");
|
|
||||||
default:
|
|
||||||
return i18n.t("general.organizer");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const icon = computed(() => {
|
|
||||||
if (!props.showIcon) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (props.selectorType) {
|
|
||||||
case Organizer.Tag:
|
|
||||||
return $globals.icons.tags;
|
|
||||||
case Organizer.Category:
|
|
||||||
return $globals.icons.categories;
|
|
||||||
case Organizer.Tool:
|
|
||||||
return $globals.icons.tools;
|
|
||||||
case Organizer.Food:
|
|
||||||
return $globals.icons.foods;
|
|
||||||
case Organizer.Household:
|
|
||||||
return $globals.icons.household;
|
|
||||||
default:
|
|
||||||
return $globals.icons.tags;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Store & Items Setup
|
|
||||||
|
|
||||||
const storeMap = {
|
|
||||||
[Organizer.Category]: useCategoryStore(),
|
|
||||||
[Organizer.Tag]: useTagStore(),
|
|
||||||
[Organizer.Tool]: useToolStore(),
|
|
||||||
[Organizer.Food]: useFoodStore(),
|
|
||||||
[Organizer.Household]: useHouseholdStore(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const store = computed(() => {
|
|
||||||
const { store } = storeMap[props.selectorType];
|
|
||||||
return store.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
const items = computed(() => {
|
|
||||||
if (!props.returnObject) {
|
|
||||||
return store.value.map(item => item.name);
|
|
||||||
}
|
|
||||||
return store.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
function removeByIndex(index: number) {
|
|
||||||
if (selected.value === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newSelected = selected.value.filter((_, i) => i !== index);
|
|
||||||
selected.value = [...newSelected];
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendCreated(item: any) {
|
|
||||||
if (selected.value === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
selected.value = [...selected.value, item];
|
|
||||||
}
|
|
||||||
|
|
||||||
const dialog = ref(false);
|
|
||||||
|
|
||||||
const searchInput = ref("");
|
|
||||||
|
|
||||||
function resetSearchInput() {
|
|
||||||
searchInput.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
Organizer,
|
|
||||||
appendCreated,
|
|
||||||
dialog,
|
|
||||||
storeItem: items,
|
|
||||||
label,
|
|
||||||
icon,
|
|
||||||
selected,
|
|
||||||
removeByIndex,
|
|
||||||
searchInput,
|
|
||||||
resetSearchInput,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selected = defineModel<(
|
||||||
|
| HouseholdSummary
|
||||||
|
| RecipeTag
|
||||||
|
| RecipeCategory
|
||||||
|
| RecipeTool
|
||||||
|
| IngredientFood
|
||||||
|
| string
|
||||||
|
)[] | undefined>({ required: true });
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (selected.value === undefined) {
|
||||||
|
selected.value = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const { $globals } = useNuxtApp();
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
if (!props.showLabel) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (props.selectorType) {
|
||||||
|
case Organizer.Tag:
|
||||||
|
return i18n.t("tag.tags");
|
||||||
|
case Organizer.Category:
|
||||||
|
return i18n.t("category.categories");
|
||||||
|
case Organizer.Tool:
|
||||||
|
return i18n.t("tool.tools");
|
||||||
|
case Organizer.Food:
|
||||||
|
return i18n.t("general.foods");
|
||||||
|
case Organizer.Household:
|
||||||
|
return i18n.t("household.households");
|
||||||
|
default:
|
||||||
|
return i18n.t("general.organizer");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const icon = computed(() => {
|
||||||
|
if (!props.showIcon) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (props.selectorType) {
|
||||||
|
case Organizer.Tag:
|
||||||
|
return $globals.icons.tags;
|
||||||
|
case Organizer.Category:
|
||||||
|
return $globals.icons.categories;
|
||||||
|
case Organizer.Tool:
|
||||||
|
return $globals.icons.tools;
|
||||||
|
case Organizer.Food:
|
||||||
|
return $globals.icons.foods;
|
||||||
|
case Organizer.Household:
|
||||||
|
return $globals.icons.household;
|
||||||
|
default:
|
||||||
|
return $globals.icons.tags;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Store & Items Setup
|
||||||
|
|
||||||
|
const storeMap = {
|
||||||
|
[Organizer.Category]: useCategoryStore(),
|
||||||
|
[Organizer.Tag]: useTagStore(),
|
||||||
|
[Organizer.Tool]: useToolStore(),
|
||||||
|
[Organizer.Food]: useFoodStore(),
|
||||||
|
[Organizer.Household]: useHouseholdStore(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = computed(() => {
|
||||||
|
const { store } = storeMap[props.selectorType];
|
||||||
|
return store.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = computed(() => {
|
||||||
|
if (!props.returnObject) {
|
||||||
|
return store.value.map(item => item.name);
|
||||||
|
}
|
||||||
|
return store.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function removeByIndex(index: number) {
|
||||||
|
if (selected.value === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newSelected = selected.value.filter((_, i) => i !== index);
|
||||||
|
selected.value = [...newSelected];
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCreated(item: any) {
|
||||||
|
if (selected.value === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selected.value = [...selected.value, item];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialog = ref(false);
|
||||||
|
|
||||||
|
const searchInput = ref("");
|
||||||
|
|
||||||
|
function resetSearchInput() {
|
||||||
|
searchInput.value = "";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -37,7 +37,7 @@
|
||||||
<RecipePageIngredientEditor v-if="isEditForm" v-model="recipe" />
|
<RecipePageIngredientEditor v-if="isEditForm" v-model="recipe" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
|
<RecipePageScale v-model="scale" :recipe="recipe" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
@ -81,7 +81,7 @@
|
||||||
</v-card>
|
</v-card>
|
||||||
<WakelockSwitch />
|
<WakelockSwitch />
|
||||||
<RecipePageComments
|
<RecipePageComments
|
||||||
v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode"
|
v-if="!recipe.settings?.disableComments && !isEditForm && !isCookMode"
|
||||||
v-model="recipe"
|
v-model="recipe"
|
||||||
class="px-1 my-4 d-print-none"
|
class="px-1 my-4 d-print-none"
|
||||||
/>
|
/>
|
||||||
|
@ -96,7 +96,7 @@
|
||||||
<v-row style="height: 100%" no-gutters class="overflow-hidden">
|
<v-row style="height: 100%" no-gutters class="overflow-hidden">
|
||||||
<v-col cols="12" sm="5" class="overflow-y-auto pl-4 pr-3 py-2" style="height: 100%">
|
<v-col cols="12" sm="5" class="overflow-y-auto pl-4 pr-3 py-2" style="height: 100%">
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
|
<RecipePageScale v-model="scale" :recipe="recipe" />
|
||||||
</div>
|
</div>
|
||||||
<RecipePageIngredientToolsView
|
<RecipePageIngredientToolsView
|
||||||
v-if="!isEditForm"
|
v-if="!isEditForm"
|
||||||
|
@ -124,7 +124,7 @@
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
<v-sheet v-show="isCookMode && hasLinkedIngredients">
|
<v-sheet v-show="isCookMode && hasLinkedIngredients">
|
||||||
<div class="mt-2 px-2 px-md-4">
|
<div class="mt-2 px-2 px-md-4">
|
||||||
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
|
<RecipePageScale v-model="scale" :recipe="recipe" />
|
||||||
</div>
|
</div>
|
||||||
<RecipePageInstructions
|
<RecipePageInstructions
|
||||||
v-model="recipe.recipeInstructions"
|
v-model="recipe.recipeInstructions"
|
||||||
|
@ -141,7 +141,6 @@
|
||||||
<RecipeIngredients
|
<RecipeIngredients
|
||||||
:value="notLinkedIngredients"
|
:value="notLinkedIngredients"
|
||||||
:scale="scale"
|
:scale="scale"
|
||||||
:disable-amount="recipe.settings.disableAmount"
|
|
||||||
:is-cook-mode="isCookMode"
|
:is-cook-mode="isCookMode"
|
||||||
/>
|
/>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
@ -278,7 +277,7 @@ async function deleteRecipe() {
|
||||||
* View Preferences
|
* View Preferences
|
||||||
*/
|
*/
|
||||||
const landscape = computed(() => {
|
const landscape = computed(() => {
|
||||||
const preferLandscape = recipe.value.settings.landscapeView;
|
const preferLandscape = recipe.value.settings?.landscapeView;
|
||||||
const smallScreen = !$vuetify.display.smAndUp.value;
|
const smallScreen = !$vuetify.display.smAndUp.value;
|
||||||
|
|
||||||
if (preferLandscape) {
|
if (preferLandscape) {
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { useRecipePermissions } from "~/composables/recipes";
|
import { useRecipePermissions } from "~/composables/recipes";
|
||||||
import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue";
|
import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue";
|
||||||
|
@ -35,82 +35,48 @@ import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||||
import type { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
|
import { usePageState, usePageUser, PageMode } from "~/composables/recipe-page/shared-state";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
components: {
|
recipe: NoUndefinedField<Recipe>;
|
||||||
RecipePageInfoCard,
|
recipeScale?: number;
|
||||||
RecipeActionMenu,
|
landscape?: boolean;
|
||||||
},
|
}
|
||||||
props: {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
recipe: {
|
recipeScale: 1,
|
||||||
type: Object as () => NoUndefinedField<Recipe>,
|
landscape: false,
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
recipeScale: {
|
|
||||||
type: Number,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
landscape: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ["save", "delete"],
|
|
||||||
setup(props) {
|
|
||||||
const { $vuetify } = useNuxtApp();
|
|
||||||
const { recipeImage } = useStaticRoutes();
|
|
||||||
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
|
||||||
const { user } = usePageUser();
|
|
||||||
const { isOwnGroup } = useLoggedInState();
|
|
||||||
|
|
||||||
const recipeHousehold = ref<HouseholdSummary>();
|
|
||||||
if (user) {
|
|
||||||
const userApi = useUserApi();
|
|
||||||
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
|
|
||||||
recipeHousehold.value = data || undefined;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const { canEditRecipe } = useRecipePermissions(props.recipe, recipeHousehold, user);
|
|
||||||
|
|
||||||
function printRecipe() {
|
|
||||||
window.print();
|
|
||||||
}
|
|
||||||
|
|
||||||
const hideImage = ref(false);
|
|
||||||
const imageHeight = computed(() => {
|
|
||||||
return $vuetify.display.xs.value ? "200" : "400";
|
|
||||||
});
|
|
||||||
|
|
||||||
const recipeImageUrl = computed(() => {
|
|
||||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => recipeImageUrl.value,
|
|
||||||
() => {
|
|
||||||
hideImage.value = false;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isOwnGroup,
|
|
||||||
setMode,
|
|
||||||
toggleEditMode,
|
|
||||||
recipeImage,
|
|
||||||
canEditRecipe,
|
|
||||||
imageKey,
|
|
||||||
user,
|
|
||||||
PageMode,
|
|
||||||
pageMode,
|
|
||||||
EditorMode,
|
|
||||||
editMode,
|
|
||||||
printRecipe,
|
|
||||||
imageHeight,
|
|
||||||
hideImage,
|
|
||||||
isEditMode,
|
|
||||||
recipeImageUrl,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
defineEmits(["save", "delete"]);
|
||||||
|
|
||||||
|
const { recipeImage } = useStaticRoutes();
|
||||||
|
const { imageKey, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
||||||
|
const { user } = usePageUser();
|
||||||
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
|
const recipeHousehold = ref<HouseholdSummary>();
|
||||||
|
if (user) {
|
||||||
|
const userApi = useUserApi();
|
||||||
|
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
|
||||||
|
recipeHousehold.value = data || undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { canEditRecipe } = useRecipePermissions(props.recipe, recipeHousehold, user);
|
||||||
|
|
||||||
|
function printRecipe() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideImage = ref(false);
|
||||||
|
|
||||||
|
const recipeImageUrl = computed(() => {
|
||||||
|
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => recipeImageUrl.value,
|
||||||
|
() => {
|
||||||
|
hideImage.value = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -35,7 +35,7 @@
|
||||||
>
|
>
|
||||||
<RecipeYield
|
<RecipeYield
|
||||||
:yield-quantity="recipe.recipeYieldQuantity"
|
:yield-quantity="recipe.recipeYieldQuantity"
|
||||||
:yield="recipe.recipeYield"
|
:yield-text="recipe.recipeYield"
|
||||||
:scale="recipeScale"
|
:scale="recipeScale"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
|
@ -76,7 +76,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
||||||
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
||||||
|
@ -86,34 +86,15 @@ import RecipePageInfoCardImage from "~/components/Domain/Recipe/RecipePage/Recip
|
||||||
import type { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
components: {
|
recipe: NoUndefinedField<Recipe>;
|
||||||
RecipeRating,
|
recipeScale?: number;
|
||||||
RecipeLastMade,
|
landscape: boolean;
|
||||||
RecipeTimeCard,
|
}
|
||||||
RecipeYield,
|
|
||||||
RecipePageInfoCardImage,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
recipe: {
|
|
||||||
type: Object as () => NoUndefinedField<Recipe>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
recipeScale: {
|
|
||||||
type: Number,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
landscape: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const { isOwnGroup } = useLoggedInState();
|
|
||||||
|
|
||||||
return {
|
withDefaults(defineProps<Props>(), {
|
||||||
isOwnGroup,
|
recipeScale: 1,
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { isOwnGroup } = useLoggedInState();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -12,60 +12,47 @@
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||||
import type { Recipe } from "~/lib/api/types/recipe";
|
import type { Recipe } from "~/lib/api/types/recipe";
|
||||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
props: {
|
recipe: NoUndefinedField<Recipe>;
|
||||||
recipe: {
|
maxWidth?: string;
|
||||||
type: Object as () => NoUndefinedField<Recipe>,
|
}
|
||||||
required: true,
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
},
|
maxWidth: undefined,
|
||||||
maxWidth: {
|
|
||||||
type: String,
|
|
||||||
default: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const { $vuetify } = useNuxtApp();
|
|
||||||
const { recipeImage } = useStaticRoutes();
|
|
||||||
const { imageKey } = usePageState(props.recipe.slug);
|
|
||||||
const { user } = usePageUser();
|
|
||||||
|
|
||||||
const recipeHousehold = ref<HouseholdSummary>();
|
|
||||||
if (user) {
|
|
||||||
const userApi = useUserApi();
|
|
||||||
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
|
|
||||||
recipeHousehold.value = data || undefined;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const hideImage = ref(false);
|
|
||||||
const imageHeight = computed(() => {
|
|
||||||
return $vuetify.display.xs.value ? "200" : "400";
|
|
||||||
});
|
|
||||||
|
|
||||||
const recipeImageUrl = computed(() => {
|
|
||||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => recipeImageUrl.value,
|
|
||||||
() => {
|
|
||||||
hideImage.value = false;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
recipeImageUrl,
|
|
||||||
imageKey,
|
|
||||||
hideImage,
|
|
||||||
imageHeight,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { $vuetify } = useNuxtApp();
|
||||||
|
const { recipeImage } = useStaticRoutes();
|
||||||
|
const { imageKey } = usePageState(props.recipe.slug);
|
||||||
|
const { user } = usePageUser();
|
||||||
|
|
||||||
|
const recipeHousehold = ref<HouseholdSummary>();
|
||||||
|
if (user) {
|
||||||
|
const userApi = useUserApi();
|
||||||
|
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
|
||||||
|
recipeHousehold.value = data || undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideImage = ref(false);
|
||||||
|
const imageHeight = computed(() => {
|
||||||
|
return $vuetify.display.xs.value ? "200" : "400";
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipeImageUrl = computed(() => {
|
||||||
|
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => recipeImageUrl.value,
|
||||||
|
() => {
|
||||||
|
hideImage.value = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
<!-- eslint-disable vue/no-mutating-props -->
|
<!-- eslint-disable vue/no-mutating-props -->
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="mb-4 text-h5 font-weight-medium opacity-80">
|
<div class="mb-4">
|
||||||
{{ $t("recipe.ingredients") }}
|
<h2 class="mb-4 text-h5 font-weight-medium opacity-80">
|
||||||
</h2>
|
{{ $t("recipe.ingredients") }}
|
||||||
|
</h2>
|
||||||
|
<BannerWarning v-if="!hasFoodOrUnit">
|
||||||
|
{{ $t("recipe.ingredients-not-parsed-description", { parse: $t('recipe.parse') }) }}
|
||||||
|
</BannerWarning>
|
||||||
|
</div>
|
||||||
<VueDraggable
|
<VueDraggable
|
||||||
v-if="recipe.recipeIngredient.length > 0"
|
v-if="recipe.recipeIngredient.length > 0"
|
||||||
v-model="recipe.recipeIngredient"
|
v-model="recipe.recipeIngredient"
|
||||||
|
@ -27,7 +32,6 @@
|
||||||
:key="ingredient.referenceId"
|
:key="ingredient.referenceId"
|
||||||
v-model="recipe.recipeIngredient[index]"
|
v-model="recipe.recipeIngredient[index]"
|
||||||
class="list-group-item"
|
class="list-group-item"
|
||||||
:disable-amount="recipe.settings.disableAmount"
|
|
||||||
@delete="recipe.recipeIngredient.splice(index, 1)"
|
@delete="recipe.recipeIngredient.splice(index, 1)"
|
||||||
@insert-above="insertNewIngredient(index)"
|
@insert-above="insertNewIngredient(index)"
|
||||||
@insert-below="insertNewIngredient(index + 1)"
|
@insert-below="insertNewIngredient(index + 1)"
|
||||||
|
@ -42,14 +46,14 @@
|
||||||
/>
|
/>
|
||||||
<div class="d-flex flex-wrap justify-center justify-sm-end mt-3">
|
<div class="d-flex flex-wrap justify-center justify-sm-end mt-3">
|
||||||
<v-tooltip
|
<v-tooltip
|
||||||
top
|
location="top"
|
||||||
color="accent"
|
color="accent"
|
||||||
>
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<span>
|
<span>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
class="mb-1"
|
class="mb-1"
|
||||||
:disabled="recipe.settings.disableAmount || hasFoodOrUnit"
|
:disabled="hasFoodOrUnit"
|
||||||
color="accent"
|
color="accent"
|
||||||
:to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`"
|
:to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`"
|
||||||
v-bind="props"
|
v-bind="props"
|
||||||
|
@ -109,10 +113,7 @@ const hasFoodOrUnit = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const parserToolTip = computed(() => {
|
const parserToolTip = computed(() => {
|
||||||
if (recipe.value.settings.disableAmount) {
|
if (hasFoodOrUnit.value) {
|
||||||
return i18n.t("recipe.enable-ingredient-amounts-to-use-this-feature");
|
|
||||||
}
|
|
||||||
else if (hasFoodOrUnit.value) {
|
|
||||||
return i18n.t("recipe.recipes-with-units-or-foods-defined-cannot-be-parsed");
|
return i18n.t("recipe.recipes-with-units-or-foods-defined-cannot-be-parsed");
|
||||||
}
|
}
|
||||||
return i18n.t("recipe.parse-ingredients");
|
return i18n.t("recipe.parse-ingredients");
|
||||||
|
@ -127,7 +128,6 @@ function addIngredient(ingredients: Array<string> | null = null) {
|
||||||
note: x,
|
note: x,
|
||||||
unit: undefined,
|
unit: undefined,
|
||||||
food: undefined,
|
food: undefined,
|
||||||
disableAmount: true,
|
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -146,7 +146,6 @@ function addIngredient(ingredients: Array<string> | null = null) {
|
||||||
unit: undefined,
|
unit: undefined,
|
||||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||||
food: undefined,
|
food: undefined,
|
||||||
disableAmount: true,
|
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -161,7 +160,6 @@ function insertNewIngredient(dest: number) {
|
||||||
unit: undefined,
|
unit: undefined,
|
||||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||||
food: undefined,
|
food: undefined,
|
||||||
disableAmount: true,
|
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
<RecipeIngredients
|
<RecipeIngredients
|
||||||
:value="recipe.recipeIngredient"
|
:value="recipe.recipeIngredient"
|
||||||
:scale="scale"
|
:scale="scale"
|
||||||
:disable-amount="recipe.settings.disableAmount"
|
|
||||||
:is-cook-mode="isCookMode"
|
:is-cook-mode="isCookMode"
|
||||||
/>
|
/>
|
||||||
<div v-if="!isEditMode && recipe.tools && recipe.tools.length > 0">
|
<div v-if="!isEditMode && recipe.tools && recipe.tools.length > 0">
|
||||||
|
@ -36,7 +35,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||||
import { useToolStore } from "~/composables/store";
|
import { useToolStore } from "~/composables/store";
|
||||||
|
@ -48,71 +47,52 @@ interface RecipeToolWithOnHand extends RecipeTool {
|
||||||
onHand: boolean;
|
onHand: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
interface Props {
|
||||||
components: {
|
recipe: NoUndefinedField<Recipe>;
|
||||||
RecipeIngredients,
|
scale: number;
|
||||||
},
|
isCookMode?: boolean;
|
||||||
props: {
|
}
|
||||||
recipe: {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
type: Object as () => NoUndefinedField<Recipe>,
|
isCookMode: false,
|
||||||
required: true,
|
});
|
||||||
},
|
|
||||||
scale: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
isCookMode: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const { isOwnGroup } = useLoggedInState();
|
|
||||||
|
|
||||||
const toolStore = isOwnGroup.value ? useToolStore() : null;
|
const { isOwnGroup } = useLoggedInState();
|
||||||
const { user } = usePageUser();
|
|
||||||
const { isEditMode } = usePageState(props.recipe.slug);
|
|
||||||
|
|
||||||
const recipeTools = computed(() => {
|
const toolStore = isOwnGroup.value ? useToolStore() : null;
|
||||||
if (!(user.householdSlug && toolStore)) {
|
const { user } = usePageUser();
|
||||||
return props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
|
const { isEditMode } = usePageState(props.recipe.slug);
|
||||||
}
|
|
||||||
else {
|
const recipeTools = computed(() => {
|
||||||
return props.recipe.tools.map((tool) => {
|
if (!(user.householdSlug && toolStore)) {
|
||||||
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
|
return props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
|
||||||
return { ...tool, onHand } as RecipeToolWithOnHand;
|
}
|
||||||
});
|
else {
|
||||||
}
|
return props.recipe.tools.map((tool) => {
|
||||||
|
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
|
||||||
|
return { ...tool, onHand } as RecipeToolWithOnHand;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function updateTool(index: number) {
|
function updateTool(index: number) {
|
||||||
if (user.id && user.householdSlug && toolStore) {
|
if (user.id && user.householdSlug && toolStore) {
|
||||||
const tool = recipeTools.value[index];
|
const tool = recipeTools.value[index];
|
||||||
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
|
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
|
||||||
if (!tool.householdsWithTool) {
|
if (!tool.householdsWithTool) {
|
||||||
tool.householdsWithTool = [user.householdSlug];
|
tool.householdsWithTool = [user.householdSlug];
|
||||||
}
|
|
||||||
else {
|
|
||||||
tool.householdsWithTool.push(user.householdSlug);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
|
|
||||||
tool.householdsWithTool = tool.householdsWithTool.filter(household => household !== user.householdSlug);
|
|
||||||
}
|
|
||||||
|
|
||||||
toolStore.actions.updateOne(tool);
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
console.log("no user, skipping server update");
|
tool.householdsWithTool.push(user.householdSlug);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
|
||||||
|
tool.householdsWithTool = tool.householdsWithTool.filter(household => household !== user.householdSlug);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
toolStore.actions.updateOne(tool);
|
||||||
toolStore,
|
}
|
||||||
recipeTools,
|
else {
|
||||||
isEditMode,
|
console.log("no user, skipping server update");
|
||||||
updateTool,
|
}
|
||||||
};
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue