mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-06-13 20:40:18 -04:00
Compare commits
310 Commits
auto-local
...
v3.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4f4ba0c8d | ||
|
|
8cd5835dd8 | ||
|
|
7aa131b326 | ||
|
|
af264bd288 | ||
|
|
72388e8bcf | ||
|
|
c0afef46d6 | ||
|
|
f90665cce9 | ||
|
|
942ac741cd | ||
|
|
1d3a7e8d62 | ||
|
|
5e85fc409e | ||
|
|
2c20e96ede | ||
|
|
608fc39747 | ||
|
|
ed2f40cd6a | ||
|
|
a080cdb432 | ||
|
|
83101e3ed5 | ||
|
|
5d90997ace | ||
|
|
c78c6cf926 | ||
|
|
e26191d116 | ||
|
|
3774f68393 | ||
|
|
c46c412bf5 | ||
|
|
aa9e61a16f | ||
|
|
b2f8d63f33 | ||
|
|
72b47a1103 | ||
|
|
29e150d547 | ||
|
|
e9ae6d86a4 | ||
|
|
f799938373 | ||
|
|
e5fff4ec5c | ||
|
|
192e531c1f | ||
|
|
45e710ee72 | ||
|
|
be579ed664 | ||
|
|
fe953896f8 | ||
|
|
decf7cb307 | ||
|
|
d396a8fdc2 | ||
|
|
a3ef49f559 | ||
|
|
41e8458389 | ||
|
|
18dc2fc6a8 | ||
|
|
6355b3c8db | ||
|
|
3ac8af138f | ||
|
|
2b3803fb2e | ||
|
|
6a80e70486 | ||
|
|
f1dc854770 | ||
|
|
581aa929bd | ||
|
|
461e51bd22 | ||
|
|
1cdf43c599 | ||
|
|
6bfbc7ca0a | ||
|
|
608dbaa4c1 | ||
|
|
89c1e007cb | ||
|
|
fb5db583d2 | ||
|
|
bef3045e65 | ||
|
|
ff958a5015 | ||
|
|
37789c342e | ||
|
|
b6b8bea925 | ||
|
|
60834178ba | ||
|
|
0375a0bd5a | ||
|
|
3361f9a7c3 | ||
|
|
0883ef05ab | ||
|
|
c4eb020a66 | ||
|
|
600f407b4f | ||
|
|
6f92a829d6 | ||
|
|
6b11ff5128 | ||
|
|
29fdad1574 | ||
|
|
54b3df105c | ||
|
|
9a3303b06c | ||
|
|
c17accd82b | ||
|
|
18f7e8d935 | ||
|
|
6d2936cab6 | ||
|
|
cc2e33a254 | ||
|
|
eee6f8113c | ||
|
|
bd10cb8cd8 | ||
|
|
d03081c4e6 | ||
|
|
64d865bf7e | ||
|
|
27efda2772 | ||
|
|
81986e63b8 | ||
|
|
42eef17cfb | ||
|
|
1f724856b1 | ||
|
|
618ea06b7a | ||
|
|
ca2039ae35 | ||
|
|
15ecab86d1 | ||
|
|
aa164424d3 | ||
|
|
99acb349bd | ||
|
|
894162a669 | ||
|
|
347af7d417 | ||
|
|
cac1699aeb | ||
|
|
d577966bfb | ||
|
|
c663efde09 | ||
|
|
9e568a1182 | ||
|
|
fc38ef2ba9 | ||
|
|
323a8100db | ||
|
|
01d3d5d325 | ||
|
|
3f52c66f02 | ||
|
|
566f744220 | ||
|
|
561b50ba45 | ||
|
|
4228c9e753 | ||
|
|
2a5c3f6457 | ||
|
|
389f8b4279 | ||
|
|
f2b71e981e | ||
|
|
ec7e3a5103 | ||
|
|
6f0183cc4b | ||
|
|
12d38c89ea | ||
|
|
492c9a948d | ||
|
|
a808c8a18b | ||
|
|
0c6483aefa | ||
|
|
1568c33c37 | ||
|
|
5d0cf8422b | ||
|
|
d322abc1b4 | ||
|
|
c34180632b | ||
|
|
c74f016f66 | ||
|
|
e11ee47109 | ||
|
|
a044ca2779 | ||
|
|
f584fa33f4 | ||
|
|
4f33c3a44f | ||
|
|
394c271294 | ||
|
|
cb3eb59501 | ||
|
|
c41a4a52ed | ||
|
|
6cbc308d83 | ||
|
|
db765b0693 | ||
|
|
184262df17 | ||
|
|
df10cf8211 | ||
|
|
c91d216fe9 | ||
|
|
1c23d855ae | ||
|
|
fd966e5843 | ||
|
|
f9f1285cb3 | ||
|
|
dbf1202f69 | ||
|
|
b3f12ee7ac | ||
|
|
556dc699c7 | ||
|
|
481ce92d84 | ||
|
|
1df26aeb99 | ||
|
|
5c4694c3d8 | ||
|
|
9b2b8091ac | ||
|
|
ad60b4445b | ||
|
|
040fb48807 | ||
|
|
f8ce5b3afb | ||
|
|
31530a68e1 | ||
|
|
51ca65e3c3 | ||
|
|
19eae21b00 | ||
|
|
34e4480f08 | ||
|
|
ad875c8fd5 | ||
|
|
6ec7baa2f1 | ||
|
|
f39929a905 | ||
|
|
4b69e5b33a | ||
|
|
4c2b559e73 | ||
|
|
178e038c79 | ||
|
|
0b3fe2c8da | ||
|
|
d4e62c5ab6 | ||
|
|
0f591fd273 | ||
|
|
d271252ecc | ||
|
|
4d211e236d | ||
|
|
7926812136 | ||
|
|
f37d8e488f | ||
|
|
56df696546 | ||
|
|
d3436a5ca8 | ||
|
|
9e46c57e78 | ||
|
|
79b6be8550 | ||
|
|
bd9e654de7 | ||
|
|
f149bcc98f | ||
|
|
0095cc7153 | ||
|
|
f276ecd96e | ||
|
|
87bfdb633b | ||
|
|
e47f386643 | ||
|
|
5c9fd22f11 | ||
|
|
30ee276e52 | ||
|
|
50c8e9be79 | ||
|
|
c4fdcec85f | ||
|
|
f7b32debbb | ||
|
|
1f1cc10f21 | ||
|
|
2ae3427a59 | ||
|
|
357f843df5 | ||
|
|
3973fe99a1 | ||
|
|
8d51c14d24 | ||
|
|
f2af8a0bc1 | ||
|
|
c130e8e92d | ||
|
|
ea099a743b | ||
|
|
d21e685219 | ||
|
|
04d0144369 | ||
|
|
8a8580e83c | ||
|
|
6b6c167153 | ||
|
|
e1c4a703a2 | ||
|
|
591c96e52b | ||
|
|
b157c7034f | ||
|
|
245ca5fe3b | ||
|
|
efc0d31724 | ||
|
|
4b7f7b4b8a | ||
|
|
7aee575352 | ||
|
|
2ef5b0d389 | ||
|
|
387a12cf1a | ||
|
|
f26e74f0f2 | ||
|
|
f2b6512eb1 | ||
|
|
93f3762016 | ||
|
|
620465f14c | ||
|
|
a132b83f1b | ||
|
|
5f522b5324 | ||
|
|
bd0aed06ce | ||
|
|
f9f88fb8a4 | ||
|
|
eefe613aaf | ||
|
|
d6d247f1f8 | ||
|
|
3b74ddd9ad | ||
|
|
eec4eeb76a | ||
|
|
73a9e470c3 | ||
|
|
b578d8d4f7 | ||
|
|
7a43546eeb | ||
|
|
e7c310934d | ||
|
|
07d5928f18 | ||
|
|
fa5bc17ed8 | ||
|
|
f8cb80ed7f | ||
|
|
066cd13e13 | ||
|
|
a087760d53 | ||
|
|
675ac9c32b | ||
|
|
d7191983bd | ||
|
|
14b3fd524f | ||
|
|
38ed0d0532 | ||
|
|
3b48f73b91 | ||
|
|
ae7e7942e3 | ||
|
|
ec1eddc06d | ||
|
|
2781771f6b | ||
|
|
9b35a2f904 | ||
|
|
a323350915 | ||
|
|
9b94cfdd24 | ||
|
|
2818ff56ee | ||
|
|
d728df7d40 | ||
|
|
a531eb649e | ||
|
|
fb4aa2b713 | ||
|
|
0df9d4b958 | ||
|
|
99523c70ed | ||
|
|
5c7a4fb861 | ||
|
|
436a24f8b2 | ||
|
|
93534de638 | ||
|
|
f29a11d20e | ||
|
|
880bd91f4a | ||
|
|
8e68782ff6 | ||
|
|
c1e5937ff3 | ||
|
|
6d1e39f871 | ||
|
|
5b92e969dc | ||
|
|
992a74499d | ||
|
|
4744e6371a | ||
|
|
00a03559ff | ||
|
|
a1a45078fb | ||
|
|
88d0b466ce | ||
|
|
e638df37d1 | ||
|
|
44b180f5c0 | ||
|
|
3feb11d138 | ||
|
|
41633be864 | ||
|
|
ce8fa2194b | ||
|
|
7d1c5ad14b | ||
|
|
6274a3dd39 | ||
|
|
a1e8e1aa20 | ||
|
|
b4aff0d8e9 | ||
|
|
e07df8fc43 | ||
|
|
9413e403b4 | ||
|
|
6eab88d44c | ||
|
|
754878eb63 | ||
|
|
c65456c646 | ||
|
|
dd55d23a37 | ||
|
|
aafed68964 | ||
|
|
108ac40b22 | ||
|
|
d2e9c04af1 | ||
|
|
3a1f58037d | ||
|
|
2731fb4a01 | ||
|
|
769db7202d | ||
|
|
644e871ec1 | ||
|
|
8987faa4f6 | ||
|
|
c237b33126 | ||
|
|
7098b67784 | ||
|
|
2d21d00651 | ||
|
|
198d5e4e06 | ||
|
|
98fc333df3 | ||
|
|
c0fb27f979 | ||
|
|
9ec1599427 | ||
|
|
9cfc54b1f5 | ||
|
|
40d2ac9a6b | ||
|
|
44db525049 | ||
|
|
d737cb3e14 | ||
|
|
1034d87a99 | ||
|
|
1243e6804c | ||
|
|
8b9e80358b | ||
|
|
2bae6e9d02 | ||
|
|
6b98a7cd74 | ||
|
|
e0238eb3a2 | ||
|
|
5adb7662c4 | ||
|
|
4e6a7a09ff | ||
|
|
719c7c9f6b | ||
|
|
7331007f30 | ||
|
|
ea329a6b71 | ||
|
|
e1a04ba673 | ||
|
|
63a4d4c801 | ||
|
|
5cf3e2565a | ||
|
|
9e1fe618ba | ||
|
|
691300e481 | ||
|
|
939588f54c | ||
|
|
2d8f491666 | ||
|
|
50754ad012 | ||
|
|
04eca1b992 | ||
|
|
aad7dc1abd | ||
|
|
2f19d31d1b | ||
|
|
095b92c29a | ||
|
|
49c704a4b1 | ||
|
|
c15a4f786b | ||
|
|
6e33878e4f | ||
|
|
5ca004802d | ||
|
|
68115cbf2f | ||
|
|
2b4bc8a662 | ||
|
|
fc801c9da4 | ||
|
|
f99b305dc3 | ||
|
|
b0b3d7e5e5 | ||
|
|
eedd2204a6 | ||
|
|
1ccc67774a | ||
|
|
6d98041ec8 | ||
|
|
c24cfb8096 | ||
|
|
ca41bc8d5c | ||
|
|
da3271f33f | ||
|
|
50a986f331 |
11
.github/workflows/locale-sync.yml
vendored
11
.github/workflows/locale-sync.yml
vendored
@@ -86,7 +86,7 @@ jobs:
|
||||
|
||||
# Add and commit changes
|
||||
git add .
|
||||
git commit -m "chore: automatic locale sync"
|
||||
git commit -m "chore: crowdin locale sync"
|
||||
|
||||
# Push the branch
|
||||
git push origin "$BRANCH_NAME"
|
||||
@@ -96,20 +96,17 @@ jobs:
|
||||
# Create PR using GitHub CLI with explicit repository
|
||||
gh pr create \
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "chore: automatic locale sync" \
|
||||
--title "chore(l10n): Crowdin locale sync" \
|
||||
--base "$BASE_BRANCH" \
|
||||
--head "$BRANCH_NAME" \
|
||||
--label "l10n" \
|
||||
--body "## Summary
|
||||
|
||||
Automatically generated locale updates from the weekly sync job.
|
||||
|
||||
## Changes
|
||||
- Updated frontend locale files
|
||||
- Generated from latest translation sources
|
||||
|
||||
## Test plan
|
||||
- [ ] Verify locale files are properly formatted
|
||||
- [ ] Test that translations load correctly in the frontend" \
|
||||
- Generated from latest translation sources" \
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
1
.github/workflows/pull-request-lint.yml
vendored
1
.github/workflows/pull-request-lint.yml
vendored
@@ -31,6 +31,7 @@ jobs:
|
||||
deps
|
||||
auto
|
||||
l10n
|
||||
config
|
||||
# Configure that a scope must always be provided.
|
||||
requireScope: false
|
||||
# If the PR contains one of these newline-delimited labels, the
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
name: Build Package
|
||||
uses: ./.github/workflows/build-package.yml
|
||||
with:
|
||||
tag: release
|
||||
tag: ${{ github.event.release.tag_name }}
|
||||
|
||||
publish:
|
||||
permissions:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
exclude: "mkdocs.yml"
|
||||
@@ -12,7 +12,7 @@ repos:
|
||||
exclude: ^tests/data/
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.12.1
|
||||
rev: v0.12.12
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
||||
@@ -71,6 +71,7 @@ tasks:
|
||||
desc: run code generators
|
||||
cmds:
|
||||
- poetry run python dev/code-generation/main.py {{ .CLI_ARGS }}
|
||||
- task: docs:gen
|
||||
- task: py:format
|
||||
|
||||
dev:services:
|
||||
@@ -87,6 +88,8 @@ tasks:
|
||||
- rm -r ./dev/data/recipes/
|
||||
- rm -r ./dev/data/users/
|
||||
- rm -f ./dev/data/mealie*.db
|
||||
- rm -f ./dev/data/mealie*.db-shm
|
||||
- rm -f ./dev/data/mealie*.db-wal
|
||||
- rm -f ./dev/data/mealie.log
|
||||
- rm -f ./dev/data/.secret
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ conventional_commits = true
|
||||
filter_unconventional = true
|
||||
# regex for preprocessing the commit messages
|
||||
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
|
||||
commit_parsers = [
|
||||
|
||||
@@ -76,7 +76,7 @@ export const LOCALES = [{% for locale in locales %}
|
||||
progress: {{ locale.progress }},
|
||||
dir: "{{ locale.dir }}",
|
||||
},{% endfor %}
|
||||
]
|
||||
];
|
||||
|
||||
"""
|
||||
|
||||
@@ -173,15 +173,36 @@ the code generation ID is hardcoded into the script and required in the nuxt con
|
||||
|
||||
|
||||
def inject_nuxt_values():
|
||||
all_date_locales = [
|
||||
f'"{match.stem}": require("./lang/dateTimeFormats/{match.name}"),' for match in datetime_dir.glob("*.json")
|
||||
]
|
||||
datetime_files = list(datetime_dir.glob("*.json"))
|
||||
datetime_files.sort()
|
||||
|
||||
datetime_imports = []
|
||||
datetime_object_entries = []
|
||||
|
||||
for match in datetime_files:
|
||||
# Convert locale name to camelCase variable name (e.g., "en-US" -> "enUS")
|
||||
var_name = match.stem.replace("-", "")
|
||||
|
||||
# Generate import statement
|
||||
import_line = f'import * as {var_name} from "./lang/dateTimeFormats/{match.name}";'
|
||||
datetime_imports.append(import_line)
|
||||
|
||||
# Generate object entry
|
||||
object_entry = f' "{match.stem}": {var_name},'
|
||||
datetime_object_entries.append(object_entry)
|
||||
|
||||
all_date_locales = datetime_imports + ["", "const datetimeFormats = {"] + datetime_object_entries + ["};"]
|
||||
|
||||
all_langs = []
|
||||
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.sort()
|
||||
|
||||
log.debug(f"injecting locales into nuxt config -> {nuxt_config}")
|
||||
inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs)
|
||||
inject_inline(i18n_config, CodeKeys.nuxt_local_dates, all_date_locales)
|
||||
|
||||
@@ -8,8 +8,8 @@ from utils import log
|
||||
# ============================================================
|
||||
|
||||
template = """// This Code is auto generated by gen_ts_types.py
|
||||
{% for name in global %}import {{ name }} from "@/components/global/{{ name }}.vue";
|
||||
{% endfor %}{% for name in layout %}import {{ name }} from "@/components/layout/{{ name }}.vue";
|
||||
{% for name in global %}import type {{ name }} from "@/components/global/{{ name }}.vue";
|
||||
{% endfor %}{% for name in layout %}import type {{ name }} from "@/components/layout/{{ name }}.vue";
|
||||
{% endfor %}
|
||||
declare module "vue" {
|
||||
export interface GlobalComponents {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
|
||||
|
||||

|
||||
|
||||
# {{ recipe.name }}
|
||||
{{ recipe.description }}
|
||||
|
||||
## Ingredients
|
||||
{% for ingredient in recipe.recipeIngredient %}
|
||||
- [ ] {{ ingredient }} {% endfor %}
|
||||
|
||||
## Instructions
|
||||
{% for step in recipe.recipeInstructions %}
|
||||
- [ ] {{ step.text }} {% endfor %}
|
||||
|
||||
{% for note in recipe.notes %}
|
||||
**{{ note.title }}:** {{ note.text }}
|
||||
{% endfor %}
|
||||
|
||||
---
|
||||
|
||||
Tags: {{ recipe.tags }}
|
||||
Categories: {{ recipe.categories }}
|
||||
Original URL: {{ recipe.orgURL }}
|
||||
@@ -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",
|
||||
"unit": None,
|
||||
"food": None,
|
||||
"disableAmount": True,
|
||||
"quantity": 1,
|
||||
"originalText": None,
|
||||
"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",
|
||||
"unit": None,
|
||||
"food": None,
|
||||
"disableAmount": True,
|
||||
"quantity": 1,
|
||||
"originalText": None,
|
||||
"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",
|
||||
"unit": None,
|
||||
"food": None,
|
||||
"disableAmount": True,
|
||||
"quantity": 1,
|
||||
"originalText": None,
|
||||
"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",
|
||||
"unit": None,
|
||||
"food": None,
|
||||
"disableAmount": True,
|
||||
"quantity": 1,
|
||||
"originalText": None,
|
||||
"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",
|
||||
"unit": None,
|
||||
"food": None,
|
||||
"disableAmount": True,
|
||||
"quantity": 1,
|
||||
"originalText": None,
|
||||
"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",
|
||||
"unit": None,
|
||||
"food": None,
|
||||
"disableAmount": True,
|
||||
"quantity": 1,
|
||||
"originalText": None,
|
||||
"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",
|
||||
"unit": None,
|
||||
"food": None,
|
||||
"disableAmount": True,
|
||||
"quantity": 1,
|
||||
"originalText": None,
|
||||
"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",
|
||||
"unit": None,
|
||||
"food": None,
|
||||
"disableAmount": True,
|
||||
"quantity": 1,
|
||||
"originalText": None,
|
||||
"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",
|
||||
"unit": None,
|
||||
"food": None,
|
||||
"disableAmount": True,
|
||||
"quantity": 1,
|
||||
"originalText": None,
|
||||
"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",
|
||||
"unit": None,
|
||||
"food": None,
|
||||
"disableAmount": True,
|
||||
"quantity": 1,
|
||||
"originalText": None,
|
||||
"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",
|
||||
"unit": None,
|
||||
"food": None,
|
||||
"disableAmount": True,
|
||||
"quantity": 1,
|
||||
"originalText": None,
|
||||
"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",
|
||||
"unit": None,
|
||||
"food": None,
|
||||
"disableAmount": True,
|
||||
"quantity": 1,
|
||||
"originalText": None,
|
||||
"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",
|
||||
"unit": None,
|
||||
"food": None,
|
||||
"disableAmount": True,
|
||||
"quantity": 1,
|
||||
"originalText": None,
|
||||
"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,
|
||||
"landscapeView": False,
|
||||
"disableComments": False,
|
||||
"disableAmount": True,
|
||||
"locked": False,
|
||||
},
|
||||
"assets": [],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
###############################################
|
||||
# Frontend Build
|
||||
###############################################
|
||||
FROM node:20 AS frontend-builder
|
||||
FROM node:20@sha256:f3e50c7689a1b6982fab45b1b23ba5adf1fd725e233dc640918fb59f7a57b174 \
|
||||
AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
@@ -20,7 +21,8 @@ RUN yarn generate
|
||||
###############################################
|
||||
# Base Image - Python
|
||||
###############################################
|
||||
FROM python:3.12-slim as python-base
|
||||
FROM python:3.12-slim@sha256:2267adc248a477c1f1a852a07a5a224d42abe54c28aafa572efa157dfb001bba \
|
||||
AS python-base
|
||||
|
||||
ENV MEALIE_HOME="/app"
|
||||
|
||||
@@ -119,7 +121,7 @@ RUN . $VENV_PATH/bin/activate \
|
||||
###############################################
|
||||
# Production Image
|
||||
###############################################
|
||||
FROM python-base as production
|
||||
FROM python-base AS production
|
||||
LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie"
|
||||
ENV PRODUCTION=true
|
||||
ENV TESTING=false
|
||||
@@ -132,7 +134,7 @@ RUN apt-get update \
|
||||
gosu \
|
||||
iproute2 \
|
||||
libldap-common \
|
||||
libldap-2.5 \
|
||||
libldap2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# create directory used for Docker Secrets
|
||||
|
||||
@@ -45,7 +45,7 @@ Once the prerequisites are installed you can cd into the project base directory
|
||||
=== "Linux / macOS"
|
||||
|
||||
```bash
|
||||
# Naviate To The Root Directory
|
||||
# Navigate To The Root Directory
|
||||
cd /path/to/project
|
||||
|
||||
# Utilize the Taskfile to Install Dependencies
|
||||
|
||||
@@ -13,14 +13,14 @@ Steps:
|
||||
|
||||
#### 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
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
rest:
|
||||
|
||||
@@ -87,6 +87,7 @@ The shopping lists feature is a great way to keep track of what you need to buy
|
||||
Managing shopping lists can be done from the Sidebar > Shopping Lists.
|
||||
|
||||
Here you will be able to:
|
||||
|
||||
- See items already on the Shopping List
|
||||
- See linked recipes with ingredients
|
||||
- Toggling via the 'Pot' icon will show you the linked recipe, allowing you to click to access it.
|
||||
@@ -117,6 +118,7 @@ Mealie is designed to integrate with many different external services. There are
|
||||
### Notifiers
|
||||
|
||||
Notifiers are event-driven notifications sent when specific actions are performed within Mealie. Some actions include:
|
||||
|
||||
- Creating / Updating a recipe
|
||||
- Adding items to a shopping list
|
||||
- Creating a new mealplan
|
||||
@@ -198,6 +200,7 @@ Mealie lets you fully customize how you organize your users. You can use Groups
|
||||
Groups are fully isolated instances of Mealie. Think of a goup as a completely separate, fully self-contained site. There is no data shared between groups. Each group has its own users, recipes, tags, categories, etc. A user logged-in to one group cannot make any changes to another.
|
||||
|
||||
Common use cases for groups include:
|
||||
|
||||
- Hosting multiple instances of Mealie for others who want to keep their data private and secure
|
||||
- Creating completely isolated recipe pools
|
||||
|
||||
@@ -206,6 +209,7 @@ Common use cases for groups include:
|
||||
Households are subdivisions within a single Group. Households maintain their own users and settings, while sharing their recipes with other households. Households also share organizers (tags, categories, etc.) with the entire group. Meal Plans, Shopping Lists, and Integrations are only accessible within a household.
|
||||
|
||||
Common use cases for households include:
|
||||
|
||||
- Sharing a common recipe pool amongst families
|
||||
- Maintaining separate meal plans and shopping lists from other households
|
||||
- Maintaining separate integrations and customizations from other households
|
||||
|
||||
@@ -32,15 +32,16 @@
|
||||
|
||||
### Database
|
||||
|
||||
| Variables | Default | Description |
|
||||
| ------------------------------------------------------- | :------: | ----------------------------------------------------------------------- |
|
||||
| DB_ENGINE | sqlite | Optional: 'sqlite', 'postgres' |
|
||||
| POSTGRES_USER<super>[†][secrets]</super> | mealie | Postgres database user |
|
||||
| POSTGRES_PASSWORD<super>[†][secrets]</super> | mealie | Postgres database password |
|
||||
| POSTGRES_SERVER<super>[†][secrets]</super> | postgres | Postgres database server address |
|
||||
| POSTGRES_PORT<super>[†][secrets]</super> | 5432 | Postgres database port |
|
||||
| POSTGRES_DB<super>[†][secrets]</super> | mealie | Postgres database name |
|
||||
| POSTGRES_URL_OVERRIDE<super>[†][secrets]</super> | None | Optional Postgres URL override to use instead of POSTGRES\_\* variables |
|
||||
| Variables | Default | Description |
|
||||
|---------------------------------------------------------|:--------:|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| DB_ENGINE | sqlite | Optional: 'sqlite', 'postgres' |
|
||||
| SQLITE_MIGRATE_JOURNAL_WAL | False | If set to true, switches SQLite's journal mode to WAL, which allows for multiple concurrent accesses. This can be useful when you have a decent amount of concurrency or when using certain remote storage systems such as Ceph. |
|
||||
| POSTGRES_USER<super>[†][secrets]</super> | mealie | Postgres database user |
|
||||
| POSTGRES_PASSWORD<super>[†][secrets]</super> | mealie | Postgres database password |
|
||||
| POSTGRES_SERVER<super>[†][secrets]</super> | postgres | Postgres database server address |
|
||||
| POSTGRES_PORT<super>[†][secrets]</super> | 5432 | Postgres database port |
|
||||
| POSTGRES_DB<super>[†][secrets]</super> | mealie | Postgres database name |
|
||||
| POSTGRES_URL_OVERRIDE<super>[†][secrets]</super> | None | Optional Postgres URL override to use instead of POSTGRES\_\* variables |
|
||||
|
||||
### Email
|
||||
|
||||
@@ -131,7 +132,7 @@ For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values
|
||||
| OPENAI_ENABLE_IMAGE_SERVICES | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
|
||||
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
||||
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
||||
| OPENAI_REQUEST_TIMEOUT | 60 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
||||
| OPENAI_REQUEST_TIMEOUT | 300 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
||||
|
||||
### Theming
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
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.1.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.
|
||||
4. Restart the container
|
||||
|
||||
@@ -60,7 +60,7 @@ The following steps were tested on a Ubuntu 20.04 server, but should work for mo
|
||||
|
||||
## Step 3: Customizing The `docker-compose.yaml` files.
|
||||
|
||||
After you've decided setup the files it's important to set a few ENV variables to ensure that you can use all the features of Mealie. I recommend that you verify and check that:
|
||||
After you've decided how to set up your files, it's important to set a few ENV variables to ensure that you can use all the features of Mealie. Verify that:
|
||||
|
||||
- [x] You've configured the relevant ENV variables for your database selection in the `docker-compose.yaml` files.
|
||||
- [x] You've configured the [SMTP server settings](./backend-config.md#email) (used for invitations, password resets, etc). You can setup a [google app password](https://support.google.com/accounts/answer/185833?hl=en) if you want to send email via gmail.
|
||||
@@ -117,7 +117,7 @@ The latest tag provides the latest released image of Mealie.
|
||||
|
||||
---
|
||||
|
||||
**These tags no are long updated**
|
||||
**These tags are no longer updated**
|
||||
|
||||
`mealie:frontend-v1.0.0beta-x` **and** `mealie:api-v1.0.0beta-x`
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 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.
|
||||
|
||||
**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
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v2.8.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.1.2 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
@@ -38,7 +41,7 @@ services:
|
||||
|
||||
postgres:
|
||||
container_name: postgres
|
||||
image: postgres:15
|
||||
image: postgres:17
|
||||
restart: always
|
||||
volumes:
|
||||
- mealie-pgdata:/var/lib/postgresql/data
|
||||
@@ -46,6 +49,7 @@ services:
|
||||
POSTGRES_PASSWORD: mealie
|
||||
POSTGRES_USER: mealie
|
||||
PGUSER: mealie
|
||||
POSTGRES_DB: mealie
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready"]
|
||||
interval: 30s
|
||||
|
||||
@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v2.8.0 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.1.2 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -2,6 +2,3 @@
|
||||
|
||||
## Feature Requests
|
||||
[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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
You MUST read the release notes prior to upgrading your container. Mealie has a robust backup and restore system for managing your data. Pre-v1.0.0 versions of Mealie use a different database structure, so if you are upgrading from pre-v1.0.0 to v1.0.0, you MUST backup your data and then re-import it. Even if you are already on v1.0.0, it is strongly recommended to backup all data before updating.
|
||||
|
||||
### Before Upgrading
|
||||
- Read The Release Notes
|
||||
- [Read The Release Notes](https://github.com/mealie-recipes/mealie/releases)
|
||||
- Identify Breaking Changes
|
||||
- Create a Backup and Download from the UI
|
||||
- Upgrade
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -351,7 +351,7 @@
|
||||
<!-- Custom narrow footer -->
|
||||
<div class="md-footer-meta__inner md-grid">
|
||||
<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">
|
||||
<svg style="width: 32px; height: 32px" viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
<script setup lang="ts">
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { QueryFilterBuilder },
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object as () => ReadCookBook,
|
||||
required: true,
|
||||
},
|
||||
actions: {
|
||||
type: Object as () => any,
|
||||
required: true,
|
||||
},
|
||||
const modelValue = defineModel<ReadCookBook>({ required: true });
|
||||
const i18n = useI18n();
|
||||
const cookbook = toRef(modelValue);
|
||||
function handleInput(value: string | undefined) {
|
||||
cookbook.value.queryFilterString = value || "";
|
||||
}
|
||||
|
||||
const fieldDefs: FieldDefinition[] = [
|
||||
{
|
||||
name: "recipe_category.id",
|
||||
label: i18n.t("category.categories"),
|
||||
type: Organizer.Category,
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { emit }) {
|
||||
const i18n = useI18n();
|
||||
|
||||
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: "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",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
<v-card-text>
|
||||
<CookbookEditor
|
||||
v-model="editTarget"
|
||||
:actions="actions"
|
||||
/>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
@@ -65,90 +64,67 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useLazyRecipes } from "~/composables/recipes";
|
||||
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
|
||||
import { useCookbookStore } from "~/composables/store/use-cookbook-store";
|
||||
import { useCookbook } from "~/composables/use-group-cookbooks";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import type { RecipeCookBook } from "~/lib/api/types/cookbook";
|
||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeCardSection, CookbookEditor },
|
||||
setup() {
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const route = useRoute();
|
||||
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 slug = route.params.slug as string;
|
||||
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
|
||||
const { actions } = useCookbookStore();
|
||||
const router = useRouter();
|
||||
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
const slug = route.params.slug as string;
|
||||
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
|
||||
const { actions } = useCookbookStore();
|
||||
const router = useRouter();
|
||||
|
||||
const tab = ref(null);
|
||||
const book = getOne(slug);
|
||||
const book = getOne(slug);
|
||||
|
||||
const isOwnHousehold = computed(() => {
|
||||
if (!($auth.user.value && book.value?.householdId)) {
|
||||
return false;
|
||||
}
|
||||
const isOwnHousehold = computed(() => {
|
||||
if (!($auth.user.value && book.value?.householdId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $auth.user.value.householdId === book.value.householdId;
|
||||
});
|
||||
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
|
||||
return $auth.user.value.householdId === book.value.householdId;
|
||||
});
|
||||
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
|
||||
|
||||
const dialogStates = reactive({
|
||||
edit: false,
|
||||
});
|
||||
const dialogStates = reactive({
|
||||
edit: false,
|
||||
});
|
||||
|
||||
const editTarget = ref<RecipeCookBook | null>(null);
|
||||
function handleEditCookbook() {
|
||||
dialogStates.edit = true;
|
||||
editTarget.value = book.value;
|
||||
}
|
||||
const editTarget = ref<ReadCookBook | null>(null);
|
||||
function handleEditCookbook() {
|
||||
dialogStates.edit = true;
|
||||
editTarget.value = book.value;
|
||||
}
|
||||
|
||||
async function editCookbook() {
|
||||
if (!editTarget.value) {
|
||||
return;
|
||||
}
|
||||
const response = await actions.updateOne(editTarget.value);
|
||||
async function editCookbook() {
|
||||
if (!editTarget.value) {
|
||||
return;
|
||||
}
|
||||
const response = await actions.updateOne(editTarget.value);
|
||||
|
||||
if (response?.slug && book.value?.slug !== response?.slug) {
|
||||
// if name changed, redirect to new slug
|
||||
router.push(`/g/${route.params.groupSlug}/cookbooks/${response?.slug}`);
|
||||
}
|
||||
else {
|
||||
// otherwise reload the page, since the recipe criteria changed
|
||||
router.go(0);
|
||||
}
|
||||
dialogStates.edit = false;
|
||||
editTarget.value = null;
|
||||
}
|
||||
if (response?.slug && book.value?.slug !== response?.slug) {
|
||||
// if name changed, redirect to new slug
|
||||
router.push(`/g/${route.params.groupSlug}/cookbooks/${response?.slug}`);
|
||||
}
|
||||
else {
|
||||
// otherwise reload the page, since the recipe criteria changed
|
||||
router.go(0);
|
||||
}
|
||||
dialogStates.edit = false;
|
||||
editTarget.value = null;
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: book?.value?.name || "Cookbook",
|
||||
});
|
||||
|
||||
return {
|
||||
book,
|
||||
slug,
|
||||
tab,
|
||||
appendRecipes,
|
||||
assignSorted,
|
||||
recipes,
|
||||
removeRecipe,
|
||||
replaceRecipes,
|
||||
canEdit,
|
||||
dialogStates,
|
||||
editTarget,
|
||||
handleEditCookbook,
|
||||
editCookbook,
|
||||
actions,
|
||||
};
|
||||
},
|
||||
useSeoMeta({
|
||||
title: book?.value?.name || "Cookbook",
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -20,45 +20,33 @@
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { parseISO, formatDistanceToNow } from "date-fns";
|
||||
import type { GroupDataExport } from "~/lib/api/types/group";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
exports: {
|
||||
type: Array as () => GroupDataExport[],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const i18n = useI18n();
|
||||
defineProps<{
|
||||
exports: GroupDataExport[];
|
||||
}>();
|
||||
|
||||
const headers = [
|
||||
{ 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" },
|
||||
];
|
||||
const i18n = useI18n();
|
||||
|
||||
function getTimeToExpire(timeString: string) {
|
||||
const expiresAt = parseISO(timeString);
|
||||
const headers = [
|
||||
{ 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, {
|
||||
addSuffix: false,
|
||||
});
|
||||
}
|
||||
function getTimeToExpire(timeString: string) {
|
||||
const expiresAt = parseISO(timeString);
|
||||
|
||||
function downloadData(_: any) {
|
||||
console.log("Downloading data...");
|
||||
}
|
||||
return formatDistanceToNow(expiresAt, {
|
||||
addSuffix: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
downloadData,
|
||||
headers,
|
||||
getTimeToExpire,
|
||||
};
|
||||
},
|
||||
});
|
||||
function downloadData(_: any) {
|
||||
console.log("Downloading data...");
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,30 +9,10 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineNuxtComponent({
|
||||
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);
|
||||
},
|
||||
});
|
||||
<script setup lang="ts">
|
||||
import type { ReadGroupPreferences } from "~/lib/api/types/user";
|
||||
|
||||
return {
|
||||
preferences,
|
||||
};
|
||||
},
|
||||
});
|
||||
const preferences = defineModel<ReadGroupPreferences>({ required: true });
|
||||
</script>
|
||||
|
||||
<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"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
:class="{ 'rounded-circle': fab }"
|
||||
:size="fab ? 'small' : undefined"
|
||||
@@ -26,7 +26,7 @@
|
||||
:icon="!fab"
|
||||
variant="text"
|
||||
dark
|
||||
v-bind="props"
|
||||
v-bind="activatorProps"
|
||||
@click.prevent
|
||||
>
|
||||
<v-icon>{{ icon }}</v-icon>
|
||||
@@ -50,7 +50,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue";
|
||||
import type { ShoppingListSummary } from "~/lib/api/types/household";
|
||||
@@ -64,101 +64,84 @@ export interface ContextMenuItem {
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeDialogAddToShoppingList,
|
||||
},
|
||||
props: {
|
||||
recipes: {
|
||||
type: Array as () => Recipe[],
|
||||
default: () => [],
|
||||
},
|
||||
menuTop: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
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,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
recipes?: Recipe[];
|
||||
menuTop?: boolean;
|
||||
fab?: boolean;
|
||||
color?: string;
|
||||
menuIcon?: string | null;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
recipes: () => [],
|
||||
menuTop: true,
|
||||
fab: false,
|
||||
color: "primary",
|
||||
menuIcon: null,
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
style="gap: 10px"
|
||||
>
|
||||
<v-select
|
||||
v-model="inputDay"
|
||||
v-model="day"
|
||||
:items="MEAL_DAY_OPTIONS"
|
||||
:label="$t('meal-plan.rule-day')"
|
||||
/>
|
||||
<v-select
|
||||
v-model="inputEntryType"
|
||||
v-model="entryType"
|
||||
:items="MEAL_TYPE_OPTIONS"
|
||||
:label="$t('meal-plan.meal-type')"
|
||||
/>
|
||||
@@ -19,157 +19,104 @@
|
||||
<div class="mb-5">
|
||||
<QueryFilterBuilder
|
||||
:field-defs="fieldDefs"
|
||||
:initial-query-filter="queryFilter"
|
||||
:initial-query-filter="props.queryFilter"
|
||||
@input="handleQueryFilterInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- TODO: proper pluralization of inputDay -->
|
||||
{{ $t('meal-plan.this-rule-will-apply', {
|
||||
dayCriteria: inputDay === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [inputDay]),
|
||||
mealTypeCriteria: inputEntryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [inputEntryType]),
|
||||
dayCriteria: day === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [day]),
|
||||
mealTypeCriteria: entryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [entryType]),
|
||||
}) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import type { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
QueryFilterBuilder,
|
||||
},
|
||||
props: {
|
||||
day: {
|
||||
type: String,
|
||||
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,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
queryFilter?: QueryFilterJSON | null;
|
||||
showHelp?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
queryFilter: null,
|
||||
showHelp: false,
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
:label="$t('settings.webhooks.webhook-url')"
|
||||
variant="underlined"
|
||||
/>
|
||||
<v-time-picker
|
||||
<v-text-field
|
||||
v-model="scheduledTime"
|
||||
class="elevation-2"
|
||||
ampm-in-title
|
||||
format="ampm"
|
||||
type="time"
|
||||
clearable
|
||||
variant="underlined"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="py-0 justify-end">
|
||||
@@ -50,52 +50,43 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import type { ReadWebhook } from "~/lib/api/types/household";
|
||||
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
webhook: {
|
||||
type: Object as () => ReadWebhook,
|
||||
required: true,
|
||||
},
|
||||
const props = defineProps<{
|
||||
webhook: ReadWebhook;
|
||||
}>();
|
||||
|
||||
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"],
|
||||
setup(props, { emit }) {
|
||||
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;
|
||||
},
|
||||
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,
|
||||
};
|
||||
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"),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -41,106 +41,76 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import type { ReadHouseholdPreferences } from "~/lib/api/types/household";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
const preferences = defineModel<ReadHouseholdPreferences>({ required: true });
|
||||
const i18n = useI18n();
|
||||
|
||||
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"),
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const i18n = useI18n();
|
||||
|
||||
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: "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"),
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
|
||||
<style lang="css">
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
:model-value="field.value"
|
||||
type="number"
|
||||
variant="underlined"
|
||||
@:model-value="setFieldValue(field, index, $event)"
|
||||
@update:model-value="setFieldValue(field, index, $event)"
|
||||
/>
|
||||
<v-checkbox
|
||||
v-else-if="field.type === 'boolean'"
|
||||
@@ -163,14 +163,14 @@
|
||||
max-width="290px"
|
||||
min-width="auto"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
v-model="field.value"
|
||||
persistent-hint
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
variant="underlined"
|
||||
color="primary"
|
||||
v-bind="props"
|
||||
v-bind="activatorProps"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
@@ -184,53 +184,53 @@
|
||||
</v-menu>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Category"
|
||||
:model-value="field.organizers"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Category"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
variant="underlined"
|
||||
@update:model-value="setOrganizerValues(field, index, $event)"
|
||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Tag"
|
||||
:model-value="field.organizers"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Tag"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
variant="underlined"
|
||||
@update:model-value="setOrganizerValues(field, index, $event)"
|
||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Tool"
|
||||
:model-value="field.organizers"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Tool"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
variant="underlined"
|
||||
@update:model-value="setOrganizerValues(field, index, $event)"
|
||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Food"
|
||||
:model-value="field.organizers"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Food"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
variant="underlined"
|
||||
@update:model-value="setOrganizerValues(field, index, $event)"
|
||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Household"
|
||||
:model-value="field.organizers"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Household"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
variant="underlined"
|
||||
@update:model-value="setOrganizerValues(field, index, $event)"
|
||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||
/>
|
||||
</v-col>
|
||||
<!-- right parenthesis -->
|
||||
@@ -297,7 +297,7 @@
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
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 { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
VueDraggable,
|
||||
RecipeOrganizerSelector,
|
||||
const props = defineProps({
|
||||
fieldDefs: {
|
||||
type: Array as () => FieldDefinition[],
|
||||
required: true,
|
||||
},
|
||||
props: {
|
||||
fieldDefs: {
|
||||
type: Array as () => FieldDefinition[],
|
||||
required: true,
|
||||
},
|
||||
initialQueryFilter: {
|
||||
type: Object as () => QueryFilterJSON | null,
|
||||
default: null,
|
||||
},
|
||||
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(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
(event: "input", value: string | undefined): void;
|
||||
(event: "inputJSON", value: QueryFilterJSON | undefined): void;
|
||||
}>();
|
||||
|
||||
const state = reactive({
|
||||
showAdvanced: false,
|
||||
qfValid: false,
|
||||
datePickers: [] as boolean[],
|
||||
drag: false,
|
||||
});
|
||||
const { household } = useHouseholdSelf();
|
||||
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
|
||||
|
||||
const storeMap = {
|
||||
[Organizer.Category]: useCategoryStore(),
|
||||
[Organizer.Tag]: useTagStore(),
|
||||
[Organizer.Tool]: useToolStore(),
|
||||
[Organizer.Food]: useFoodStore(),
|
||||
[Organizer.Household]: useHouseholdStore(),
|
||||
};
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
function onDragEnd(event: any) {
|
||||
state.drag = false;
|
||||
const state = reactive({
|
||||
showAdvanced: false,
|
||||
qfValid: false,
|
||||
datePickers: [] as boolean[],
|
||||
drag: false,
|
||||
});
|
||||
const { showAdvanced, datePickers, drag } = toRefs(state);
|
||||
|
||||
const oldIndex: number = event.oldIndex;
|
||||
const newIndex: number = event.newIndex;
|
||||
state.datePickers[oldIndex] = false;
|
||||
state.datePickers[newIndex] = false;
|
||||
const storeMap = {
|
||||
[Organizer.Category]: useCategoryStore(),
|
||||
[Organizer.Tag]: useTagStore(),
|
||||
[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
|
||||
type FieldWithId = Field & { id: number };
|
||||
const fields = ref<FieldWithId[]>([]);
|
||||
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;
|
||||
|
||||
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);
|
||||
};
|
||||
if (field.leftParenthesis || field.rightParenthesis) {
|
||||
state.showAdvanced = true;
|
||||
}
|
||||
|
||||
function setField(index: number, fieldLabel: string) {
|
||||
state.datePickers[index] = false;
|
||||
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.label === fieldLabel);
|
||||
if (!fieldDef) {
|
||||
return;
|
||||
}
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
if (typeof part.value === "string") {
|
||||
field.values = part.value ? [part.value] : [];
|
||||
}
|
||||
else {
|
||||
field.values = part.value || [];
|
||||
}
|
||||
|
||||
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions);
|
||||
const updatedField = { ...fields.value[index], ...fieldDef };
|
||||
if (isOrganizerType(field.type)) {
|
||||
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
|
||||
updatedField.fieldOptions = fieldDef.fieldOptions;
|
||||
initFields.push(field);
|
||||
}
|
||||
|
||||
fields.value[index] = {
|
||||
...getFieldFromFieldDef(updatedField, resetValue),
|
||||
id: fields.value[index].id, // keep the id
|
||||
};
|
||||
}
|
||||
if (initFields.length && !error) {
|
||||
fields.value = initFields;
|
||||
}
|
||||
else {
|
||||
initFieldsError();
|
||||
}
|
||||
}
|
||||
|
||||
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
|
||||
fields.value[index].leftParenthesis = value;
|
||||
}
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await initializeFields();
|
||||
}
|
||||
catch (error) {
|
||||
initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
|
||||
}
|
||||
});
|
||||
|
||||
function setRightParenthesisValue(field: FieldWithId, index: number, value: string) {
|
||||
fields.value[index].rightParenthesis = value;
|
||||
}
|
||||
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,
|
||||
};
|
||||
|
||||
function setLogicalOperatorValue(field: FieldWithId, index: number, value: LogicalOperator | undefined) {
|
||||
if (!value) {
|
||||
value = logOps.value.AND.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();
|
||||
}
|
||||
|
||||
fields.value[index].logicalOperator = value ? logOps.value[value] : undefined;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
|
||||
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
|
||||
fields.value[index].relationalOperatorValue = relOps.value[value];
|
||||
}
|
||||
const qfJSON = { parts } as QueryFilterJSON;
|
||||
console.debug(`Built query filter JSON: ${JSON.stringify(qfJSON)}`);
|
||||
return qfJSON;
|
||||
}
|
||||
|
||||
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 setOrganizerValues(field: FieldWithId, index: number, values: OrganizerBase[]) {
|
||||
setFieldValues(field, index, values.map(value => value.id.toString()));
|
||||
fields.value[index].organizers = values;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
context.emit("input", qf || undefined);
|
||||
context.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;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
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;`,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<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!" />
|
||||
<div v-if="loggedIn">
|
||||
<v-tooltip v-if="canEdit" bottom color="info">
|
||||
<template #activator="{ props }">
|
||||
<v-tooltip v-if="canEdit" location="bottom" color="info">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-btn
|
||||
icon
|
||||
variant="flat"
|
||||
@@ -26,7 +26,7 @@
|
||||
size="small"
|
||||
color="info"
|
||||
class="ml-1"
|
||||
v-bind="props"
|
||||
v-bind="tooltipProps"
|
||||
@click="$emit('edit', true)"
|
||||
>
|
||||
<v-icon size="x-large">
|
||||
@@ -86,8 +86,8 @@
|
||||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
<script setup lang="ts">
|
||||
import RecipeContextMenu from "./RecipeContextMenu/RecipeContextMenu.vue";
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
@@ -97,103 +97,75 @@ const DELETE_EVENT = "delete";
|
||||
const CLOSE_EVENT = "close";
|
||||
const JSON_EVENT = "json";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge },
|
||||
props: {
|
||||
recipe: {
|
||||
required: true,
|
||||
type: Object as () => Recipe,
|
||||
},
|
||||
slug: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
recipeScale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
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,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
recipe: Recipe;
|
||||
slug: string;
|
||||
recipeScale?: number;
|
||||
open: boolean;
|
||||
name: string;
|
||||
loggedIn?: boolean;
|
||||
recipeId: string;
|
||||
canEdit?: boolean;
|
||||
}
|
||||
withDefaults(defineProps<Props>(), {
|
||||
recipeScale: 1,
|
||||
loggedIn: false,
|
||||
canEdit: false,
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<template>
|
||||
<div v-if="value.length > 0 || edit">
|
||||
<div v-if="model.length > 0 || edit">
|
||||
<v-card class="mt-4">
|
||||
<v-card-title class="py-2">
|
||||
{{ $t("asset.assets") }}
|
||||
</v-card-title>
|
||||
<v-divider class="mx-2" />
|
||||
<v-list
|
||||
v-if="value.length > 0"
|
||||
v-if="model.length > 0"
|
||||
:flat="!edit"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(item, i) in value"
|
||||
v-for="(item, i) in model"
|
||||
:key="i"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="ma-auto">
|
||||
<v-tooltip bottom>
|
||||
<template #activator="{ props }">
|
||||
<v-icon v-bind="props">
|
||||
<v-tooltip location="bottom">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-icon v-bind="tooltipProps">
|
||||
{{ getIconDefinition(item.icon).icon }}
|
||||
</v-icon>
|
||||
</template>
|
||||
@@ -44,7 +44,7 @@
|
||||
color="error"
|
||||
icon
|
||||
top
|
||||
@click="value.splice(i, 1)"
|
||||
@click="model.splice(i, 1)"
|
||||
>
|
||||
<v-icon>{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
@@ -113,124 +113,109 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import type { RecipeAsset } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
recipeId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: Array as () => RecipeAsset[],
|
||||
required: true,
|
||||
},
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
const props = defineProps({
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const api = useUserApi();
|
||||
|
||||
const state = reactive({
|
||||
newAssetDialog: false,
|
||||
fileObject: {} as File,
|
||||
newAsset: {
|
||||
name: "",
|
||||
icon: "mdi-file",
|
||||
},
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const iconOptions = [
|
||||
{
|
||||
name: "mdi-file",
|
||||
title: i18n.t("asset.file"),
|
||||
icon: $globals.icons.file,
|
||||
},
|
||||
{
|
||||
name: "mdi-file-pdf-box",
|
||||
title: i18n.t("asset.pdf"),
|
||||
icon: $globals.icons.filePDF,
|
||||
},
|
||||
{
|
||||
name: "mdi-file-image",
|
||||
title: i18n.t("asset.image"),
|
||||
icon: $globals.icons.fileImage,
|
||||
},
|
||||
{
|
||||
name: "mdi-code-json",
|
||||
title: i18n.t("asset.code"),
|
||||
icon: $globals.icons.codeJson,
|
||||
},
|
||||
{
|
||||
name: "mdi-silverware-fork-knife",
|
||||
title: i18n.t("asset.recipe"),
|
||||
icon: $globals.icons.primary,
|
||||
},
|
||||
];
|
||||
|
||||
const serverBase = useRequestURL().origin;
|
||||
|
||||
function getIconDefinition(icon: string) {
|
||||
return iconOptions.find(item => item.name === icon) || iconOptions[0];
|
||||
}
|
||||
|
||||
const { recipeAssetPath } = useStaticRoutes();
|
||||
function assetURL(assetName: string) {
|
||||
return recipeAssetPath(props.recipeId, assetName);
|
||||
}
|
||||
|
||||
function assetEmbed(name: string) {
|
||||
return `<img src="${serverBase}${assetURL(name)}" height="100%" width="100%"> </img>`;
|
||||
}
|
||||
|
||||
function setFileObject(fileObject: File) {
|
||||
state.fileObject = fileObject;
|
||||
}
|
||||
|
||||
function validFields() {
|
||||
return state.newAsset.name.length > 0 && state.fileObject.name.length > 0;
|
||||
}
|
||||
|
||||
async function addAsset() {
|
||||
if (!validFields()) {
|
||||
alert.error(i18n.t("asset.error-submitting-form") as string);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await api.recipes.createAsset(props.slug, {
|
||||
name: state.newAsset.name,
|
||||
icon: state.newAsset.icon,
|
||||
file: state.fileObject,
|
||||
extension: state.fileObject.name.split(".").pop() || "",
|
||||
});
|
||||
|
||||
context.emit("update:modelValue", [...props.modelValue, data]);
|
||||
state.newAsset = { name: "", icon: "mdi-file" };
|
||||
state.fileObject = {} as File;
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
addAsset,
|
||||
assetURL,
|
||||
assetEmbed,
|
||||
getIconDefinition,
|
||||
iconOptions,
|
||||
setFileObject,
|
||||
};
|
||||
recipeId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const model = defineModel<RecipeAsset[]>({ required: true });
|
||||
|
||||
const api = useUserApi();
|
||||
|
||||
const state = reactive({
|
||||
newAssetDialog: false,
|
||||
fileObject: {} as File,
|
||||
newAsset: {
|
||||
name: "",
|
||||
icon: "mdi-file",
|
||||
},
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const iconOptions = [
|
||||
{
|
||||
name: "mdi-file",
|
||||
title: i18n.t("asset.file"),
|
||||
icon: $globals.icons.file,
|
||||
},
|
||||
{
|
||||
name: "mdi-file-pdf-box",
|
||||
title: i18n.t("asset.pdf"),
|
||||
icon: $globals.icons.filePDF,
|
||||
},
|
||||
{
|
||||
name: "mdi-file-image",
|
||||
title: i18n.t("asset.image"),
|
||||
icon: $globals.icons.fileImage,
|
||||
},
|
||||
{
|
||||
name: "mdi-code-json",
|
||||
title: i18n.t("asset.code"),
|
||||
icon: $globals.icons.codeJson,
|
||||
},
|
||||
{
|
||||
name: "mdi-silverware-fork-knife",
|
||||
title: i18n.t("asset.recipe"),
|
||||
icon: $globals.icons.primary,
|
||||
},
|
||||
];
|
||||
|
||||
const serverBase = useRequestURL().origin;
|
||||
|
||||
function getIconDefinition(icon: string) {
|
||||
return iconOptions.find(item => item.name === icon) || iconOptions[0];
|
||||
}
|
||||
|
||||
const { recipeAssetPath } = useStaticRoutes();
|
||||
function assetURL(assetName: string) {
|
||||
return recipeAssetPath(props.recipeId, assetName);
|
||||
}
|
||||
|
||||
function assetEmbed(name: string) {
|
||||
return `<img src="${serverBase}${assetURL(name)}" height="100%" width="100%"> </img>`;
|
||||
}
|
||||
|
||||
function setFileObject(fileObject: File) {
|
||||
state.fileObject = fileObject;
|
||||
}
|
||||
|
||||
function validFields() {
|
||||
return state.newAsset.name.length > 0 && state.fileObject.name.length > 0;
|
||||
}
|
||||
|
||||
async function addAsset() {
|
||||
if (!validFields()) {
|
||||
alert.error(i18n.t("asset.error-submitting-form") as string);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await api.recipes.createAsset(props.slug, {
|
||||
name: state.newAsset.name,
|
||||
icon: state.newAsset.icon,
|
||||
file: state.fileObject,
|
||||
extension: state.fileObject.name.split(".").pop() || "",
|
||||
});
|
||||
if (data) {
|
||||
model.value = [...model.value, data];
|
||||
}
|
||||
state.newAsset = { name: "", icon: "mdi-file" };
|
||||
state.fileObject = {} as File;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<template>
|
||||
<!-- Wrap v-hover with a div to provide a proper DOM element for the transition -->
|
||||
<v-lazy>
|
||||
<div>
|
||||
<v-hover
|
||||
v-slot="{ isHovering, props }"
|
||||
v-slot="{ isHovering, props: hoverProps }"
|
||||
:open-delay="50"
|
||||
>
|
||||
<v-card
|
||||
v-bind="props"
|
||||
v-bind="hoverProps"
|
||||
:class="{ 'on-hover': isHovering }"
|
||||
:style="{ cursor }"
|
||||
:elevation="isHovering ? 12 : 2"
|
||||
@@ -56,12 +55,9 @@
|
||||
/>
|
||||
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
|
||||
|
||||
<RecipeRating
|
||||
class="ml-n2"
|
||||
:value="rating"
|
||||
<RecipeCardRating
|
||||
:model-value="rating"
|
||||
:recipe-id="recipeId"
|
||||
:slug="slug"
|
||||
small
|
||||
/>
|
||||
<v-spacer />
|
||||
<RecipeChips
|
||||
@@ -76,9 +72,10 @@
|
||||
|
||||
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
||||
<RecipeContextMenu
|
||||
v-if="isOwnGroup"
|
||||
v-if="isOwnGroup && showRecipeContent"
|
||||
color="grey-darken-2"
|
||||
:slug="slug"
|
||||
:menu-icon="$globals.icons.dotsVertical"
|
||||
:name="name"
|
||||
:recipe-id="recipeId"
|
||||
:use-items="{
|
||||
@@ -91,7 +88,7 @@
|
||||
printPreferences: false,
|
||||
share: true,
|
||||
}"
|
||||
@delete="$emit('delete', slug)"
|
||||
@deleted="$emit('delete', slug)"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</slot>
|
||||
@@ -99,80 +96,51 @@
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</div>
|
||||
</v-lazy>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
import RecipeChips from "./RecipeChips.vue";
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
import RecipeContextMenu from "./RecipeContextMenu/RecipeContextMenu.vue";
|
||||
import RecipeCardImage from "./RecipeCardImage.vue";
|
||||
import RecipeRating from "./RecipeRating.vue";
|
||||
import RecipeCardRating from "./RecipeCardRating.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage },
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
rating: {
|
||||
type: Number,
|
||||
required: false,
|
||||
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,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
rating?: number;
|
||||
ratingColor?: string;
|
||||
image?: string;
|
||||
tags?: Array<any>;
|
||||
recipeId: string;
|
||||
imageHeight?: number;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
description: null,
|
||||
rating: 0,
|
||||
ratingColor: "secondary",
|
||||
image: "abc123",
|
||||
tags: () => [],
|
||||
imageHeight: 200,
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<style>
|
||||
@@ -197,6 +165,7 @@ export default defineNuxtComponent({
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 8;
|
||||
line-clamp: 8;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,84 +28,60 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
<script setup lang="ts">
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
tiny: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
},
|
||||
large: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
},
|
||||
iconSize: {
|
||||
type: [Number, String],
|
||||
default: 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,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
tiny?: boolean | null;
|
||||
small?: boolean | null;
|
||||
large?: boolean | null;
|
||||
iconSize?: number | string;
|
||||
slug?: string | null;
|
||||
recipeId: string;
|
||||
imageVersion?: string | null;
|
||||
height?: number | string;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
tiny: null,
|
||||
small: null,
|
||||
large: null,
|
||||
iconSize: 100,
|
||||
slug: null,
|
||||
imageVersion: null,
|
||||
height: "100%",
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<v-expand-transition>
|
||||
<v-card
|
||||
:ripple="false"
|
||||
:class="isFlat ? 'mx-auto flat' : 'mx-auto'"
|
||||
:class="[
|
||||
isFlat ? 'mx-auto flat' : 'mx-auto',
|
||||
{ 'disable-highlight': disableHighlight },
|
||||
]"
|
||||
:style="{ cursor }"
|
||||
hover
|
||||
height="100%"
|
||||
@@ -84,13 +87,11 @@
|
||||
class="ma-0 pa-0"
|
||||
/>
|
||||
<div v-else class="my-0 px-1 py-0" /> <!-- Empty div to keep the layout consistent -->
|
||||
<RecipeRating
|
||||
<RecipeCardRating
|
||||
v-if="showRecipeContent"
|
||||
:class="[{ 'pb-2': !isOwnGroup }, 'ml-n2']"
|
||||
:value="rating"
|
||||
:model-value="rating"
|
||||
:recipe-id="recipeId"
|
||||
:slug="slug"
|
||||
small
|
||||
/>
|
||||
|
||||
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
||||
@@ -123,86 +124,52 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
import RecipeContextMenu from "./RecipeContextMenu/RecipeContextMenu.vue";
|
||||
import RecipeCardImage from "./RecipeCardImage.vue";
|
||||
import RecipeRating from "./RecipeRating.vue";
|
||||
import RecipeCardRating from "./RecipeCardRating.vue";
|
||||
import RecipeChips from "./RecipeChips.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeFavoriteBadge,
|
||||
RecipeContextMenu,
|
||||
RecipeRating,
|
||||
RecipeCardImage,
|
||||
RecipeChips,
|
||||
},
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
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,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
rating?: number;
|
||||
image?: string;
|
||||
tags?: Array<any>;
|
||||
recipeId: string;
|
||||
vertical?: boolean;
|
||||
isFlat?: boolean;
|
||||
height?: number;
|
||||
disableHighlight?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rating: 0,
|
||||
image: "abc123",
|
||||
tags: () => [],
|
||||
vertical: false,
|
||||
isFlat: false,
|
||||
height: 150,
|
||||
disableHighlight: false,
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -241,4 +208,8 @@ export default defineNuxtComponent({
|
||||
box-shadow: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.disable-highlight :deep(.v-card__overlay) {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
101
frontend/components/Domain/Recipe/RecipeCardRating.vue
Normal file
101
frontend/components/Domain/Recipe/RecipeCardRating.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="rating-display">
|
||||
<span
|
||||
v-for="(star, index) in ratingDisplay"
|
||||
:key="index"
|
||||
class="star"
|
||||
:class="{
|
||||
'star-half': star === 'half',
|
||||
'text-secondary': !useGroupStyle,
|
||||
'text-grey-darken-1': useGroupStyle,
|
||||
}"
|
||||
>
|
||||
<!-- We render both the full and empty stars for "half" stars because they're layered over each other -->
|
||||
<span
|
||||
v-if="star === 'empty' || star === 'half'"
|
||||
class="star-empty"
|
||||
>
|
||||
☆
|
||||
</span>
|
||||
<span
|
||||
v-if="star === 'full' || star === 'half'"
|
||||
class="star-full"
|
||||
>
|
||||
★
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useUserSelfRatings } from "~/composables/use-users";
|
||||
|
||||
type Star = "full" | "half" | "empty";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
recipeId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const { userRatings } = useUserSelfRatings();
|
||||
|
||||
const userRating = computed(() => {
|
||||
return userRatings.value.find(r => r.recipeId === props.recipeId)?.rating ?? undefined;
|
||||
});
|
||||
|
||||
const ratingValue = computed(() => userRating.value || props.modelValue || 0);
|
||||
const useGroupStyle = computed(() => isOwnGroup.value && !userRating.value && props.modelValue);
|
||||
const ratingDisplay = computed<Star[]>(
|
||||
() => {
|
||||
const stars: Star[] = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const diff = ratingValue.value - i;
|
||||
if (diff >= 1) {
|
||||
stars.push("full");
|
||||
}
|
||||
else if (diff >= 0.25) { // round to half star if rating is at least 0.25 but not quite a full star
|
||||
stars.push("half");
|
||||
}
|
||||
else {
|
||||
stars.push("empty");
|
||||
}
|
||||
}
|
||||
|
||||
return stars;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rating-display {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
|
||||
.star {
|
||||
font-size: 18px;
|
||||
transition: color 0.2s ease;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
&.star-half {
|
||||
.star-full {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -36,11 +36,11 @@
|
||||
offset-y
|
||||
start
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
variant="text"
|
||||
:icon="$vuetify.display.xs"
|
||||
v-bind="props"
|
||||
v-bind="activatorProps"
|
||||
:loading="sortLoading"
|
||||
>
|
||||
<v-icon :start="!$vuetify.display.xs">
|
||||
@@ -162,7 +162,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useThrottleFn } from "@vueuse/core";
|
||||
import RecipeCard from "./RecipeCard.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 APPEND_RECIPES_EVENT = "appendRecipes";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeCard,
|
||||
RecipeCardMobile,
|
||||
},
|
||||
props: {
|
||||
disableToolbar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableSort: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: 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();
|
||||
interface Props {
|
||||
disableToolbar?: boolean;
|
||||
disableSort?: boolean;
|
||||
icon?: string | null;
|
||||
title?: string | null;
|
||||
singleColumn?: boolean;
|
||||
recipes?: Recipe[];
|
||||
query?: RecipeSearchQuery | null;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disableToolbar: false,
|
||||
disableSort: false,
|
||||
icon: null,
|
||||
title: null,
|
||||
singleColumn: false,
|
||||
recipes: () => [],
|
||||
query: null,
|
||||
});
|
||||
|
||||
const EVENTS = {
|
||||
az: "az",
|
||||
rating: "rating",
|
||||
created: "created",
|
||||
updated: "updated",
|
||||
lastMade: "lastMade",
|
||||
shuffle: "shuffle",
|
||||
};
|
||||
const emit = defineEmits<{
|
||||
replaceRecipes: [recipes: Recipe[]];
|
||||
appendRecipes: [recipes: Recipe[]];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const useMobileCards = computed(() => {
|
||||
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
|
||||
});
|
||||
const display = useDisplay();
|
||||
const preferences = useUserSortPreferences();
|
||||
|
||||
const displayTitleIcon = computed(() => {
|
||||
return props.icon || $globals.icons.tags;
|
||||
});
|
||||
const EVENTS = {
|
||||
az: "az",
|
||||
rating: "rating",
|
||||
created: "created",
|
||||
updated: "updated",
|
||||
lastMade: "lastMade",
|
||||
shuffle: "shuffle",
|
||||
};
|
||||
|
||||
const state = reactive({
|
||||
sortLoading: false,
|
||||
});
|
||||
const $auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const useMobileCards = computed(() => {
|
||||
return display.smAndDown.value || preferences.value.useMobileCards;
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const displayTitleIcon = computed(() => {
|
||||
return props.icon || $globals.icons.tags;
|
||||
});
|
||||
|
||||
const page = ref(1);
|
||||
const perPage = 32;
|
||||
const hasMore = ref(true);
|
||||
const ready = ref(false);
|
||||
const loading = ref(false);
|
||||
const sortLoading = ref(false);
|
||||
|
||||
const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const queryFilter = computed(() => {
|
||||
return props.query.queryFilter || null;
|
||||
const page = ref(1);
|
||||
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 orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
|
||||
const queryFilter = computed(() => {
|
||||
return props.query?.queryFilter || null;
|
||||
|
||||
// if (props.query.queryFilter && orderByFilter) {
|
||||
// return `(${props.query.queryFilter}) AND ${orderByFilter}`;
|
||||
// } else if (props.query.queryFilter) {
|
||||
// return props.query.queryFilter;
|
||||
// } else {
|
||||
// return orderByFilter;
|
||||
// }
|
||||
});
|
||||
// TODO: allow user to filter out null values when ordering by a value that may be null (such as lastMade)
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
// const orderBy = props.query?.orderBy || preferences.value.orderBy;
|
||||
// const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
|
||||
|
||||
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();
|
||||
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>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -23,66 +23,38 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
|
||||
export type UrlPrefixParam = "tags" | "categories" | "tools";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
truncate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
items: {
|
||||
type: Array as () => RecipeCategory[] | RecipeTag[] | RecipeTool[],
|
||||
default: () => [],
|
||||
},
|
||||
title: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
urlPrefix: {
|
||||
type: String as () => UrlPrefixParam,
|
||||
default: "categories",
|
||||
},
|
||||
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,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
truncate?: boolean;
|
||||
items?: RecipeCategory[] | RecipeTag[] | RecipeTool[];
|
||||
title?: boolean;
|
||||
urlPrefix?: UrlPrefixParam;
|
||||
limit?: number;
|
||||
small?: boolean;
|
||||
maxWidth?: string | null;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
truncate: false,
|
||||
items: () => [],
|
||||
title: false,
|
||||
urlPrefix: "categories",
|
||||
limit: 999,
|
||||
small: false,
|
||||
maxWidth: null,
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<style></style>
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
<template>
|
||||
<div class="text-center">
|
||||
<!-- Recipe Share Dialog -->
|
||||
<RecipeDialogShare v-model="shareDialog" :recipe-id="recipeId" :name="name" />
|
||||
<RecipeDialogPrintPreferences v-model="printPreferencesDialog" :recipe="recipeRef" />
|
||||
<BaseDialog
|
||||
v-model="recipeDeleteDialog"
|
||||
:title="$t('recipe.delete-recipe')"
|
||||
color="error"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
can-confirm
|
||||
@confirm="deleteRecipe()"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("recipe.delete-confirmation") }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<BaseDialog
|
||||
v-model="recipeDuplicateDialog"
|
||||
:title="$t('recipe.duplicate')"
|
||||
color="primary"
|
||||
:icon="$globals.icons.duplicate"
|
||||
can-confirm
|
||||
@confirm="duplicateRecipe()"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="recipeName"
|
||||
density="compact"
|
||||
:label="$t('recipe.recipe-name')"
|
||||
autofocus
|
||||
@keyup.enter="duplicateRecipe()"
|
||||
/>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<BaseDialog
|
||||
v-model="mealplannerDialog"
|
||||
:title="$t('recipe.add-recipe-to-mealplan')"
|
||||
color="primary"
|
||||
:icon="$globals.icons.calendar"
|
||||
can-confirm
|
||||
@confirm="addRecipeToPlan()"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-menu
|
||||
v-model="pickerMenu"
|
||||
:close-on-content-click="false"
|
||||
transition="scale-transition"
|
||||
offset-y
|
||||
max-width="290px"
|
||||
min-width="auto"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-text-field
|
||||
v-model="newMealdateString"
|
||||
:label="$t('general.date')"
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="props"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
v-model="newMealdate"
|
||||
hide-header
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
:local="$i18n.locale"
|
||||
@update:model-value="pickerMenu = false"
|
||||
/>
|
||||
</v-menu>
|
||||
<v-select
|
||||
v-model="newMealType"
|
||||
:return-object="false"
|
||||
:items="planTypeOptions"
|
||||
:label="$t('recipe.entry-type')"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
/>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<RecipeDialogAddToShoppingList
|
||||
v-if="shoppingLists && recipeRefWithScale"
|
||||
v-model="shoppingListDialog"
|
||||
:recipes="[recipeRefWithScale]"
|
||||
:shopping-lists="shoppingLists"
|
||||
/>
|
||||
<v-menu
|
||||
offset-y
|
||||
start
|
||||
:bottom="!menuTop"
|
||||
:nudge-bottom="!menuTop ? '5' : '0'"
|
||||
:top="menuTop"
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="$vuetify.display.mdAndUp"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
icon
|
||||
:variant="fab ? 'flat' : undefined"
|
||||
:rounded="fab ? 'circle' : undefined"
|
||||
:size="fab ? 'small' : undefined"
|
||||
:color="fab ? 'info' : 'secondary'"
|
||||
:fab="fab"
|
||||
v-bind="props"
|
||||
@click.prevent
|
||||
>
|
||||
<v-icon
|
||||
:size="!fab ? undefined : 'x-large'"
|
||||
:color="fab ? 'white' : 'secondary'"
|
||||
>
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
|
||||
<template #prepend>
|
||||
<v-icon :color="item.color">
|
||||
{{ item.icon }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
|
||||
<v-divider />
|
||||
<v-list-group @click.stop>
|
||||
<template #activator="{ props }">
|
||||
<v-list-item-title v-bind="props">
|
||||
{{ $t("recipe.recipe-actions") }}
|
||||
</v-list-item-title>
|
||||
</template>
|
||||
<v-list density="compact" class="ma-0 pa-0">
|
||||
<v-list-item
|
||||
v-for="(action, index) in recipeActions"
|
||||
:key="index"
|
||||
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>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue";
|
||||
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
|
||||
import RecipeDialogShare from "./RecipeDialogShare.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { useGroupRecipeActions } from "~/composables/use-group-recipe-actions";
|
||||
import { useHouseholdSelf } from "~/composables/use-households";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household";
|
||||
import type { PlanEntryType } from "~/lib/api/types/meal-plan";
|
||||
import { useDownloader } from "~/composables/api/use-downloader";
|
||||
|
||||
export interface ContextMenuIncludes {
|
||||
delete: boolean;
|
||||
edit: boolean;
|
||||
download: boolean;
|
||||
duplicate: boolean;
|
||||
mealplanner: boolean;
|
||||
shoppingList: boolean;
|
||||
print: boolean;
|
||||
printPreferences: boolean;
|
||||
share: boolean;
|
||||
recipeActions: boolean;
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
title: string;
|
||||
icon: string;
|
||||
color: string | undefined;
|
||||
event: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeDialogAddToShoppingList,
|
||||
RecipeDialogPrintPreferences,
|
||||
RecipeDialogShare,
|
||||
},
|
||||
props: {
|
||||
useItems: {
|
||||
type: Object as () => ContextMenuIncludes,
|
||||
default: () => ({
|
||||
delete: true,
|
||||
edit: true,
|
||||
download: true,
|
||||
duplicate: false,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: true,
|
||||
printPreferences: true,
|
||||
share: true,
|
||||
recipeActions: true,
|
||||
}),
|
||||
},
|
||||
// Append items are added at the end of the useItems list
|
||||
appendItems: {
|
||||
type: Array as () => ContextMenuItem[],
|
||||
default: () => [],
|
||||
},
|
||||
// Append items are added at the beginning of the useItems list
|
||||
leadingItems: {
|
||||
type: Array as () => ContextMenuItem[],
|
||||
default: () => [],
|
||||
},
|
||||
menuTop: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="text-center">
|
||||
<v-menu
|
||||
offset-y
|
||||
start
|
||||
:eager="isMenuContentLoaded"
|
||||
:bottom="!menuTop"
|
||||
:nudge-bottom="!menuTop ? '5' : '0'"
|
||||
:top="menuTop"
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="$vuetify.display.mdAndUp"
|
||||
content-class="d-print-none"
|
||||
@update:model-value="onMenuToggle"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
icon
|
||||
:variant="fab ? 'flat' : undefined"
|
||||
:rounded="fab ? 'circle' : undefined"
|
||||
:size="fab ? 'small' : undefined"
|
||||
:color="fab ? 'info' : 'secondary'"
|
||||
:fab="fab"
|
||||
v-bind="activatorProps"
|
||||
@click.prevent
|
||||
@mouseenter="onHover"
|
||||
>
|
||||
<v-icon
|
||||
:size="!fab ? undefined : 'x-large'"
|
||||
:color="fab ? 'white' : 'secondary'"
|
||||
>
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<RecipeContextMenuContent
|
||||
v-if="isMenuContentLoaded"
|
||||
v-bind="contentProps"
|
||||
@deleted="$emit('deleted', $event)"
|
||||
/>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
interface ContextMenuIncludes {
|
||||
delete?: boolean;
|
||||
edit?: boolean;
|
||||
download?: boolean;
|
||||
duplicate?: boolean;
|
||||
mealplanner?: boolean;
|
||||
shoppingList?: boolean;
|
||||
print?: boolean;
|
||||
printPreferences?: boolean;
|
||||
share?: boolean;
|
||||
recipeActions?: boolean;
|
||||
}
|
||||
|
||||
interface ContextMenuItem {
|
||||
title: string;
|
||||
icon: string;
|
||||
color?: string;
|
||||
event: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
useItems?: ContextMenuIncludes;
|
||||
appendItems?: ContextMenuItem[];
|
||||
leadingItems?: ContextMenuItem[];
|
||||
menuTop?: boolean;
|
||||
fab?: boolean;
|
||||
color?: string;
|
||||
slug: string;
|
||||
menuIcon?: string | null;
|
||||
name: string;
|
||||
recipe?: Recipe;
|
||||
recipeId: string;
|
||||
recipeScale?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
useItems: () => ({
|
||||
delete: true,
|
||||
edit: true,
|
||||
download: true,
|
||||
duplicate: false,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: true,
|
||||
printPreferences: true,
|
||||
share: true,
|
||||
recipeActions: true,
|
||||
}),
|
||||
appendItems: () => [],
|
||||
leadingItems: () => [],
|
||||
menuTop: true,
|
||||
fab: false,
|
||||
color: "primary",
|
||||
menuIcon: null,
|
||||
recipe: undefined,
|
||||
recipeScale: 1,
|
||||
});
|
||||
|
||||
defineEmits<{
|
||||
[key: string]: any;
|
||||
deleted: [slug: string];
|
||||
}>();
|
||||
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const isMenuContentLoaded = ref(false);
|
||||
|
||||
const icon = computed(() => {
|
||||
return props.menuIcon || $globals.icons.dotsVertical;
|
||||
});
|
||||
|
||||
// Props to pass to the content component (excluding internal wrapper props)
|
||||
const contentProps = computed(() => {
|
||||
const { ...rest } = props;
|
||||
return rest;
|
||||
});
|
||||
|
||||
function onHover() {
|
||||
if (!isMenuContentLoaded.value) {
|
||||
isMenuContentLoaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onMenuToggle(isOpen: boolean) {
|
||||
if (isOpen && !isMenuContentLoaded.value) {
|
||||
isMenuContentLoaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
const RecipeContextMenuContent = defineAsyncComponent(
|
||||
() => import("./RecipeContextMenuContent.vue"),
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,461 @@
|
||||
<template>
|
||||
<RecipeDialogShare v-model="shareDialog" :recipe-id="recipeId" :name="name" />
|
||||
<RecipeDialogPrintPreferences v-model="printPreferencesDialog" :recipe="recipeRef" />
|
||||
<BaseDialog
|
||||
v-model="recipeDeleteDialog"
|
||||
:title="$t('recipe.delete-recipe')"
|
||||
color="error"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
can-confirm
|
||||
@confirm="deleteRecipe()"
|
||||
>
|
||||
<v-card-text>
|
||||
<template v-if="isAdminAndNotOwner">
|
||||
{{ $t("recipe.admin-delete-confirmation") }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t("recipe.delete-confirmation") }}
|
||||
</template>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<BaseDialog
|
||||
v-model="recipeDuplicateDialog"
|
||||
:title="$t('recipe.duplicate')"
|
||||
color="primary"
|
||||
:icon="$globals.icons.duplicate"
|
||||
can-confirm
|
||||
@confirm="duplicateRecipe()"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="recipeName"
|
||||
density="compact"
|
||||
:label="$t('recipe.recipe-name')"
|
||||
autofocus
|
||||
@keyup.enter="duplicateRecipe()"
|
||||
/>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<BaseDialog
|
||||
v-model="mealplannerDialog"
|
||||
:title="$t('recipe.add-recipe-to-mealplan')"
|
||||
color="primary"
|
||||
:icon="$globals.icons.calendar"
|
||||
can-confirm
|
||||
@confirm="addRecipeToPlan()"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-menu
|
||||
v-model="pickerMenu"
|
||||
:close-on-content-click="false"
|
||||
transition="scale-transition"
|
||||
offset-y
|
||||
max-width="290px"
|
||||
min-width="auto"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
v-model="newMealdateString"
|
||||
:label="$t('general.date')"
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="activatorProps"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
v-model="newMealdate"
|
||||
hide-header
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
:local="$i18n.locale"
|
||||
@update:model-value="pickerMenu = false"
|
||||
/>
|
||||
</v-menu>
|
||||
<v-select
|
||||
v-model="newMealType"
|
||||
:return-object="false"
|
||||
:items="planTypeOptions"
|
||||
:label="$t('recipe.entry-type')"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
/>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<RecipeDialogAddToShoppingList
|
||||
v-if="shoppingLists && recipeRefWithScale"
|
||||
v-model="shoppingListDialog"
|
||||
:recipes="[recipeRefWithScale]"
|
||||
:shopping-lists="shoppingLists"
|
||||
/>
|
||||
|
||||
<v-list density="compact">
|
||||
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
|
||||
<template #prepend>
|
||||
<v-icon :color="item.color">
|
||||
{{ item.icon }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
|
||||
<v-divider />
|
||||
<v-list-item
|
||||
v-for="(action, index) in recipeActions"
|
||||
:key="index"
|
||||
@click="executeRecipeAction(action)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon color="undefined">
|
||||
{{ $globals.icons.linkVariantPlus }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ action.title }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</div>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue";
|
||||
import RecipeDialogPrintPreferences from "~/components/Domain/Recipe/RecipeDialogPrintPreferences.vue";
|
||||
import RecipeDialogShare from "~/components/Domain/Recipe/RecipeDialogShare.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { useGroupRecipeActions } from "~/composables/use-group-recipe-actions";
|
||||
import { useHouseholdSelf } from "~/composables/use-households";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household";
|
||||
import type { PlanEntryType } from "~/lib/api/types/meal-plan";
|
||||
import { useDownloader } from "~/composables/api/use-downloader";
|
||||
|
||||
export interface ContextMenuIncludes {
|
||||
delete: boolean;
|
||||
edit: boolean;
|
||||
download: boolean;
|
||||
duplicate: boolean;
|
||||
mealplanner: boolean;
|
||||
shoppingList: boolean;
|
||||
print: boolean;
|
||||
printPreferences: boolean;
|
||||
share: boolean;
|
||||
recipeActions: boolean;
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
title: string;
|
||||
icon: string;
|
||||
color: string | undefined;
|
||||
event: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
useItems?: ContextMenuIncludes;
|
||||
appendItems?: ContextMenuItem[];
|
||||
leadingItems?: ContextMenuItem[];
|
||||
menuTop?: boolean;
|
||||
fab?: boolean;
|
||||
color?: string;
|
||||
slug: string;
|
||||
menuIcon?: string | null;
|
||||
name: string;
|
||||
recipe?: Recipe;
|
||||
recipeId: string;
|
||||
recipeScale?: number;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
useItems: () => ({
|
||||
delete: true,
|
||||
edit: true,
|
||||
download: true,
|
||||
duplicate: false,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: true,
|
||||
printPreferences: true,
|
||||
share: true,
|
||||
recipeActions: true,
|
||||
}),
|
||||
appendItems: () => [],
|
||||
leadingItems: () => [],
|
||||
menuTop: true,
|
||||
fab: false,
|
||||
color: "primary",
|
||||
menuIcon: null,
|
||||
recipe: undefined,
|
||||
recipeScale: 1,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
[key: string]: any;
|
||||
deleted: [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];
|
||||
|
||||
// ===========================================================================
|
||||
// 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("deleted", 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>
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { whenever } from "@vueuse/core";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||
@@ -42,86 +42,66 @@ export interface GenericAlias {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
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);
|
||||
},
|
||||
});
|
||||
interface Props {
|
||||
data: IngredientFood | IngredientUnit;
|
||||
}
|
||||
|
||||
function createAlias() {
|
||||
aliases.value.push({
|
||||
name: "",
|
||||
});
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
function deleteAlias(index: number) {
|
||||
aliases.value.splice(index, 1);
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
submit: [aliases: GenericAlias[]];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const aliases = ref<GenericAlias[]>(props.data.aliases || []);
|
||||
function initAliases() {
|
||||
aliases.value = [...props.data.aliases || []];
|
||||
if (!aliases.value.length) {
|
||||
createAlias();
|
||||
}
|
||||
}
|
||||
// V-Model Support
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
|
||||
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();
|
||||
whenever(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
initAliases();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function saveAliases() {
|
||||
const seenAliasNames: string[] = [];
|
||||
const keepAliases: GenericAlias[] = [];
|
||||
aliases.value.forEach((alias) => {
|
||||
if (
|
||||
!alias.name
|
||||
|| alias.name === props.data.name
|
||||
|| alias.name === props.data.pluralName
|
||||
|| alias.name === props.data.abbreviation
|
||||
|| alias.name === props.data.pluralAbbreviation
|
||||
|| seenAliasNames.includes(alias.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
keepAliases.push(alias);
|
||||
seenAliasNames.push(alias.name);
|
||||
});
|
||||
|
||||
aliases.value = keepAliases;
|
||||
context.emit("submit", keepAliases);
|
||||
function saveAliases() {
|
||||
const seenAliasNames: string[] = [];
|
||||
const keepAliases: GenericAlias[] = [];
|
||||
aliases.value.forEach((alias) => {
|
||||
if (
|
||||
!alias.name
|
||||
|| alias.name === props.data.name
|
||||
|| alias.name === props.data.pluralName
|
||||
|| alias.name === props.data.abbreviation
|
||||
|| alias.name === props.data.pluralAbbreviation
|
||||
|| seenAliasNames.includes(alias.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
aliases,
|
||||
createAlias,
|
||||
dialog,
|
||||
deleteAlias,
|
||||
saveAliases,
|
||||
validators,
|
||||
};
|
||||
},
|
||||
});
|
||||
keepAliases.push(alias);
|
||||
seenAliasNames.push(alias.name);
|
||||
});
|
||||
|
||||
aliases.value = keepAliases;
|
||||
emit("submit", keepAliases);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
v-model="selected"
|
||||
item-key="id"
|
||||
show-select
|
||||
:sort-by="[{ key: 'dateAdded', order: 'desc' }]"
|
||||
:sort-by="sortBy"
|
||||
:headers="headers"
|
||||
:items="recipes"
|
||||
:items-per-page="15"
|
||||
class="elevation-0"
|
||||
:loading="loading"
|
||||
return-object
|
||||
>
|
||||
<template #[`item.name`]="{ item }">
|
||||
<a
|
||||
@@ -61,7 +62,7 @@
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import UserAvatar from "../User/UserAvatar.vue";
|
||||
import RecipeChip from "./RecipeChips.vue";
|
||||
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 { RecipeTag } from "~/lib/api/types/household";
|
||||
|
||||
const INPUT_EVENT = "update:modelValue";
|
||||
|
||||
interface ShowHeaders {
|
||||
id: boolean;
|
||||
owner: boolean;
|
||||
@@ -83,136 +82,114 @@ interface ShowHeaders {
|
||||
dateAdded: boolean;
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeChip, UserAvatar },
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array as PropType<Recipe[]>,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
recipes: {
|
||||
type: Array as () => Recipe[],
|
||||
default: () => [],
|
||||
},
|
||||
showHeaders: {
|
||||
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,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
loading?: boolean;
|
||||
recipes?: Recipe[];
|
||||
showHeaders?: ShowHeaders;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
recipes: () => [],
|
||||
showHeaders: () => ({
|
||||
id: true,
|
||||
owner: false,
|
||||
tags: true,
|
||||
categories: true,
|
||||
tools: true,
|
||||
recipeServings: true,
|
||||
recipeYieldQuantity: true,
|
||||
recipeYield: true,
|
||||
dateAdded: true,
|
||||
}),
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<BaseDialog
|
||||
v-if="shoppingListIngredientDialog"
|
||||
v-model="dialog"
|
||||
:title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')"
|
||||
:title="selectedShoppingList?.name || $t('recipe.add-to-list')"
|
||||
:icon="$globals.icons.cartCheck"
|
||||
width="70%"
|
||||
:submit-text="$t('recipe.add-to-list')"
|
||||
@@ -130,20 +130,23 @@
|
||||
.ingredients[i]
|
||||
.checked"
|
||||
>
|
||||
<v-checkbox
|
||||
hide-details
|
||||
:model-value="ingredientData.checked"
|
||||
class="pt-0 my-auto py-auto"
|
||||
color="secondary"
|
||||
density="compact"
|
||||
/>
|
||||
<div :key="ingredientData.ingredient.quantity">
|
||||
<RecipeIngredientListItem
|
||||
:ingredient="ingredientData.ingredient"
|
||||
:disable-amount="ingredientData.disableAmount"
|
||||
:scale="recipeSection.recipeScale"
|
||||
/>
|
||||
</div>
|
||||
<v-container class="pa-0 ma-0">
|
||||
<v-row no-gutters>
|
||||
<v-checkbox
|
||||
hide-details
|
||||
:model-value="ingredientData.checked"
|
||||
class="pt-0 my-auto py-auto mr-2"
|
||||
color="secondary"
|
||||
density="compact"
|
||||
/>
|
||||
<div :key="`${ingredientData.ingredient.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
|
||||
<RecipeIngredientListItem
|
||||
:ingredient="ingredientData.ingredient"
|
||||
:scale="recipeSection.recipeScale"
|
||||
/>
|
||||
</div>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-list-item>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,7 +175,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from "@vueuse/core";
|
||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
@@ -188,7 +191,6 @@ export interface RecipeWithScale extends Recipe {
|
||||
export interface ShoppingListIngredient {
|
||||
checked: boolean;
|
||||
ingredient: RecipeIngredient;
|
||||
disableAmount: boolean;
|
||||
}
|
||||
|
||||
export interface ShoppingListIngredientSection {
|
||||
@@ -203,240 +205,214 @@ export interface ShoppingListRecipeIngredientSection {
|
||||
ingredientSections: ShoppingListIngredientSection[];
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeIngredientListItem,
|
||||
interface Props {
|
||||
recipes?: RecipeWithScale[];
|
||||
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
|
||||
const dialog = computed({
|
||||
get: () => {
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:modelValue", val);
|
||||
initState();
|
||||
},
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
initState();
|
||||
}
|
||||
});
|
||||
|
||||
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
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({
|
||||
shoppingListDialog: true,
|
||||
shoppingListIngredientDialog: false,
|
||||
shoppingListShowAllToggled: false,
|
||||
});
|
||||
let currentTitle = "";
|
||||
const onHandIngs: ShoppingListIngredient[] = [];
|
||||
const shoppingListIngredientSections = shoppingListIngredients.reduce((sections, ing) => {
|
||||
if (ing.ingredient.title) {
|
||||
currentTitle = ing.ingredient.title;
|
||||
}
|
||||
|
||||
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);
|
||||
// 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;
|
||||
}
|
||||
else {
|
||||
ready.value = true;
|
||||
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,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>();
|
||||
for (const recipe of recipes) {
|
||||
if (!recipe.slug) {
|
||||
continue;
|
||||
}
|
||||
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"));
|
||||
|
||||
if (recipeSectionMap.has(recipe.slug)) {
|
||||
recipeSectionMap.get(recipe.slug).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,
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
state.shoppingListDialog = false;
|
||||
state.shoppingListIngredientDialog = false;
|
||||
dialog.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="css">
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
v-model="dialog"
|
||||
width="800"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<BaseButton
|
||||
v-bind="props"
|
||||
v-bind="activatorProps"
|
||||
@click="inputText = inputTextProp"
|
||||
>
|
||||
{{ $t("new-recipe.bulk-add") }}
|
||||
@@ -89,88 +89,75 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
inputTextProp: {
|
||||
type: String,
|
||||
required: false,
|
||||
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),
|
||||
};
|
||||
},
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
inputTextProp?: string;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
inputTextProp: "",
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
<v-switch
|
||||
v-model="preferences.showDescription"
|
||||
hide-details
|
||||
color="primary"
|
||||
:label="$t('recipe.description')"
|
||||
/>
|
||||
</v-row>
|
||||
@@ -51,6 +52,7 @@
|
||||
<v-switch
|
||||
v-model="preferences.showNotes"
|
||||
hide-details
|
||||
color="primary"
|
||||
:label="$t('recipe.notes')"
|
||||
/>
|
||||
</v-row>
|
||||
@@ -63,6 +65,7 @@
|
||||
<v-switch
|
||||
v-model="preferences.showNutrition"
|
||||
hide-details
|
||||
color="primary"
|
||||
:label="$t('recipe.nutrition')"
|
||||
/>
|
||||
</v-row>
|
||||
@@ -83,45 +86,19 @@
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
||||
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipePrintView,
|
||||
},
|
||||
props: {
|
||||
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,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
recipe?: NoUndefinedField<Recipe>;
|
||||
}
|
||||
withDefaults(defineProps<Props>(), {
|
||||
recipe: undefined,
|
||||
});
|
||||
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
const preferences = useUserPrintPreferences();
|
||||
</script>
|
||||
|
||||
@@ -52,10 +52,6 @@
|
||||
<div class="mr-auto">
|
||||
{{ $t("search.results") }}
|
||||
</div>
|
||||
<!-- <router-link
|
||||
:to="advancedSearchUrl"
|
||||
class="text-primary"
|
||||
> {{ $t("search.advanced-search") }} </router-link> -->
|
||||
</v-card-actions>
|
||||
|
||||
<RecipeCardMobile
|
||||
@@ -76,7 +72,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
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";
|
||||
|
||||
const SELECTED_EVENT = "selected";
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeCardMobile,
|
||||
},
|
||||
|
||||
setup(_, context) {
|
||||
const $auth = useMealieAuth();
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
selectedIndex: -1,
|
||||
});
|
||||
// Define emits
|
||||
const emit = defineEmits<{
|
||||
selected: [recipe: RecipeSummary];
|
||||
}>();
|
||||
|
||||
// ===========================================================================
|
||||
// Dialog State Management
|
||||
const dialog = ref(false);
|
||||
const $auth = useMealieAuth();
|
||||
const loading = ref(false);
|
||||
const selectedIndex = ref(-1);
|
||||
|
||||
// Reset or Grab Recipes on Change
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
search.query.value = "";
|
||||
state.selectedIndex = -1;
|
||||
search.data.value = [];
|
||||
}
|
||||
});
|
||||
// ===========================================================================
|
||||
// Dialog State Management
|
||||
const dialog = ref(false);
|
||||
|
||||
// ===========================================================================
|
||||
// Event Handlers
|
||||
// Reset or Grab Recipes on Change
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
search.query.value = "";
|
||||
selectedIndex.value = -1;
|
||||
search.data.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
function selectRecipe() {
|
||||
const recipeCards = document.getElementsByClassName("arrow-nav");
|
||||
if (recipeCards) {
|
||||
if (state.selectedIndex < 0) {
|
||||
state.selectedIndex = -1;
|
||||
document.getElementById("arrow-search")?.focus();
|
||||
return;
|
||||
}
|
||||
// ===========================================================================
|
||||
// Event Handlers
|
||||
|
||||
if (state.selectedIndex >= recipeCards.length) {
|
||||
state.selectedIndex = recipeCards.length - 1;
|
||||
}
|
||||
|
||||
(recipeCards[state.selectedIndex] as HTMLElement).focus();
|
||||
}
|
||||
function selectRecipe() {
|
||||
const recipeCards = document.getElementsByClassName("arrow-nav");
|
||||
if (recipeCards) {
|
||||
if (selectedIndex.value < 0) {
|
||||
selectedIndex.value = -1;
|
||||
document.getElementById("arrow-search")?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
function onUpDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
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();
|
||||
if (selectedIndex.value >= recipeCards.length) {
|
||||
selectedIndex.value = recipeCards.length - 1;
|
||||
}
|
||||
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
document.removeEventListener("keyup", onUpDown);
|
||||
}
|
||||
else {
|
||||
document.addEventListener("keyup", onUpDown);
|
||||
}
|
||||
});
|
||||
(recipeCards[selectedIndex.value] as HTMLElement).focus();
|
||||
}
|
||||
}
|
||||
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const route = useRoute();
|
||||
const advancedSearchUrl = computed(() => `/g/${groupSlug.value}`);
|
||||
watch(route, close);
|
||||
function onUpDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
console.log(document.activeElement);
|
||||
// (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() {
|
||||
dialog.value = true;
|
||||
}
|
||||
function close() {
|
||||
dialog.value = false;
|
||||
}
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
document.removeEventListener("keyup", onUpDown);
|
||||
}
|
||||
else {
|
||||
document.addEventListener("keyup", onUpDown);
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Basic Search
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
|
||||
const search = useRecipeSearch(api);
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
watch(route, close);
|
||||
|
||||
// Select Handler
|
||||
function open() {
|
||||
dialog.value = true;
|
||||
}
|
||||
function close() {
|
||||
dialog.value = false;
|
||||
}
|
||||
|
||||
function handleSelect(recipe: RecipeSummary) {
|
||||
close();
|
||||
context.emit(SELECTED_EVENT, recipe);
|
||||
}
|
||||
// ===========================================================================
|
||||
// Basic Search
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
|
||||
const search = useRecipeSearch(api);
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
advancedSearchUrl,
|
||||
dialog,
|
||||
open,
|
||||
close,
|
||||
handleSelect,
|
||||
search,
|
||||
};
|
||||
},
|
||||
// Select Handler
|
||||
function handleSelect(recipe: RecipeSummary) {
|
||||
close();
|
||||
emit(SELECTED_EVENT, recipe);
|
||||
}
|
||||
|
||||
// Expose functions to parent components
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
max-width="290px"
|
||||
min-width="auto"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
v-model="expirationDateString"
|
||||
:label="$t('recipe-share.expiration-date')"
|
||||
:hint="$t('recipe-share.default-30-days')"
|
||||
persistent-hint
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="props"
|
||||
v-bind="activatorProps"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
@@ -92,150 +92,116 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useClipboard, useShare, whenever } from "@vueuse/core";
|
||||
import type { RecipeShareToken } from "~/lib/api/types/recipe";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { useHouseholdSelf } from "~/composables/use-households";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
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);
|
||||
},
|
||||
});
|
||||
interface Props {
|
||||
recipeId: string;
|
||||
name: string;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const state = reactive({
|
||||
datePickerMenu: false,
|
||||
expirationDate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
|
||||
tokens: [] as RecipeShareToken[],
|
||||
});
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
|
||||
const expirationDateString = computed(() => {
|
||||
return state.expirationDate.toISOString().substring(0, 10);
|
||||
});
|
||||
const datePickerMenu = ref(false);
|
||||
const expirationDate = ref(new Date(Date.now() - new Date().getTimezoneOffset() * 60000));
|
||||
const tokens = ref<RecipeShareToken[]>([]);
|
||||
|
||||
whenever(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
// 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,
|
||||
};
|
||||
},
|
||||
const expirationDateString = computed(() => {
|
||||
return expirationDate.value.toISOString().substring(0, 10);
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,710 +0,0 @@
|
||||
<template>
|
||||
<v-container
|
||||
fluid
|
||||
class="px-0"
|
||||
>
|
||||
<div class="search-container pb-8">
|
||||
<form
|
||||
class="search-box pa-2"
|
||||
@submit.prevent="search"
|
||||
>
|
||||
<div class="d-flex justify-center mb-2">
|
||||
<v-text-field
|
||||
ref="input"
|
||||
v-model="state.search"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
color="primary"
|
||||
:placeholder="$t('search.search-placeholder')"
|
||||
:prepend-inner-icon="$globals.icons.search"
|
||||
@keyup.enter="hideKeyboard"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-row">
|
||||
<!-- Category Filter -->
|
||||
<SearchFilter
|
||||
v-if="categories"
|
||||
v-model="selectedCategories"
|
||||
v-model:require-all="state.requireAllCategories"
|
||||
:items="categories"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.categories }}
|
||||
</v-icon>
|
||||
{{ $t("category.categories") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Tag Filter -->
|
||||
<SearchFilter
|
||||
v-if="tags"
|
||||
v-model="selectedTags"
|
||||
v-model:require-all="state.requireAllTags"
|
||||
:items="tags"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.tags }}
|
||||
</v-icon>
|
||||
{{ $t("tag.tags") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Tool Filter -->
|
||||
<SearchFilter
|
||||
v-if="tools"
|
||||
v-model="selectedTools"
|
||||
v-model:require-all="state.requireAllTools"
|
||||
:items="tools"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
{{ $t("tool.tools") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Food Filter -->
|
||||
<SearchFilter
|
||||
v-if="foods"
|
||||
v-model="selectedFoods"
|
||||
v-model:require-all="state.requireAllFoods"
|
||||
:items="foods"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.foods }}
|
||||
</v-icon>
|
||||
{{ $t("general.foods") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Household Filter -->
|
||||
<SearchFilter
|
||||
v-if="households.length > 1"
|
||||
v-model="selectedHouseholds"
|
||||
:items="households"
|
||||
radio
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.household }}
|
||||
</v-icon>
|
||||
{{ $t("household.households") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Sort Options -->
|
||||
<v-menu
|
||||
offset-y
|
||||
nudge-bottom="3"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
class="ml-auto"
|
||||
size="small"
|
||||
color="accent"
|
||||
v-bind="props"
|
||||
>
|
||||
<v-icon :start="!$vuetify.display.xs">
|
||||
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
|
||||
</v-icon>
|
||||
{{ $vuetify.display.xs ? null : sortText }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
slim
|
||||
density="comfortable"
|
||||
:prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortDescending : $globals.icons.sortAscending"
|
||||
:title="state.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
|
||||
@click="toggleOrderDirection()"
|
||||
/>
|
||||
<v-divider />
|
||||
<v-list-item
|
||||
v-for="v in sortable"
|
||||
:key="v.name"
|
||||
:active="state.orderBy === v.value"
|
||||
slim
|
||||
density="comfortable"
|
||||
:prepend-icon="v.icon"
|
||||
:title="v.name"
|
||||
@click="state.orderBy = v.value"
|
||||
/>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- Settings -->
|
||||
<v-menu
|
||||
offset-y
|
||||
bottom
|
||||
start
|
||||
nudge-bottom="3"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
size="small"
|
||||
color="accent"
|
||||
dark
|
||||
v-bind="props"
|
||||
>
|
||||
<v-icon size="small">
|
||||
{{ $globals.icons.cog }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<v-switch
|
||||
v-model="state.auto"
|
||||
:label="$t('search.auto-search')"
|
||||
single-line
|
||||
/>
|
||||
<v-btn
|
||||
block
|
||||
color="primary"
|
||||
@click="reset"
|
||||
>
|
||||
{{ $t("general.reset") }}
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</div>
|
||||
<div
|
||||
v-if="!state.auto"
|
||||
class="search-button-container"
|
||||
>
|
||||
<v-btn
|
||||
size="x-large"
|
||||
color="primary"
|
||||
type="submit"
|
||||
block
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.search }}
|
||||
</v-icon>
|
||||
{{ $t("search.search") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<v-divider />
|
||||
<v-container class="mt-6 px-md-6">
|
||||
<RecipeCardSection
|
||||
v-if="state.ready"
|
||||
class="mt-n5"
|
||||
:icon="$globals.icons.silverwareForkKnife"
|
||||
:title="$t('general.recipes')"
|
||||
:recipes="recipes"
|
||||
:query="passedQueryWithSeed"
|
||||
disable-sort
|
||||
@item-selected="filterItems"
|
||||
@replace-recipes="replaceRecipes"
|
||||
@append-recipes="appendRecipes"
|
||||
/>
|
||||
</v-container>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { watchDebounced } from "@vueuse/shared";
|
||||
import SearchFilter from "~/components/Domain/SearchFilter.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import {
|
||||
useCategoryStore,
|
||||
usePublicCategoryStore,
|
||||
useFoodStore,
|
||||
usePublicFoodStore,
|
||||
useHouseholdStore,
|
||||
usePublicHouseholdStore,
|
||||
useTagStore,
|
||||
usePublicTagStore,
|
||||
useToolStore,
|
||||
usePublicToolStore,
|
||||
} from "~/composables/store";
|
||||
import { useUserSearchQuerySession } from "~/composables/use-users/preferences";
|
||||
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
|
||||
import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { useLazyRecipes } from "~/composables/recipes";
|
||||
import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { SearchFilter, RecipeCardSection },
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const state = ref({
|
||||
auto: true,
|
||||
ready: false,
|
||||
search: "",
|
||||
orderBy: "created_at",
|
||||
orderDirection: "desc" as "asc" | "desc",
|
||||
|
||||
// and/or
|
||||
requireAllCategories: false,
|
||||
requireAllTags: false,
|
||||
requireAllTools: false,
|
||||
requireAllFoods: false,
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const searchQuerySession = useUserSearchQuerySession();
|
||||
|
||||
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
const categories = isOwnGroup.value ? useCategoryStore() : usePublicCategoryStore(groupSlug.value);
|
||||
const selectedCategories = ref<NoUndefinedField<RecipeCategory>[]>([]);
|
||||
|
||||
const foods = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
|
||||
const selectedFoods = ref<IngredientFood[]>([]);
|
||||
|
||||
const households = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value);
|
||||
const selectedHouseholds = ref([] as NoUndefinedField<HouseholdSummary>[]);
|
||||
|
||||
const tags = isOwnGroup.value ? useTagStore() : usePublicTagStore(groupSlug.value);
|
||||
const selectedTags = ref<NoUndefinedField<RecipeTag>[]>([]);
|
||||
|
||||
const tools = isOwnGroup.value ? useToolStore() : usePublicToolStore(groupSlug.value);
|
||||
const selectedTools = ref<NoUndefinedField<RecipeTool>[]>([]);
|
||||
|
||||
function calcPassedQuery(): RecipeSearchQuery {
|
||||
return {
|
||||
// the search clear button sets search to null, which still renders the query param for a moment,
|
||||
// whereas an empty string is not rendered
|
||||
search: state.value.search ? state.value.search : "",
|
||||
categories: toIDArray(selectedCategories.value),
|
||||
foods: toIDArray(selectedFoods.value),
|
||||
households: toIDArray(selectedHouseholds.value),
|
||||
tags: toIDArray(selectedTags.value),
|
||||
tools: toIDArray(selectedTools.value),
|
||||
requireAllCategories: state.value.requireAllCategories,
|
||||
requireAllTags: state.value.requireAllTags,
|
||||
requireAllTools: state.value.requireAllTools,
|
||||
requireAllFoods: state.value.requireAllFoods,
|
||||
orderBy: state.value.orderBy,
|
||||
orderDirection: state.value.orderDirection,
|
||||
};
|
||||
}
|
||||
const passedQuery = ref<RecipeSearchQuery>(calcPassedQuery());
|
||||
|
||||
// we calculate this separately because otherwise we can't check for query changes
|
||||
const passedQueryWithSeed = computed(() => {
|
||||
return {
|
||||
...passedQuery.value,
|
||||
_searchSeed: Date.now().toString(),
|
||||
};
|
||||
});
|
||||
|
||||
const queryDefaults = {
|
||||
search: "",
|
||||
orderBy: "created_at",
|
||||
orderDirection: "desc" as "asc" | "desc",
|
||||
requireAllCategories: false,
|
||||
requireAllTags: false,
|
||||
requireAllTools: false,
|
||||
requireAllFoods: false,
|
||||
};
|
||||
|
||||
function reset() {
|
||||
state.value.search = queryDefaults.search;
|
||||
state.value.orderBy = queryDefaults.orderBy;
|
||||
state.value.orderDirection = queryDefaults.orderDirection;
|
||||
state.value.requireAllCategories = queryDefaults.requireAllCategories;
|
||||
state.value.requireAllTags = queryDefaults.requireAllTags;
|
||||
state.value.requireAllTools = queryDefaults.requireAllTools;
|
||||
state.value.requireAllFoods = queryDefaults.requireAllFoods;
|
||||
selectedCategories.value = [];
|
||||
selectedFoods.value = [];
|
||||
selectedHouseholds.value = [];
|
||||
selectedTags.value = [];
|
||||
selectedTools.value = [];
|
||||
}
|
||||
|
||||
function toggleOrderDirection() {
|
||||
state.value.orderDirection = state.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
}
|
||||
|
||||
function toIDArray(array: { id: string }[]) {
|
||||
// we sort the array to make sure the query is always the same
|
||||
return array.map(item => item.id).sort();
|
||||
}
|
||||
|
||||
function hideKeyboard() {
|
||||
input.value.blur();
|
||||
}
|
||||
|
||||
const input: Ref<any> = ref(null);
|
||||
|
||||
async function search() {
|
||||
const oldQueryValueString = JSON.stringify(passedQuery.value);
|
||||
const newQueryValue = calcPassedQuery();
|
||||
const newQueryValueString = JSON.stringify(newQueryValue);
|
||||
if (oldQueryValueString === newQueryValueString) {
|
||||
return;
|
||||
}
|
||||
|
||||
passedQuery.value = newQueryValue;
|
||||
const query = {
|
||||
categories: passedQuery.value.categories,
|
||||
foods: passedQuery.value.foods,
|
||||
tags: passedQuery.value.tags,
|
||||
tools: passedQuery.value.tools,
|
||||
// Only add the query param if it's not the default value
|
||||
...{
|
||||
auto: state.value.auto ? undefined : "false",
|
||||
search: passedQuery.value.search === queryDefaults.search ? undefined : passedQuery.value.search,
|
||||
orderBy: passedQuery.value.orderBy === queryDefaults.orderBy ? undefined : passedQuery.value.orderBy,
|
||||
orderDirection: passedQuery.value.orderDirection === queryDefaults.orderDirection ? undefined : passedQuery.value.orderDirection,
|
||||
households: !passedQuery.value.households?.length || passedQuery.value.households?.length === households.store.value.length ? undefined : passedQuery.value.households,
|
||||
requireAllCategories: passedQuery.value.requireAllCategories ? "true" : undefined,
|
||||
requireAllTags: passedQuery.value.requireAllTags ? "true" : undefined,
|
||||
requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined,
|
||||
requireAllFoods: passedQuery.value.requireAllFoods ? "true" : undefined,
|
||||
},
|
||||
};
|
||||
await router.push({ query });
|
||||
searchQuerySession.value.recipe = JSON.stringify(query);
|
||||
}
|
||||
|
||||
function waitUntilAndExecute(
|
||||
condition: () => boolean,
|
||||
callback: () => void,
|
||||
opts = { timeout: 2000, interval: 500 },
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = {
|
||||
timeout: undefined as number | undefined,
|
||||
interval: undefined as number | undefined,
|
||||
};
|
||||
|
||||
const check = () => {
|
||||
if (condition()) {
|
||||
clearInterval(state.interval);
|
||||
clearTimeout(state.timeout);
|
||||
callback();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
// For some reason these were returning NodeJS.Timeout
|
||||
state.interval = setInterval(check, opts.interval) as unknown as number;
|
||||
state.timeout = setTimeout(() => {
|
||||
clearInterval(state.interval);
|
||||
reject(new Error("Timeout"));
|
||||
}, opts.timeout) as unknown as number;
|
||||
});
|
||||
}
|
||||
|
||||
const sortText = computed(() => {
|
||||
const sort = sortable.find(s => s.value === state.value.orderBy);
|
||||
if (!sort) return "";
|
||||
return `${sort.name}`;
|
||||
});
|
||||
|
||||
const sortable = [
|
||||
{
|
||||
icon: $globals.icons.orderAlphabeticalAscending,
|
||||
name: i18n.t("general.sort-alphabetically"),
|
||||
value: "name",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.newBox,
|
||||
name: i18n.t("general.created"),
|
||||
value: "created_at",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.chefHat,
|
||||
name: i18n.t("general.last-made"),
|
||||
value: "last_made",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.star,
|
||||
name: i18n.t("general.rating"),
|
||||
value: "rating",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.update,
|
||||
name: i18n.t("general.updated"),
|
||||
value: "updated_at",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.diceMultiple,
|
||||
name: i18n.t("general.random"),
|
||||
value: "random",
|
||||
},
|
||||
];
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
if (!Object.keys(route.query).length) {
|
||||
reset();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function filterItems(item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) {
|
||||
if (urlPrefix === "categories") {
|
||||
const result = categories.store.value.filter(category => (category.id as string).includes(item.id as string));
|
||||
selectedCategories.value = result as NoUndefinedField<RecipeTag>[];
|
||||
}
|
||||
else if (urlPrefix === "tags") {
|
||||
const result = tags.store.value.filter(tag => (tag.id as string).includes(item.id as string));
|
||||
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
||||
}
|
||||
else if (urlPrefix === "tools") {
|
||||
const result = tools.store.value.filter(tool => (tool.id).includes(item.id || ""));
|
||||
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateSearch() {
|
||||
const query = router.currentRoute.value.query;
|
||||
if (query.auto?.length) {
|
||||
state.value.auto = query.auto === "true";
|
||||
}
|
||||
|
||||
if (query.search?.length) {
|
||||
state.value.search = query.search as string;
|
||||
}
|
||||
else {
|
||||
state.value.search = queryDefaults.search;
|
||||
}
|
||||
|
||||
if (query.orderBy?.length) {
|
||||
state.value.orderBy = query.orderBy as string;
|
||||
}
|
||||
else {
|
||||
state.value.orderBy = queryDefaults.orderBy;
|
||||
}
|
||||
|
||||
if (query.orderDirection?.length) {
|
||||
state.value.orderDirection = query.orderDirection as "asc" | "desc";
|
||||
}
|
||||
else {
|
||||
state.value.orderDirection = queryDefaults.orderDirection;
|
||||
}
|
||||
|
||||
if (query.requireAllCategories?.length) {
|
||||
state.value.requireAllCategories = query.requireAllCategories === "true";
|
||||
}
|
||||
else {
|
||||
state.value.requireAllCategories = queryDefaults.requireAllCategories;
|
||||
}
|
||||
|
||||
if (query.requireAllTags?.length) {
|
||||
state.value.requireAllTags = query.requireAllTags === "true";
|
||||
}
|
||||
else {
|
||||
state.value.requireAllTags = queryDefaults.requireAllTags;
|
||||
}
|
||||
|
||||
if (query.requireAllTools?.length) {
|
||||
state.value.requireAllTools = query.requireAllTools === "true";
|
||||
}
|
||||
else {
|
||||
state.value.requireAllTools = queryDefaults.requireAllTools;
|
||||
}
|
||||
|
||||
if (query.requireAllFoods?.length) {
|
||||
state.value.requireAllFoods = query.requireAllFoods === "true";
|
||||
}
|
||||
else {
|
||||
state.value.requireAllFoods = queryDefaults.requireAllFoods;
|
||||
}
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
if (query.categories?.length) {
|
||||
promises.push(
|
||||
waitUntilAndExecute(
|
||||
() => categories.store.value.length > 0,
|
||||
() => {
|
||||
const result = categories.store.value.filter(item =>
|
||||
(query.categories as string[]).includes(item.id as string),
|
||||
);
|
||||
|
||||
selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
else {
|
||||
selectedCategories.value = [];
|
||||
}
|
||||
|
||||
if (query.tags?.length) {
|
||||
promises.push(
|
||||
waitUntilAndExecute(
|
||||
() => tags.store.value.length > 0,
|
||||
() => {
|
||||
const result = tags.store.value.filter(item => (query.tags as string[]).includes(item.id as string));
|
||||
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
else {
|
||||
selectedTags.value = [];
|
||||
}
|
||||
|
||||
if (query.tools?.length) {
|
||||
promises.push(
|
||||
waitUntilAndExecute(
|
||||
() => tools.store.value.length > 0,
|
||||
() => {
|
||||
const result = tools.store.value.filter(item => (query.tools as string[]).includes(item.id));
|
||||
selectedTools.value = result as NoUndefinedField<RecipeTool>[];
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
else {
|
||||
selectedTools.value = [];
|
||||
}
|
||||
|
||||
if (query.foods?.length) {
|
||||
promises.push(
|
||||
waitUntilAndExecute(
|
||||
() => {
|
||||
if (foods.store.value) {
|
||||
return foods.store.value.length > 0;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
() => {
|
||||
const result = foods.store.value?.filter(item => (query.foods as string[]).includes(item.id));
|
||||
selectedFoods.value = result ?? [];
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
else {
|
||||
selectedFoods.value = [];
|
||||
}
|
||||
|
||||
if (query.households?.length) {
|
||||
promises.push(
|
||||
waitUntilAndExecute(
|
||||
() => {
|
||||
if (households.store.value) {
|
||||
return households.store.value.length > 0;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
() => {
|
||||
const result = households.store.value?.filter(item => (query.households as string[]).includes(item.id));
|
||||
selectedHouseholds.value = result as NoUndefinedField<HouseholdSummary>[] ?? [];
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
else {
|
||||
selectedHouseholds.value = [];
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
// restore the user's last search query
|
||||
if (searchQuerySession.value.recipe && !(Object.keys(route.query).length > 0)) {
|
||||
try {
|
||||
const query = JSON.parse(searchQuerySession.value.recipe);
|
||||
await router.replace({ query });
|
||||
}
|
||||
catch {
|
||||
searchQuerySession.value.recipe = "";
|
||||
router.replace({ query: {} });
|
||||
}
|
||||
}
|
||||
|
||||
await hydrateSearch();
|
||||
await search();
|
||||
state.value.ready = true;
|
||||
});
|
||||
|
||||
watchDebounced(
|
||||
[
|
||||
() => state.value.search,
|
||||
() => state.value.requireAllCategories,
|
||||
() => state.value.requireAllTags,
|
||||
() => state.value.requireAllTools,
|
||||
() => state.value.requireAllFoods,
|
||||
() => state.value.orderBy,
|
||||
() => state.value.orderDirection,
|
||||
selectedCategories,
|
||||
selectedFoods,
|
||||
selectedHouseholds,
|
||||
selectedTags,
|
||||
selectedTools,
|
||||
],
|
||||
async () => {
|
||||
if (state.value.ready && state.value.auto) {
|
||||
await search();
|
||||
}
|
||||
},
|
||||
{
|
||||
debounce: 500,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
sortText,
|
||||
search,
|
||||
reset,
|
||||
state,
|
||||
categories: categories.store as unknown as NoUndefinedField<RecipeCategory>[],
|
||||
tags: tags.store as unknown as NoUndefinedField<RecipeTag>[],
|
||||
foods: foods.store,
|
||||
tools: tools.store as unknown as NoUndefinedField<RecipeTool>[],
|
||||
households: households.store as unknown as NoUndefinedField<HouseholdSummary>[],
|
||||
|
||||
sortable,
|
||||
toggleOrderDirection,
|
||||
hideKeyboard,
|
||||
input,
|
||||
|
||||
selectedCategories,
|
||||
selectedFoods,
|
||||
selectedHouseholds,
|
||||
selectedTags,
|
||||
selectedTools,
|
||||
appendRecipes,
|
||||
assignSorted,
|
||||
recipes,
|
||||
removeRecipe,
|
||||
replaceRecipes,
|
||||
passedQueryWithSeed,
|
||||
|
||||
filterItems,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="css">
|
||||
.search-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 950px;
|
||||
}
|
||||
|
||||
.search-button-container {
|
||||
margin: 3rem auto 0 auto;
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<v-container
|
||||
fluid
|
||||
class="px-0"
|
||||
>
|
||||
<RecipeExplorerPageSearch
|
||||
ref="searchComponent"
|
||||
@ready="onSearchReady"
|
||||
/>
|
||||
<v-divider />
|
||||
<v-container class="mt-6 px-md-6">
|
||||
<RecipeCardSection
|
||||
v-if="ready"
|
||||
class="mt-n5"
|
||||
:icon="$globals.icons.silverwareForkKnife"
|
||||
:title="$t('general.recipes')"
|
||||
:recipes="recipes"
|
||||
:query="searchQuery"
|
||||
disable-sort
|
||||
@item-selected="onItemSelected"
|
||||
@replace-recipes="replaceRecipes"
|
||||
@append-recipes="appendRecipes"
|
||||
/>
|
||||
</v-container>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import RecipeExplorerPageSearch from "./RecipeExplorerPageParts/RecipeExplorerPageSearch.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
|
||||
import { useLazyRecipes } from "~/composables/recipes";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeCardSection, RecipeExplorerPageSearch },
|
||||
setup() {
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const { recipes, appendRecipes, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
|
||||
const ready = ref(false);
|
||||
const searchComponent = ref<InstanceType<typeof RecipeExplorerPageSearch>>();
|
||||
|
||||
const searchQuery = computed(() => {
|
||||
return searchComponent.value?.passedQueryWithSeed || {};
|
||||
});
|
||||
|
||||
function onSearchReady() {
|
||||
ready.value = true;
|
||||
}
|
||||
|
||||
function onItemSelected(item: any, urlPrefix: string) {
|
||||
searchComponent.value?.filterItems(item, urlPrefix);
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
searchComponent,
|
||||
searchQuery,
|
||||
recipes,
|
||||
appendRecipes,
|
||||
replaceRecipes,
|
||||
onSearchReady,
|
||||
onItemSelected,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div class="search-container pb-8">
|
||||
<form
|
||||
class="search-box pa-2"
|
||||
@submit.prevent="search"
|
||||
>
|
||||
<div class="d-flex justify-center mb-2">
|
||||
<v-text-field
|
||||
ref="input"
|
||||
v-model="state.search"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
color="primary"
|
||||
:placeholder="$t('search.search-placeholder')"
|
||||
:prepend-inner-icon="$globals.icons.search"
|
||||
@keyup.enter="hideKeyboard"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-row">
|
||||
<RecipeExplorerPageSearchFilters />
|
||||
<!-- Sort Options -->
|
||||
<v-menu
|
||||
offset-y
|
||||
nudge-bottom="3"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
class="ml-auto"
|
||||
size="small"
|
||||
color="accent"
|
||||
v-bind="props"
|
||||
>
|
||||
<v-icon :start="!$vuetify.display.xs">
|
||||
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
|
||||
</v-icon>
|
||||
{{ $vuetify.display.xs ? null : sortText }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
slim
|
||||
density="comfortable"
|
||||
:prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortDescending : $globals.icons.sortAscending"
|
||||
:title="state.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
|
||||
@click="toggleOrderDirection"
|
||||
/>
|
||||
<v-divider />
|
||||
<v-list-item
|
||||
v-for="v in sortable"
|
||||
:key="v.name"
|
||||
:active="state.orderBy === v.value"
|
||||
slim
|
||||
density="comfortable"
|
||||
:prepend-icon="v.icon"
|
||||
:title="v.name"
|
||||
@click="setOrderBy(v.value)"
|
||||
/>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- Settings -->
|
||||
<v-menu
|
||||
offset-y
|
||||
bottom
|
||||
start
|
||||
nudge-bottom="3"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
size="small"
|
||||
color="accent"
|
||||
dark
|
||||
v-bind="props"
|
||||
>
|
||||
<v-icon size="small">
|
||||
{{ $globals.icons.cog }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<v-switch
|
||||
v-model="state.auto"
|
||||
:label="$t('search.auto-search')"
|
||||
single-line
|
||||
/>
|
||||
<v-btn
|
||||
block
|
||||
color="primary"
|
||||
@click="reset"
|
||||
>
|
||||
{{ $t("general.reset") }}
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</div>
|
||||
<div
|
||||
v-if="!state.auto"
|
||||
class="search-button-container"
|
||||
>
|
||||
<v-btn
|
||||
size="x-large"
|
||||
color="primary"
|
||||
type="submit"
|
||||
block
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.search }}
|
||||
</v-icon>
|
||||
{{ $t("search.search") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import RecipeExplorerPageSearchFilters from "./RecipeExplorerPageSearchFilters.vue";
|
||||
import { useRecipeExplorerSearch } from "~/composables/use-recipe-explorer-search";
|
||||
|
||||
const emit = defineEmits<{
|
||||
ready: [];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const { $globals } = useNuxtApp();
|
||||
const i18n = useI18n();
|
||||
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const {
|
||||
state,
|
||||
passedQueryWithSeed,
|
||||
search,
|
||||
reset,
|
||||
toggleOrderDirection,
|
||||
setOrderBy,
|
||||
filterItems,
|
||||
initialize,
|
||||
} = useRecipeExplorerSearch(groupSlug);
|
||||
|
||||
defineExpose({
|
||||
passedQueryWithSeed,
|
||||
filterItems,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await initialize();
|
||||
emit("ready");
|
||||
});
|
||||
|
||||
const sortText = computed(() => {
|
||||
const sort = sortable.value.find(s => s.value === state.value.orderBy);
|
||||
if (!sort) return "";
|
||||
return `${sort.name}`;
|
||||
});
|
||||
|
||||
const sortable = computed(() => [
|
||||
{
|
||||
icon: $globals.icons.orderAlphabeticalAscending,
|
||||
name: i18n.t("general.sort-alphabetically"),
|
||||
value: "name",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.newBox,
|
||||
name: i18n.t("general.created"),
|
||||
value: "created_at",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.chefHat,
|
||||
name: i18n.t("general.last-made"),
|
||||
value: "last_made",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.star,
|
||||
name: i18n.t("general.rating"),
|
||||
value: "rating",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.update,
|
||||
name: i18n.t("general.updated"),
|
||||
value: "updated_at",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.diceMultiple,
|
||||
name: i18n.t("general.random"),
|
||||
value: "random",
|
||||
},
|
||||
]);
|
||||
|
||||
// Methods
|
||||
const input: Ref<any> = ref(null);
|
||||
|
||||
function hideKeyboard() {
|
||||
input.value?.blur();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 950px;
|
||||
}
|
||||
|
||||
.search-button-container {
|
||||
margin: 3rem auto 0 auto;
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<!-- Category Filter -->
|
||||
<SearchFilter
|
||||
v-if="categories"
|
||||
v-model="selectedCategories"
|
||||
v-model:require-all="state.requireAllCategories"
|
||||
:items="categories"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.categories }}
|
||||
</v-icon>
|
||||
{{ $t("category.categories") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Tag Filter -->
|
||||
<SearchFilter
|
||||
v-if="tags"
|
||||
v-model="selectedTags"
|
||||
v-model:require-all="state.requireAllTags"
|
||||
:items="tags"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.tags }}
|
||||
</v-icon>
|
||||
{{ $t("tag.tags") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Tool Filter -->
|
||||
<SearchFilter
|
||||
v-if="tools"
|
||||
v-model="selectedTools"
|
||||
v-model:require-all="state.requireAllTools"
|
||||
:items="tools"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
{{ $t("tool.tools") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Food Filter -->
|
||||
<SearchFilter
|
||||
v-if="foods"
|
||||
v-model="selectedFoods"
|
||||
v-model:require-all="state.requireAllFoods"
|
||||
:items="foods"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.foods }}
|
||||
</v-icon>
|
||||
{{ $t("general.foods") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Household Filter -->
|
||||
<SearchFilter
|
||||
v-if="households.length > 1"
|
||||
v-model="selectedHouseholds"
|
||||
:items="households"
|
||||
radio
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.household }}
|
||||
</v-icon>
|
||||
{{ $t("household.households") }}
|
||||
</SearchFilter>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useRecipeExplorerSearch } from "~/composables/use-recipe-explorer-search";
|
||||
import {
|
||||
useCategoryStore,
|
||||
usePublicCategoryStore,
|
||||
useFoodStore,
|
||||
usePublicFoodStore,
|
||||
useHouseholdStore,
|
||||
usePublicHouseholdStore,
|
||||
useTagStore,
|
||||
usePublicTagStore,
|
||||
useToolStore,
|
||||
usePublicToolStore,
|
||||
} from "~/composables/store";
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const {
|
||||
state,
|
||||
selectedCategories,
|
||||
selectedFoods,
|
||||
selectedHouseholds,
|
||||
selectedTags,
|
||||
selectedTools,
|
||||
} = useRecipeExplorerSearch(groupSlug);
|
||||
|
||||
const { store: categories } = isOwnGroup.value ? useCategoryStore() : usePublicCategoryStore(groupSlug.value);
|
||||
const { store: tags } = isOwnGroup.value ? useTagStore() : usePublicTagStore(groupSlug.value);
|
||||
const { store: tools } = isOwnGroup.value ? useToolStore() : usePublicToolStore(groupSlug.value);
|
||||
const { store: foods } = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
|
||||
const { store: households } = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value);
|
||||
</script>
|
||||
@@ -4,7 +4,7 @@
|
||||
nudge-right="50"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-btn
|
||||
v-if="isFavorite || showAlways"
|
||||
icon
|
||||
@@ -13,7 +13,7 @@
|
||||
size="small"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
:fab="buttonStyle"
|
||||
v-bind="{ ...props, ...$attrs }"
|
||||
v-bind="{ ...tooltipProps, ...$attrs }"
|
||||
@click.prevent="toggleFavorite"
|
||||
>
|
||||
<v-icon
|
||||
@@ -28,47 +28,39 @@
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useUserSelfRatings } from "~/composables/use-users";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
recipeId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
showAlways: {
|
||||
type: Boolean,
|
||||
default: 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 };
|
||||
},
|
||||
interface Props {
|
||||
recipeId?: string;
|
||||
showAlways?: boolean;
|
||||
buttonStyle?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
recipeId: "",
|
||||
showAlways: false,
|
||||
buttonStyle: false,
|
||||
});
|
||||
|
||||
const { userRatings, refreshUserRatings } = useUserSelfRatings();
|
||||
|
||||
const isFavorite = computed(() => {
|
||||
const rating = userRatings.value.find(r => r.recipeId === props.recipeId);
|
||||
return rating?.isFavorite || false;
|
||||
});
|
||||
|
||||
async function toggleFavorite() {
|
||||
const api = useUserApi();
|
||||
const $auth = useMealieAuth();
|
||||
|
||||
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>
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
nudge-top="6"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
color="accent"
|
||||
dark
|
||||
v-bind="props"
|
||||
v-bind="activatorProps"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.fileImage }}
|
||||
@@ -61,52 +61,42 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
const REFRESH_EVENT = "refresh";
|
||||
const UPLOAD_EVENT = "upload";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const state = reactive({
|
||||
url: "",
|
||||
loading: false,
|
||||
menu: false,
|
||||
});
|
||||
const props = defineProps<{ slug: string }>();
|
||||
|
||||
function uploadImage(fileObject: File) {
|
||||
context.emit(UPLOAD_EVENT, fileObject);
|
||||
state.menu = false;
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
upload: [fileObject: File];
|
||||
}>();
|
||||
|
||||
const api = useUserApi();
|
||||
async function getImageFromURL() {
|
||||
state.loading = true;
|
||||
if (await api.recipes.updateImagebyURL(props.slug, state.url)) {
|
||||
context.emit(REFRESH_EVENT);
|
||||
}
|
||||
state.loading = false;
|
||||
state.menu = false;
|
||||
}
|
||||
const url = ref("");
|
||||
const loading = ref(false);
|
||||
const menu = ref(false);
|
||||
|
||||
const i18n = useI18n();
|
||||
const messages = props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")];
|
||||
function uploadImage(fileObject: File) {
|
||||
emit(UPLOAD_EVENT, fileObject);
|
||||
menu.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
uploadImage,
|
||||
getImageFromURL,
|
||||
messages,
|
||||
};
|
||||
},
|
||||
});
|
||||
const api = useUserApi();
|
||||
async function getImageFromURL() {
|
||||
loading.value = true;
|
||||
if (await api.recipes.updateImagebyURL(props.slug, url.value)) {
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user