mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-01-03 11:58:28 -05:00
Compare commits
203 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7b191a5ee | ||
|
|
5620370ade | ||
|
|
d333d47e34 | ||
|
|
b34b1c9be3 | ||
|
|
8c5010148d | ||
|
|
a17b0e329e | ||
|
|
8ab69a7d7a | ||
|
|
f4ecf74b91 | ||
|
|
ba9d816f64 | ||
|
|
6895b49543 | ||
|
|
fffe7b05e0 | ||
|
|
1271e0e49b | ||
|
|
478054b724 | ||
|
|
57d259a7a3 | ||
|
|
a4a6d4dfb1 | ||
|
|
f7b4f79312 | ||
|
|
434d312f7c | ||
|
|
bda460b49e | ||
|
|
d3e1c48655 | ||
|
|
b2a3430f2c | ||
|
|
3d792d9333 | ||
|
|
2e028d7e12 | ||
|
|
c63932e8b3 | ||
|
|
3ba2227bc7 | ||
|
|
67af391c6b | ||
|
|
70ae0dac25 | ||
|
|
e15a9c3c9f | ||
|
|
9d40d60b3b | ||
|
|
e2760f7247 | ||
|
|
83bf21b947 | ||
|
|
d1824affff | ||
|
|
4827e1092f | ||
|
|
7db767b075 | ||
|
|
afdd0b15dc | ||
|
|
37c9166a77 | ||
|
|
ba0b9d4cd9 | ||
|
|
9fd99a86b8 | ||
|
|
824603a578 | ||
|
|
e3f120c680 | ||
|
|
d16a10440d | ||
|
|
ecdf7de386 | ||
|
|
0e10ed8461 | ||
|
|
1684169e7b | ||
|
|
3d9f2bef82 | ||
|
|
a722b05fb5 | ||
|
|
187e83eeb5 | ||
|
|
f3cc51190c | ||
|
|
33aedd6904 | ||
|
|
ea9a25a891 | ||
|
|
3a237258a1 | ||
|
|
d29de8e679 | ||
|
|
79367872ac | ||
|
|
f058dec27b | ||
|
|
c87acf54db | ||
|
|
84c144e40f | ||
|
|
474cf299cd | ||
|
|
1cababc5a5 | ||
|
|
8705bcf195 | ||
|
|
bdb511c1c8 | ||
|
|
c9f3f65f36 | ||
|
|
3ec55f0e48 | ||
|
|
7d43c7c7a2 | ||
|
|
c710e9d3f5 | ||
|
|
0313e6b3b8 | ||
|
|
24b890136d | ||
|
|
4b67554b36 | ||
|
|
679a42a7cc | ||
|
|
4dfc32a314 | ||
|
|
96acc6fc4b | ||
|
|
249c9e8f23 | ||
|
|
7413185300 | ||
|
|
6168ea0150 | ||
|
|
f7ba7862d4 | ||
|
|
cec6d2c5ec | ||
|
|
b27977fbdf | ||
|
|
2a60b330ac | ||
|
|
72ec5bd13e | ||
|
|
bb45cbb0a2 | ||
|
|
c929a03b57 | ||
|
|
9e5a54477f | ||
|
|
078b4563b3 | ||
|
|
a9090bc2bd | ||
|
|
cb8c1423c5 | ||
|
|
f6a1b5f4eb | ||
|
|
7623b72c4c | ||
|
|
17d40e34df | ||
|
|
bade6968a3 | ||
|
|
92a142125f | ||
|
|
d39c2a2874 | ||
|
|
324de7fb10 | ||
|
|
c4544ea042 | ||
|
|
a5dda74812 | ||
|
|
fd7e58e40c | ||
|
|
5e42841a7d | ||
|
|
ae9306b8c2 | ||
|
|
7f0c5cbcc4 | ||
|
|
a7d8bcc6ba | ||
|
|
b94ef78a12 | ||
|
|
db2c14093d | ||
|
|
9a0525c3a0 | ||
|
|
a2e5826da0 | ||
|
|
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 |
@@ -11,7 +11,7 @@
|
||||
// Use -bullseye variants on local on arm64/Apple Silicon.
|
||||
"VARIANT": "3.12-bullseye",
|
||||
// Options
|
||||
"NODE_VERSION": "20"
|
||||
"NODE_VERSION": "22"
|
||||
}
|
||||
},
|
||||
"mounts": [
|
||||
|
||||
2
.github/workflows/build-package.yml
vendored
2
.github/workflows/build-package.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup node env 🏗
|
||||
uses: actions/setup-node@v4.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
check-latest: true
|
||||
|
||||
- name: Get yarn cache directory path 🛠
|
||||
|
||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: ./tests/e2e/yarn.lock
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
5
.github/workflows/locale-sync.yml
vendored
5
.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,9 +96,10 @@ 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.
|
||||
|
||||
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
@@ -77,7 +77,7 @@ jobs:
|
||||
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/sqlite.md
|
||||
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/postgres.md
|
||||
sed -i 's/^version = "[^"]*"/version = "${{ env.VERSION_NUM }}"/' pyproject.toml
|
||||
sed -i 's/^\s*"version": "[^"]*"/"version": "${{ env.VERSION_NUM }}"/' frontend/package.json
|
||||
sed -i 's/\("version": "\)[^"]*"/\1${{ env.VERSION_NUM }}"/' frontend/package.json
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
|
||||
2
.github/workflows/test-frontend.yml
vendored
2
.github/workflows/test-frontend.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
- name: Setup node env 🏗
|
||||
uses: actions/setup-node@v4.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
check-latest: true
|
||||
|
||||
- name: Get yarn cache directory path 🛠
|
||||
|
||||
@@ -12,7 +12,7 @@ repos:
|
||||
exclude: ^tests/data/
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.12.9
|
||||
rev: v0.13.3
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -59,8 +59,11 @@
|
||||
"netlify.toml": "runtime.txt",
|
||||
"README.md": "LICENSE, SECURITY.md"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[vue]": {
|
||||
"editor.formatOnSave": false
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[python]": {
|
||||
"editor.formatOnSave": true,
|
||||
|
||||
@@ -88,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
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -105,12 +106,16 @@ def main():
|
||||
# Flatten list of lists
|
||||
all_children = [item for sublist in all_children for item in sublist]
|
||||
|
||||
out_path = GENERATED / "__init__.py"
|
||||
render_python_template(
|
||||
TEMPLATE,
|
||||
GENERATED / "__init__.py",
|
||||
out_path,
|
||||
{"children": all_children},
|
||||
)
|
||||
|
||||
subprocess.run(["poetry", "run", "ruff", "check", str(out_path), "--fix"])
|
||||
subprocess.run(["poetry", "run", "ruff", "format", str(out_path)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from utils import PROJECT_DIR, log, render_python_template
|
||||
@@ -84,16 +85,23 @@ def find_modules(root: pathlib.Path) -> list[Modules]:
|
||||
return modules
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
modules = find_modules(SCHEMA_PATH)
|
||||
|
||||
template_paths: list[pathlib.Path] = []
|
||||
for module in modules:
|
||||
log.debug(f"Module: {module.directory.name}")
|
||||
for file in module.files:
|
||||
log.debug(f" File: {file.import_path}")
|
||||
log.debug(f" Classes: [{', '.join(file.classes)}]")
|
||||
|
||||
render_python_template(template, module.directory / "__init__.py", {"module": module})
|
||||
template_path = module.directory / "__init__.py"
|
||||
template_paths.append(template_path)
|
||||
render_python_template(template, template_path, {"module": module})
|
||||
|
||||
path_args = (str(p) for p in template_paths)
|
||||
subprocess.run(["poetry", "run", "ruff", "check", *path_args, "--fix"])
|
||||
subprocess.run(["poetry", "run", "ruff", "format", *path_args])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -173,9 +173,25 @@ 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"):
|
||||
@@ -186,7 +202,6 @@ def inject_nuxt_values():
|
||||
all_langs.append(lang_string)
|
||||
|
||||
all_langs.sort()
|
||||
all_date_locales.sort()
|
||||
|
||||
log.debug(f"injecting locales into nuxt config -> {nuxt_config}")
|
||||
inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Template
|
||||
@@ -189,6 +190,7 @@ def generate_typescript_types() -> None: # noqa: C901
|
||||
skipped_dirs: list[Path] = []
|
||||
failed_modules: list[Path] = []
|
||||
|
||||
out_paths: list[Path] = []
|
||||
for module in schema_path.iterdir():
|
||||
if module.is_dir() and module.stem in ignore_dirs:
|
||||
skipped_dirs.append(module)
|
||||
@@ -205,10 +207,18 @@ def generate_typescript_types() -> None: # noqa: C901
|
||||
path_as_module = path_to_module(module)
|
||||
generate_typescript_defs(path_as_module, str(out_path), exclude=("MealieModel")) # type: ignore
|
||||
clean_output_file(out_path)
|
||||
out_paths.append(out_path)
|
||||
except Exception:
|
||||
failed_modules.append(module)
|
||||
log.exception(f"Module Error: {module}")
|
||||
|
||||
# Run ESLint --fix on the files to clean up any formatting issues
|
||||
subprocess.run(
|
||||
["yarn", "lint", "--fix", *(str(path) for path in out_paths)],
|
||||
check=True,
|
||||
cwd=PROJECT_DIR / "frontend",
|
||||
)
|
||||
|
||||
log.debug("\n📁 Skipped Directories:")
|
||||
for skipped_dir in skipped_dirs:
|
||||
log.debug(f" 📁 {skipped_dir.name}")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -23,11 +22,6 @@ def render_python_template(template_file: Path | str, dest: Path, data: dict):
|
||||
|
||||
dest.write_text(text)
|
||||
|
||||
# lint/format file with Ruff
|
||||
log.info(f"Formatting {dest}")
|
||||
subprocess.run(["poetry", "run", "ruff", "check", str(dest), "--fix"])
|
||||
subprocess.run(["poetry", "run", "ruff", "format", str(dest)])
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodeSlicer:
|
||||
@@ -37,7 +31,7 @@ class CodeSlicer:
|
||||
indentation: str | None
|
||||
text: list[str]
|
||||
|
||||
_next_line = None
|
||||
_next_line: int | None = None
|
||||
|
||||
def purge_lines(self) -> None:
|
||||
start = self.start + 1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
###############################################
|
||||
# Frontend Build
|
||||
###############################################
|
||||
FROM node:20@sha256:572a90df10a58ebb7d3f223d661d964a6c2383a9c2b5763162b4f631c53dc56a \
|
||||
FROM node:22@sha256:2bb201f33898d2c0ce638505b426f4dd038cc00e5b2b4cbba17b069f0fff1496 \
|
||||
AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
@@ -34,7 +34,7 @@ Make sure the VSCode Dev Containers extension is installed, then select "Dev Con
|
||||
|
||||
- [Python 3.12](https://www.python.org/downloads/)
|
||||
- [Poetry](https://python-poetry.org/docs/#installation)
|
||||
- [Node v16.x](https://nodejs.org/en/)
|
||||
- [Node](https://nodejs.org/en/)
|
||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable)
|
||||
- [task](https://taskfile.dev/#/installation)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
!!! info
|
||||
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
||||
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
||||
|
||||
Mealie supports adding the ingredients of a recipe to your [Bring](https://www.getbring.com/) shopping list, as you can
|
||||
see [here](https://docs.mealie.io/documentation/getting-started/features/#recipe-actions).
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
!!! info
|
||||
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
||||
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
||||
|
||||
In a lot of ways, Home Assistant is why this project exists! Since Mealie has a robust API it makes it a great fit for interacting with Home Assistant and pulling information into your dashboard.
|
||||
|
||||
### Display Today's Meal in Lovelace
|
||||
## Display Today's Meal in Lovelace
|
||||
|
||||
You can use the Mealie API to get access to meal plans in Home Assistant like in the image below.
|
||||
|
||||

|
||||
|
||||
Steps:
|
||||
## Steps:
|
||||
|
||||
#### 1. Get your API Token
|
||||
### 1. Get your API Token
|
||||
|
||||
Create an API token from Mealie's User Settings page (https://docs.mealie.io/documentation/getting-started/api-usage/#getting-a-token)
|
||||
Create an API token from Mealie's User Settings page (see [this page](https://docs.mealie.io/documentation/getting-started/api-usage/#getting-a-token) to learn how).
|
||||
|
||||
#### 2. Create Home Assistant Sensors
|
||||
### 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.
|
||||
@@ -40,7 +40,7 @@ rest:
|
||||
unique_id: mealie_todays_meal_id
|
||||
```
|
||||
|
||||
#### 3. Create a Camera Entity
|
||||
### 3. Create a Camera Entity
|
||||
|
||||
We will create a camera entity to display the image of today's meal in Lovelace.
|
||||
|
||||
@@ -52,7 +52,7 @@ In the still image url field put in:
|
||||
Under the entity page for the new camera, rename it.
|
||||
e.g. `camera.mealie_todays_meal_image`
|
||||
|
||||
#### 4. Create a Lovelace Card
|
||||
### 4. Create a Lovelace Card
|
||||
|
||||
Create a picture entity card and set the entity to `mealie_todays_meal` and the camera entity to `camera.mealie_todays_meal_image` or set in the yaml directly.
|
||||
|
||||
@@ -76,4 +76,4 @@ card_mod:
|
||||
```
|
||||
|
||||
!!! tip
|
||||
Due to how Home Assistant works with images, I had to include the additional styling to get the images to not appear distorted. This requires an [additional installation](https://github.com/thomasloven/lovelace-card-mod) from HACS.
|
||||
Due to how Home Assistant works with images, I had to include the additional styling to get the images to not appear distorted. This requires an [additional installation](https://github.com/thomasloven/lovelace-card-mod) from HACS.
|
||||
|
||||
@@ -12,12 +12,10 @@ var url = document.URL.endsWith('/') ?
|
||||
document.URL;
|
||||
var mealie = "http://localhost:8080";
|
||||
var group_slug = "home" // Change this to your group slug. You can obtain this from your URL after logging-in to Mealie
|
||||
var use_keywords= "&use_keywords=1" // Optional - use keywords from recipe - update to "" if you don't want that
|
||||
var edity = "&edit=1" // Optional - keep in edit mode - update to "" if you don't want that
|
||||
|
||||
if (mealie.slice(-1) === "/") {
|
||||
mealie = mealie.slice(0, -1)
|
||||
}
|
||||
var dest = mealie + "/g/" + group_slug + "/r/create/url?recipe_import_url=" + url + use_keywords + edity;
|
||||
var dest = mealie + "/g/" + group_slug + "/r/create/url?recipe_import_url=" + url;
|
||||
window.open(dest, "_blank");
|
||||
```
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
!!! info
|
||||
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
||||
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
||||
|
||||
An easy way to add recipes to Mealie from an Apple device is via an Apple Shortcut. This is a short guide to install an configure a shortcut able to add recipes via a link or image(s).
|
||||
|
||||
*Note: if adding via images make sure to enable [Mealie's openai integration](https://docs.mealie.io/documentation/getting-started/installation/open-ai/)*
|
||||
!!! note
|
||||
If adding via images make sure to enable [Mealie's OpenAI Integration](https://docs.mealie.io/documentation/getting-started/installation/open-ai/)
|
||||
|
||||
## Javascript can only be run via Shortcuts on the Safari browser on MacOS and iOS. If you do not use Safari you may skip this section
|
||||
Some sites have begun blocking AI scraping bots, inadvertently blocking the recipe scraping library Mealie uses as well. To circumvent this, the shortcut uses javascript to capture the raw html loaded in the browser and sends that to mealie when possible.
|
||||
@@ -16,12 +17,13 @@ Settings app -> apps -> Shortcuts -> Advanced -> Allow Running Scripts
|
||||
|
||||
Shortcuts app -> Settings (CMD ,) -> Advanced -> Allow Running Scripts
|
||||
|
||||
## Initial setup
|
||||
## Initial Setup
|
||||
An API key is needed to authenticate with mealie. To create an api key for a user, navigate to http://YOUR_MEALIE_URL/user/profile/api-tokens. Alternatively you can create a key via the mealie home page by clicking the user's profile pic in the top left -> Api Tokens
|
||||
|
||||
The shortcut can be installed via **[This link](https://www.icloud.com/shortcuts/52834724050b42aebe0f2efd8d067360)**. Upon install, replace "MEALIE_API_KEY" with the API key generated previously and "MEALIE_URI" with the full URL used to access your mealie instance e.g. "http://10.0.0.5:9000" or "https://mealie.domain.com".
|
||||
|
||||
## Using the shortcut
|
||||
## Using the Shortcut
|
||||
Once installed, the shortcut will automatically appear as an option when sharing an image or webpage. It can also be useful to add the shortcut to the home screen of your device. If selected from the home screen or shortcuts app, a menu will appear with prompts to import via **taking photo(s)**, **selecting photo(s)**, **scanning a URL**, or **pasting a URL**.
|
||||
|
||||
*Note: despite the mealie API being able to accept multiple recipe images for import it is currently impossible to send multiple files in 1 web request via Shortcuts. Instead, the shortcut combines the images into a singular, vertically-concatenated image to send to mealie. This can result in slightly less-accurate text recognition.*
|
||||
!!! note
|
||||
Despite the Mealie API being able to accept multiple recipe images for import it is currently impossible to send multiple files in 1 web request via Shortcuts. Instead, the shortcut combines the images into a singular, vertically-concatenated image to send to mealie. This can result in slightly less-accurate text recognition.
|
||||
@@ -1,71 +1,77 @@
|
||||
# Automating Backups with n8n
|
||||
|
||||
!!! info
|
||||
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
||||
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
||||
|
||||
> [n8n](https://github.com/n8n-io/n8n) is a free and source-available fair-code licensed workflow automation tool. Alternative to Zapier or Make, allowing you to use a UI to create automated workflows.
|
||||
[n8n](https://github.com/n8n-io/n8n) is a free and source-available fair-code licensed workflow automation tool. It's an alternative to tools like Zapier or Make, allowing you to use a UI to create automated workflows.
|
||||
|
||||
This example workflow:
|
||||
|
||||
1. Backups Mealie every morning via an API call
|
||||
2. Deletes all but the last 7 backups
|
||||
1. Creates a Mealie backup every morning via an API call
|
||||
2. Keeps the last 7 backups, deleting older ones
|
||||
|
||||
> [!CAUTION]
|
||||
> This only automates the backup function, this does not backup your data to anywhere except your local instance. Please make sure you are backing up your data to an external source.
|
||||
!!! warning "Important"
|
||||
This only automates the backup function, this does not backup your data to anywhere except your local instance. Please make sure you are backing up your data to an external source.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
# Setup
|
||||
## Setup
|
||||
|
||||
## Deploying n8n
|
||||
### Deploying n8n
|
||||
|
||||
Follow the relevant guide in the [n8n Documentation](https://docs.n8n.io/)
|
||||
|
||||
## Importing n8n workflow
|
||||
### Importing n8n workflow
|
||||
|
||||
1. In n8n, add a new workflow
|
||||
2. In the top right hit the 3 dot menu and select 'Import from URL...'
|
||||
|
||||

|
||||

|
||||
|
||||
3. Paste `https://github.com/mealie-recipes/mealie/blob/mealie-next/docs/docs/assets/other/n8n/n8n-mealie-backup.json` and click Import
|
||||
3. Paste `https://github.com/mealie-recipes/mealie/blob/mealie-next/docs/docs/assets/other/n8n/n8n-mealie-backup.json` and click 'Import'
|
||||
4. Click through the nodes and update the URLs for your environment
|
||||
|
||||
## API Credentials
|
||||
### API Credentials
|
||||
|
||||
#### Generate Mealie API Token
|
||||
|
||||
1. Head to https://mealie.example.com/user/profile/api-tokens
|
||||
> If you dont see this screen make sure that "Show advanced features" is checked under https://mealie.example.com/user/profile/edit
|
||||
2. Under token name, enter the name of the token i.e. 'n8n' and hit Generate
|
||||
1. Head to `<YOUR MEALIE INSTANCE>/user/profile/api-tokens`
|
||||
|
||||
!!! tip
|
||||
If you dont see this screen make sure that "Show advanced features" is checked under `<YOUR MEALIE INSTANCE>/user/profile/edit`
|
||||
|
||||
2. Under token name, enter the name of the token (for example, 'n8n') and hit 'Generate'
|
||||
|
||||
3. Copy and keep this API Token somewhere safe, this is like your password!
|
||||
|
||||
> You can use your normal user for this, but assuming you're an admin you could also choose to create a user named n8n and generate the API key against that user.
|
||||
!!! tip
|
||||
You can use your normal user for this, but assuming you're an admin you could also choose to create a user named n8n and generate the API key against that user.
|
||||
|
||||
#### Setup Credentials in n8n
|
||||
|
||||
> [n8n Docs](https://docs.n8n.io/credentials/add-edit-credentials/)
|
||||
See also [n8n Docs](https://docs.n8n.io/credentials/add-edit-credentials/).
|
||||
|
||||
1. Create a new "Header Auth" Credential
|
||||
|
||||

|
||||

|
||||
|
||||
2. In the connection screen set - Name as `Authorization` - Value as `Bearer {INSERT MEALIE API KEY}`
|
||||
|
||||

|
||||

|
||||
|
||||
3. In the workflow you created, for the "Run Backup", "Get All backups", and "Delete Oldies" nodes, update:
|
||||
- Authentication to `Generic Credential Type`
|
||||
- Generic Auth Type to `Header Auth`
|
||||
- Header Auth to `Mealie API` or whatever you named your credentials
|
||||
|
||||

|
||||
- Authentication to `Generic Credential Type`
|
||||
- Generic Auth Type to `Header Auth`
|
||||
- Header Auth to `Mealie API` or whatever you named your credentials
|
||||
|
||||
## Notification Node
|
||||

|
||||
|
||||
> Please use error notifications of some kind. It's very easy to set and forget an automation, then have the worst happen and lose data.
|
||||
### Notification Node
|
||||
|
||||
!!! warning "Important"
|
||||
Please use error notifications of some kind. It's very easy to set and forget an automation, then have the worst happen and lose data.
|
||||
|
||||
[ntfy](https://github.com/binwiederhier/ntfy) is a great open source, self-hostable tool for sending notifications.
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
# Using SWAG as Reverse Proxy
|
||||
|
||||
!!! info
|
||||
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
||||
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
|
||||
|
||||
To make the setup of a Reverse Proxy much easier, Linuxserver.io developed [SWAG](https://github.com/linuxserver/docker-swag).
|
||||
|
||||
|
||||
To make the setup of a Reverse Proxy much easier, Linuxserver.io developed [SWAG](https://github.com/linuxserver/docker-swag)
|
||||
SWAG - Secure Web Application Gateway (formerly known as letsencrypt, no relation to Let's Encrypt™) sets up an Nginx web server and reverse proxy with PHP support and a built-in certbot client that automates free SSL server certificate generation and renewal processes (Let's Encrypt and ZeroSSL). It also contains fail2ban for intrusion prevention.
|
||||
|
||||
## Step 1: Get a domain
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
Mealie allows you to link ingredients to specific steps in a recipe, ensuring you know exactly when to add each ingredient during the cooking process.
|
||||
|
||||
**Link Ingredients to Steps in a Recipe**
|
||||
|
||||
|
||||
1. Go to a recipe
|
||||
2. Click the Edit button/icon
|
||||
3. Scroll down to the step you want to link ingredients to
|
||||
@@ -82,7 +82,7 @@
|
||||
7. Click 'Save' on the Recipe
|
||||
|
||||
You can optionally link the same ingredient to multiple steps, which is useful for prepping an ingredient in one step and using it in another.
|
||||
|
||||
|
||||
??? question "What is fuzzy search and how do I use it?"
|
||||
|
||||
### What is fuzzy search and how do I use it?
|
||||
@@ -111,7 +111,7 @@
|
||||
|
||||
You can change the theme by settings the environment variables.
|
||||
|
||||
- [Backend Config - Themeing](./installation/backend-config.md#themeing)
|
||||
- [Backend Config - Theming](./installation/backend-config.md#theming)
|
||||
|
||||
|
||||
??? question "How can I change the login session timeout?"
|
||||
@@ -233,7 +233,7 @@
|
||||
|
||||
### How can I use Mealie externally
|
||||
|
||||
Exposing Mealie or any service to the internet can pose significant security risks. Before proceeding, carefully evaluate the potential impacts on your system. Due to the unique nature of each network, we cannot provide specific steps for your setup.
|
||||
Exposing Mealie or any service to the internet can pose significant security risks. Before proceeding, carefully evaluate the potential impacts on your system. Due to the unique nature of each network, we cannot provide specific steps for your setup.
|
||||
|
||||
There is a community guide available for one way to potentially set this up, and you could reach out on Discord for further discussion on what may be best for your network.
|
||||
|
||||
@@ -267,7 +267,7 @@
|
||||
|
||||
### Why setup Email?
|
||||
|
||||
Mealie uses email to send account invites and password resets. If you don't use these features, you don't need to set up email. There are also other methods to perform these actions that do not require the setup of Email.
|
||||
Mealie uses email to send account invites and password resets. If you don't use these features, you don't need to set up email. There are also other methods to perform these actions that do not require the setup of Email.
|
||||
|
||||
Email settings can be adjusted via environment variables on the backend container:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| DEFAULT_GROUP | Home | The default group for users |
|
||||
| DEFAULT_HOUSEHOLD | Family | The default household for users in each group |
|
||||
| BASE_URL | http://localhost:8080 | Used for Notifications |
|
||||
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid |
|
||||
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid. Must be <= 87600 (10 years, in hours). |
|
||||
| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
|
||||
| API_DOCS | True | Turns on/off access to the API documentation locally |
|
||||
| TZ | UTC | Must be set to get correct date/time on the server |
|
||||
@@ -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,12 +132,19 @@ 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
|
||||
|
||||
Setting the following environmental variables will change the theme of the frontend. Note that the themes are the same for all users. This is a break-change when migration from v0.x.x -> 1.x.x.
|
||||
|
||||
!!! info
|
||||
If you're setting these variables but not seeing these changes persist, try removing the `#` character. Also, depending on which syntax you're using, double-check you're using quotes correctly.
|
||||
|
||||
If using YAML mapping syntax, be sure to include quotes around these values, otherwise they will be treated as comments in your YAML file:<br>`THEME_LIGHT_PRIMARY: '#E58325'` or `THEME_LIGHT_PRIMARY: 'E58325'`
|
||||
|
||||
If using YAML sequence syntax, don't include any quotes:<br>`THEME_LIGHT_PRIMARY=#E58325` or `THEME_LIGHT_PRIMARY=E58325`
|
||||
|
||||
| Variables | Default | Description |
|
||||
| --------------------- | :-----: | --------------------------- |
|
||||
| THEME_LIGHT_PRIMARY | #E58325 | Light Theme Config Variable |
|
||||
|
||||
@@ -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:v3.0.2`
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.3.1`
|
||||
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`
|
||||
|
||||
|
||||
@@ -10,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:v3.0.2 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.3.1 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -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:v3.0.2 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.3.1 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Backups and Restores
|
||||
|
||||
Mealie provides an integrated mechanic for doing full installation backups of the database.
|
||||
Mealie provides an integrated mechanic for doing full installation backups of the database.
|
||||
|
||||
Navigate to Settings > Backups or manually by adding `/admin/backups` to your instance URL.
|
||||
Navigate to Settings > Admin Settings > Backups or manually by adding `/admin/backups` to your instance URL.
|
||||
|
||||
From this page, you will be able to:
|
||||
From this page, you will be able to:
|
||||
|
||||
- See a list of available backups
|
||||
- Create a backup
|
||||
@@ -39,7 +39,7 @@ Restoring the Database when using Postgres requires Mealie to be configured with
|
||||
```sql
|
||||
ALTER USER mealie WITH SUPERUSER;
|
||||
|
||||
# Run restore from Mealie
|
||||
-- Run restore from Mealie
|
||||
|
||||
ALTER USER mealie WITH NOSUPERUSER;
|
||||
```
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Permissions and Public Access
|
||||
|
||||
Mealie provides various levels of user access and permissions. This includes:
|
||||
|
||||
- Authentication and registration ([LDAP](../authentication/ldap.md) and [OpenID Connect](../authentication/oidc.md) are both supported)
|
||||
- Customizable user permissions
|
||||
- Fine-tuned public access for non-users
|
||||
@@ -8,12 +9,12 @@ Mealie provides various levels of user access and permissions. This includes:
|
||||
## Customizable User Permissions
|
||||
|
||||
Each user can be configured to have varying levels of access. Some of these permissions include:
|
||||
|
||||
- Access to Administrator tools
|
||||
- Access to inviting other users
|
||||
- Access to manage their group and group data
|
||||
|
||||
Administrators can navigate to the Settings page and access the User Management page to configure these settings.
|
||||
|
||||
Administrators can configure these settings on the User Management page (navigate to Settings > Admin Settings > Users or append `/admin/manage/users` to your instance URL).
|
||||
|
||||
[User Management Demo](https://demo.mealie.io/admin/manage/users){ .md-button .md-button--primary }
|
||||
|
||||
@@ -22,8 +23,8 @@ Administrators can navigate to the Settings page and access the User Management
|
||||
By default, groups and households are set to private, meaning only logged-in users may access the group/household. In order for a recipe to be viewable by public (not logged-in) users, three criteria must be met:
|
||||
|
||||
1. The group must not be private
|
||||
2. The household must not be private, *and* the household setting for allowing users outside of your group to see your recipes must be enabled. These can be toggled on the Household Settings page
|
||||
2. The recipe must be set to public. This can be toggled for each recipe individually, or in bulk using the Recipe Data Management page
|
||||
2. The household must not be private, _and_ the household setting for allowing users outside of your group to see your recipes must be enabled. These can be toggled on the Household Management page (navigate to Settings > Admin Settings > Households or append `/admin/manage/households` to your instance URL)
|
||||
3. The recipe must be set to public. This can be toggled for each recipe individually, or in bulk using the Recipe Data Management page
|
||||
|
||||
Additionally, if the group is not private, public users can view all public group data (public recipes, public cookbooks, etc.) from the home page ([e.g. the demo home page](https://demo.mealie.io/g/home)).
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -86,7 +86,7 @@ nav:
|
||||
|
||||
- Community Guides:
|
||||
- Bring API without internet exposure: "documentation/community-guide/bring-api.md"
|
||||
- Automate Backups with n8n: "documentation/community-guide/n8n-backup-automation.md"
|
||||
- Automating Backups with n8n: "documentation/community-guide/n8n-backup-automation.md"
|
||||
- Bulk Url Import: "documentation/community-guide/bulk-url-import.md"
|
||||
- Home Assistant: "documentation/community-guide/home-assistant.md"
|
||||
- Import Bookmarklet: "documentation/community-guide/import-recipe-bookmarklet.md"
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
}
|
||||
|
||||
.handle {
|
||||
cursor: grab;
|
||||
cursor: grab !important;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
|
||||
@@ -32,9 +32,9 @@
|
||||
>
|
||||
<div class="d-flex align-center w-100 mb-2">
|
||||
<v-toolbar-title class="headline mb-0">
|
||||
<v-icon size="large" class="mr-3">
|
||||
{{ $globals.icons.pages }}
|
||||
</v-icon>
|
||||
<v-icon size="large" class="mr-3">
|
||||
{{ $globals.icons.pages }}
|
||||
</v-icon>
|
||||
{{ book.name }}
|
||||
</v-toolbar-title>
|
||||
<BaseButton
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
<template>
|
||||
<div v-if="preferences">
|
||||
<BaseCardSectionTitle :title="$t('household.household-preferences')" />
|
||||
<div class="mb-6">
|
||||
<v-checkbox v-model="preferences.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("household.private-household-description") }}
|
||||
</p>
|
||||
<DocLink class="mt-2" link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<v-checkbox v-model="preferences.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("household.lock-recipe-edits-from-other-households-description") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<v-select
|
||||
v-model="preferences.firstDayOfWeek"
|
||||
:prepend-icon="$globals.icons.calendarWeekBegin"
|
||||
:items="allDays"
|
||||
item-title="name"
|
||||
item-value="value"
|
||||
:label="$t('settings.first-day-of-week')"
|
||||
variant="underlined"
|
||||
flat
|
||||
/>
|
||||
<div v-if="preferences">
|
||||
<BaseCardSectionTitle :title="$t('household.household-preferences')" />
|
||||
<div class="mb-6">
|
||||
<v-checkbox v-model="preferences.privateHousehold" hide-details density="compact" :label="$t('household.private-household')" color="primary" />
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("household.private-household-description") }}
|
||||
</p>
|
||||
<DocLink class="mt-2" link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<v-checkbox v-model="preferences.lockRecipeEditsFromOtherHouseholds" hide-details density="compact" :label="$t('household.lock-recipe-edits-from-other-households')" color="primary" />
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("household.lock-recipe-edits-from-other-households-description") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<v-select
|
||||
v-model="preferences.firstDayOfWeek"
|
||||
:prepend-icon="$globals.icons.calendarWeekBegin"
|
||||
:items="allDays"
|
||||
item-title="name"
|
||||
item-value="value"
|
||||
:label="$t('settings.first-day-of-week')"
|
||||
variant="underlined"
|
||||
flat
|
||||
/>
|
||||
|
||||
<BaseCardSectionTitle class="mt-5" :title="$t('household.household-recipe-preferences')" />
|
||||
<div class="preference-container">
|
||||
<div v-for="p in recipePreferences" :key="p.key">
|
||||
<v-checkbox v-model="preferences[p.key]" hide-details density="compact" :label="p.label" color="primary" />
|
||||
<p class="ml-8 text-subtitle-2 my-0 py-0">
|
||||
{{ p.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BaseCardSectionTitle class="mt-5" :title="$t('household.household-recipe-preferences')" />
|
||||
<div class="preference-container">
|
||||
<div v-for="p in recipePreferences" :key="p.key">
|
||||
<v-checkbox v-model="preferences[p.key]" hide-details density="compact" :label="p.label" color="primary" />
|
||||
<p class="ml-8 text-subtitle-2 my-0 py-0">
|
||||
{{ p.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -432,9 +432,9 @@ function removeField(index: number) {
|
||||
|
||||
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
|
||||
/* newFields.forEach((field, index) => {
|
||||
const updatedField = getFieldFromFieldDef(field);
|
||||
fields.value[index] = updatedField; // recursive!!!
|
||||
}); */
|
||||
const updatedField = getFieldFromFieldDef(field);
|
||||
fields.value[index] = updatedField; // recursive!!!
|
||||
}); */
|
||||
|
||||
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
||||
if (qf) {
|
||||
|
||||
@@ -5,8 +5,14 @@
|
||||
density="compact"
|
||||
elevation="0"
|
||||
>
|
||||
<BaseDialog v-model="deleteDialog" :title="$t('recipe.delete-recipe')" color="error"
|
||||
:icon="$globals.icons.alertCircle" can-confirm @confirm="emitDelete()">
|
||||
<BaseDialog
|
||||
v-model="deleteDialog"
|
||||
:title="$t('recipe.delete-recipe')"
|
||||
color="error"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
can-confirm
|
||||
@confirm="emitDelete()"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("recipe.delete-confirmation") }}
|
||||
</v-card-text>
|
||||
@@ -15,7 +21,14 @@
|
||||
<v-spacer />
|
||||
<div v-if="!open" class="custom-btn-group ma-1">
|
||||
<RecipeFavoriteBadge v-if="loggedIn" color="info" button-style :recipe-id="recipe.id!" show-always />
|
||||
<RecipeTimelineBadge v-if="loggedIn" class="ml-1" color="info" button-style :slug="recipe.slug" :recipe-name="recipe.name!" />
|
||||
<RecipeTimelineBadge
|
||||
v-if="loggedIn"
|
||||
class="ml-1"
|
||||
color="info"
|
||||
button-style
|
||||
:slug="recipe.slug"
|
||||
:recipe-name="recipe.name!"
|
||||
/>
|
||||
<div v-if="loggedIn">
|
||||
<v-tooltip v-if="canEdit" location="bottom" color="info">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
@@ -87,7 +100,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
import RecipeContextMenu from "./RecipeContextMenu/RecipeContextMenu.vue";
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
@@ -1,111 +1,109 @@
|
||||
<template>
|
||||
<!-- Wrap v-hover with a div to provide a proper DOM element for the transition -->
|
||||
<div>
|
||||
<v-hover
|
||||
v-slot="{ isHovering, props: hoverProps }"
|
||||
:open-delay="50"
|
||||
<div>
|
||||
<v-hover
|
||||
v-slot="{ isHovering, props: hoverProps }"
|
||||
:open-delay="50"
|
||||
>
|
||||
<v-card
|
||||
v-bind="hoverProps"
|
||||
:class="{ 'on-hover': isHovering }"
|
||||
:style="{ cursor }"
|
||||
:elevation="isHovering ? 12 : 2"
|
||||
:to="recipeRoute"
|
||||
:min-height="imageHeight + 75"
|
||||
@click.self="$emit('click')"
|
||||
>
|
||||
<v-card
|
||||
v-bind="hoverProps"
|
||||
:class="{ 'on-hover': isHovering }"
|
||||
:style="{ cursor }"
|
||||
:elevation="isHovering ? 12 : 2"
|
||||
:to="recipeRoute"
|
||||
:min-height="imageHeight + 75"
|
||||
@click.self="$emit('click')"
|
||||
<RecipeCardImage
|
||||
:icon-size="imageHeight"
|
||||
:height="imageHeight"
|
||||
:slug="slug"
|
||||
:recipe-id="recipeId"
|
||||
size="small"
|
||||
:image-version="image"
|
||||
>
|
||||
<RecipeCardImage
|
||||
:icon-size="imageHeight"
|
||||
:height="imageHeight"
|
||||
:slug="slug"
|
||||
:recipe-id="recipeId"
|
||||
size="small"
|
||||
:image-version="image"
|
||||
>
|
||||
<v-expand-transition v-if="description">
|
||||
<div
|
||||
v-if="isHovering"
|
||||
class="d-flex transition-fast-in-fast-out bg-secondary v-card--reveal"
|
||||
style="height: 100%"
|
||||
>
|
||||
<v-card-text class="v-card--text-show white--text">
|
||||
<div class="descriptionWrapper">
|
||||
<SafeMarkdown :source="description" />
|
||||
</div>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</RecipeCardImage>
|
||||
<v-card-title class="mb-n3 px-4">
|
||||
<div class="headerClass">
|
||||
{{ name }}
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<slot name="actions">
|
||||
<v-card-actions
|
||||
v-if="showRecipeContent"
|
||||
class="px-1"
|
||||
<v-expand-transition v-if="description">
|
||||
<div
|
||||
v-if="isHovering"
|
||||
class="d-flex transition-fast-in-fast-out bg-secondary v-card--reveal"
|
||||
style="height: 100%"
|
||||
>
|
||||
<RecipeFavoriteBadge
|
||||
v-if="isOwnGroup"
|
||||
class="absolute"
|
||||
:recipe-id="recipeId"
|
||||
show-always
|
||||
/>
|
||||
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
|
||||
<v-card-text class="v-card--text-show white--text">
|
||||
<div class="descriptionWrapper">
|
||||
<SafeMarkdown :source="description" />
|
||||
</div>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</RecipeCardImage>
|
||||
<v-card-title class="mb-n3 px-4">
|
||||
<div class="headerClass">
|
||||
{{ name }}
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<RecipeRating
|
||||
class="ml-n2"
|
||||
:model-value="rating"
|
||||
:recipe-id="recipeId"
|
||||
:slug="slug"
|
||||
small
|
||||
/>
|
||||
<v-spacer />
|
||||
<RecipeChips
|
||||
:truncate="true"
|
||||
:items="tags"
|
||||
:title="false"
|
||||
:limit="2"
|
||||
small
|
||||
url-prefix="tags"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<slot name="actions">
|
||||
<v-card-actions
|
||||
v-if="showRecipeContent"
|
||||
class="px-1"
|
||||
>
|
||||
<RecipeFavoriteBadge
|
||||
v-if="isOwnGroup"
|
||||
class="absolute"
|
||||
:recipe-id="recipeId"
|
||||
show-always
|
||||
/>
|
||||
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
|
||||
|
||||
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
||||
<RecipeContextMenu
|
||||
v-if="isOwnGroup"
|
||||
color="grey-darken-2"
|
||||
:slug="slug"
|
||||
:name="name"
|
||||
:recipe-id="recipeId"
|
||||
:use-items="{
|
||||
delete: false,
|
||||
edit: false,
|
||||
download: true,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: false,
|
||||
printPreferences: false,
|
||||
share: true,
|
||||
}"
|
||||
@delete="$emit('delete', slug)"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</slot>
|
||||
<slot />
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</div>
|
||||
<RecipeCardRating
|
||||
:model-value="rating"
|
||||
:recipe-id="recipeId"
|
||||
/>
|
||||
<v-spacer />
|
||||
<RecipeChips
|
||||
:truncate="true"
|
||||
:items="tags"
|
||||
:title="false"
|
||||
:limit="2"
|
||||
small
|
||||
url-prefix="tags"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
|
||||
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
||||
<RecipeContextMenu
|
||||
v-if="isOwnGroup && showRecipeContent"
|
||||
color="grey-darken-2"
|
||||
:slug="slug"
|
||||
:menu-icon="$globals.icons.dotsVertical"
|
||||
:name="name"
|
||||
:recipe-id="recipeId"
|
||||
:use-items="{
|
||||
delete: false,
|
||||
edit: false,
|
||||
download: true,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: false,
|
||||
printPreferences: false,
|
||||
share: true,
|
||||
}"
|
||||
@deleted="$emit('delete', slug)"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</slot>
|
||||
<slot />
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -87,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 -->
|
||||
@@ -118,7 +116,7 @@
|
||||
@deleted="$emit('delete', slug)"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</slot>
|
||||
</slot>
|
||||
</v-list-item>
|
||||
<slot />
|
||||
</v-card>
|
||||
@@ -128,9 +126,9 @@
|
||||
|
||||
<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";
|
||||
|
||||
|
||||
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>
|
||||
@@ -199,7 +199,7 @@ const emit = defineEmits<{
|
||||
appendRecipes: [recipes: Recipe[]];
|
||||
}>();
|
||||
|
||||
const { $vuetify } = useNuxtApp();
|
||||
const display = useDisplay();
|
||||
const preferences = useUserSortPreferences();
|
||||
|
||||
const EVENTS = {
|
||||
@@ -215,7 +215,7 @@ const $auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const useMobileCards = computed(() => {
|
||||
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
|
||||
return display.smAndDown.value || preferences.value.useMobileCards;
|
||||
});
|
||||
|
||||
const displayTitleIcon = computed(() => {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<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"
|
||||
@print="$emit('print')"
|
||||
@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;
|
||||
print: [];
|
||||
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>
|
||||
@@ -1,159 +1,125 @@
|
||||
<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>
|
||||
<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-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: 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
|
||||
>
|
||||
<v-icon
|
||||
:size="!fab ? undefined : 'x-large'"
|
||||
:color="fab ? 'white' : 'secondary'"
|
||||
>
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
<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>
|
||||
<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>
|
||||
</v-menu>
|
||||
</div>
|
||||
<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 "./RecipeDialogAddToShoppingList.vue";
|
||||
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
|
||||
import RecipeDialogShare from "./RecipeDialogShare.vue";
|
||||
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";
|
||||
@@ -225,7 +191,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits<{
|
||||
[key: string]: any;
|
||||
delete: [slug: string];
|
||||
deleted: [slug: string];
|
||||
}>();
|
||||
|
||||
const api = useUserApi();
|
||||
@@ -336,8 +302,6 @@ const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||
// Add leading and Appending Items
|
||||
menuItems.value = [...menuItems.value, ...props.leadingItems, ...props.appendItems];
|
||||
|
||||
const icon = props.menuIcon || $globals.icons.dotsVertical;
|
||||
|
||||
// ===========================================================================
|
||||
// Context Menu Event Handler
|
||||
|
||||
@@ -407,7 +371,7 @@ async function deleteRecipe() {
|
||||
if (data?.slug) {
|
||||
router.push(`/g/${groupSlug.value}`);
|
||||
}
|
||||
emit("delete", props.slug);
|
||||
emit("deleted", props.slug);
|
||||
}
|
||||
|
||||
const download = useDownloader();
|
||||
@@ -1,8 +1,15 @@
|
||||
<template>
|
||||
<div>
|
||||
<BaseDialog v-model="dialog" :title="$t('data-pages.manage-aliases')" :icon="$globals.icons.edit"
|
||||
:submit-icon="$globals.icons.check" :submit-text="$t('general.confirm')" can-submit @submit="saveAliases"
|
||||
@cancel="$emit('cancel')">
|
||||
<BaseDialog
|
||||
v-model="dialog"
|
||||
:title="$t('data-pages.manage-aliases')"
|
||||
:icon="$globals.icons.edit"
|
||||
:submit-icon="$globals.icons.check"
|
||||
:submit-text="$t('general.confirm')"
|
||||
can-submit
|
||||
@submit="saveAliases"
|
||||
@cancel="$emit('cancel')"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-container>
|
||||
<v-row v-for="alias, i in aliases" :key="i">
|
||||
@@ -10,13 +17,16 @@
|
||||
<v-text-field v-model="alias.name" :label="$t('general.name')" :rules="[validators.required]" />
|
||||
</v-col>
|
||||
<v-col cols="2">
|
||||
<BaseButtonGroup :buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
]" @delete="deleteAlias(i)" />
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
]"
|
||||
@delete="deleteAlias(i)"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
@@ -1,715 +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="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>
|
||||
<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, useUserSortPreferences } 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 sortPreferences = useUserSortPreferences();
|
||||
|
||||
watch(() => state.value.orderBy, (newValue) => {
|
||||
sortPreferences.value.orderBy = newValue;
|
||||
});
|
||||
|
||||
watch(() => state.value.orderDirection, (newValue) => {
|
||||
sortPreferences.value.orderDirection = newValue;
|
||||
});
|
||||
|
||||
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;
|
||||
sortPreferences.value.orderBy = queryDefaults.orderBy;
|
||||
sortPreferences.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";
|
||||
sortPreferences.value.orderDirection = state.value.orderDirection;
|
||||
}
|
||||
|
||||
function setOrderBy(value: string) {
|
||||
state.value.orderBy = value;
|
||||
sortPreferences.value.orderBy = value;
|
||||
}
|
||||
|
||||
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,
|
||||
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;
|
||||
}
|
||||
|
||||
state.value.orderBy = sortPreferences.value.orderBy;
|
||||
state.value.orderDirection = sortPreferences.value.orderDirection as "asc" | "desc";
|
||||
|
||||
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,
|
||||
setOrderBy,
|
||||
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,231 @@
|
||||
<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, clearRecipeExplorerSearchState } 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");
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clear the cache when component unmounts to ensure fresh state on remount
|
||||
clearRecipeExplorerSearchState(groupSlug.value);
|
||||
});
|
||||
|
||||
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>
|
||||
@@ -43,8 +43,6 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
buttonStyle: false,
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
const $auth = useMealieAuth();
|
||||
const { userRatings, refreshUserRatings } = useUserSelfRatings();
|
||||
|
||||
const isFavorite = computed(() => {
|
||||
@@ -53,6 +51,9 @@ const isFavorite = computed(() => {
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
:placeholder="$t('recipe.quantity')"
|
||||
@keypress="quantityFilter"
|
||||
>
|
||||
<template #prepend>
|
||||
<template v-if="enableDragHandle" #prepend>
|
||||
<v-icon
|
||||
class="mr-n1 handle"
|
||||
>
|
||||
@@ -59,6 +59,7 @@
|
||||
class="mx-1"
|
||||
:placeholder="$t('recipe.choose-unit')"
|
||||
clearable
|
||||
:menu-props="{ attach: props.menuAttachTarget, maxHeight: '250px' }"
|
||||
@keyup.enter="handleUnitEnter"
|
||||
>
|
||||
<template #prepend>
|
||||
@@ -115,6 +116,7 @@
|
||||
class="mx-1 py-0"
|
||||
:placeholder="$t('recipe.choose-food')"
|
||||
clearable
|
||||
:menu-props="{ attach: props.menuAttachTarget, maxHeight: '250px' }"
|
||||
@keyup.enter="handleFoodEnter"
|
||||
>
|
||||
<template #prepend>
|
||||
@@ -165,12 +167,12 @@
|
||||
@click="$emit('clickIngredientField', 'note')"
|
||||
/>
|
||||
<BaseButtonGroup
|
||||
v-if="enableContextMenu"
|
||||
hover
|
||||
:large="false"
|
||||
class="my-auto d-flex"
|
||||
:buttons="btns"
|
||||
@toggle-section="toggleTitle"
|
||||
@toggle-original="toggleOriginalText"
|
||||
@insert-above="$emit('insert-above')"
|
||||
@insert-below="$emit('insert-below')"
|
||||
@delete="$emit('delete')"
|
||||
@@ -178,13 +180,7 @@
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<p
|
||||
v-if="showOriginalText"
|
||||
class="text-caption"
|
||||
>
|
||||
{{ $t("recipe.original-text-with-value", { originalText: model.originalText }) }}
|
||||
</p>
|
||||
|
||||
<slot name="before-divider" />
|
||||
<v-divider
|
||||
v-if="!mdAndUp"
|
||||
class="my-4"
|
||||
@@ -203,7 +199,11 @@ import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
// defineModel replaces modelValue prop
|
||||
const model = defineModel<RecipeIngredient>({ required: true });
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
menuAttachTarget: {
|
||||
type: String,
|
||||
default: "body",
|
||||
},
|
||||
unitError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -220,6 +220,18 @@ defineProps({
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
enableContextMenu: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
enableDragHandle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
deleteDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits([
|
||||
@@ -235,7 +247,6 @@ const { $globals } = useNuxtApp();
|
||||
|
||||
const state = reactive({
|
||||
showTitle: false,
|
||||
showOriginalText: false,
|
||||
});
|
||||
|
||||
const contextMenuOptions = computed(() => {
|
||||
@@ -254,13 +265,6 @@ const contextMenuOptions = computed(() => {
|
||||
},
|
||||
];
|
||||
|
||||
if (model.value.originalText) {
|
||||
options.push({
|
||||
text: i18n.t("recipe.see-original-text"),
|
||||
event: "toggle-original",
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
@@ -281,8 +285,8 @@ const btns = computed(() => {
|
||||
text: i18n.t("general.delete"),
|
||||
event: "delete",
|
||||
children: undefined,
|
||||
disabled: props.deleteDisabled,
|
||||
});
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
@@ -319,10 +323,6 @@ function toggleTitle() {
|
||||
state.showTitle = !state.showTitle;
|
||||
}
|
||||
|
||||
function toggleOriginalText() {
|
||||
state.showOriginalText = !state.showOriginalText;
|
||||
}
|
||||
|
||||
function handleUnitEnter() {
|
||||
if (
|
||||
model.value.unit === undefined
|
||||
@@ -349,7 +349,7 @@ function quantityFilter(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
const { showTitle, showOriginalText } = toRefs(state);
|
||||
const { showTitle } = toRefs(state);
|
||||
|
||||
const foods = foodStore.store;
|
||||
const units = unitStore.store;
|
||||
|
||||
@@ -1,15 +1,44 @@
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-html="safeMarkup" />
|
||||
<div class="ingredient-link-label links-disabled">
|
||||
<SafeMarkdown v-if="baseText" :source="baseText" />
|
||||
<SafeMarkdown
|
||||
v-if="ingredient?.note"
|
||||
class="d-inline"
|
||||
:source="` ${ingredient.note}`"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients";
|
||||
import { computed } from "vue";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { useParsedIngredientText } from "~/composables/recipes";
|
||||
|
||||
interface Props {
|
||||
markup: string;
|
||||
ingredient?: RecipeIngredient;
|
||||
scale?: number;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
|
||||
const { ingredient, scale = 1 } = defineProps<Props>();
|
||||
|
||||
const baseText = computed(() => {
|
||||
if (!ingredient) return "";
|
||||
const parsed = useParsedIngredientText(ingredient, scale);
|
||||
return [parsed.quantity, parsed.unit, parsed.name].filter(Boolean).join(" ").trim();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ingredient-link-label {
|
||||
display: block;
|
||||
line-height: 1.25;
|
||||
word-break: break-word;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.links-disabled :deep(a) {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
color: var(--v-theme-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { whenever } from "@vueuse/core";
|
||||
import { formatISO } from "date-fns";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { useHouseholdSelf } from "~/composables/use-households";
|
||||
@@ -148,7 +149,7 @@ const newTimelineEventImageName = ref<string>("");
|
||||
const newTimelineEventImagePreviewUrl = ref<string>();
|
||||
const newTimelineEventTimestamp = ref<Date>(new Date());
|
||||
const newTimelineEventTimestampString = computed(() => {
|
||||
return newTimelineEventTimestamp.value.toISOString().substring(0, 10);
|
||||
return formatISO(newTimelineEventTimestamp.value, { representation: "date" });
|
||||
});
|
||||
|
||||
const lastMade = ref(props.recipe.lastMade);
|
||||
@@ -169,7 +170,7 @@ whenever(
|
||||
() => madeThisDialog.value,
|
||||
() => {
|
||||
// Set timestamp to now
|
||||
newTimelineEventTimestamp.value = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
|
||||
newTimelineEventTimestamp.value = new Date();
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-container v-show="!isCookMode" key="recipe-page" class="px-0" :class="{ 'pa-0': $vuetify.display.smAndDown.value }">
|
||||
<v-card :flat="$vuetify.display.smAndDown.value" class="d-print-none">
|
||||
<RecipePageParseDialog
|
||||
:model-value="isParsing"
|
||||
:ingredients="recipe.recipeIngredient"
|
||||
:width="$vuetify.display.smAndDown ? '100%' : '80%'"
|
||||
@update:model-value="toggleIsParsing"
|
||||
@save="saveParsedIngredients"
|
||||
/>
|
||||
<v-container v-show="!isCookMode" key="recipe-page" class="px-0" :class="{ 'pa-0': $vuetify.display.smAndDown }">
|
||||
<v-card :flat="$vuetify.display.smAndDown" class="d-print-none">
|
||||
<RecipePageHeader
|
||||
:recipe="recipe"
|
||||
:recipe-scale="scale"
|
||||
@@ -106,9 +113,13 @@
|
||||
/>
|
||||
<v-divider />
|
||||
</v-col>
|
||||
<v-col class="overflow-y-auto"
|
||||
:class="$vuetify.display.smAndDown.value ? 'py-2': 'py-6'"
|
||||
style="height: 100%" cols="12" sm="7">
|
||||
<v-col
|
||||
class="overflow-y-auto"
|
||||
:class="$vuetify.display.smAndDown ? 'py-2': 'py-6'"
|
||||
style="height: 100%"
|
||||
cols="12"
|
||||
sm="7"
|
||||
>
|
||||
<h2 class="text-h5 px-4 font-weight-medium opacity-80">
|
||||
{{ $t('recipe.instructions') }}
|
||||
</h2>
|
||||
@@ -168,6 +179,7 @@ import RecipePageIngredientEditor from "./RecipePageParts/RecipePageIngredientEd
|
||||
import RecipePageIngredientToolsView from "./RecipePageParts/RecipePageIngredientToolsView.vue";
|
||||
import RecipePageInstructions from "./RecipePageParts/RecipePageInstructions.vue";
|
||||
import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue";
|
||||
import RecipePageParseDialog from "./RecipePageParts/RecipePageParseDialog.vue";
|
||||
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
|
||||
import RecipePageInfoEditor from "./RecipePageParts/RecipePageInfoEditor.vue";
|
||||
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
|
||||
@@ -178,26 +190,28 @@ import {
|
||||
usePageState,
|
||||
} from "~/composables/recipe-page/shared-state";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import type { Recipe, RecipeCategory, RecipeIngredient, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import { useRouteQuery } from "~/composables/use-router";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { uuid4, deepCopy } from "~/composables/use-utils";
|
||||
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
||||
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useNavigationWarning } from "~/composables/use-navigation-warning";
|
||||
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
|
||||
const { $vuetify } = useNuxtApp();
|
||||
const display = useDisplay();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const groupSlug = computed(() => (route.params.groupSlug as string) || $auth.user?.value?.groupSlug || "");
|
||||
|
||||
const router = useRouter();
|
||||
const api = useUserApi();
|
||||
const { setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode }
|
||||
const { setMode, isEditForm, isEditJSON, isCookMode, isEditMode, isParsing, toggleCookMode, toggleIsParsing }
|
||||
= usePageState(recipe.value.slug);
|
||||
const { deactivateNavigationWarning } = useNavigationWarning();
|
||||
const notLinkedIngredients = computed(() => {
|
||||
@@ -246,12 +260,29 @@ const hasLinkedIngredients = computed(() => {
|
||||
|
||||
type BooleanString = "true" | "false" | "";
|
||||
|
||||
const edit = useRouteQuery<BooleanString>("edit", "");
|
||||
const paramsEdit = useRouteQuery<BooleanString>("edit", "");
|
||||
const paramsParse = useRouteQuery<BooleanString>("parse", "");
|
||||
|
||||
onMounted(() => {
|
||||
if (edit.value === "true") {
|
||||
if (paramsEdit.value === "true" && isOwnGroup.value) {
|
||||
setMode(PageMode.EDIT);
|
||||
}
|
||||
|
||||
if (paramsParse.value === "true" && isOwnGroup.value) {
|
||||
toggleIsParsing(true);
|
||||
}
|
||||
});
|
||||
|
||||
watch(isEditMode, (newVal) => {
|
||||
if (!newVal) {
|
||||
paramsEdit.value = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
watch(isParsing, () => {
|
||||
if (!isParsing.value) {
|
||||
paramsParse.value = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
/** =============================================================
|
||||
@@ -266,6 +297,12 @@ async function saveRecipe() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveParsedIngredients(ingredients: NoUndefinedField<RecipeIngredient[]>) {
|
||||
recipe.value.recipeIngredient = ingredients;
|
||||
await saveRecipe();
|
||||
toggleIsParsing(false);
|
||||
}
|
||||
|
||||
async function deleteRecipe() {
|
||||
const { data } = await api.recipes.deleteOne(recipe.value.slug);
|
||||
if (data?.slug) {
|
||||
@@ -278,7 +315,7 @@ async function deleteRecipe() {
|
||||
*/
|
||||
const landscape = computed(() => {
|
||||
const preferLandscape = recipe.value.settings?.landscapeView;
|
||||
const smallScreen = !$vuetify.display.smAndUp.value;
|
||||
const smallScreen = !display.smAndUp.value;
|
||||
|
||||
if (preferLandscape) {
|
||||
return true;
|
||||
@@ -302,7 +339,7 @@ function addStep(steps: Array<string> | null = null) {
|
||||
|
||||
if (steps) {
|
||||
const cleanedSteps = steps.map((step) => {
|
||||
return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
|
||||
return { id: uuid4(), text: step, title: "", summary: "", ingredientReferences: [] };
|
||||
});
|
||||
|
||||
recipe.value.recipeInstructions.push(...cleanedSteps);
|
||||
|
||||
@@ -13,25 +13,25 @@
|
||||
@upload="uploadImage"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-select
|
||||
v-model="recipe.userId"
|
||||
class="my-2"
|
||||
max-width="300"
|
||||
:items="allUsers"
|
||||
:item-props="itemsProps"
|
||||
:label="$t('general.owner')"
|
||||
:disabled="!canEditOwner"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
>
|
||||
<template #prepend>
|
||||
<UserAvatar
|
||||
:user-id="recipe.userId"
|
||||
:tooltip="false"
|
||||
/>
|
||||
</template>
|
||||
</v-select>
|
||||
</div>
|
||||
<v-select
|
||||
v-model="recipe.userId"
|
||||
class="my-2"
|
||||
max-width="300"
|
||||
:items="allUsers"
|
||||
:item-props="itemsProps"
|
||||
:label="$t('general.owner')"
|
||||
:disabled="!canEditOwner"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
>
|
||||
<template #prepend>
|
||||
<UserAvatar
|
||||
:user-id="recipe.userId"
|
||||
:tooltip="false"
|
||||
/>
|
||||
</template>
|
||||
</v-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
class="mb-2 mx-n2"
|
||||
>
|
||||
<v-card-title class="text-h5 font-weight-medium opacity-80">
|
||||
{{ $t('recipe.api-extras') }}
|
||||
</v-card-title>
|
||||
{{ $t('recipe.api-extras') }}
|
||||
</v-card-title>
|
||||
<v-divider class="ml-4" />
|
||||
<v-card-text>
|
||||
{{ $t('recipe.api-extras-description') }}
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
>
|
||||
<v-card-text class="w-100">
|
||||
<div class="d-flex flex-column align-center">
|
||||
<v-card-title class="text-h5 font-weight-regular pa-0 text-wrap text-center opacity-80">
|
||||
{{ recipe.name }}
|
||||
</v-card-title>
|
||||
<RecipeRating
|
||||
<v-card-title class="text-h5 font-weight-regular pa-0 text-wrap text-center opacity-80">
|
||||
{{ recipe.name }}
|
||||
</v-card-title>
|
||||
<RecipeRating
|
||||
:key="recipe.slug"
|
||||
:value="recipe.rating"
|
||||
:recipe-id="recipe.id"
|
||||
|
||||
@@ -27,7 +27,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
maxWidth: undefined,
|
||||
});
|
||||
|
||||
const { $vuetify } = useNuxtApp();
|
||||
const display = useDisplay();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const { user } = usePageUser();
|
||||
@@ -42,7 +42,7 @@ if (user) {
|
||||
|
||||
const hideImage = ref(false);
|
||||
const imageHeight = computed(() => {
|
||||
return $vuetify.display.xs.value ? "200" : "400";
|
||||
return display.xs.value ? "200" : "400";
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- eslint-disable vue/no-mutating-props -->
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
@@ -31,6 +30,8 @@
|
||||
v-for="(ingredient, index) in recipe.recipeIngredient"
|
||||
:key="ingredient.referenceId"
|
||||
v-model="recipe.recipeIngredient[index]"
|
||||
enable-drag-handle
|
||||
enable-context-menu
|
||||
class="list-group-item"
|
||||
@delete="recipe.recipeIngredient.splice(index, 1)"
|
||||
@insert-above="insertNewIngredient(index)"
|
||||
@@ -55,8 +56,8 @@
|
||||
class="mb-1"
|
||||
:disabled="hasFoodOrUnit"
|
||||
color="accent"
|
||||
:to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`"
|
||||
v-bind="props"
|
||||
@click="toggleIsParsing(true)"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.foods }}
|
||||
@@ -87,16 +88,14 @@ import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
|
||||
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { uuid4 } from "~/composables/use-utils";
|
||||
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
|
||||
const drag = ref(false);
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const { toggleIsParsing } = usePageState(recipe.value.slug);
|
||||
|
||||
const hasFoodOrUnit = computed(() => {
|
||||
if (!recipe.value) {
|
||||
@@ -128,7 +127,7 @@ function addIngredient(ingredients: Array<string> | null = null) {
|
||||
note: x,
|
||||
unit: undefined,
|
||||
food: undefined,
|
||||
quantity: 1,
|
||||
quantity: 0,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -146,7 +145,7 @@ function addIngredient(ingredients: Array<string> | null = null) {
|
||||
unit: undefined,
|
||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||
food: undefined,
|
||||
quantity: 1,
|
||||
quantity: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -160,7 +159,7 @@ function insertNewIngredient(dest: number) {
|
||||
unit: undefined,
|
||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||
food: undefined,
|
||||
quantity: 1,
|
||||
quantity: 0,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -29,31 +29,48 @@
|
||||
{{ activeText }}
|
||||
</p>
|
||||
<v-divider class="mb-4" />
|
||||
<v-checkbox-btn
|
||||
v-for="ing in unusedIngredients"
|
||||
:key="ing.referenceId"
|
||||
v-model="activeRefs"
|
||||
:value="ing.referenceId"
|
||||
>
|
||||
<template #label>
|
||||
<RecipeIngredientHtml :markup="parseIngredientText(ing)" />
|
||||
<template v-if="Object.keys(groupedUnusedIngredients).length > 0">
|
||||
<h4 class="py-3 ml-1">
|
||||
{{ $t("recipe.unlinked") }}
|
||||
</h4>
|
||||
<template v-for="(ingredients, title) in groupedUnusedIngredients" :key="title">
|
||||
<h4 v-if="title" class="py-3 ml-1 pl-4">
|
||||
{{ title }}
|
||||
</h4>
|
||||
<v-checkbox-btn
|
||||
v-for="ing in ingredients"
|
||||
:key="ing.referenceId"
|
||||
v-model="activeRefs"
|
||||
:value="ing.referenceId"
|
||||
class="ml-4"
|
||||
>
|
||||
<template #label>
|
||||
<RecipeIngredientHtml :ingredient="ing" :scale="scale" />
|
||||
</template>
|
||||
</v-checkbox-btn>
|
||||
</template>
|
||||
</v-checkbox-btn>
|
||||
</template>
|
||||
|
||||
<template v-if="usedIngredients.length > 0">
|
||||
<template v-if="Object.keys(groupedUsedIngredients).length > 0">
|
||||
<h4 class="py-3 ml-1">
|
||||
{{ $t("recipe.linked-to-other-step") }}
|
||||
</h4>
|
||||
<v-checkbox-btn
|
||||
v-for="ing in usedIngredients"
|
||||
:key="ing.referenceId"
|
||||
v-model="activeRefs"
|
||||
:value="ing.referenceId"
|
||||
>
|
||||
<template #label>
|
||||
<RecipeIngredientHtml :markup="parseIngredientText(ing)" />
|
||||
</template>
|
||||
</v-checkbox-btn>
|
||||
<template v-for="(ingredients, title) in groupedUsedIngredients" :key="title">
|
||||
<h4 v-if="title" class="py-3 ml-1 pl-4">
|
||||
{{ title }}
|
||||
</h4>
|
||||
<v-checkbox-btn
|
||||
v-for="ing in ingredients"
|
||||
:key="ing.referenceId"
|
||||
v-model="activeRefs"
|
||||
:value="ing.referenceId"
|
||||
class="ml-4"
|
||||
>
|
||||
<template #label>
|
||||
<RecipeIngredientHtml :ingredient="ing" :scale="scale" />
|
||||
</template>
|
||||
</v-checkbox-btn>
|
||||
</template>
|
||||
</template>
|
||||
</v-card-text>
|
||||
|
||||
@@ -167,17 +184,17 @@
|
||||
<v-hover v-slot="{ isHovering }">
|
||||
<v-card
|
||||
class="my-3"
|
||||
:class="[{ 'on-hover': isHovering }, isChecked(index)]"
|
||||
:class="[{ 'on-hover': isHovering }, { 'cursor-default': isEditForm }, isChecked(index)]"
|
||||
:elevation="isHovering ? 12 : 2"
|
||||
:ripple="false"
|
||||
@click="toggleDisabled(index)"
|
||||
>
|
||||
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
|
||||
<div class="d-flex align-center">
|
||||
<v-card-title class="recipe-step-title pt-3" :class="!isChecked(index) ? 'pb-0' : 'pb-3'">
|
||||
<div class="d-flex align-center w-100">
|
||||
<v-text-field
|
||||
v-if="isEditForm"
|
||||
v-model="step.summary"
|
||||
class="headline handle"
|
||||
class="headline"
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="solo"
|
||||
@@ -185,14 +202,27 @@
|
||||
:placeholder="$t('recipe.step-index', { step: index + 1 })"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="26">
|
||||
<v-icon size="26" class="handle">
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
<span v-else>
|
||||
{{ step.summary ? step.summary : $t("recipe.step-index", { step: index + 1 }) }}
|
||||
</span>
|
||||
<div
|
||||
v-else
|
||||
class="summary-wrapper"
|
||||
>
|
||||
<template v-if="step.summary">
|
||||
<SafeMarkdown
|
||||
class="pr-2"
|
||||
:source="step.summary"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>
|
||||
{{ $t('recipe.step-index', { step: index + 1 }) }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="isEditForm">
|
||||
<div class="ml-auto">
|
||||
<BaseButtonGroup
|
||||
@@ -297,11 +327,22 @@
|
||||
persistentHint: true,
|
||||
}"
|
||||
/>
|
||||
<RecipeIngredientHtml
|
||||
v-for="ing in step.ingredientReferences"
|
||||
:key="ing.referenceId!"
|
||||
:markup="getIngredientByRefId(ing.referenceId!)"
|
||||
/>
|
||||
<div
|
||||
v-if="step.ingredientReferences && step.ingredientReferences.length"
|
||||
class="linked-ingredients-editor"
|
||||
>
|
||||
<div
|
||||
v-for="(linkRef, i) in step.ingredientReferences"
|
||||
:key="linkRef.referenceId ?? i"
|
||||
class="mb-1"
|
||||
>
|
||||
<RecipeIngredientHtml
|
||||
v-if="linkRef.referenceId && ingredientLookup[linkRef.referenceId]"
|
||||
:ingredient="ingredientLookup[linkRef.referenceId]"
|
||||
:scale="scale"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</DropZone>
|
||||
<v-expand-transition>
|
||||
@@ -356,9 +397,7 @@
|
||||
<script setup lang="ts">
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import RecipeIngredientHtml from "../../RecipeIngredientHtml.vue";
|
||||
import type { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import { uuid4 } from "~/composables/use-utils";
|
||||
import { useUserApi, useStaticRoutes } from "~/composables/api";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
@@ -366,6 +405,7 @@ import { useExtractIngredientReferences } from "~/composables/recipe-page/use-ex
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import DropZone from "~/components/global/DropZone.vue";
|
||||
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
|
||||
import RecipeIngredientHtml from "~/components/Domain/Recipe/RecipeIngredientHtml.vue";
|
||||
|
||||
interface MergerHistory {
|
||||
target: number;
|
||||
@@ -483,10 +523,9 @@ function openDialog(idx: number, text: string, refs?: IngredientReferences[]) {
|
||||
instructionList.value[idx].ingredientReferences = [];
|
||||
refs = instructionList.value[idx].ingredientReferences as IngredientReferences[];
|
||||
}
|
||||
|
||||
setUsedIngredients();
|
||||
activeText.value = text;
|
||||
activeIndex.value = idx;
|
||||
activeText.value = text;
|
||||
setUsedIngredients();
|
||||
dialog.value = true;
|
||||
activeRefs.value = refs.map(ref => ref.referenceId ?? "");
|
||||
}
|
||||
@@ -527,29 +566,26 @@ function saveAndOpenNextLinkIngredients() {
|
||||
function setUsedIngredients() {
|
||||
const usedRefs: { [key: string]: boolean } = {};
|
||||
|
||||
instructionList.value.forEach((element) => {
|
||||
instructionList.value.forEach((element, idx) => {
|
||||
if (idx === activeIndex.value) return;
|
||||
element.ingredientReferences?.forEach((ref) => {
|
||||
if (ref.referenceId !== undefined) {
|
||||
usedRefs[ref.referenceId!] = true;
|
||||
}
|
||||
if (ref.referenceId) usedRefs[ref.referenceId] = true;
|
||||
});
|
||||
});
|
||||
|
||||
usedIngredients.value = props.recipe.recipeIngredient.filter((ing) => {
|
||||
return ing.referenceId !== undefined && ing.referenceId in usedRefs;
|
||||
});
|
||||
usedIngredients.value = props.recipe.recipeIngredient.filter(ing => !!ing.referenceId && ing.referenceId in usedRefs);
|
||||
|
||||
unusedIngredients.value = props.recipe.recipeIngredient.filter((ing) => {
|
||||
return !(ing.referenceId !== undefined && ing.referenceId in usedRefs);
|
||||
});
|
||||
unusedIngredients.value = props.recipe.recipeIngredient.filter(ing => !!ing.referenceId && !(ing.referenceId in usedRefs));
|
||||
}
|
||||
|
||||
watch(activeRefs, () => setUsedIngredients());
|
||||
|
||||
function autoSetReferences() {
|
||||
useExtractIngredientReferences(
|
||||
props.recipe.recipeIngredient,
|
||||
activeRefs.value,
|
||||
activeText.value,
|
||||
).forEach((ingredient: string) => activeRefs.value.push(ingredient));
|
||||
).forEach(ingredient => activeRefs.value.push(ingredient));
|
||||
}
|
||||
|
||||
const ingredientLookup = computed(() => {
|
||||
@@ -563,15 +599,60 @@ const ingredientLookup = computed(() => {
|
||||
}, results);
|
||||
});
|
||||
|
||||
function getIngredientByRefId(refId: string | undefined) {
|
||||
if (refId === undefined) {
|
||||
return "";
|
||||
}
|
||||
// Map each ingredient's referenceId to its section title
|
||||
const ingredientSectionTitles = computed(() => {
|
||||
const titleMap: { [key: string]: string } = {};
|
||||
let currentTitle = "";
|
||||
|
||||
const ing = ingredientLookup.value[refId];
|
||||
if (!ing) return "";
|
||||
return parseIngredientText(ing, props.scale);
|
||||
}
|
||||
// Go through all ingredients in order
|
||||
props.recipe.recipeIngredient.forEach((ingredient) => {
|
||||
if (ingredient.referenceId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If this ingredient has a title, update the current title
|
||||
if (ingredient.title) {
|
||||
currentTitle = ingredient.title;
|
||||
}
|
||||
|
||||
// Assign the current title to this ingredient
|
||||
titleMap[ingredient.referenceId] = currentTitle;
|
||||
});
|
||||
|
||||
return titleMap;
|
||||
});
|
||||
|
||||
const groupedUnusedIngredients = computed((): Record<string, RecipeIngredient[]> => {
|
||||
const groups: Record<string, RecipeIngredient[]> = {};
|
||||
|
||||
// Group ingredients by section title
|
||||
unusedIngredients.value.forEach((ingredient) => {
|
||||
if (ingredient.referenceId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the section title from the mapping, or fallback to the ingredient's own title
|
||||
const title = ingredientSectionTitles.value[ingredient.referenceId] || ingredient.title || "";
|
||||
(groups[title] ||= []).push(ingredient);
|
||||
});
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
const groupedUsedIngredients = computed((): Record<string, RecipeIngredient[]> => {
|
||||
const groups: Record<string, RecipeIngredient[]> = {};
|
||||
usedIngredients.value.forEach((ingredient) => {
|
||||
if (ingredient.referenceId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the section title from the mapping, or fallback to the ingredient's own title
|
||||
const title = ingredientSectionTitles.value[ingredient.referenceId] || ingredient.title || "";
|
||||
(groups[title] ||= []).push(ingredient);
|
||||
});
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
// ===============================================================
|
||||
// Instruction Merger
|
||||
@@ -765,7 +846,21 @@ function openImageUpload(index: number) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.v-text-field >>> input {
|
||||
.v-text-field :deep(input) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.recipe-step-title {
|
||||
/* Multiline display */
|
||||
white-space: normal;
|
||||
line-height: 1.25;
|
||||
word-break: break-word;
|
||||
}
|
||||
.summary-wrapper {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; /* wrapping in flex container */
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
:title="$t('recipe.parse-ingredients')"
|
||||
:icon="$globals.icons.fileSign"
|
||||
disable-submit-on-enter
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-container fluid class="pa-2 ma-0" style="background-color: rgb(var(--v-theme-background));">
|
||||
<div v-if="state.loading.parser" class="my-6">
|
||||
<AppLoader waiting-text="" class="my-6" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<BaseCardSectionTitle :title="$t('recipe.parser.ingredient-parser')">
|
||||
<div v-if="!state.allReviewed" class="mb-4">
|
||||
<p>{{ $t("recipe.parser.ingredient-parser-description") }}</p>
|
||||
<p>{{ $t("recipe.parser.ingredient-parser-final-review-description") }}</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-center">
|
||||
<div class="text-body-2 mr-2">
|
||||
{{ $t("recipe.parser.select-parser") }}
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<BaseOverflowButton
|
||||
v-model="parser"
|
||||
:disabled="state.loading.parser"
|
||||
btn-class="mx-2"
|
||||
:items="availableParsers"
|
||||
/>
|
||||
<v-btn
|
||||
icon
|
||||
size="40"
|
||||
color="info"
|
||||
:disabled="state.loading.parser"
|
||||
@click="parseIngredients"
|
||||
>
|
||||
<v-icon>{{ $globals.icons.refresh }}</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCardSectionTitle>
|
||||
<v-card v-if="!state.allReviewed && currentIng">
|
||||
<v-card-text class="pb-0 mb-0">
|
||||
<div class="text-center px-8 py-4 mb-6">
|
||||
<p class="text-h5 font-italic">
|
||||
{{ currentIng.input }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex align-center pa-0 ma-0">
|
||||
<v-icon
|
||||
:color="(currentIng.confidence?.average || 0) < confidenceThreshold ? 'error' : 'success'"
|
||||
>
|
||||
{{ (currentIng.confidence?.average || 0) < confidenceThreshold ? $globals.icons.alert : $globals.icons.check }}
|
||||
</v-icon>
|
||||
<span
|
||||
class="ml-2"
|
||||
:color="currentIngHasError ? 'error-text' : 'success-text'"
|
||||
>
|
||||
{{ $t("recipe.parser.confidence-score") }}: {{ currentIng.confidence ? asPercentage(currentIng.confidence?.average!) : "" }}
|
||||
</span>
|
||||
</div>
|
||||
<RecipeIngredientEditor
|
||||
v-model="currentIng.ingredient"
|
||||
:unit-error="!!currentMissingUnit"
|
||||
:unit-error-tooltip="$t('recipe.parser.this-unit-could-not-be-parsed-automatically')"
|
||||
:food-error="!!currentMissingFood"
|
||||
:food-error-tooltip="$t('recipe.parser.this-food-could-not-be-parsed-automatically')"
|
||||
/>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<BaseButton
|
||||
v-if="currentMissingUnit && !currentIng.ingredient.unit?.id"
|
||||
color="warning"
|
||||
size="small"
|
||||
@click="createMissingUnit"
|
||||
>
|
||||
{{ i18n.t("recipe.parser.missing-unit", { unit: currentMissingUnit }) }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="
|
||||
currentMissingUnit
|
||||
&& currentIng.ingredient.unit?.id
|
||||
&& currentMissingUnit.toLowerCase() != currentIng.ingredient.unit?.name.toLowerCase()
|
||||
"
|
||||
color="warning"
|
||||
size="small"
|
||||
@click="addMissingUnitAsAlias"
|
||||
>
|
||||
{{ i18n.t("recipe.parser.add-text-as-alias-for-item", { text: currentMissingUnit, item: currentIng.ingredient.unit.name }) }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="currentMissingFood && !currentIng.ingredient.food?.id"
|
||||
color="warning"
|
||||
size="small"
|
||||
@click="createMissingFood"
|
||||
>
|
||||
{{ i18n.t("recipe.parser.missing-food", { food: currentMissingFood }) }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="
|
||||
currentMissingFood
|
||||
&& currentIng.ingredient.food?.id
|
||||
&& currentMissingFood.toLowerCase() != currentIng.ingredient.food?.name.toLowerCase()
|
||||
"
|
||||
color="warning"
|
||||
size="small"
|
||||
@click="addMissingFoodAsAlias"
|
||||
>
|
||||
{{ i18n.t("recipe.parser.add-text-as-alias-for-item", { text: currentMissingFood, item: currentIng.ingredient.food.name }) }}
|
||||
</BaseButton>
|
||||
</v-card-actions>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<div v-else>
|
||||
<v-card-title class="text-center pt-0 pb-8">
|
||||
{{ $t("recipe.parser.review-parsed-ingredients") }}
|
||||
</v-card-title>
|
||||
<v-card-text style="max-height: 60vh; overflow-y: auto;">
|
||||
<VueDraggable
|
||||
v-model="parsedIngs"
|
||||
handle=".handle"
|
||||
:delay="250"
|
||||
:delay-on-touch-only="true"
|
||||
v-bind="{
|
||||
animation: 200,
|
||||
group: 'recipe-ingredients',
|
||||
disabled: false,
|
||||
ghostClass: 'ghost',
|
||||
}"
|
||||
class="px-6"
|
||||
@start="drag = true"
|
||||
@end="drag = false"
|
||||
>
|
||||
<TransitionGroup
|
||||
type="transition"
|
||||
>
|
||||
<v-lazy v-for="(ingredient, index) in parsedIngs" :key="index">
|
||||
<RecipeIngredientEditor
|
||||
v-model="ingredient.ingredient"
|
||||
enable-drag-handle
|
||||
enable-context-menu
|
||||
class="list-group-item pb-8"
|
||||
:delete-disabled="parsedIngs.length <= 1"
|
||||
@delete="parsedIngs.splice(index, 1)"
|
||||
@insert-above="insertNewIngredient(index)"
|
||||
@insert-below="insertNewIngredient(index + 1)"
|
||||
>
|
||||
<template #before-divider>
|
||||
<p v-if="ingredient.input" class="py-0 my-0 text-caption">
|
||||
{{ $t("recipe.original-text-with-value", { originalText: ingredient.input }) }}
|
||||
</p>
|
||||
</template>
|
||||
</RecipeIngredientEditor>
|
||||
</v-lazy>
|
||||
</TransitionGroup>
|
||||
</VueDraggable>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</div>
|
||||
</v-container>
|
||||
<template v-if="!state.loading.parser" #custom-card-action>
|
||||
<!-- Parse -->
|
||||
<div v-if="!state.allReviewed" class="d-flex justify-space-between align-center">
|
||||
<v-checkbox
|
||||
v-model="currentIngShouldDelete"
|
||||
color="error"
|
||||
hide-details
|
||||
density="compact"
|
||||
:label="i18n.t('recipe.parser.delete-item')"
|
||||
class="mr-4"
|
||||
/>
|
||||
<BaseButton
|
||||
:color="currentIngShouldDelete ? 'error' : 'info'"
|
||||
:icon="currentIngShouldDelete ? $globals.icons.delete : $globals.icons.arrowRightBold"
|
||||
:icon-right="!currentIngShouldDelete"
|
||||
:text="$t(currentIngShouldDelete ? 'recipe.parser.delete-item' : 'general.next')"
|
||||
@click="nextIngredient"
|
||||
/>
|
||||
</div>
|
||||
<!-- Review -->
|
||||
<div v-else>
|
||||
<BaseButton
|
||||
create
|
||||
:text="$t('general.save')"
|
||||
:icon="$globals.icons.save"
|
||||
:loading="state.loading.save"
|
||||
@click="saveIngs"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import type { Parser } from "~/lib/api/user/recipes/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { useAppInfo, useUserApi } from "~/composables/api";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
|
||||
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { useParsingPreferences } from "~/composables/use-users/preferences";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
ingredients: NoUndefinedField<RecipeIngredient[]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: boolean): void;
|
||||
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
|
||||
}>();
|
||||
|
||||
const i18n = useGlobalI18n();
|
||||
const api = useUserApi();
|
||||
const appInfo = useAppInfo();
|
||||
const drag = ref(false);
|
||||
|
||||
const unitStore = useUnitStore();
|
||||
const unitData = useUnitData();
|
||||
const foodStore = useFoodStore();
|
||||
const foodData = useFoodData();
|
||||
|
||||
const parserPreferences = useParsingPreferences();
|
||||
const parser = ref<Parser>(parserPreferences.value.parser || "nlp");
|
||||
const availableParsers = computed(() => {
|
||||
return [
|
||||
{
|
||||
text: i18n.t("recipe.parser.natural-language-processor"),
|
||||
value: "nlp",
|
||||
},
|
||||
{
|
||||
text: i18n.t("recipe.parser.brute-parser"),
|
||||
value: "brute",
|
||||
},
|
||||
{
|
||||
text: i18n.t("recipe.parser.openai-parser"),
|
||||
value: "openai",
|
||||
hide: !appInfo.value?.enableOpenai,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
/**
|
||||
* If confidence of parsing is below this threshold,
|
||||
* we will prompt the user to review the parsed ingredient.
|
||||
*/
|
||||
const confidenceThreshold = 0.85;
|
||||
const parsedIngs = ref<ParsedIngredient[]>([]);
|
||||
|
||||
const currentIng = ref<ParsedIngredient | null>(null);
|
||||
const currentMissingUnit = ref("");
|
||||
const currentMissingFood = ref("");
|
||||
const currentIngHasError = computed(() => currentMissingUnit.value || currentMissingFood.value);
|
||||
const currentIngShouldDelete = ref(false);
|
||||
|
||||
const state = reactive({
|
||||
currentParsedIndex: -1,
|
||||
allReviewed: false,
|
||||
loading: {
|
||||
parser: false,
|
||||
save: false,
|
||||
},
|
||||
});
|
||||
|
||||
function shouldReview(ing: ParsedIngredient): boolean {
|
||||
console.debug(`Checking if ingredient needs review (input="${ing.input})":`, ing);
|
||||
|
||||
if ((ing.confidence?.average || 0) < confidenceThreshold) {
|
||||
console.debug("Needs review due to low confidence:", ing.confidence?.average);
|
||||
return true;
|
||||
}
|
||||
|
||||
const food = ing.ingredient.food;
|
||||
if (food && !food.id) {
|
||||
console.debug("Needs review due to missing food ID:", food);
|
||||
return true;
|
||||
}
|
||||
|
||||
const unit = ing.ingredient.unit;
|
||||
if (unit && !unit.id) {
|
||||
console.debug("Needs review due to missing unit ID:", unit);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.debug("No review needed");
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkUnit(ing: ParsedIngredient) {
|
||||
const unit = ing.ingredient.unit?.name;
|
||||
if (!unit || ing.ingredient.unit?.id) {
|
||||
currentMissingUnit.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const potentialMatch = createdUnits.get(unit.toLowerCase());
|
||||
if (potentialMatch) {
|
||||
ing.ingredient.unit = potentialMatch;
|
||||
currentMissingUnit.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
currentMissingUnit.value = unit;
|
||||
ing.ingredient.unit = undefined;
|
||||
}
|
||||
|
||||
function checkFood(ing: ParsedIngredient) {
|
||||
const food = ing.ingredient.food?.name;
|
||||
if (!food || ing.ingredient.food?.id) {
|
||||
currentMissingFood.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const potentialMatch = createdFoods.get(food.toLowerCase());
|
||||
if (potentialMatch) {
|
||||
ing.ingredient.food = potentialMatch;
|
||||
currentMissingFood.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
currentMissingFood.value = food;
|
||||
ing.ingredient.food = undefined;
|
||||
}
|
||||
|
||||
function nextIngredient() {
|
||||
let nextIndex = state.currentParsedIndex;
|
||||
if (currentIngShouldDelete.value) {
|
||||
parsedIngs.value.splice(state.currentParsedIndex, 1);
|
||||
currentIngShouldDelete.value = false;
|
||||
}
|
||||
else {
|
||||
nextIndex += 1;
|
||||
}
|
||||
|
||||
while (nextIndex < parsedIngs.value.length) {
|
||||
const current = parsedIngs.value[nextIndex];
|
||||
if (shouldReview(current)) {
|
||||
state.currentParsedIndex = nextIndex;
|
||||
currentIng.value = current;
|
||||
currentIngShouldDelete.value = false;
|
||||
checkUnit(current);
|
||||
checkFood(current);
|
||||
return;
|
||||
}
|
||||
|
||||
nextIndex += 1;
|
||||
}
|
||||
|
||||
// No more to review
|
||||
state.allReviewed = true;
|
||||
}
|
||||
|
||||
async function parseIngredients() {
|
||||
if (state.loading.parser) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.ingredients || props.ingredients.length === 0) {
|
||||
state.loading.parser = false;
|
||||
return;
|
||||
}
|
||||
state.loading.parser = true;
|
||||
try {
|
||||
const ingsAsString = props.ingredients.map(ing => parseIngredientText(ing, 1, false) ?? "");
|
||||
const { data, error } = await api.recipes.parseIngredients(parser.value, ingsAsString);
|
||||
if (error || !data) {
|
||||
throw new Error("Failed to parse ingredients");
|
||||
}
|
||||
parsedIngs.value = data;
|
||||
state.currentParsedIndex = -1;
|
||||
state.allReviewed = false;
|
||||
createdUnits.clear();
|
||||
createdFoods.clear();
|
||||
currentIngShouldDelete.value = false;
|
||||
nextIngredient();
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error parsing ingredients:", error);
|
||||
alert.error(i18n.t("events.something-went-wrong"));
|
||||
}
|
||||
finally {
|
||||
state.loading.parser = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cache of lowercased created units to avoid duplicate creations */
|
||||
const createdUnits = new Map<string, IngredientUnit>();
|
||||
/** Cache of lowercased created foods to avoid duplicate creations */
|
||||
const createdFoods = new Map<string, IngredientFood>();
|
||||
|
||||
async function createMissingUnit() {
|
||||
if (!currentMissingUnit.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
unitData.reset();
|
||||
unitData.data.name = currentMissingUnit.value;
|
||||
|
||||
let newUnit: IngredientUnit | null = null;
|
||||
if (createdUnits.has(unitData.data.name)) {
|
||||
newUnit = createdUnits.get(unitData.data.name)!;
|
||||
}
|
||||
else {
|
||||
newUnit = await unitStore.actions.createOne(unitData.data);
|
||||
}
|
||||
|
||||
if (!newUnit) {
|
||||
alert.error(i18n.t("events.something-went-wrong"));
|
||||
return;
|
||||
}
|
||||
|
||||
currentIng.value!.ingredient.unit = newUnit;
|
||||
createdUnits.set(newUnit.name.toLowerCase(), newUnit);
|
||||
currentMissingUnit.value = "";
|
||||
}
|
||||
|
||||
async function createMissingFood() {
|
||||
if (!currentMissingFood.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
foodData.reset();
|
||||
foodData.data.name = currentMissingFood.value;
|
||||
|
||||
let newFood: IngredientFood | null = null;
|
||||
if (createdFoods.has(foodData.data.name)) {
|
||||
newFood = createdFoods.get(foodData.data.name)!;
|
||||
}
|
||||
else {
|
||||
newFood = await foodStore.actions.createOne(foodData.data);
|
||||
}
|
||||
|
||||
if (!newFood) {
|
||||
alert.error(i18n.t("events.something-went-wrong"));
|
||||
return;
|
||||
}
|
||||
|
||||
currentIng.value!.ingredient.food = newFood;
|
||||
createdFoods.set(newFood.name.toLowerCase(), newFood);
|
||||
currentMissingFood.value = "";
|
||||
}
|
||||
|
||||
async function addMissingUnitAsAlias() {
|
||||
const unit = currentIng.value?.ingredient.unit as IngredientUnit | undefined;
|
||||
if (!currentMissingUnit.value || !unit?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
unit.aliases = unit.aliases || [];
|
||||
if (unit.aliases.map(a => a.name).includes(currentMissingUnit.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
unit.aliases.push({ name: currentMissingUnit.value });
|
||||
const updated = await unitStore.actions.updateOne(unit);
|
||||
if (!updated) {
|
||||
alert.error(i18n.t("events.something-went-wrong"));
|
||||
return;
|
||||
}
|
||||
|
||||
currentIng.value!.ingredient.unit = updated;
|
||||
currentMissingUnit.value = "";
|
||||
}
|
||||
|
||||
async function addMissingFoodAsAlias() {
|
||||
const food = currentIng.value?.ingredient.food as IngredientFood | undefined;
|
||||
if (!currentMissingFood.value || !food?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
food.aliases = food.aliases || [];
|
||||
if (food.aliases.map(a => a.name).includes(currentMissingFood.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
food.aliases.push({ name: currentMissingFood.value });
|
||||
const updated = await foodStore.actions.updateOne(food);
|
||||
if (!updated) {
|
||||
alert.error(i18n.t("events.something-went-wrong"));
|
||||
return;
|
||||
}
|
||||
|
||||
currentIng.value!.ingredient.food = updated;
|
||||
currentMissingFood.value = "";
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
if (!props.modelValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
parseIngredients();
|
||||
});
|
||||
|
||||
watch(parser, () => {
|
||||
parserPreferences.value.parser = parser.value;
|
||||
parseIngredients();
|
||||
});
|
||||
|
||||
watch([parsedIngs, () => state.allReviewed], () => {
|
||||
if (!state.allReviewed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parsedIngs.value.length) {
|
||||
insertNewIngredient(0);
|
||||
}
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
function asPercentage(num: number | undefined): string {
|
||||
if (!num) {
|
||||
return "0%";
|
||||
}
|
||||
|
||||
return Math.round(num * 100).toFixed(2) + "%";
|
||||
}
|
||||
|
||||
function insertNewIngredient(index: number) {
|
||||
const ing = {
|
||||
input: "",
|
||||
confidence: {},
|
||||
ingredient: {
|
||||
quantity: 0,
|
||||
referenceId: uuid4(),
|
||||
},
|
||||
} as ParsedIngredient;
|
||||
|
||||
parsedIngs.value.splice(index, 0, ing);
|
||||
}
|
||||
|
||||
function saveIngs() {
|
||||
emit("save", parsedIngs.value.map(x => x.ingredient as NoUndefinedField<RecipeIngredient>));
|
||||
state.loading.save = true;
|
||||
}
|
||||
</script>
|
||||
@@ -4,20 +4,23 @@
|
||||
<section>
|
||||
<v-container class="ma-0 pa-0">
|
||||
<v-row>
|
||||
<v-col v-if="preferences.imagePosition && preferences.imagePosition != ImagePosition.hidden"
|
||||
:order="preferences.imagePosition == ImagePosition.left ? -1 : 1"
|
||||
cols="4"
|
||||
align-self="center"
|
||||
<v-col
|
||||
v-if="preferences.imagePosition && preferences.imagePosition != ImagePosition.hidden"
|
||||
:order="preferences.imagePosition == ImagePosition.left ? -1 : 1"
|
||||
cols="4"
|
||||
align-self="center"
|
||||
>
|
||||
<img :key="imageKey"
|
||||
:src="recipeImageUrl"
|
||||
style="min-height: 50; max-width: 100%;"
|
||||
<img
|
||||
:key="imageKey"
|
||||
:src="recipeImageUrl"
|
||||
style="min-height: 50; max-width: 100%;"
|
||||
>
|
||||
</v-col>
|
||||
<v-col order="0">
|
||||
<v-card-title class="headline pl-0">
|
||||
<v-icon start
|
||||
color="primary"
|
||||
<v-icon
|
||||
start
|
||||
color="primary"
|
||||
>
|
||||
{{ $globals.icons.primary }}
|
||||
</v-icon>
|
||||
@@ -36,17 +39,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<v-row class="d-flex justify-start">
|
||||
<RecipeTimeCard :prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
:perform-time="recipe.performTime"
|
||||
small
|
||||
color="white"
|
||||
class="ml-4"
|
||||
<RecipeTimeCard
|
||||
:prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
:perform-time="recipe.performTime"
|
||||
small
|
||||
color="white"
|
||||
class="ml-4"
|
||||
/>
|
||||
</v-row>
|
||||
|
||||
<v-card-text v-if="preferences.showDescription"
|
||||
class="px-0"
|
||||
<v-card-text
|
||||
v-if="preferences.showDescription"
|
||||
class="px-0"
|
||||
>
|
||||
<SafeMarkdown :source="recipe.description" />
|
||||
</v-card-text>
|
||||
@@ -60,24 +65,29 @@
|
||||
<v-card-title class="headline pl-0">
|
||||
{{ $t("recipe.ingredients") }}
|
||||
</v-card-title>
|
||||
<div v-for="(ingredientSection, sectionIndex) in ingredientSections"
|
||||
:key="`ingredient-section-${sectionIndex}`"
|
||||
class="print-section"
|
||||
<div
|
||||
v-for="(ingredientSection, sectionIndex) in ingredientSections"
|
||||
:key="`ingredient-section-${sectionIndex}`"
|
||||
class="print-section"
|
||||
>
|
||||
<h4 v-if="ingredientSection.ingredients[0].title"
|
||||
class="ingredient-title mt-2"
|
||||
<h4
|
||||
v-if="ingredientSection.ingredients[0].title"
|
||||
class="ingredient-title mt-2"
|
||||
>
|
||||
{{ ingredientSection.ingredients[0].title }}
|
||||
</h4>
|
||||
<div class="ingredient-grid"
|
||||
:style="{ gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
|
||||
<div
|
||||
class="ingredient-grid"
|
||||
:style="{ gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
|
||||
>
|
||||
<template v-for="(ingredient, ingredientIndex) in ingredientSection.ingredients"
|
||||
:key="`ingredient-${ingredientIndex}`"
|
||||
<template
|
||||
v-for="(ingredient, ingredientIndex) in ingredientSection.ingredients"
|
||||
:key="`ingredient-${ingredientIndex}`"
|
||||
>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<p class="ingredient-body"
|
||||
v-html="parseText(ingredient)"
|
||||
<p
|
||||
class="ingredient-body"
|
||||
v-html="parseText(ingredient)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
@@ -86,22 +96,26 @@
|
||||
|
||||
<!-- Instructions -->
|
||||
<section>
|
||||
<div v-for="(instructionSection, sectionIndex) in instructionSections"
|
||||
:key="`instruction-section-${sectionIndex}`"
|
||||
:class="{ 'print-section': instructionSection.sectionName }"
|
||||
<div
|
||||
v-for="(instructionSection, sectionIndex) in instructionSections"
|
||||
:key="`instruction-section-${sectionIndex}`"
|
||||
:class="{ 'print-section': instructionSection.sectionName }"
|
||||
>
|
||||
<v-card-title v-if="!sectionIndex"
|
||||
class="headline pl-0"
|
||||
<v-card-title
|
||||
v-if="!sectionIndex"
|
||||
class="headline pl-0"
|
||||
>
|
||||
{{ $t("recipe.instructions") }}
|
||||
</v-card-title>
|
||||
<div v-for="(step, stepIndex) in instructionSection.instructions"
|
||||
:key="`instruction-${stepIndex}`"
|
||||
<div
|
||||
v-for="(step, stepIndex) in instructionSection.instructions"
|
||||
:key="`instruction-${stepIndex}`"
|
||||
>
|
||||
<div class="print-section">
|
||||
<h4 v-if="step.title"
|
||||
:key="`instruction-title-${stepIndex}`"
|
||||
class="instruction-title mb-2"
|
||||
<h4
|
||||
v-if="step.title"
|
||||
:key="`instruction-title-${stepIndex}`"
|
||||
class="instruction-title mb-2"
|
||||
>
|
||||
{{ step.title }}
|
||||
</h4>
|
||||
@@ -112,8 +126,9 @@
|
||||
+ 1,
|
||||
}) }}
|
||||
</h5>
|
||||
<SafeMarkdown :source="step.text"
|
||||
class="recipe-step-body"
|
||||
<SafeMarkdown
|
||||
:source="step.text"
|
||||
class="recipe-step-body"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,18 +137,21 @@
|
||||
|
||||
<!-- Notes -->
|
||||
<div v-if="preferences.showNotes">
|
||||
<v-divider v-if="hasNotes"
|
||||
class="grey my-4"
|
||||
<v-divider
|
||||
v-if="hasNotes"
|
||||
class="grey my-4"
|
||||
/>
|
||||
|
||||
<section>
|
||||
<div v-for="(note, index) in recipe.notes"
|
||||
:key="index + 'note'"
|
||||
<div
|
||||
v-for="(note, index) in recipe.notes"
|
||||
:key="index + 'note'"
|
||||
>
|
||||
<div class="print-section">
|
||||
<h4>{{ note.title }}</h4>
|
||||
<SafeMarkdown :source="note.text"
|
||||
class="note-body"
|
||||
<SafeMarkdown
|
||||
:source="note.text"
|
||||
class="note-body"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,8 +168,9 @@
|
||||
<div class="print-section">
|
||||
<table class="nutrition-table">
|
||||
<tbody>
|
||||
<tr v-for="(value, key) in recipe.nutrition"
|
||||
:key="key"
|
||||
<tr
|
||||
v-for="(value, key) in recipe.nutrition"
|
||||
:key="key"
|
||||
>
|
||||
<template v-if="value">
|
||||
<td>{{ labels[key].label }}</td>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<div @click.prevent>
|
||||
<!-- User Rating -->
|
||||
<v-hover v-slot="{ isHovering, props }">
|
||||
<v-rating v-if="isOwnGroup && (userRating || isHovering || !ratingsLoaded)"
|
||||
<v-rating
|
||||
v-if="isOwnGroup && (userRating || isHovering || !ratingsLoaded)"
|
||||
v-bind="props"
|
||||
:model-value="userRating"
|
||||
active-color="secondary"
|
||||
@@ -13,10 +14,10 @@
|
||||
hover
|
||||
clearable
|
||||
@update:model-value="updateRating(+$event)"
|
||||
@click="updateRating"
|
||||
/>
|
||||
<!-- Group Rating -->
|
||||
<v-rating v-else
|
||||
<v-rating
|
||||
v-else
|
||||
v-bind="props"
|
||||
:model-value="groupRating"
|
||||
:half-increments="true"
|
||||
@@ -83,7 +84,7 @@ export default defineNuxtComponent({
|
||||
});
|
||||
|
||||
function updateRating(val?: number) {
|
||||
if (!isOwnGroup.value) {
|
||||
if (!isOwnGroup.value || !val) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
:recipe="recipes.get(event.recipeId)"
|
||||
:show-recipe-cards="showRecipeCards"
|
||||
:width="$vuetify.display.smAndDown ? '100%' : undefined"
|
||||
@update="updateTimelineEvent(index)"
|
||||
@update="updateTimelineEvent(index, $event)"
|
||||
@delete="deleteTimelineEvent(index)"
|
||||
/>
|
||||
</v-timeline>
|
||||
@@ -186,20 +186,17 @@ function toggleEventTypeOption(option: TimelineEventType) {
|
||||
}
|
||||
|
||||
// Timeline Actions
|
||||
async function updateTimelineEvent(index: number) {
|
||||
const event = timelineEvents.value[index];
|
||||
const payload: RecipeTimelineEventUpdate = {
|
||||
subject: event.subject,
|
||||
eventMessage: event.eventMessage,
|
||||
image: event.image,
|
||||
};
|
||||
|
||||
const { response } = await api.recipes.updateTimelineEvent(event.id, payload);
|
||||
async function updateTimelineEvent(index: number, event: RecipeTimelineEventUpdate) {
|
||||
const eventId = timelineEvents.value[index].id;
|
||||
const { response } = await api.recipes.updateTimelineEvent(eventId, event);
|
||||
if (response?.status !== 200) {
|
||||
alert.error(i18n.t("events.something-went-wrong") as string);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the local event data to reflect the changes in the UI
|
||||
timelineEvents.value[index] = response.data;
|
||||
|
||||
alert.success(i18n.t("events.event-updated") as string);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
edit: true,
|
||||
delete: true,
|
||||
}"
|
||||
@update="$emit('update')"
|
||||
@update="$emit('update', $event)"
|
||||
@delete="$emit('delete')"
|
||||
/>
|
||||
</v-col>
|
||||
@@ -96,7 +96,7 @@ import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
import { useTimelineEventTypes } from "~/composables/recipes/use-recipe-timeline-events";
|
||||
import type { Recipe, RecipeTimelineEventOut } from "~/lib/api/types/recipe";
|
||||
import type { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate } from "~/lib/api/types/recipe";
|
||||
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
||||
import SafeMarkdown from "~/components/global/SafeMarkdown.vue";
|
||||
|
||||
@@ -113,11 +113,12 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
defineEmits<{
|
||||
selected: [];
|
||||
update: [];
|
||||
update: [event: RecipeTimelineEventUpdate];
|
||||
delete: [];
|
||||
}>();
|
||||
|
||||
const { $vuetify, $globals } = useNuxtApp();
|
||||
const { $globals } = useNuxtApp();
|
||||
const display = useDisplay();
|
||||
const { recipeTimelineEventImage } = useStaticRoutes();
|
||||
const { eventTypeOptions } = useTimelineEventTypes();
|
||||
|
||||
@@ -127,7 +128,7 @@ const route = useRoute();
|
||||
const groupSlug = computed(() => (route.params.groupSlug as string) || currentUser?.value?.groupSlug || "");
|
||||
|
||||
const useMobileFormat = computed(() => {
|
||||
return $vuetify.display.smAndDown.value;
|
||||
return display.smAndDown.value;
|
||||
});
|
||||
|
||||
const attrs = computed(() => {
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-badge
|
||||
:model-value="selected.length > 0"
|
||||
v-memo="[selectedCount]"
|
||||
:model-value="selectedCount > 0"
|
||||
size="small"
|
||||
color="primary"
|
||||
:content="selected.length"
|
||||
:content="selectedCount"
|
||||
>
|
||||
<v-btn
|
||||
size="small"
|
||||
@@ -28,6 +29,7 @@
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="state.search"
|
||||
v-memo="[state.search]"
|
||||
class="mb-2"
|
||||
hide-details
|
||||
density="comfortable"
|
||||
@@ -43,7 +45,7 @@
|
||||
hide-details
|
||||
class="my-auto"
|
||||
color="primary"
|
||||
:label="`${requireAll ? $t('search.has-all') : $t('search.has-any')}`"
|
||||
:label="requireAllValue ? $t('search.has-all') : $t('search.has-any')"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
@@ -73,7 +75,8 @@
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<v-list-item
|
||||
:key="item.id"
|
||||
:key="`radio-${item.id}`"
|
||||
v-memo="[item.id, item.name, selectedRadio?.id]"
|
||||
:value="item"
|
||||
:title="item.name"
|
||||
>
|
||||
@@ -101,7 +104,8 @@
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<v-list-item
|
||||
:key="item.id"
|
||||
:key="`checkbox-${item.id}`"
|
||||
v-memo="[item.id, item.name, selectedIds.has(item.id)]"
|
||||
:value="item"
|
||||
:title="item.name"
|
||||
>
|
||||
@@ -134,6 +138,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
|
||||
export interface SelectableItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -165,6 +171,9 @@ export default defineNuxtComponent({
|
||||
menu: false,
|
||||
});
|
||||
|
||||
// Use shallowRef for better performance with arrays
|
||||
const debouncedSearch = shallowRef("");
|
||||
|
||||
const requireAllValue = computed({
|
||||
get: () => props.requireAll,
|
||||
set: (value) => {
|
||||
@@ -172,6 +181,7 @@ export default defineNuxtComponent({
|
||||
},
|
||||
});
|
||||
|
||||
// Use shallowRef to prevent deep reactivity on large arrays
|
||||
const selected = computed({
|
||||
get: () => props.modelValue as SelectableItem[],
|
||||
set: (value) => {
|
||||
@@ -186,21 +196,40 @@ export default defineNuxtComponent({
|
||||
},
|
||||
});
|
||||
|
||||
watchDebounced(
|
||||
() => state.search,
|
||||
(newSearch) => {
|
||||
debouncedSearch.value = newSearch;
|
||||
},
|
||||
{ debounce: 500, maxWait: 1500, immediate: false }, // Increased debounce time
|
||||
);
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (!state.search) {
|
||||
return props.items;
|
||||
const items = props.items;
|
||||
const search = debouncedSearch.value;
|
||||
|
||||
if (!search || search.length < 2) { // Only filter after 2 characters
|
||||
return items;
|
||||
}
|
||||
|
||||
return props.items.filter(item => item.name.toLowerCase().includes(state.search.toLowerCase()));
|
||||
const searchLower = search.toLowerCase();
|
||||
return items.filter(item => item.name.toLowerCase().includes(searchLower));
|
||||
});
|
||||
|
||||
const selectedCount = computed(() => selected.value.length);
|
||||
const selectedIds = computed(() => {
|
||||
return new Set(selected.value.map(item => item.id));
|
||||
});
|
||||
|
||||
const handleCheckboxClick = (item: SelectableItem) => {
|
||||
console.log(selected.value, item);
|
||||
if (selected.value.includes(item)) {
|
||||
selected.value = selected.value.filter(i => i !== item);
|
||||
const currentSelection = selected.value;
|
||||
const isSelected = selectedIds.value.has(item.id);
|
||||
|
||||
if (isSelected) {
|
||||
selected.value = currentSelection.filter(i => i.id !== item.id);
|
||||
}
|
||||
else {
|
||||
selected.value.push(item);
|
||||
selected.value = [...currentSelection, item];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -221,6 +250,8 @@ export default defineNuxtComponent({
|
||||
state,
|
||||
selected,
|
||||
selectedRadio,
|
||||
selectedCount,
|
||||
selectedIds,
|
||||
filtered,
|
||||
handleCheckboxClick,
|
||||
handleRadioClick,
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
</div>
|
||||
<BaseButton
|
||||
v-if="listItem.labelId && listItem.food && listItem.labelId !== listItem.food.labelId"
|
||||
size="small"
|
||||
small
|
||||
color="info"
|
||||
:icon="$globals.icons.tagArrowRight"
|
||||
:text="$t('shopping-list.save-label')"
|
||||
@@ -84,12 +84,12 @@
|
||||
:buttons="[
|
||||
...(allowDelete
|
||||
? [
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
]
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: $globals.icons.close,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="d-flex justify-center pb-6 mt-n1">
|
||||
<div class="d-flex pb-6 mt-n1 ml-10">
|
||||
<div style="flex-basis: 500px">
|
||||
<strong> {{ $t("user.password-strength", { strength: pwStrength.strength.value }) }}</strong>
|
||||
<v-progress-linear
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card-title>
|
||||
<v-card-title class="pt-0">
|
||||
<v-icon
|
||||
size="large"
|
||||
class="mr-3"
|
||||
@@ -10,7 +10,7 @@
|
||||
<span class="headline"> {{ $t("user-registration.account-details") }}</span>
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<v-card-text class="mt-2">
|
||||
<v-form
|
||||
ref="domAccountForm"
|
||||
@submit.prevent
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<v-app dark>
|
||||
<NuxtPwaManifest />
|
||||
<TheSnackbar />
|
||||
|
||||
<AppHeader>
|
||||
@@ -17,7 +16,6 @@
|
||||
absolute
|
||||
:top-link="topLinks"
|
||||
:secondary-links="cookbookLinks || []"
|
||||
:bottom-links="bottomLinks"
|
||||
>
|
||||
<v-menu
|
||||
offset-y
|
||||
@@ -80,30 +78,11 @@
|
||||
<v-list-item-subtitle class="font-weight-medium" style="font-size: small;">
|
||||
{{ item.subtitle }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list-item>
|
||||
</div>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<template #bottom>
|
||||
<v-list-item @click.stop="languageDialog = true">
|
||||
<template #prepend>
|
||||
<v-icon>{{ $globals.icons.translate }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ $t("sidebar.language") }}</v-list-item-title>
|
||||
<LanguageDialog v-model="languageDialog" />
|
||||
</v-list-item>
|
||||
<v-list-item @click="toggleDark">
|
||||
<template #prepend>
|
||||
<v-icon>
|
||||
{{ $vuetify.theme.current.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ $vuetify.theme.current.dark ? $t("settings.theme.light-mode") : $t("settings.theme.dark-mode") }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</AppSidebar>
|
||||
<v-main class="pt-12">
|
||||
<v-scroll-x-transition>
|
||||
@@ -121,29 +100,22 @@ import type { SideBarLink } from "~/types/application-types";
|
||||
import { useAppInfo } from "~/composables/api";
|
||||
import { useCookbookPreferences } from "~/composables/use-users/preferences";
|
||||
import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store";
|
||||
import { useHouseholdStore, usePublicHouseholdStore } from "~/composables/store/use-household-store";
|
||||
import { useToggleDarkMode } from "~/composables/use-utils";
|
||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
setup() {
|
||||
const i18n = useI18n();
|
||||
const { $globals, $vuetify } = useNuxtApp();
|
||||
const { $globals } = useNuxtApp();
|
||||
const display = useDisplay();
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const isAdmin = computed(() => $auth.user.value?.admin);
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const cookbookPreferences = useCookbookPreferences();
|
||||
|
||||
const ownCookbookStore = useCookbookStore(i18n);
|
||||
const ownHouseholdStore = useHouseholdStore(i18n);
|
||||
|
||||
const publicCookbookStoreCache = ref<Record<string, ReturnType<typeof usePublicCookbookStore>>>({});
|
||||
const publicHouseholdStoreCache = ref<Record<string, ReturnType<typeof usePublicHouseholdStore>>>({});
|
||||
|
||||
function getPublicCookbookStore(slug: string) {
|
||||
if (!publicCookbookStoreCache.value[slug]) {
|
||||
@@ -152,13 +124,6 @@ export default defineNuxtComponent({
|
||||
return publicCookbookStoreCache.value[slug];
|
||||
}
|
||||
|
||||
function getPublicHouseholdStore(slug: string) {
|
||||
if (!publicHouseholdStoreCache.value[slug]) {
|
||||
publicHouseholdStoreCache.value[slug] = usePublicHouseholdStore(slug, i18n);
|
||||
}
|
||||
return publicHouseholdStoreCache.value[slug];
|
||||
}
|
||||
|
||||
const cookbooks = computed(() => {
|
||||
if (isOwnGroup.value) {
|
||||
return ownCookbookStore.store.value;
|
||||
@@ -170,34 +135,14 @@ export default defineNuxtComponent({
|
||||
return [];
|
||||
});
|
||||
|
||||
const households = computed(() => {
|
||||
if (isOwnGroup.value) {
|
||||
return ownHouseholdStore.store.value;
|
||||
}
|
||||
else if (groupSlug.value) {
|
||||
const publicStore = getPublicHouseholdStore(groupSlug.value);
|
||||
return unref(publicStore.store);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const householdsById = computed(() => {
|
||||
return households.value.reduce((acc, household) => {
|
||||
acc[household.id] = household;
|
||||
return acc;
|
||||
}, {} as { [key: string]: HouseholdSummary });
|
||||
});
|
||||
|
||||
const appInfo = useAppInfo();
|
||||
const showImageImport = computed(() => appInfo.value?.enableOpenaiImageServices);
|
||||
|
||||
const toggleDark = useToggleDarkMode();
|
||||
|
||||
const languageDialog = ref<boolean>(false);
|
||||
|
||||
const sidebar = ref<boolean>(false);
|
||||
onMounted(() => {
|
||||
sidebar.value = $vuetify.display.mdAndUp.value;
|
||||
sidebar.value = display.lgAndUp.value;
|
||||
});
|
||||
|
||||
function cookbookAsLink(cookbook: ReadCookBook): SideBarLink {
|
||||
@@ -221,11 +166,8 @@ export default defineNuxtComponent({
|
||||
const ownLinks: SideBarLink[] = [];
|
||||
const links: SideBarLink[] = [];
|
||||
const cookbooksByHousehold = sortedCookbooks.reduce((acc, cookbook) => {
|
||||
const householdName = householdsById.value[cookbook.householdId]?.name || "";
|
||||
if (!acc[householdName]) {
|
||||
acc[householdName] = [];
|
||||
}
|
||||
acc[householdName].push(cookbook);
|
||||
const householdName = cookbook.household?.name || "";
|
||||
(acc[householdName] ||= []).push(cookbook);
|
||||
return acc;
|
||||
}, {} as Record<string, ReadCookBook[]>);
|
||||
|
||||
@@ -286,19 +228,6 @@ export default defineNuxtComponent({
|
||||
},
|
||||
]);
|
||||
|
||||
const bottomLinks = computed<SideBarLink[]>(() =>
|
||||
isAdmin.value
|
||||
? [
|
||||
{
|
||||
icon: $globals.icons.cog,
|
||||
title: i18n.t("general.settings"),
|
||||
to: "/admin/site-settings",
|
||||
restricted: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
const topLinks = computed<SideBarLink[]>(() => [
|
||||
{
|
||||
icon: $globals.icons.silverwareForkKnife,
|
||||
@@ -367,11 +296,9 @@ export default defineNuxtComponent({
|
||||
groupSlug,
|
||||
cookbookLinks,
|
||||
createLinks,
|
||||
bottomLinks,
|
||||
topLinks,
|
||||
isOwnGroup,
|
||||
languageDialog,
|
||||
toggleDark,
|
||||
sidebar,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -128,7 +128,7 @@ export default defineNuxtComponent({
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await $auth.signOut({ callbackUrl: "/login?direct=1" });
|
||||
await $auth.signOut("/login?direct=1");
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<v-navigation-drawer v-model="showDrawer" class="d-flex flex-column d-print-none position-fixed">
|
||||
<v-navigation-drawer v-model="showDrawer" class="d-flex flex-column d-print-none position-fixed" touchless>
|
||||
<LanguageDialog v-model="languageDialog" />
|
||||
<!-- User Profile -->
|
||||
<template v-if="loggedIn">
|
||||
<v-list-item lines="two" :to="userProfileLink" exact>
|
||||
@@ -32,20 +33,39 @@
|
||||
<template v-for="nav in topLink">
|
||||
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
||||
<!-- Multi Items -->
|
||||
<v-list-group v-if="nav.children" :key="(nav.key || nav.title) + 'multi-item'"
|
||||
v-model="dropDowns[nav.title]" color="primary" :prepend-icon="nav.icon" :fluid="true">
|
||||
<v-list-group
|
||||
v-if="nav.children"
|
||||
:key="(nav.key || nav.title) + 'multi-item'"
|
||||
v-model="dropDowns[nav.title]"
|
||||
color="primary"
|
||||
:prepend-icon="nav.icon"
|
||||
:fluid="true"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
|
||||
</template>
|
||||
|
||||
<v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to"
|
||||
:prepend-icon="child.icon" :title="child.title" class="ml-4" />
|
||||
<v-list-item
|
||||
v-for="child in nav.children"
|
||||
:key="child.key || child.title"
|
||||
exact
|
||||
:to="child.to"
|
||||
:prepend-icon="child.icon"
|
||||
:title="child.title"
|
||||
class="ml-4"
|
||||
/>
|
||||
</v-list-group>
|
||||
|
||||
<!-- Single Item -->
|
||||
<template v-else>
|
||||
<v-list-item :key="(nav.key || nav.title) + 'single-item'" exact link :to="nav.to"
|
||||
:prepend-icon="nav.icon" :title="nav.title" />
|
||||
<v-list-item
|
||||
:key="(nav.key || nav.title) + 'single-item'"
|
||||
exact
|
||||
link
|
||||
:to="nav.to"
|
||||
:prepend-icon="nav.icon"
|
||||
:title="nav.title"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -59,14 +79,27 @@
|
||||
<template v-for="nav in secondaryLinks">
|
||||
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
||||
<!-- Multi Items -->
|
||||
<v-list-group v-if="nav.children" :key="(nav.key || nav.title) + 'multi-item'"
|
||||
v-model="dropDowns[nav.title]" color="primary" :prepend-icon="nav.icon" fluid>
|
||||
<v-list-group
|
||||
v-if="nav.children"
|
||||
:key="(nav.key || nav.title) + 'multi-item'"
|
||||
v-model="dropDowns[nav.title]"
|
||||
color="primary"
|
||||
:prepend-icon="nav.icon"
|
||||
fluid
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-list-item v-bind="props" :prepend-icon="nav.icon" :title="nav.title" />
|
||||
</template>
|
||||
|
||||
<v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to"
|
||||
class="ml-2" :prepend-icon="child.icon" :title="child.title" />
|
||||
<v-list-item
|
||||
v-for="child in nav.children"
|
||||
:key="child.key || child.title"
|
||||
exact
|
||||
:to="child.to"
|
||||
class="ml-2"
|
||||
:prepend-icon="child.icon"
|
||||
:title="child.title"
|
||||
/>
|
||||
</v-list-group>
|
||||
|
||||
<!-- Single Item -->
|
||||
@@ -82,30 +115,32 @@
|
||||
</template>
|
||||
|
||||
<!-- Bottom Navigation Links -->
|
||||
<template v-if="bottomLinks" #append>
|
||||
<v-list v-model:selected="bottomSelected" nav density="compact">
|
||||
<template v-for="nav in bottomLinks">
|
||||
<div v-if="!nav.restricted || isOwnGroup" :key="nav.key || nav.title">
|
||||
<v-list-item :key="nav.key || nav.title" exact link :to="nav.to" :href="nav.href"
|
||||
:target="nav.href ? '_blank' : null">
|
||||
<template #prepend>
|
||||
<v-icon>{{ nav.icon }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ nav.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</div>
|
||||
</template>
|
||||
<slot name="bottom" />
|
||||
<template #append>
|
||||
<v-list v-model:selected="bottomSelected" nav density="comfortable">
|
||||
<v-menu location="end bottom" :offset="15">
|
||||
<template #activator="{ props }">
|
||||
<v-list-item v-bind="props" :prepend-icon="$globals.icons.cog" :title="$t('general.settings')" />
|
||||
</template>
|
||||
<v-list density="comfortable" color="primary">
|
||||
<v-list-item :prepend-icon="$globals.icons.translate" :title="$t('sidebar.language')" @click="languageDialog=true" />
|
||||
<v-list-item :prepend-icon="$vuetify.theme.current.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight" :title="$vuetify.theme.current.dark ? $t('settings.theme.light-mode') : $t('settings.theme.dark-mode')" @click="toggleDark" />
|
||||
<v-divider v-if="loggedIn" class="my-2" />
|
||||
<v-list-item v-if="loggedIn" :prepend-icon="$globals.icons.cog" :title="$t('profile.user-settings')" to="/user/profile" />
|
||||
<v-list-item v-if="canManage" :prepend-icon="$globals.icons.manageData" :title="$t('data-pages.data-management')" to="/group/data" />
|
||||
<v-divider v-if="isAdmin" class="my-2" />
|
||||
<v-list-item v-if="isAdmin" :prepend-icon="$globals.icons.wrench" :title="$t('settings.admin-settings')" to="/admin/site-settings" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-list>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import type { SidebarLinks } from "~/types/application-types";
|
||||
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
||||
import { useToggleDarkMode } from "~/composables/use-utils";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
@@ -130,48 +165,34 @@ export default defineNuxtComponent({
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
bottomLinks: {
|
||||
type: Array as () => SidebarLinks,
|
||||
required: false,
|
||||
default: () => ([]),
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const $auth = useMealieAuth();
|
||||
const { loggedIn, isOwnGroup } = useLoggedInState();
|
||||
const isAdmin = computed(() => $auth.user.value?.admin);
|
||||
const canManage = computed(() => $auth.user.value?.canManage);
|
||||
|
||||
const userFavoritesLink = computed(() => $auth.user.value ? `/user/${$auth.user.value.id}/favorites` : undefined);
|
||||
const userProfileLink = computed(() => $auth.user.value ? "/user/profile" : undefined);
|
||||
|
||||
const toggleDark = useToggleDarkMode();
|
||||
|
||||
const state = reactive({
|
||||
dropDowns: {} as Record<string, boolean>,
|
||||
topSelected: null as string[] | null,
|
||||
secondarySelected: null as string[] | null,
|
||||
bottomSelected: null as string[] | null,
|
||||
hasOpenedBefore: false as boolean,
|
||||
languageDialog: false as boolean,
|
||||
});
|
||||
// model to control the drawer
|
||||
const showDrawer = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => context.emit("update:modelValue", value),
|
||||
});
|
||||
watch(showDrawer, () => {
|
||||
if (window.innerWidth < 760 && state.hasOpenedBefore === false) {
|
||||
state.hasOpenedBefore = true;
|
||||
}
|
||||
});
|
||||
const { width: wWidth } = useWindowSize();
|
||||
watch(wWidth, (w) => {
|
||||
if (w > 760) {
|
||||
showDrawer.value = true;
|
||||
}
|
||||
else {
|
||||
showDrawer.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const allLinks = computed(() => [...props.topLink, ...(props.secondaryLinks || []), ...(props.bottomLinks || [])]);
|
||||
const allLinks = computed(() => [...props.topLink, ...(props.secondaryLinks || [])]);
|
||||
function initDropdowns() {
|
||||
allLinks.value.forEach((link) => {
|
||||
state.dropDowns[link.title] = link.childrenStartExpanded || false;
|
||||
@@ -193,8 +214,11 @@ export default defineNuxtComponent({
|
||||
userProfileLink,
|
||||
showDrawer,
|
||||
loggedIn,
|
||||
isAdmin,
|
||||
canManage,
|
||||
isOwnGroup,
|
||||
sessionUser: $auth.user,
|
||||
toggleDark,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
48
frontend/components/global/AppLogo.vue
Normal file
48
frontend/components/global/AppLogo.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="icon-container">
|
||||
<v-divider class="icon-divider" />
|
||||
<v-avatar
|
||||
:class="['pa-2', 'icon-avatar']"
|
||||
color="primary"
|
||||
:size="size"
|
||||
>
|
||||
<slot>
|
||||
<svg
|
||||
class="icon-white"
|
||||
viewBox="0 0 24 24"
|
||||
:style="{ width: size + 'px', height: size + 'px' }"
|
||||
>
|
||||
<path
|
||||
d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z"
|
||||
/>
|
||||
</svg>
|
||||
</slot>
|
||||
</v-avatar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 75 });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon-white {
|
||||
fill: white;
|
||||
}
|
||||
.icon-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
.icon-divider {
|
||||
width: 100%;
|
||||
margin-bottom: -2.5rem;
|
||||
}
|
||||
.icon-avatar {
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
border: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -33,14 +33,16 @@
|
||||
<!-- Check Box -->
|
||||
<v-checkbox
|
||||
v-if="inputField.type === fieldTypes.BOOLEAN"
|
||||
v-model="modelValue[inputField.varName]"
|
||||
v-model="model[inputField.varName]"
|
||||
:name="inputField.varName"
|
||||
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
:hint="inputField.hint"
|
||||
:hide-details="!inputField.hint"
|
||||
:persistent-hint="!!inputField.hint"
|
||||
density="comfortable"
|
||||
@change="emitBlur">
|
||||
@change="emitBlur"
|
||||
>
|
||||
<template #label>
|
||||
<span class="ml-4">
|
||||
{{ inputField.label }}
|
||||
@@ -51,9 +53,9 @@
|
||||
<!-- Text Field -->
|
||||
<v-text-field
|
||||
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
|
||||
v-model="modelValue[inputField.varName]"
|
||||
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
|
||||
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
@@ -62,7 +64,7 @@
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:hint="inputField.hint || ''"
|
||||
:rules="!(inputField.disableUpdate && updateMode) ? [...rulesByKey(inputField.rules), ...defaultRules] : []"
|
||||
:rules="!(inputField.disableUpdate && updateMode) ? [...rulesByKey(inputField.rules as any), ...defaultRules] : []"
|
||||
lazy-validation
|
||||
@blur="emitBlur"
|
||||
/>
|
||||
@@ -70,9 +72,9 @@
|
||||
<!-- Text Area -->
|
||||
<v-textarea
|
||||
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
|
||||
v-model="modelValue[inputField.varName]"
|
||||
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
|
||||
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
rows="3"
|
||||
@@ -81,7 +83,7 @@
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:hint="inputField.hint || ''"
|
||||
:rules="[...rulesByKey(inputField.rules), ...defaultRules]"
|
||||
:rules="[...rulesByKey(inputField.rules as any), ...defaultRules]"
|
||||
lazy-validation
|
||||
@blur="emitBlur"
|
||||
/>
|
||||
@@ -89,12 +91,11 @@
|
||||
<!-- Option Select -->
|
||||
<v-select
|
||||
v-else-if="inputField.type === fieldTypes.SELECT"
|
||||
v-model="modelValue[inputField.varName]"
|
||||
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))"
|
||||
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:prepend-icon="inputField.icons ? modelValue[inputField.varName] : null"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:items="inputField.options"
|
||||
@@ -106,15 +107,7 @@
|
||||
persistent-hint
|
||||
lazy-validation
|
||||
@blur="emitBlur"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
:title="item.raw.text"
|
||||
:subtitle="item.raw.description"
|
||||
/>
|
||||
</template>
|
||||
</v-select>
|
||||
/>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<div
|
||||
@@ -127,7 +120,7 @@
|
||||
<v-btn
|
||||
class="my-2 ml-auto"
|
||||
style="min-width: 200px"
|
||||
:color="modelValue[inputField.varName]"
|
||||
:color="model[inputField.varName]"
|
||||
dark
|
||||
v-bind="templateProps"
|
||||
>
|
||||
@@ -135,7 +128,7 @@
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-color-picker
|
||||
v-model="modelValue[inputField.varName]"
|
||||
v-model="model[inputField.varName]"
|
||||
value="#7417BE"
|
||||
hide-canvas
|
||||
hide-inputs
|
||||
@@ -146,11 +139,12 @@
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<!-- Object Type -->
|
||||
<div v-else-if="inputField.type === fieldTypes.OBJECT">
|
||||
<auto-form
|
||||
v-model="modelValue[inputField.varName]"
|
||||
v-model="model[inputField.varName]"
|
||||
:color="color"
|
||||
:items="inputField.items"
|
||||
:items="(inputField as any).items"
|
||||
@blur="emitBlur"
|
||||
/>
|
||||
</div>
|
||||
@@ -158,7 +152,7 @@
|
||||
<!-- List Type -->
|
||||
<div v-else-if="inputField.type === fieldTypes.LIST">
|
||||
<div
|
||||
v-for="(item, idx) in modelValue[inputField.varName]"
|
||||
v-for="(item, idx) in model[inputField.varName]"
|
||||
:key="idx"
|
||||
>
|
||||
<p>
|
||||
@@ -168,15 +162,15 @@
|
||||
class="ml-5"
|
||||
x-small
|
||||
delete
|
||||
@click="removeByIndex(modelValue[inputField.varName], idx)"
|
||||
@click="removeByIndex(model[inputField.varName], idx)"
|
||||
/>
|
||||
</span>
|
||||
</p>
|
||||
<v-divider class="mb-5 mx-2" />
|
||||
<auto-form
|
||||
v-model="modelValue[inputField.varName][idx]"
|
||||
v-model="model[inputField.varName][idx]"
|
||||
:color="color"
|
||||
:items="inputField.items"
|
||||
:items="(inputField as any).items"
|
||||
@blur="emitBlur"
|
||||
/>
|
||||
</div>
|
||||
@@ -184,7 +178,7 @@
|
||||
<v-spacer />
|
||||
<BaseButton
|
||||
small
|
||||
@click="modelValue[inputField.varName].push(getTemplate(inputField.items))"
|
||||
@click="model[inputField.varName].push(getTemplate((inputField as any).items))"
|
||||
>
|
||||
{{ $t("general.new") }}
|
||||
</BaseButton>
|
||||
@@ -205,7 +199,13 @@ const BLUR_EVENT = "blur";
|
||||
type ValidatorKey = keyof typeof validators;
|
||||
|
||||
// Use defineModel for v-model
|
||||
const modelValue = defineModel<[object, Array<any>]>();
|
||||
const modelValue = defineModel<Record<string, any> | any[]>({
|
||||
type: [Object, Array],
|
||||
required: true,
|
||||
});
|
||||
|
||||
// alias to avoid template TS complaining about possible undefined
|
||||
const model = modelValue as any;
|
||||
|
||||
const props = defineProps({
|
||||
updateMode: {
|
||||
@@ -246,26 +246,39 @@ const emit = defineEmits(["blur", "update:modelValue"]);
|
||||
|
||||
function rulesByKey(keys?: ValidatorKey[] | null) {
|
||||
if (keys === undefined || keys === null) {
|
||||
return [];
|
||||
return [] as any[];
|
||||
}
|
||||
|
||||
const list = [] as ((v: string) => boolean | string)[];
|
||||
const list: any[] = [];
|
||||
keys.forEach((key) => {
|
||||
const split = key.split(":");
|
||||
const validatorKey = split[0] as ValidatorKey;
|
||||
if (validatorKey in validators) {
|
||||
if (split.length === 1) {
|
||||
list.push(validators[validatorKey]);
|
||||
list.push((validators as any)[validatorKey]);
|
||||
}
|
||||
else {
|
||||
list.push(validators[validatorKey](split[1]));
|
||||
list.push((validators as any)[validatorKey](split[1] as any));
|
||||
}
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
const defaultRules = computed(() => rulesByKey(props.globalRules as ValidatorKey[]));
|
||||
const defaultRules = computed<any[]>(() => rulesByKey(props.globalRules as any));
|
||||
|
||||
// Combined state map for readonly and disabled fields
|
||||
const fieldState = computed<Record<string, { readonly: boolean; disabled: boolean }>>(() => {
|
||||
const map: Record<string, { readonly: boolean; disabled: boolean }> = {};
|
||||
(props.items || []).forEach((field: any) => {
|
||||
const base = (field.disableUpdate && props.updateMode) || (!props.updateMode && field.disableCreate);
|
||||
map[field.varName] = {
|
||||
readonly: base || !!props.readonlyFields?.includes(field.varName),
|
||||
disabled: base || !!props.disabledFields?.includes(field.varName),
|
||||
};
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
function removeByIndex(list: never[], index: number) {
|
||||
// Removes the item at the index
|
||||
|
||||
@@ -10,9 +10,7 @@
|
||||
:max-width="maxWidth ?? undefined"
|
||||
:content-class="top ? 'top-dialog' : undefined"
|
||||
:fullscreen="$vuetify.display.xs"
|
||||
@keydown.enter="() => {
|
||||
emit('submit'); dialog = false;
|
||||
}"
|
||||
@keydown.enter="submitOnEnter"
|
||||
@click:outside="emit('cancel')"
|
||||
@keydown.esc="emit('cancel')"
|
||||
>
|
||||
@@ -127,6 +125,7 @@ interface DialogProps {
|
||||
canDelete?: boolean;
|
||||
canConfirm?: boolean;
|
||||
canSubmit?: boolean;
|
||||
disableSubmitOnEnter?: boolean;
|
||||
}
|
||||
|
||||
interface DialogEmits {
|
||||
@@ -150,6 +149,7 @@ const props = withDefaults(defineProps<DialogProps>(), {
|
||||
canDelete: false,
|
||||
canConfirm: false,
|
||||
canSubmit: false,
|
||||
disableSubmitOnEnter: false,
|
||||
});
|
||||
const emit = defineEmits<DialogEmits>();
|
||||
|
||||
@@ -181,6 +181,14 @@ function submitEvent() {
|
||||
submitted.value = true;
|
||||
}
|
||||
|
||||
function submitOnEnter() {
|
||||
if (props.disableSubmitOnEnter) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitEvent();
|
||||
}
|
||||
|
||||
function deleteEvent() {
|
||||
emit("delete");
|
||||
submitted.value = true;
|
||||
@@ -192,8 +200,8 @@ function open() {
|
||||
}
|
||||
|
||||
/* function close() {
|
||||
dialog.value = false;
|
||||
logDeprecatedProp("close");
|
||||
dialog.value = false;
|
||||
logDeprecatedProp("close");
|
||||
} */
|
||||
|
||||
function logDeprecatedProp(val: string) {
|
||||
|
||||
@@ -90,13 +90,13 @@ export default defineNuxtComponent({
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { $vuetify } = useNuxtApp();
|
||||
const display = useDisplay();
|
||||
const hasHeading = computed(() => false);
|
||||
const hasAltHeading = computed(() => false);
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
"v-card--material--has-heading": hasHeading,
|
||||
"mt-3": $vuetify.display.name.value === "xs" || $vuetify.display.name.value === "sm",
|
||||
"mt-3": display.name.value === "xs" || display.name.value === "sm",
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
<template>
|
||||
<div :style="`width: ${width}; height: 100%;`">
|
||||
<LanguageDialog v-model="langDialog" />
|
||||
<v-card>
|
||||
<div>
|
||||
<v-toolbar
|
||||
width="100%"
|
||||
color="primary"
|
||||
class="d-flex justify-center"
|
||||
style="margin-bottom: 4rem"
|
||||
dark
|
||||
>
|
||||
<v-toolbar-title class="headline text-h4 text-center mx-0">
|
||||
Mealie
|
||||
</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
|
||||
<div class="icon-container">
|
||||
<v-divider class="icon-divider" />
|
||||
<v-avatar
|
||||
class="pa-2 icon-avatar"
|
||||
color="primary"
|
||||
size="75"
|
||||
>
|
||||
<svg
|
||||
class="icon-white"
|
||||
style="width: 75"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z"
|
||||
/>
|
||||
</svg>
|
||||
</v-avatar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-center grow items-center my-4">
|
||||
<slot :width="pageWidth" />
|
||||
</div>
|
||||
<div class="mx-2 my-4">
|
||||
<v-progress-linear
|
||||
v-if="wizardPage > 0"
|
||||
:value="Math.ceil((wizardPage / maxPageNumber) * 100)"
|
||||
striped
|
||||
height="10"
|
||||
/>
|
||||
</div>
|
||||
<v-divider class="ma-2" />
|
||||
<v-card-actions width="100%">
|
||||
<v-btn
|
||||
v-if="prevButtonShow"
|
||||
:disabled="!prevButtonEnable"
|
||||
:color="prevButtonColor"
|
||||
@click="decrementPage"
|
||||
>
|
||||
<v-icon v-if="prevButtonIconRef">
|
||||
{{ prevButtonIconRef }}
|
||||
</v-icon>
|
||||
{{ prevButtonTextRef }}
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="nextButtonShow"
|
||||
variant="elevated"
|
||||
:disabled="!nextButtonEnable"
|
||||
:color="nextButtonColorRef"
|
||||
@click="incrementPage"
|
||||
>
|
||||
<div v-if="isSubmitting">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="white"
|
||||
size="24"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<v-icon v-if="nextButtonIconRef && !nextButtonIconAfter">
|
||||
{{ nextButtonIconRef }}
|
||||
</v-icon>
|
||||
{{ nextButtonTextRef }}
|
||||
<v-icon v-if="nextButtonIconRef && nextButtonIconAfter">
|
||||
{{ nextButtonIconRef }}
|
||||
</v-icon>
|
||||
</div>
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
<v-card-actions class="justify-center flex-column py-8">
|
||||
<BaseButton
|
||||
large
|
||||
color="primary"
|
||||
@click="langDialog = true"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.translate }}
|
||||
</template>
|
||||
{{ $t("language-dialog.choose-language") }}
|
||||
</BaseButton>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
minPageNumber: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
maxPageNumber: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
default: "1200px",
|
||||
},
|
||||
pageWidth: {
|
||||
type: [String, Number],
|
||||
default: "600px",
|
||||
},
|
||||
prevButtonText: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
prevButtonIcon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
prevButtonColor: {
|
||||
type: String,
|
||||
default: "grey-darken-3",
|
||||
},
|
||||
prevButtonShow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
prevButtonEnable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
nextButtonText: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
nextButtonIcon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
nextButtonIconAfter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
nextButtonColor: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
nextButtonShow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
nextButtonEnable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
nextButtonIsSubmit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
isSubmitting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "submit"],
|
||||
setup(props, context) {
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const ready = ref(false);
|
||||
const langDialog = ref(false);
|
||||
|
||||
const wizardPage = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => context.emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
const prevButtonTextRef = computed(() => props.prevButtonText || i18n.t("general.back"));
|
||||
const prevButtonIconRef = computed(() => props.prevButtonIcon || $globals.icons.back);
|
||||
const nextButtonTextRef = computed(
|
||||
() => props.nextButtonText || (
|
||||
props.nextButtonIsSubmit ? i18n.t("general.submit") : i18n.t("general.next")
|
||||
),
|
||||
);
|
||||
const nextButtonIconRef = computed(
|
||||
() => props.nextButtonIcon || (
|
||||
props.nextButtonIsSubmit ? $globals.icons.createAlt : $globals.icons.forward
|
||||
),
|
||||
);
|
||||
const nextButtonColorRef = computed(
|
||||
() => props.nextButtonColor || (props.nextButtonIsSubmit ? "success" : "info"),
|
||||
);
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page < props.minPageNumber) {
|
||||
goToPage(props.minPageNumber);
|
||||
return;
|
||||
}
|
||||
else if (page > props.maxPageNumber) {
|
||||
goToPage(props.maxPageNumber);
|
||||
return;
|
||||
}
|
||||
wizardPage.value = page;
|
||||
}
|
||||
|
||||
function decrementPage() {
|
||||
goToPage(wizardPage.value - 1);
|
||||
}
|
||||
|
||||
function incrementPage() {
|
||||
if (props.nextButtonIsSubmit) {
|
||||
context.emit("submit", wizardPage.value);
|
||||
}
|
||||
else {
|
||||
goToPage(wizardPage.value + 1);
|
||||
}
|
||||
}
|
||||
|
||||
ready.value = true;
|
||||
|
||||
return {
|
||||
wizardPage,
|
||||
ready,
|
||||
langDialog,
|
||||
prevButtonTextRef,
|
||||
prevButtonIconRef,
|
||||
nextButtonTextRef,
|
||||
nextButtonIconRef,
|
||||
nextButtonColorRef,
|
||||
decrementPage,
|
||||
incrementPage,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.icon-primary {
|
||||
fill: var(--v-primary-base);
|
||||
}
|
||||
|
||||
.icon-white {
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.icon-divider {
|
||||
width: 100%;
|
||||
margin-bottom: -2.5rem;
|
||||
}
|
||||
|
||||
.icon-avatar {
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
border: 2px;
|
||||
}
|
||||
|
||||
.bg-off-white {
|
||||
background: #f5f8fa;
|
||||
}
|
||||
|
||||
.preferred-width {
|
||||
width: 840px;
|
||||
}
|
||||
</style>
|
||||
@@ -10,8 +10,9 @@
|
||||
:min="min"
|
||||
:max="max"
|
||||
type="number"
|
||||
size="small"
|
||||
variant="plain"
|
||||
density="compact"
|
||||
style="width: 60px;"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html is safe here because all HTML is sanitized with DOMPurify in setup() -->
|
||||
<div v-html="value" />
|
||||
<!-- eslint-disable-next-line vue/no-v-html is safe here because all HTML is sanitized with DOMPurify in setup() -->
|
||||
<div v-html="value" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -39,7 +39,7 @@ export default defineNuxtComponent({
|
||||
}
|
||||
|
||||
const value = computed(() => {
|
||||
const rawHtml = marked.parse(props.source || "", { async: false });
|
||||
const rawHtml = marked.parse(props.source || "", { async: false, breaks: true });
|
||||
return sanitizeMarkdown(rawHtml);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Composer } from "vue-i18n";
|
||||
import type { ApiRequestInstance, RequestResponse } from "~/lib/api/types/non-generated";
|
||||
import { AdminAPI, PublicApi, UserApi } from "~/lib/api";
|
||||
import { PublicExploreApi } from "~/lib/api/client-public";
|
||||
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||
|
||||
const request = {
|
||||
async safe<T, U>(
|
||||
@@ -56,8 +57,7 @@ function getRequests(axiosInstance: AxiosInstance): ApiRequestInstance {
|
||||
export const useRequests = function (i18n?: Composer): ApiRequestInstance {
|
||||
const { $axios } = useNuxtApp();
|
||||
if (!i18n) {
|
||||
// Only works in a setup block
|
||||
i18n = useI18n();
|
||||
i18n = useGlobalI18n();
|
||||
}
|
||||
|
||||
$axios.defaults.headers.common["Accept-Language"] = i18n.locale.value;
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { useAsyncKey } from "../use-utils";
|
||||
import type { AppInfo } from "~/lib/api/types/admin";
|
||||
|
||||
export function useAppInfo(): Ref<AppInfo | null> {
|
||||
const appInfo = ref<null | AppInfo>(null);
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $axios } = useNuxtApp();
|
||||
$axios.defaults.headers.common["Accept-Language"] = i18n.locale.value;
|
||||
|
||||
useAsyncData(useAsyncKey(), async () => {
|
||||
const { data: appInfo } = useAsyncData("app-info", async () => {
|
||||
const data = await $axios.get<AppInfo>("/api/app/about");
|
||||
appInfo.value = data.data;
|
||||
return data.data;
|
||||
});
|
||||
|
||||
return appInfo;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useAsyncKey } from "../use-utils";
|
||||
import type { AsyncData, NuxtError } from "#app";
|
||||
import type { BoundT } from "./types";
|
||||
import type { BaseCRUDAPI, BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
|
||||
import type { QueryValue } from "~/lib/api/base/route";
|
||||
|
||||
interface ReadOnlyStoreActions<T extends BoundT> {
|
||||
getAll(page?: number, perPage?: number, params?: any): Ref<T[] | null>;
|
||||
getAll(page?: number, perPage?: number, params?: any): AsyncData<T[] | null, NuxtError<unknown> | null>;
|
||||
refresh(page?: number, perPage?: number, params?: any): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface StoreActions<T extends BoundT> extends ReadOnlyStoreActions<T> {
|
||||
* a lot of refreshing hooks to be called on operations
|
||||
*/
|
||||
export function useReadOnlyActions<T extends BoundT>(
|
||||
storeKey: string,
|
||||
api: BaseCRUDAPIReadOnly<T>,
|
||||
allRef: Ref<T[] | null> | null,
|
||||
loading: Ref<boolean>,
|
||||
@@ -29,20 +30,24 @@ export function useReadOnlyActions<T extends BoundT>(
|
||||
params.orderBy ??= "name";
|
||||
params.orderDirection ??= "asc";
|
||||
|
||||
loading.value = true;
|
||||
const allItems = useAsyncData(useAsyncKey(), async () => {
|
||||
const { data } = await api.getAll(page, perPage, params);
|
||||
loading.value = false;
|
||||
const allItems = useAsyncData(storeKey, async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.getAll(page, perPage, params);
|
||||
|
||||
if (data && allRef) {
|
||||
allRef.value = data.items;
|
||||
}
|
||||
if (data && allRef) {
|
||||
allRef.value = data.items;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
return data.items ?? [];
|
||||
if (data) {
|
||||
return data.items ?? [];
|
||||
}
|
||||
else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
else {
|
||||
return [];
|
||||
finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,6 +81,7 @@ export function useReadOnlyActions<T extends BoundT>(
|
||||
* a lot of refreshing hooks to be called on operations
|
||||
*/
|
||||
export function useStoreActions<T extends BoundT>(
|
||||
storeKey: string,
|
||||
api: BaseCRUDAPI<unknown, T, unknown>,
|
||||
allRef: Ref<T[] | null> | null,
|
||||
loading: Ref<boolean>,
|
||||
@@ -84,20 +90,24 @@ export function useStoreActions<T extends BoundT>(
|
||||
params.orderBy ??= "name";
|
||||
params.orderDirection ??= "asc";
|
||||
|
||||
loading.value = true;
|
||||
const allItems = useAsyncData(useAsyncKey(), async () => {
|
||||
const { data } = await api.getAll(page, perPage, params);
|
||||
loading.value = false;
|
||||
const allItems = useAsyncData(storeKey, async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.getAll(page, perPage, params);
|
||||
|
||||
if (data && allRef) {
|
||||
allRef.value = data.items;
|
||||
}
|
||||
if (data && allRef) {
|
||||
allRef.value = data.items;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
return data.items ?? [];
|
||||
if (data) {
|
||||
return data.items ?? [];
|
||||
}
|
||||
else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
else {
|
||||
return [];
|
||||
finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -13,12 +13,13 @@ export const useData = function <T extends BoundT>(defaultObject: T) {
|
||||
};
|
||||
|
||||
export const useReadOnlyStore = function <T extends BoundT>(
|
||||
storeKey: string,
|
||||
store: Ref<T[]>,
|
||||
loading: Ref<boolean>,
|
||||
api: BaseCRUDAPIReadOnly<T>,
|
||||
params = {} as Record<string, QueryValue>,
|
||||
) {
|
||||
const storeActions = useReadOnlyActions(api, store, loading);
|
||||
const storeActions = useReadOnlyActions(`${storeKey}-store-readonly`, api, store, loading);
|
||||
const actions = {
|
||||
...storeActions,
|
||||
async refresh() {
|
||||
@@ -29,21 +30,22 @@ export const useReadOnlyStore = function <T extends BoundT>(
|
||||
},
|
||||
};
|
||||
|
||||
if (!loading.value && (!store.value || store.value.length === 0)) {
|
||||
const result = actions.getAll(1, -1, params);
|
||||
store.value = result.value || [];
|
||||
// initial hydration
|
||||
if (!loading.value && !store.value.length) {
|
||||
actions.refresh();
|
||||
}
|
||||
|
||||
return { store, actions };
|
||||
};
|
||||
|
||||
export const useStore = function <T extends BoundT>(
|
||||
storeKey: string,
|
||||
store: Ref<T[]>,
|
||||
loading: Ref<boolean>,
|
||||
api: BaseCRUDAPI<unknown, T, unknown>,
|
||||
params = {} as Record<string, QueryValue>,
|
||||
) {
|
||||
const storeActions = useStoreActions(api, store, loading);
|
||||
const storeActions = useStoreActions(`${storeKey}-store`, api, store, loading);
|
||||
const actions = {
|
||||
...storeActions,
|
||||
async refresh() {
|
||||
@@ -54,9 +56,9 @@ export const useStore = function <T extends BoundT>(
|
||||
},
|
||||
};
|
||||
|
||||
if (!loading.value && (!store.value || store.value.length === 0)) {
|
||||
const result = actions.getAll(1, -1, params);
|
||||
store.value = result.value || [];
|
||||
// initial hydration
|
||||
if (!loading.value && !store.value.length) {
|
||||
actions.refresh();
|
||||
}
|
||||
|
||||
return { store, actions };
|
||||
|
||||
@@ -29,26 +29,31 @@ interface PageState {
|
||||
editMode: ComputedRef<EditorMode>;
|
||||
|
||||
/**
|
||||
* true is the page is in edit mode and the edit mode is in form mode.
|
||||
*/
|
||||
* true is the page is in edit mode and the edit mode is in form mode.
|
||||
*/
|
||||
isEditForm: ComputedRef<boolean>;
|
||||
/**
|
||||
* true is the page is in edit mode and the edit mode is in json mode.
|
||||
*/
|
||||
* true is the page is in edit mode and the edit mode is in json mode.
|
||||
*/
|
||||
isEditJSON: ComputedRef<boolean>;
|
||||
/**
|
||||
* true is the page is in view mode.
|
||||
*/
|
||||
* true is the page is in view mode.
|
||||
*/
|
||||
isEditMode: ComputedRef<boolean>;
|
||||
/**
|
||||
* true is the page is in cook mode.
|
||||
*/
|
||||
* true is the page is in cook mode.
|
||||
*/
|
||||
isCookMode: ComputedRef<boolean>;
|
||||
/**
|
||||
* true if the recipe is currently being parsed.
|
||||
*/
|
||||
isParsing: ComputedRef<boolean>;
|
||||
|
||||
setMode: (v: PageMode) => void;
|
||||
setEditMode: (v: EditorMode) => void;
|
||||
toggleEditMode: () => void;
|
||||
toggleCookMode: () => void;
|
||||
toggleIsParsing: (v?: boolean) => void;
|
||||
}
|
||||
|
||||
type PageRefs = ReturnType<typeof pageRefs>;
|
||||
@@ -60,11 +65,12 @@ function pageRefs(slug: string) {
|
||||
slugRef: ref(slug),
|
||||
pageModeRef: ref(PageMode.VIEW),
|
||||
editModeRef: ref(EditorMode.FORM),
|
||||
isParsingRef: ref(false),
|
||||
imageKey: ref(1),
|
||||
};
|
||||
}
|
||||
|
||||
function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): PageState {
|
||||
function pageState({ slugRef, pageModeRef, editModeRef, isParsingRef, imageKey }: PageRefs): PageState {
|
||||
const { activateNavigationWarning, deactivateNavigationWarning } = useNavigationWarning();
|
||||
|
||||
const toggleEditMode = () => {
|
||||
@@ -83,6 +89,14 @@ function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): P
|
||||
pageModeRef.value = PageMode.COOK;
|
||||
};
|
||||
|
||||
const toggleIsParsing = (v: boolean | null = null) => {
|
||||
if (v === null) {
|
||||
v = !isParsingRef.value;
|
||||
}
|
||||
|
||||
isParsingRef.value = v;
|
||||
};
|
||||
|
||||
const setEditMode = (v: EditorMode) => {
|
||||
editModeRef.value = v;
|
||||
};
|
||||
@@ -113,6 +127,7 @@ function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): P
|
||||
setMode,
|
||||
setEditMode,
|
||||
toggleCookMode,
|
||||
toggleIsParsing,
|
||||
|
||||
isEditForm: computed(() => {
|
||||
return pageModeRef.value === PageMode.EDIT && editModeRef.value === EditorMode.FORM;
|
||||
@@ -126,6 +141,9 @@ function pageState({ slugRef, pageModeRef, editModeRef, imageKey }: PageRefs): P
|
||||
isCookMode: computed(() => {
|
||||
return pageModeRef.value === PageMode.COOK;
|
||||
}),
|
||||
isParsing: computed(() => {
|
||||
return isParsingRef.value;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ function useUnitName(unit: CreateIngredientUnit | IngredientUnit | undefined, us
|
||||
}
|
||||
|
||||
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true) {
|
||||
const { quantity, food, unit, note } = ingredient;
|
||||
const { quantity, food, unit, note, title } = ingredient;
|
||||
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
|
||||
const usePluralFood = (!quantity) || quantity * scale > 1;
|
||||
|
||||
@@ -66,6 +66,7 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1,
|
||||
const foodName = useFoodName(food || undefined, usePluralFood);
|
||||
|
||||
return {
|
||||
title: title ? sanitizeIngredientHTML(title) : undefined,
|
||||
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
|
||||
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
|
||||
name: foodName ? sanitizeIngredientHTML(foodName) : undefined,
|
||||
|
||||
@@ -150,7 +150,10 @@ export function useShoppingListCrud(
|
||||
|
||||
loadingCounter.value += 1;
|
||||
|
||||
// make sure it's inserted into the end of the list, which may have been updated
|
||||
// ensure list id is set to the current list, which may not have loaded yet in the factory
|
||||
createListItemData.value.shoppingListId = shoppingList.value.id;
|
||||
|
||||
// ensure item is inserted into the end of the list, which may have been updated
|
||||
createListItemData.value.position = shoppingList.value?.listItems?.length
|
||||
? (shoppingList.value.listItems.reduce((a, b) => (a.position || 0) > (b.position || 0) ? a : b).position || 0) + 1
|
||||
: 0;
|
||||
|
||||
@@ -17,10 +17,10 @@ export const useCategoryData = function () {
|
||||
|
||||
export const useCategoryStore = function (i18n?: Composer) {
|
||||
const api = useUserApi(i18n);
|
||||
return useStore<RecipeCategory>(store, loading, api.categories);
|
||||
return useStore<RecipeCategory>("category", store, loading, api.categories);
|
||||
};
|
||||
|
||||
export const usePublicCategoryStore = function (groupSlug: string, i18n?: Composer) {
|
||||
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
||||
return useReadOnlyStore<RecipeCategory>(store, publicLoading, api.categories);
|
||||
return useReadOnlyStore<RecipeCategory>("category", store, publicLoading, api.categories);
|
||||
};
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import type { Composer } from "vue-i18n";
|
||||
import { useReadOnlyStore, useStore } from "../partials/use-store-factory";
|
||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
import type { ReadCookBook, UpdateCookBook } from "~/lib/api/types/cookbook";
|
||||
import { usePublicExploreApi, useUserApi } from "~/composables/api";
|
||||
|
||||
const store: Ref<ReadCookBook[]> = ref([]);
|
||||
const cookbooks: Ref<ReadCookBook[]> = ref([]);
|
||||
const loading = ref(false);
|
||||
const publicLoading = ref(false);
|
||||
|
||||
export const useCookbookStore = function (i18n?: Composer) {
|
||||
const api = useUserApi(i18n);
|
||||
return useStore<ReadCookBook>(store, loading, api.cookbooks);
|
||||
const store = useStore<ReadCookBook>("cookbook", cookbooks, loading, api.cookbooks);
|
||||
|
||||
const updateAll = async function (updateData: UpdateCookBook[]) {
|
||||
loading.value = true;
|
||||
updateData.forEach((cookbook, index) => {
|
||||
cookbook.position = index;
|
||||
});
|
||||
const { data } = await api.cookbooks.updateAll(updateData);
|
||||
loading.value = false;
|
||||
return data;
|
||||
};
|
||||
return { ...store, updateAll };
|
||||
};
|
||||
|
||||
export const usePublicCookbookStore = function (groupSlug: string, i18n?: Composer) {
|
||||
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
||||
return useReadOnlyStore<ReadCookBook>(store, publicLoading, api.cookbooks);
|
||||
return useReadOnlyStore<ReadCookBook>("cookbook", cookbooks, publicLoading, api.cookbooks);
|
||||
};
|
||||
|
||||
@@ -18,10 +18,10 @@ export const useFoodData = function () {
|
||||
|
||||
export const useFoodStore = function (i18n?: Composer) {
|
||||
const api = useUserApi(i18n);
|
||||
return useStore<IngredientFood>(store, loading, api.foods);
|
||||
return useStore<IngredientFood>("food", store, loading, api.foods);
|
||||
};
|
||||
|
||||
export const usePublicFoodStore = function (groupSlug: string, i18n?: Composer) {
|
||||
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
||||
return useReadOnlyStore<IngredientFood>(store, publicLoading, api.foods);
|
||||
return useReadOnlyStore<IngredientFood>("food", store, publicLoading, api.foods);
|
||||
};
|
||||
|
||||
@@ -9,10 +9,10 @@ const publicLoading = ref(false);
|
||||
|
||||
export const useHouseholdStore = function (i18n?: Composer) {
|
||||
const api = useUserApi(i18n);
|
||||
return useReadOnlyStore<HouseholdSummary>(store, loading, api.households);
|
||||
return useReadOnlyStore<HouseholdSummary>("household", store, loading, api.households);
|
||||
};
|
||||
|
||||
export const usePublicHouseholdStore = function (groupSlug: string, i18n?: Composer) {
|
||||
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
||||
return useReadOnlyStore<HouseholdSummary>(store, publicLoading, api.households);
|
||||
return useReadOnlyStore<HouseholdSummary>("household-public", store, publicLoading, api.households);
|
||||
};
|
||||
|
||||
@@ -17,5 +17,5 @@ export const useLabelData = function () {
|
||||
|
||||
export const useLabelStore = function (i18n?: Composer) {
|
||||
const api = useUserApi(i18n);
|
||||
return useStore<MultiPurposeLabelOut>(store, loading, api.multiPurposeLabels);
|
||||
return useStore<MultiPurposeLabelOut>("label", store, loading, api.multiPurposeLabels);
|
||||
};
|
||||
|
||||
@@ -17,10 +17,10 @@ export const useTagData = function () {
|
||||
|
||||
export const useTagStore = function (i18n?: Composer) {
|
||||
const api = useUserApi(i18n);
|
||||
return useStore<RecipeTag>(store, loading, api.tags);
|
||||
return useStore<RecipeTag>("tag", store, loading, api.tags);
|
||||
};
|
||||
|
||||
export const usePublicTagStore = function (groupSlug: string, i18n?: Composer) {
|
||||
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
||||
return useReadOnlyStore<RecipeTag>(store, publicLoading, api.tags);
|
||||
return useReadOnlyStore<RecipeTag>("tag", store, publicLoading, api.tags);
|
||||
};
|
||||
|
||||
@@ -23,10 +23,10 @@ export const useToolData = function () {
|
||||
|
||||
export const useToolStore = function (i18n?: Composer) {
|
||||
const api = useUserApi(i18n);
|
||||
return useStore<RecipeTool>(store, loading, api.tools);
|
||||
return useStore<RecipeTool>("tool", store, loading, api.tools);
|
||||
};
|
||||
|
||||
export const usePublicToolStore = function (groupSlug: string, i18n?: Composer) {
|
||||
const api = usePublicExploreApi(groupSlug, i18n).explore;
|
||||
return useReadOnlyStore<RecipeTool>(store, publicLoading, api.tools);
|
||||
return useReadOnlyStore<RecipeTool>("tool", store, publicLoading, api.tools);
|
||||
};
|
||||
|
||||
@@ -18,5 +18,5 @@ export const useUnitData = function () {
|
||||
|
||||
export const useUnitStore = function (i18n?: Composer) {
|
||||
const api = useUserApi(i18n);
|
||||
return useStore<IngredientUnit>(store, loading, api.units);
|
||||
return useStore<IngredientUnit>("unit", store, loading, api.units);
|
||||
};
|
||||
|
||||
@@ -16,5 +16,5 @@ export const useUserStore = function (i18n?: Composer) {
|
||||
const requests = useRequests(i18n);
|
||||
const api = new GroupUserAPIReadOnly(requests);
|
||||
|
||||
return useReadOnlyStore<UserSummary>(store, loading, api, { orderBy: "full_name" });
|
||||
return useReadOnlyStore<UserSummary>("user", store, loading, api, { orderBy: "full_name" });
|
||||
};
|
||||
|
||||
10
frontend/composables/use-global-i18n.ts
Normal file
10
frontend/composables/use-global-i18n.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Composer } from "vue-i18n";
|
||||
|
||||
let i18n: Composer | null = null;
|
||||
|
||||
export function useGlobalI18n() {
|
||||
if (!i18n) {
|
||||
i18n = useI18n();
|
||||
}
|
||||
return i18n;
|
||||
}
|
||||
@@ -29,8 +29,8 @@ export function useGroupRecipeActionData() {
|
||||
}
|
||||
|
||||
export const useGroupRecipeActions = function (
|
||||
orderBy: string | null = "title",
|
||||
orderDirection: string | null = "asc",
|
||||
orderBy: string | null = "title",
|
||||
orderDirection: string | null = "asc",
|
||||
) {
|
||||
const api = useUserApi();
|
||||
|
||||
@@ -78,7 +78,7 @@ export const useGroupRecipeActions = function (
|
||||
};
|
||||
|
||||
const actions = {
|
||||
...useStoreActions<GroupRecipeActionOut>(api.groupRecipeActions, groupRecipeActions, loading),
|
||||
...useStoreActions<GroupRecipeActionOut>("group-recipe-actions", api.groupRecipeActions, groupRecipeActions, loading),
|
||||
flushStore() {
|
||||
groupRecipeActions.value = [];
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "简体中文 (Chinese simplified)",
|
||||
value: "zh-CN",
|
||||
progress: 35,
|
||||
progress: 38,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -21,7 +21,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Українська (Ukrainian)",
|
||||
value: "uk-UA",
|
||||
progress: 37,
|
||||
progress: 55,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -33,7 +33,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Svenska (Swedish)",
|
||||
value: "sv-SE",
|
||||
progress: 52,
|
||||
progress: 53,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -45,19 +45,19 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Slovenščina (Slovenian)",
|
||||
value: "sl-SI",
|
||||
progress: 39,
|
||||
progress: 40,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Slovenčina (Slovak)",
|
||||
value: "sk-SK",
|
||||
progress: 37,
|
||||
progress: 46,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Pусский (Russian)",
|
||||
value: "ru-RU",
|
||||
progress: 40,
|
||||
progress: 41,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -75,25 +75,25 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Português do Brasil (Brazilian Portuguese)",
|
||||
value: "pt-BR",
|
||||
progress: 41,
|
||||
progress: 46,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Polski (Polish)",
|
||||
value: "pl-PL",
|
||||
progress: 40,
|
||||
progress: 42,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Norsk (Norwegian)",
|
||||
value: "no-NO",
|
||||
progress: 39,
|
||||
progress: 40,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Nederlands (Dutch)",
|
||||
value: "nl-NL",
|
||||
progress: 45,
|
||||
progress: 52,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -105,7 +105,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Lietuvių (Lithuanian)",
|
||||
value: "lt-LT",
|
||||
progress: 26,
|
||||
progress: 27,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -123,25 +123,25 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Italiano (Italian)",
|
||||
value: "it-IT",
|
||||
progress: 39,
|
||||
progress: 43,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Íslenska (Icelandic)",
|
||||
value: "is-IS",
|
||||
progress: 2,
|
||||
progress: 10,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Magyar (Hungarian)",
|
||||
value: "hu-HU",
|
||||
progress: 40,
|
||||
progress: 45,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Hrvatski (Croatian)",
|
||||
value: "hr-HR",
|
||||
progress: 27,
|
||||
progress: 28,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -159,7 +159,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Français (French)",
|
||||
value: "fr-FR",
|
||||
progress: 52,
|
||||
progress: 67,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -171,7 +171,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Belge (Belgian)",
|
||||
value: "fr-BE",
|
||||
progress: 36,
|
||||
progress: 41,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -189,7 +189,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Español (Spanish)",
|
||||
value: "es-ES",
|
||||
progress: 41,
|
||||
progress: 45,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -201,31 +201,31 @@ export const LOCALES = [
|
||||
{
|
||||
name: "British English",
|
||||
value: "en-GB",
|
||||
progress: 23,
|
||||
progress: 43,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Ελληνικά (Greek)",
|
||||
value: "el-GR",
|
||||
progress: 39,
|
||||
progress: 41,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Deutsch (German)",
|
||||
value: "de-DE",
|
||||
progress: 66,
|
||||
progress: 80,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Dansk (Danish)",
|
||||
value: "da-DK",
|
||||
progress: 40,
|
||||
progress: 43,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
name: "Čeština (Czech)",
|
||||
value: "cs-CZ",
|
||||
progress: 39,
|
||||
progress: 42,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
@@ -237,7 +237,7 @@ export const LOCALES = [
|
||||
{
|
||||
name: "Български (Bulgarian)",
|
||||
value: "bg-BG",
|
||||
progress: 31,
|
||||
progress: 44,
|
||||
dir: "ltr",
|
||||
},
|
||||
{
|
||||
|
||||
85
frontend/composables/use-new-recipe-options.ts
Normal file
85
frontend/composables/use-new-recipe-options.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useRecipeCreatePreferences } from "~/composables/use-users/preferences";
|
||||
|
||||
export interface UseNewRecipeOptionsProps {
|
||||
enableImportKeywords?: boolean;
|
||||
enableStayInEditMode?: boolean;
|
||||
enableParseRecipe?: boolean;
|
||||
}
|
||||
|
||||
export function useNewRecipeOptions(props: UseNewRecipeOptionsProps = {}) {
|
||||
const {
|
||||
enableImportKeywords = true,
|
||||
enableStayInEditMode = true,
|
||||
enableParseRecipe = true,
|
||||
} = props;
|
||||
|
||||
const router = useRouter();
|
||||
const recipeCreatePreferences = useRecipeCreatePreferences();
|
||||
|
||||
const importKeywordsAsTags = computed({
|
||||
get() {
|
||||
if (!enableImportKeywords) return false;
|
||||
return recipeCreatePreferences.value.importKeywordsAsTags;
|
||||
},
|
||||
set(v: boolean) {
|
||||
if (!enableImportKeywords) return;
|
||||
recipeCreatePreferences.value.importKeywordsAsTags = v;
|
||||
},
|
||||
});
|
||||
|
||||
const stayInEditMode = computed({
|
||||
get() {
|
||||
if (!enableStayInEditMode) return false;
|
||||
return recipeCreatePreferences.value.stayInEditMode;
|
||||
},
|
||||
set(v: boolean) {
|
||||
if (!enableStayInEditMode) return;
|
||||
recipeCreatePreferences.value.stayInEditMode = v;
|
||||
},
|
||||
});
|
||||
|
||||
const parseRecipe = computed({
|
||||
get() {
|
||||
if (!enableParseRecipe) return false;
|
||||
return recipeCreatePreferences.value.parseRecipe;
|
||||
},
|
||||
set(v: boolean) {
|
||||
if (!enableParseRecipe) return;
|
||||
recipeCreatePreferences.value.parseRecipe = v;
|
||||
},
|
||||
});
|
||||
|
||||
function navigateToRecipe(recipeSlug: string, groupSlug: string, createPagePath: string) {
|
||||
const editParam = enableStayInEditMode ? stayInEditMode.value : false;
|
||||
const parseParam = enableParseRecipe ? parseRecipe.value : false;
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
if (editParam) {
|
||||
queryParams.set("edit", "true");
|
||||
}
|
||||
if (parseParam) {
|
||||
queryParams.set("parse", "true");
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const recipeUrl = `/g/${groupSlug}/r/${recipeSlug}${queryString ? `?${queryString}` : ""}`;
|
||||
|
||||
// Replace current entry to prevent re-import on back navigation
|
||||
router.replace(createPagePath).then(() => router.push(recipeUrl));
|
||||
}
|
||||
|
||||
return {
|
||||
// Computed properties for the checkboxes
|
||||
importKeywordsAsTags,
|
||||
stayInEditMode,
|
||||
parseRecipe,
|
||||
|
||||
// Helper functions
|
||||
navigateToRecipe,
|
||||
|
||||
// Props for conditional rendering
|
||||
enableImportKeywords,
|
||||
enableStayInEditMode,
|
||||
enableParseRecipe,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user